capybara-simulated 0.4.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.
@@ -152,6 +152,10 @@ module Capybara
152
152
  def terminate = @iso.terminate
153
153
  def dispose = @iso.dispose
154
154
  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`.
157
+ def heap_statistics = @iso.heap_statistics
158
+ def low_memory_notification = @iso.low_memory_notification
155
159
 
156
160
  def dynamic_import_resolver=(prc)
157
161
  @iso.dynamic_import_resolver = prc
@@ -360,6 +364,10 @@ module Capybara
360
364
  # Browser's `@current_realm_id` may still point at it; the Browser uses
361
365
  # this to raise a stale-element instead of running a frame handle op
362
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
+
363
371
  def frame_realm_alive?(realm_id)
364
372
  !(realm_id.nil? || realm_id.zero?) && frame_realms.key?(realm_id)
365
373
  end
@@ -396,8 +404,29 @@ module Capybara
396
404
  # nothing else would ever free them.
397
405
  def frame_realms = (@frame_realms ||= {})
398
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
+
399
425
  def dispose_frame_realms
400
426
  @realm_module_handles&.clear
427
+ @frame_realm_depths&.clear
428
+ @frame_realm_parents&.clear
429
+ @window_realm_meta&.clear
401
430
  return if @frame_realms.nil?
402
431
  @frame_realms.each_value {|fr| fr.dispose rescue nil }
403
432
  @frame_realms.clear
@@ -415,11 +444,35 @@ module Capybara
415
444
  create_frame_realm(ctx, url, body, content_type, parent_id)
416
445
  end
417
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
+
418
459
  # Tear down a single frame realm (e.g. a descendant frame destroyed when an
419
460
  # ancestor frame re-navigates). No-op for nil/0/unknown ids.
420
461
  def dispose_frame_realm(id)
421
462
  return if id.nil? || id.zero?
463
+ # The frame's browsing context is going away — revoke the blob URLs it
464
+ # created (url-lifetime "Removing an iframe").
465
+ @browser.revoke_realm_blobs(id) rescue nil
422
466
  @realm_module_handles&.delete(id)
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
423
476
  fr = frame_realms.delete(id)
424
477
  fr.dispose rescue nil if fr
425
478
  nil
@@ -499,6 +552,16 @@ module Capybara
499
552
  if @ctx
500
553
  begin
501
554
  @ctx.reset
555
+ # `@ctx.reset` swaps in a snapshot-fresh realm and `dispose_frame_realms`
556
+ # above tore down the visit's iframe realms — but V8 keeps those dead
557
+ # contexts as RECLAIMABLE GARBAGE: a per-frame realm (within_frame /
558
+ # `Isolate#create_context`) is a large GC root that V8's incremental
559
+ # GC won't collect between visits. Over a long run (esp. iframe-heavy
560
+ # pages) they pile toward the old-space cap, where the near-heap-limit
561
+ # GC thrashes instead of reclaiming. A full GC under pressure drops the
562
+ # used heap back to baseline (measured: ~450 MB -> ~150 MB, native
563
+ # contexts N -> 1). See `relieve_heap_pressure`.
564
+ relieve_heap_pressure
502
565
  attach_host_fns(@ctx)
503
566
  @ctx.eval('__csim_installWorker();')
504
567
  return @ctx
@@ -520,14 +583,7 @@ module Capybara
520
583
  # `ctx`), which rusty_racer's thread-confined isolates require. This
521
584
  # cold path is only the rare reset-failure fallback, so the inline
522
585
  # teardown isn't on the steady-state path.
523
- if old
524
- @@live_lock.synchronize { @@live.delete(old) }
525
- begin
526
- old.terminate rescue nil
527
- old.dispose
528
- rescue StandardError
529
- end
530
- end
586
+ dispose_ctx(old) if old
531
587
  @ctx = build_and_track_ctx
532
588
  end
533
589
 
@@ -536,6 +592,48 @@ module Capybara
536
592
  # path is the same operation.
537
593
  def reset_page = rebuild_ctx
538
594
 
