capybara-simulated 0.5.0 → 0.6.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.
@@ -225,11 +225,19 @@ module Capybara
225
225
  # introduce a NoMethodError window for any stray post-close call (eval/call
226
226
  # don't all nil-guard `@vm`) with no leak benefit.
227
227
 
228
- # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU
229
- # built-in but QuickJS gates it behind a polyfill flag. Other JS
230
- # surfaces bridge.js touches (URL / TextEncoder / atob/btoa /
231
- # crypto) are already routed through Ruby-side host fns, so
232
- # POLYFILL_INTL is the only one we strictly need.
228
+ # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU built-in but
229
+ # QuickJS gates it behind a polyfill flag (other surfaces bridge.js touches
230
+ # URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns,
231
+ # so POLYFILL_INTL is the only one we strictly need).
232
+ #
233
+ # PERF (rule 3): quickjs is pinned to `~> 0.18.0` in the Gemfile. quickjs 0.19
234
+ # both split the Intl polyfills into a separate quickjs-polyfill-intl gem (which
235
+ # eval's them per VM at ~226 ms — vs this single bundled flag's ~140 ms) AND
236
+ # regressed interpreter execution ~2.8× (measured: the QuickJS spec suite ran
237
+ # 5.6 min on 0.18 vs 15.5 min on 0.19 with an equivalent Intl set). The 0.19
238
+ # migration is recorded in the cross-window / quickjs-CI memory; re-migrate to
239
+ # 0.19 + quickjs-polyfill-intl once that upstream perf regression is fixed.
240
+ INTL_FEATURES = [Quickjs::POLYFILL_INTL].freeze
233
241
  #
234
242
  # `max_stack_size: 0` — `JS_SetMaxStackSize` measures C stack
235
243
  # delta from runtime construction; Ruby callers reach QuickJS
@@ -245,7 +253,7 @@ module Capybara
245
253
  # handler returns `elapsed >= limit_ms`, so 0 fires on the first
246
254
  # check), so practical no-limit.
