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.
- checksums.yaml +4 -4
- data/README.md +76 -204
- data/exe/capybara-simulated +143 -0
- data/lib/capybara/simulated/browser.rb +1488 -153
- data/lib/capybara/simulated/driver.rb +260 -11
- data/lib/capybara/simulated/js/bridge.bundle.js +20801 -10479
- data/lib/capybara/simulated/js/snapshot_stubs.js +34 -0
- data/lib/capybara/simulated/minitest.rb +65 -0
- data/lib/capybara/simulated/quickjs_runtime.rb +26 -6
- data/lib/capybara/simulated/rspec.rb +32 -0
- data/lib/capybara/simulated/runtime_shared.rb +57 -6
- data/lib/capybara/simulated/trace.rb +35 -2
- data/lib/capybara/simulated/trace_persistence.rb +48 -0
- data/lib/capybara/simulated/trace_viewer.html +408 -0
- data/lib/capybara/simulated/v8_runtime.rb +317 -27
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +24 -10
- metadata +23 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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) },
|