595
+ # Memory-pressure threshold (MB) above which `rebuild_ctx` forces a full
596
+ # GC to reclaim dead per-frame realms (see the call site). Measured
597
+ # against used heap **+ external** (ArrayBuffer backing stores, image
598
+ # pixel buffers) — `used_heap_size` alone misses the external component,
599
+ # which on image-heavy specs is the bulk of the footprint. Default 1 GB:
600
+ # far above a normal single visit (~200-500 MB) so ordinary specs never
601
+ # trigger it, far below the 4 GB old-space cap so a multi-visit spec
602
+ # reclaims long before the near-heap-limit GC would thrash. `0` disables.
603
+ GC_PRESSURE_MB = (ENV['CSIM_V8_GC_PRESSURE_MB'] || '1024').to_i
604
+
605
+ # Opt-in heap accounting on every rebuild (CSIM_HEAP_DIAG). Resolved once
606
+ # at load (rule 3: don't re-read env per call); zero cost when off.
607
+ HEAP_DIAG = !ENV['CSIM_HEAP_DIAG'].nil?
608
+
609
+ # Forced full GC when V8-managed memory (used heap + external) crosses
610
+ # GC_PRESSURE_MB. The stat read is cheap (a counter snapshot every visit);
611
+ # the GC itself only fires once a multi-visit spec has actually piled up
612
+ # dead realms — measured at ~once per 25-50 iframe-heavy visits, reclaiming
613
+ # native contexts back to 1 and the heap to baseline — so the amortized
614
+ # cost is negligible while memory stays bounded. No-op when disabled
615
+ # (GC_PRESSURE_MB <= 0).
616
+ def relieve_heap_pressure
617
+ return unless GC_PRESSURE_MB.positive?
618
+ s = @ctx.heap_statistics
619
+ over = (s[:used_heap_size].to_i + s[:external_memory].to_i) > GC_PRESSURE_MB * 1_048_576
620
+ if HEAP_DIAG
621
+ warn(format('[csim heap] used=%dMB ext=%dMB native_ctx=%d over=%s',
622
+ s[:used_heap_size].to_i >> 20, s[:external_memory].to_i >> 20,
623
+ s[:number_of_native_contexts].to_i, over))
624
+ end
625
+ return unless over
626
+ @ctx.low_memory_notification
627
+ if HEAP_DIAG
628
+ a = @ctx.heap_statistics
629
+ warn(format('[csim heap] -> after GC used=%dMB native_ctx=%d',
630
+ a[:used_heap_size].to_i >> 20, a[:number_of_native_contexts].to_i))
631
+ end
632
+ rescue StandardError
633
+ # A transient read failure — heap relief is best-effort, never fail a
634
+ # visit over it.
635
+ end
636
+
539
637
  # Built lazily on first use, on the calling (main) thread. There is no
540
638
  # pool / background pre-warm: under warm-compile the steady-state visit
541
639
  # reuses this one isolate via `Context#reset` (rebuild_ctx) and never
@@ -545,6 +643,7 @@ module Capybara
545
643
  # every isolate confined to one thread for its whole life. The one-time
546
644
  # synchronous build is ~3 ms.
547
645
  def ctx
646
+ return @ctx if @disposed # don't resurrect a disposed runtime (closed window)
548
647
  @ctx ||= build_and_track_ctx
549
648
  end
550
649
 
@@ -555,11 +654,46 @@ module Capybara
555
654
  c
556
655
  end
557
656
 
657
+ # Terminate + dispose a tracked isolate and drop it from the at-exit
658
+ # `@@live` registry. Dispose FIRST and de-register only on success: if
659
+ # `dispose` raises (rescued), the isolate stays in `@@live` so the at_exit
660
+ # sweep retries it instead of leaking it un-disposed. Shared by the
661
+ # cold-rebuild fallback (`rebuild_ctx`) and `#dispose`.
662
+ def dispose_ctx(c)
663
+ return unless c
664
+ c.terminate rescue nil
665
+ c.dispose
666
+ @@live_lock.synchronize { @@live.delete(c) }
667
+ rescue StandardError
668
+ end
669
+
670
+ # Tear this runtime's isolate down for good. Each auxiliary window
671
+ # (`window.open` / a switched-into `target=_blank`) is its own Browser +
672
+ # V8Runtime + isolate; without this, closing the window reaped its
673
+ # background threads (Browser#dispose) but left the isolate ALIVE — the
674
+ # `@@live` at-exit registry holds a strong reference, so a bare GC never
675
+ # reclaimed it. Over a long suite those isolates (and their RSS)
676
+ # accumulated (measured: V8 isolate count 2 → 10, RSS ~2.7 → 6.6 GB across
677
+ # the Discourse suite). Idempotent; only ever called on teardown
678
+ # (Browser#dispose) — never on the per-test `reset_page` path, which
679
+ # reuses the isolate via `Context#reset`. The `ctx` getter stops rebuilding
680
+ # once `@disposed`, so a stray post-close call can't resurrect the isolate.
681
+ def dispose
682
+ return if @disposed
683
+ @disposed = true
684
+ dispose_frame_realms rescue nil
685
+ c, @ctx = @ctx, nil
686
+ dispose_ctx(c)
687
+ end
688
+
558
689
  # Per-call wall-clock cap (ms). Off by default. Opt in via