247
255
  VM_OPTIONS = {
248
- features: [Quickjs::POLYFILL_INTL].freeze,
256
+ features: INTL_FEATURES,
249
257
  max_stack_size: 0,
250
258
  # quickjs.rb's 128 MB default trips "out of memory in regexp
251
259
  # execution" on class-attribute-heavy polls and the heaviest
@@ -353,6 +361,9 @@ module Capybara
353
361
  # flip doesn't race main's `polling?` gate. See v8_runtime's
354
362
  # build_worker for the long-form rationale.
355
363
  vm.define_function('__setTimersActive') {|_flag| nil }
364
+ # importScripts runs a classic script at top-level scope so its top-level
365
+ # const/let/class share the realm's global lexical env (see v8_runtime).
366
+ vm.define_function('__csim_workerImportEval') {|src| vm.eval_code(src.to_s); nil }
356
367
  vm.eval_code('__csim_installWorkerScope();')
357
368
  vm.drain_jobs!
358
369
  WorkerRuntime.new(
@@ -49,6 +49,10 @@ module Capybara
49
49
  # realm id). Deferred + applied by re-navigating the owning iframe, so a
50
50
  # frame's `location.href = …` navigates the frame, not the top page.
51
51
  '__csimFrameNavigate' => ->(b, *a) { b.frame_navigate_self(a[0], a[1].to_i); nil },
52
+ # A same-origin window realm (window.open in this isolate) navigating itself
53
+ # (a[0] = url, a[1] = the window's realm id). Deferred + applied by reloading
54
+ # that realm's document in place — see Browser#window_realm_navigate_self.
55
+ '__csimWindowRealmNavigate' => ->(b, *a) { b.window_realm_navigate_self(a[0], a[1].to_i); nil },
52
56
  # A nested browsing context reloading its OWN location (a[0] = the frame's
53
57
  # realm id). Deferred + applied by re-navigating the owning iframe to its
54
58
  # current document — see Browser#frame_reload_self.
@@ -57,6 +61,7 @@ module Capybara
57
61
  # initiating frame's realm id). Deferred + applied against that realm,
58
62
  # like __csimFrameNavigate — see Browser#frame_submit_self.
59
63
  '__csimFrameSubmit' => ->(b, *a) { b.frame_submit_self(a[0].to_i); nil },
64
+ '__csimFrameHistoryGo' => ->(b, *a) { b.frame_history_go(a[0].to_i, a[1].to_i); nil },
60
65
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
61
66
  '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
62
67
  '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
@@ -83,20 +88,37 @@ module Capybara
83
88
  '__csim_wsClose' => ->(b, *a) { b.ws_close(a[0], a[1], a[2]); nil },
84
89
  '__csim_rackFetchAsync' => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
85
90
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
86
- # Cross-window references (window.open / opener / postMessage). Each
87
- # window is a separate VM, so these forward to the Driver to route to
88
- # the target window's Browser.
89
- '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1]) },
91
+ # Cross-window references (window.open / opener / postMessage). A separate-VM
92
+ # aux window forwards to the Driver; a same-origin window realm lives in this
93
+ # isolate. a[2] is the opener's realm id (for wiring window.opener).
94
+ '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2]) },
95
+ # A `target=_blank`/named link/area activation from a frame or window realm:
96
+ # open a new auxiliary window (the realm's VM isn't rebuilt — a fresh window
97
+ # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
98
+ # forces noopener for a cross-partition blob: target.
99
+ '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
90
100
  '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
91
- '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1]); nil },
101
+ '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i); nil },
92
102
  '__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
93
103
  '__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
104
+ # Cross-window remote-ref RPC: route an opener's node/object proxy op to
105
+ # the target window's VM (a[0]=window handle, a[1]=ref id, a[2]=prop/method).
106
+ '__csimWindowRefGet' => ->(b, *a) { b.window_ref_get(a[0], a[1], a[2]) },
107
+ '__csimWindowRefSet' => ->(b, *a) { b.window_ref_set(a[0], a[1], a[2], a[3]); nil },
108
+ '__csimWindowRefCall' => ->(b, *a) { b.window_ref_call(a[0], a[1], a[2], a[3]) },
94
109
  '__csimWindowLocation' => ->(b, *a) { b.window_location_of(a[0]) },
95
110
  '__csimWindowSetLocation' => ->(b, *a) { b.set_window_location(a[0], a[1]); nil },
111
+ '__csimWindowHistoryGo' => ->(b, *a) { b.window_history_go(a[0], a[1]) },
96
112
  '__csimWindowClosed' => ->(b, *a) { b.window_closed?(a[0]) },
97
113
  '__csimWindowClose' => ->(b, *a) { b.close_child_window(a[0]); nil },
98
114
  '__csimWindowOpener' => ->(b, *_) { b.opener_handle },
115
+ # Fire an aux window's OWN `load` event (in its VM) — deferred by the
116
+ # opener so a child `window.onload` runs after the opener's current task.
117
+ '__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
99
118
  '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
119
+ # navigator.serviceWorker.register (universal-server only) — spawn a worker
120
+ # running the SW script as an executor context. Returns its handle.
121
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true) },
100
122
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
101
123
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
102
124
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
@@ -116,6 +138,11 @@ module Capybara
116
138
  '__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
117
139
  # Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
118
140
  '__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
141
+ # Universal-server context (WPT runner)? Gates cross-origin eager frame
142
+ # building: only there is a cross-origin iframe's content served locally,
143
+ # so an ordinary app leaves cross-origin frames lazy (= baseline) and never
144
+ # eager-@app.calls a foreign URL (side effects: extra visit / log row).
145
+ '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
119
146
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
120
147
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
121
148
  # WebAuthn create / get raise `WebauthnState::Error` carrying
@@ -106,18 +106,8 @@ module Capybara
106
106
  @ctx = @iso.context
107
107
  @attached = []
108
108
  @generation = 0
109
- # rusty's heap-accounting API (heap_statistics / low_memory_notification)
110
- # landed in 0.1.9. Probe the real Isolate once here — the Ctx wrapper
111
- # delegates these unconditionally, so a `respond_to?` on the wrapper
112
- # would always be true and never gate the version. Cached so the
113
- # per-visit pressure check (rule 3) is an ivar read, not a dispatch.
114
- @heap_accounting = @iso.respond_to?(:heap_statistics)
115
109
  end