559
690
  # `CSIM_V8_CALL_TIMEOUT_MS=30000` for long-running suites where an
560
691
  # occasional JS-side infinite loop would otherwise stall the whole
561
692
  # run; the timeout converts the hang into a
562
- # `RustyRacer::ScriptTerminatedError` on that one example. The
693
+ # `RustyRacer::ScriptTerminatedError` on that one example — whose
694
+ # `#message` / `#js_backtrace` (rusty >= 0.1.10) name the looping JS
695
+ # frame (function + source position), so an in-V8 hang is diagnosable
696
+ # from the failure alone, no live debugger attach needed. The
563
697
  # terminate escalates through any nested frames (it is
564
698
  # isolate-global by design), and the isolate itself stays healthy
565
699
  # for subsequent calls — csim treats a terminated call as fatal to
@@ -599,17 +733,15 @@ module Capybara
599
733
  # id (or nil on failure — then the bridge keeps its same-realm fallback).
600
734
  # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
601
735
  def attach_frame_realm_loader(c)
602
- c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0) {
603
- RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id) }
736
+ 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) {
737
+ RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source) }
604
738
  })
605
739
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
606
740
  # the bridge calls this to tear down the superseded one so it doesn't
607
741
  # linger in @frame_realms and get re-drained on every poll tick.
608
742
  # Disposing a non-executing child realm mid-callback is safe.
609
743
  c.attach('__csim_disposeFrameRealm', ->(id) {
610
- @realm_module_handles&.delete(id)
611
- fr = frame_realms.delete(id)
612
- fr.dispose rescue nil if fr
744
+ dispose_frame_realm(id) # also revokes the realm's blob URLs
613
745
  nil
614
746
  })
615
747
  end
@@ -620,22 +752,46 @@ module Capybara
620
752
  # state, point it at its own URL with the top frame as parent/top, then
621
753
  # load its document (running its scripts in the realm). Tracked for
622
754
  # event-loop draining + teardown.
623
- def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0)
624
- realm = parent_ctx.create_context
625
- # Re-evaling the snapshot source would redefine snapshot globals (e.g.
626
- # the `scrollX` accessor) and throw re-entrantly. Only eval the
627
- # source on a bare no-snapshot dev ctx, where the realm boots empty.
628
- # Host fns are replayed onto the realm by `Ctx#create_context`.
755
+ # Browsers cap nested browsing-context depth; with eager frame building a
756
+ # self-referential or pathologically nested iframe (`<iframe src=self>`)
757
+ # would otherwise build realms without bound and stall the settle loop.
758
+ # Depth is one more than the parent realm's (the main frame is depth 0).
759
+ MAX_FRAME_DEPTH = 16
760
+
761
+ def frame_realm_depths = (@frame_realm_depths ||= {})
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)
629
772
  has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
630
773
  realm.eval(RuntimeShared.snapshot_src) unless has_bridge
631
- # The replayed `__csim_runScriptCached` / `__csim_runScriptEval` /
632
- # `__csim_evalEsmEntry` close over the context they EXECUTE in (the
633
- # main ctx) — left as-is, a frame script that routes through them
634
- # (leading-lexical, ≥64KB, or `type=module`) would run against the
635
- # PARENT realm's document. Rebind realm-executing variants on top.
636
774
  attach_run_script_with_cache(realm)
637
775
  attach_realm_esm_entry(realm)
638
776
  reseed_realm_js(realm)
777
+ realm
778
+ end
779
+
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)
781
+ depth = (frame_realm_depths[parent_id] || 0) + 1
782
+ if depth > MAX_FRAME_DEPTH
783
+ @browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
784
+ return nil
785
+ end
786
+ realm = parent_ctx.create_context
787
+ # Record depth BEFORE loading the document: the frame's own scripts run
788
+ # during __csimLoadDocument below and may synchronously build NESTED frames
789
+ # (their create_frame_realm looks up this realm's depth as their parent's),
790
+ # so it must already be set or the nested depth undercounts and the cap
791
+ # never trips.
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)
639
795
  # Wire `parent` / `top` to the realm that owns this iframe (its context
640
796
  # id passed from `__csimFrameWindow`), BEFORE the frame's scripts run —
641
797
  # `top` propagates up the chain (the main realm's `top` is itself). A
@@ -645,8 +801,22 @@ module Capybara
645
801
  if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
646
802
  var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
647
803
  if (__parentWin) {
648
- globalThis.parent = __parentWin;
649
- globalThis.top = __parentWin.top || __parentWin;
804
+ // Expose `parent`/`top` as THIS realm's WindowProxy for them (not the
805
+ // raw parent global) so `e.source === parent` holds and a frame's
806
+ // `parent.postMessage(...)` attributes the sender. `top` resolves
807
+ // through the parent's own top (already a proxy if the parent is a
808
+ // frame), unwrapped to its raw global then re-proxied for this realm.
809
+ var __NS = globalThis.#{HOST_NAMESPACE_NAME};
810
+ var __pf = globalThis.__csimFrameWindowProxyFor;
811
+ if (__pf && __NS && typeof __NS.contextOf === 'function') {
812
+ var __topRaw = __parentWin.top || __parentWin;
813
+ if (__topRaw && __topRaw.__csimRawWindow) __topRaw = __topRaw.__csimRawWindow;
814
+ globalThis.parent = __pf(__NS.contextOf(__parentWin)) || __parentWin;
815
+ globalThis.top = __pf(__NS.contextOf(__topRaw)) || __topRaw;
816
+ } else {
817
+ globalThis.parent = __parentWin;
818
+ globalThis.top = __parentWin.top || __parentWin;
819
+ }
650
820
  }
651
821
  }
652
822
  JS
@@ -655,8 +825,42 @@ module Capybara
655
825
  # HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
656
826
  # JS string escaper — it mangles \a, \e, and binary bytes).
657
827
  realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
828
+ # Set window.name from the container's `name` attribute BEFORE the document
829
+ # loads, so a frame whose load handler reads window.name to identify itself
830
+ # (declarative-shadow declarative-child-frame) sees it.
831
+ realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
832
+ # Seed the frame's document origin (opaque/inherited) BEFORE the document
833
+ # loads, so its load-time scripts read the right self.origin. nil → a
834
+ # real-URL frame whose origin is its own location origin.
835
+ realm.call('__csimSetDocumentOrigin', frame_doc_origin.to_s) unless frame_doc_origin.nil?
836
+ # The frame's location.origin (opaque "null" for about:blank / srcdoc /
837
+ # javascript:); decoupled from the location string so navigation is intact.
838
+ realm.call('__csimSetLocationOrigin', frame_location_origin.to_s) unless frame_location_origin.nil?
658
839
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
840
+ # A `javascript:` URL frame: the initial empty document is now loaded and
841
+ # parent/top are wired, so evaluate the URL's script in the realm (global
842
+ # scope). Per HTML, only a STRING result navigates the frame to a new
843
+ # document built from it; any other result (incl. the common undefined)
844
+ # leaves the about:blank document, so only its side effects (e.g.
845
+ # `parent.foo()`) take effect. A throwing script is reported and left as a
846
+ # no-op rather than aborting the frame build.
847
+ unless js_url_source.nil?
848
+ begin
849
+ result = realm.eval(js_url_source.to_s)
850
+ realm.call('__csimLoadDocument', result, 'text/html') if result.is_a?(String)
851
+ rescue StandardError => e
852
+ @browser.log_console('warn', "javascript: URL frame threw: #{e.message}")
853
+ end
854
+ end
659
855
  frame_realms[realm.id] = realm
856
+ # Fire the nested document's window `load`. The frame's inline scripts ran
857
+ # during __csimLoadDocument and registered their `window.onload` (the usual
858
+ # `window.onload = () => parent.postMessage(...)` a frame reports back
859
+ # through); without firing it, an eagerly-built frame that the parent never
860
+ # touches would never run its load handler. Safe if no handler is set
861
+ # (dispatches to an empty listener list). Guarded so a frame whose load
862
+ # handler throws doesn't abort the build.
863
+ realm.call('__csimFireWindowLoad') rescue nil
660
864
  realm.id
661
865
  rescue StandardError => e
662
866
  @browser.log_console('warn', "frame realm load failed: #{e.message}")
@@ -671,6 +875,86 @@ module Capybara
671
875
  nil
672
876
  end
673
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
+
674
958
  # `target` is the context the module graph compiles + evaluates in,
675
959
  # `handles` its module-handle cache — the main ctx + `native_module_handles`
676
960
  # by default, or a frame realm + its realm-local cache (Module handles are
@@ -1034,6 +1318,12 @@ module Capybara
1034
1318
  # pending XHRs the moment the worker's queue empties. The settle
1035
1319
  # loop already polls `worker_pending?` for worker thread activity.
1036
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 })
1037
1327
  c.eval('__csim_installWorkerScope();')
1038
1328
  WorkerRuntime.new(
1039
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.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end