116
110
 
117
- # True iff the underlying rusty Isolate exposes the 0.1.9 heap-accounting
118
- # API. Drives `relieve_heap_pressure`'s version gate.
119
- def heap_accounting? = @heap_accounting
120
-
121
111
  # ── Context surface ─────────────────────────────────────────
122
112
  # rusty drains microtasks at call-depth zero (V8's default kAuto
123
113
  # policy), so a returned eval/call has already run its end-of-script
@@ -162,8 +152,8 @@ module Capybara
162
152
  def terminate = @iso.terminate
163
153
  def dispose = @iso.dispose
164
154
  def perform_microtask_checkpoint = @iso.perform_microtask_checkpoint
165
- # rusty >= 0.1.9: V8 heap accounting + a forced full GC. Used by the
166
- # per-visit heap-pressure relief in `rebuild_ctx` (see there).
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`.
167
157
  def heap_statistics = @iso.heap_statistics
168
158
  def low_memory_notification = @iso.low_memory_notification
169
159
 
@@ -374,6 +364,10 @@ module Capybara
374
364
  # Browser's `@current_realm_id` may still point at it; the Browser uses
375
365
  # this to raise a stale-element instead of running a frame handle op
376
366
  # against the main registry.
367
+ # Ids of every live frame / window realm in this isolate (excludes the main
368
+ # realm, id 0). Used to fan a BroadcastChannel post out to sibling realms.
369
+ def frame_realm_ids = frame_realms.keys
370
+
377
371
  def frame_realm_alive?(realm_id)
378
372
  !(realm_id.nil? || realm_id.zero?) && frame_realms.key?(realm_id)
379
373
  end
@@ -410,9 +404,29 @@ module Capybara
410
404
  # nothing else would ever free them.
411
405
  def frame_realms = (@frame_realms ||= {})
412
406
 
407
+ # Parent realm id per frame realm, captured at `create_frame_realm` time
408
+ # (parallel to `@frame_realm_depths`). Lets a form/navigation that reaches a
409
+ # frame via `contentWindow` (so it never entered `within_frame` and has no
410
+ # `@frame_stack` entry) recover the realm that OWNS the iframe element, to
411
+ # rebuild + rebind it. 0/nil = the main realm.
412
+ def frame_realm_parents = (@frame_realm_parents ||= {})
413
+
414
+ def frame_realm_parent(realm_id)
415
+ return 0 if realm_id.nil? || realm_id.zero?
416
+ frame_realm_parents[realm_id] || 0
417
+ end
418
+
419
+ # Per-window-realm metadata (opener id + window.name) captured at create time so a
420
+ # window's self-navigation (which builds a fresh realm via `reload_window_realm`)
421
+ # can carry them across, the way a real popup keeps `window.opener` / `window.name`
422
+ # through its own navigation.
423
+ def window_realm_meta = (@window_realm_meta ||= {})
424
+
413
425
  def dispose_frame_realms
414
426
  @realm_module_handles&.clear
415
427
  @frame_realm_depths&.clear
428
+ @frame_realm_parents&.clear
429
+ @window_realm_meta&.clear
416
430
  return if @frame_realms.nil?
417
431
  @frame_realms.each_value {|fr| fr.dispose rescue nil }
418
432
  @frame_realms.clear
@@ -430,6 +444,18 @@ module Capybara
430
444
  create_frame_realm(ctx, url, body, content_type, parent_id)
431
445
  end
432
446
 
447
+ # Navigate a window realm (`win.location = …`): a FRESH realm for the new
448
+ # document, like reload_frame_realm — an in-place `__csimLoadDocument` on an
449
+ # already-loaded realm does NOT re-run inline scripts, but a fresh realm does.
450
+ # Returns the new realm's context id. (The opener's WindowProxy, keyed by the
451
+ # old id, goes stale across this — acceptable while no window.open test scripts
452
+ # the window after navigating it; a stable WindowProxy is future work.)
453
+ def reload_window_realm(old_id, url, body, content_type)
454
+ meta = window_realm_meta[old_id] || {}
455
+ dispose_frame_realm(old_id)
456
+ create_window_realm(url, body, content_type, opener_id: meta[:opener_id], window_name: meta[:window_name])
457
+ end
458
+
433
459
  # Tear down a single frame realm (e.g. a descendant frame destroyed when an
434
460
  # ancestor frame re-navigates). No-op for nil/0/unknown ids.
435
461
  def dispose_frame_realm(id)
@@ -439,6 +465,14 @@ module Capybara
439
465
  @browser.revoke_realm_blobs(id) rescue nil
440
466
  @realm_module_handles&.delete(id)
441
467
  @frame_realm_depths&.delete(id)
468
+ @frame_realm_parents&.delete(id)
469
+ @window_realm_meta&.delete(id)
470
+ # Evict from the main realm's child-realm set so `drainChildRealms` stops
471
+ # stepping a disposed context. A FRAME realm is also evicted via the DOM
472
+ # unregister path (its iframe element going away), but a WINDOW realm has no
473
+ # element — this is its only eviction, and it covers the reload (dispose +
474
+ # recreate) path too, where the old id would otherwise leak in the set.
475
+ ctx.eval("globalThis.__csimChildRealmIds && globalThis.__csimChildRealmIds.delete(#{id.to_i});") rescue nil
442
476
  fr = frame_realms.delete(id)
443
477
  fr.dispose rescue nil if fr
444
478
  nil
@@ -577,10 +611,10 @@ module Capybara
577
611
  # the GC itself only fires once a multi-visit spec has actually piled up
578
612
  # dead realms — measured at ~once per 25-50 iframe-heavy visits, reclaiming
579
613
  # native contexts back to 1 and the heap to baseline — so the amortized
580
- # cost is negligible while memory stays bounded. No-op on rusty < 0.1.9
581
- # (no heap_statistics) or when disabled.
614
+ # cost is negligible while memory stays bounded. No-op when disabled
615
+ # (GC_PRESSURE_MB <= 0).
582
616
  def relieve_heap_pressure
583
- return unless GC_PRESSURE_MB.positive? && @ctx.heap_accounting?
617
+ return unless GC_PRESSURE_MB.positive?
584
618
  s = @ctx.heap_statistics
585
619
  over = (s[:used_heap_size].to_i + s[:external_memory].to_i) > GC_PRESSURE_MB * 1_048_576
586
620
  if HEAP_DIAG
@@ -596,8 +630,8 @@ module Capybara
596
630
  a[:used_heap_size].to_i >> 20, a[:number_of_native_contexts].to_i))
597
631
  end
598
632
  rescue StandardError
599
- # Older rusty without the diagnostics API, or a transient read failure
600
- # heap relief is best-effort, never fail a visit over it.
633
+ # A transient read failure heap relief is best-effort, never fail a
634
+ # visit over it.
601
635
  end
602
636
 
603
637
  # Built lazily on first use, on the calling (main) thread. There is no
@@ -726,6 +760,23 @@ module Capybara
726
760
 
727
761
  def frame_realm_depths = (@frame_realm_depths ||= {})
728
762
 
763
+ # Bring a freshly-created realm context up to a runnable bridge, shared by the
764
+ # frame and window realm constructors. Re-evaling the snapshot source would
765
+ # redefine snapshot globals (e.g. the `scrollX` accessor) and throw, so only
766
+ # eval it on a bare no-snapshot dev ctx where the realm boots empty. The
767
+ # replayed `__csim_runScriptCached` / `__csim_evalEsmEntry` close over the MAIN
768
+ # ctx they were first attached to, so a realm script routing through them
769
+ # (leading-lexical, ≥64KB, or `type=module`) would run against the main
770
+ # document — rebind realm-executing variants on top, then reseed per-realm JS.
771
+ def seed_realm_bridge(realm)
772
+ has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
773
+ realm.eval(RuntimeShared.snapshot_src) unless has_bridge
774
+ attach_run_script_with_cache(realm)
775
+ attach_realm_esm_entry(realm)
776
+ reseed_realm_js(realm)
777
+ realm
778
+ end
779
+
729
780
  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)
730
781
  depth = (frame_realm_depths[parent_id] || 0) + 1
731
782
  if depth > MAX_FRAME_DEPTH
@@ -738,21 +789,9 @@ module Capybara
738
789
  # (their create_frame_realm looks up this realm's depth as their parent's),
739
790
  # so it must already be set or the nested depth undercounts and the cap
740
791
  # never trips.
741
- frame_realm_depths[realm.id] = depth
742
- # Re-evaling the snapshot source would redefine snapshot globals (e.g.
743
- # the `scrollX` accessor) and throw — re-entrantly. Only eval the
744
- # source on a bare no-snapshot dev ctx, where the realm boots empty.
745
- # Host fns are replayed onto the realm by `Ctx#create_context`.
746
- has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
747
- realm.eval(RuntimeShared.snapshot_src) unless has_bridge
748
- # The replayed `__csim_runScriptCached` / `__csim_runScriptEval` /
749
- # `__csim_evalEsmEntry` close over the context they EXECUTE in (the
750
- # main ctx) — left as-is, a frame script that routes through them
751
- # (leading-lexical, ≥64KB, or `type=module`) would run against the
752
- # PARENT realm's document. Rebind realm-executing variants on top.
753
- attach_run_script_with_cache(realm)
754
- attach_realm_esm_entry(realm)
755
- reseed_realm_js(realm)
792
+ frame_realm_depths[realm.id] = depth
793
+ frame_realm_parents[realm.id] = parent_id.to_i # owning realm, for contentWindow-reached rebuilds
794
+ seed_realm_bridge(realm)
756
795
  # Wire `parent` / `top` to the realm that owns this iframe (its context
757
796
  # id passed from `__csimFrameWindow`), BEFORE the frame's scripts run —
758
797
  # `top` propagates up the chain (the main realm's `top` is itself). A
@@ -836,6 +875,86 @@ module Capybara
836
875
  nil
837
876
  end
838
877
 
878
+ # Build a same-origin auxiliary WINDOW (window.open / open_new_window) as a
879
+ # realm in THIS isolate — like an iframe's frame realm, but top-level: `parent`
880
+ # === `top` === the window itself (the bridge default, so unlike
881
+ # create_frame_realm we don't wire them to a container), and `window.opener`
882
+ # points at the opener realm's WindowProxy. Tracked in `frame_realms` so
883
+ # realm_call / drainChildRealms / dispose_frame_realms cover it for free.
884
+ # Returns the new realm's context id; the opener-side `window.open` wraps it in
885
+ # a native `__csimFrameWindowProxyFor`, so `popup.document` is a real
886
+ # same-isolate Document (cross-window adoptNode works). The single isolate also
887
+ # makes a same-origin window far cheaper than today's isolate-per-window.
888
+ def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil)
889
+ realm = seed_realm_bridge(ctx.create_context)
890
+ # Mark it a top-level window realm so its location setter routes to
891
+ # __csimWindowRealmNavigate (reload THIS realm) rather than the frame-nav or
892
+ # top-page path — top === self here, so neither default branch fits. Also give
893
+ # it the window-lifecycle surface a popup needs but a frame realm doesn't:
894
+ # `window.closed` (flag-backed) and `window.close()` (marks closed; the realm
895
+ # lingers inert until the Browser tears the isolate down — matching a real
896
+ # closed window whose proxy stays valid and reports closed === true).
897
+ realm.eval(<<~JS)
898
+ globalThis.__csimIsWindowRealm = true;
899
+ globalThis.__csimWindowClosedFlag = false;
900
+ try {
901
+ Object.defineProperty(globalThis, 'closed', {
902
+ configurable: true,
903
+ get() { return !!globalThis.__csimWindowClosedFlag; }
904
+ });
905
+ } catch (_) {}
906
+ globalThis.close = function () { globalThis.__csimWindowClosedFlag = true; };
907
+ JS
908
+ # window.opener → a WindowProxy for the opener realm. opener_id is the opener's
909
+ # context id (0 = the main realm, a VALID opener); nil means "no opener", so the
910
+ # guard is on nil, not on 0 (0 is falsy but real here). Assigning globalThis.opener
911
+ # routes through the bridge's opener setter (stores the override the getter returns).
912
+ unless opener_id.nil?
913
+ realm.eval(<<~JS)
914
+ if (typeof globalThis.__csimFrameWindowProxyFor === 'function') {
915
+ var __op = globalThis.__csimFrameWindowProxyFor(#{opener_id.to_i});
916
+ if (__op) globalThis.opener = __op;
917
+ }
918
+ JS
919
+ end
920
+ realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
921
+ realm.call('__csimSetWindowName', window_name.to_s) unless window_name.nil?
922
+ realm.call('__csimSetDocumentOrigin', doc_origin.to_s) unless doc_origin.nil?
923
+ realm.call('__csimSetLocationOrigin', location_origin.to_s) unless location_origin.nil?
924
+ # Register the realm as alive BEFORE its document loads: its inline scripts run
925
+ # synchronously inside __csimLoadDocument and may post to a BroadcastChannel the
926
+ # opener listens on (a blob popup that posts then self.close()s). The delivery
927
+ # path gates on `frame_realm_alive?`, so the realm must already be tracked or its
928
+ # own load-time post is dropped. Mirrors create_frame_realm seeding its depth /
929
+ # parent maps before the load for the same reason.
930
+ frame_realms[realm.id] = realm
931
+ frame_realm_parents[realm.id] = 0 # top-level (no parent frame)
932
+ # Register with the opener (main) realm's child-realm set so `drainChildRealms`
933
+ # steps THIS realm's event loop too — otherwise its queued tasks (e.g. a
934
+ # BroadcastChannel delivery from a blob document) never fire.
935
+ ctx.eval("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
936
+ # Remember the window's opener / name so a self-navigation (reload_window_realm
937
+ # builds a FRESH realm) can carry them across — a real popup keeps window.opener
938
+ # and window.name through its own navigation.
939
+ window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name}
940
+ realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
941
+ realm.call('__csimFireWindowLoad') rescue nil
942
+ realm.id
943
+ rescue StandardError => e
944
+ @browser.log_console('warn', "window realm load failed: #{e.message}")
945
+ if realm
946
+ # The realm is registered (frame_realms / parents / __csimChildRealmIds)
947
+ # BEFORE the document loads, so a load-time throw must roll all of that back
948
+ # — otherwise frame_realm_alive? and drainChildRealms keep treating a
949
+ # disposed context as live. dispose_frame_realm unwinds the registries (and
950
+ # disposes if registered); the explicit dispose is the backstop for a throw
951
+ # that happened before registration.
952
+ dispose_frame_realm(realm.id)
953
+ realm.dispose rescue nil
954
+ end
955
+ nil
956
+ end
957
+
839
958
  # `target` is the context the module graph compiles + evaluates in,
840
959
  # `handles` its module-handle cache — the main ctx + `native_module_handles`
841
960
  # by default, or a frame realm + its realm-local cache (Module handles are
@@ -1199,6 +1318,12 @@ module Capybara
1199
1318
  # pending XHRs the moment the worker's queue empties. The settle
1200
1319
  # loop already polls `worker_pending?` for worker thread activity.
1201
1320
  c.attach('__setTimersActive', ->(_flag) { nil })
1321
+ # `importScripts` runs a classic script at the worker's TOP-LEVEL script scope,
1322
+ # so its top-level `const`/`let`/`class` (dispatcher.js's `const send`/`receive`)
1323
+ # join the realm's shared global lexical environment where later code sees them.
1324
+ # `(0, eval)` would block-scope them to the eval and they'd vanish. `c.eval` is
1325
+ # the top-level-script path (same as the worker's own body eval).
1326
+ c.attach('__csim_workerImportEval', ->(src) { c.eval(src.to_s); nil })
1202
1327
  c.eval('__csim_installWorkerScope();')
1203
1328
  WorkerRuntime.new(
1204
1329
  eval_fn: ->(s) { c.eval(s.to_s) },
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end