capybara-simulated 0.4.0 → 0.5.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 +37 -5
- data/exe/capybara-simulated +143 -0
- data/lib/capybara/simulated/browser.rb +570 -53
- data/lib/capybara/simulated/driver.rb +52 -6
- data/lib/capybara/simulated/js/bridge.bundle.js +11575 -5716
- 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 +9 -0
- data/lib/capybara/simulated/rspec.rb +32 -0
- data/lib/capybara/simulated/runtime_shared.rb +26 -2
- 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 +182 -17
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +21 -10
- metadata +23 -3
|
@@ -106,8 +106,18 @@ 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)
|
|
109
115
|
end
|
|
110
116
|
|
|
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
|
+
|
|
111
121
|
# ── Context surface ─────────────────────────────────────────
|
|
112
122
|
# rusty drains microtasks at call-depth zero (V8's default kAuto
|
|
113
123
|
# policy), so a returned eval/call has already run its end-of-script
|
|
@@ -152,6 +162,10 @@ module Capybara
|
|
|
152
162
|
def terminate = @iso.terminate
|
|
153
163
|
def dispose = @iso.dispose
|
|
154
164
|
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).
|
|
167
|
+
def heap_statistics = @iso.heap_statistics
|
|
168
|
+
def low_memory_notification = @iso.low_memory_notification
|
|
155
169
|
|
|
156
170
|
def dynamic_import_resolver=(prc)
|
|
157
171
|
@iso.dynamic_import_resolver = prc
|
|
@@ -398,6 +412,7 @@ module Capybara
|
|
|
398
412
|
|
|
399
413
|
def dispose_frame_realms
|
|
400
414
|
@realm_module_handles&.clear
|
|
415
|
+
@frame_realm_depths&.clear
|
|
401
416
|
return if @frame_realms.nil?
|
|
402
417
|
@frame_realms.each_value {|fr| fr.dispose rescue nil }
|
|
403
418
|
@frame_realms.clear
|
|
@@ -419,7 +434,11 @@ module Capybara
|
|
|
419
434
|
# ancestor frame re-navigates). No-op for nil/0/unknown ids.
|
|
420
435
|
def dispose_frame_realm(id)
|
|
421
436
|
return if id.nil? || id.zero?
|
|
437
|
+
# The frame's browsing context is going away — revoke the blob URLs it
|
|
438
|
+
# created (url-lifetime "Removing an iframe").
|
|
439
|
+
@browser.revoke_realm_blobs(id) rescue nil
|
|
422
440
|
@realm_module_handles&.delete(id)
|
|
441
|
+
@frame_realm_depths&.delete(id)
|
|
423
442
|
fr = frame_realms.delete(id)
|
|
424
443
|
fr.dispose rescue nil if fr
|
|
425
444
|
nil
|
|
@@ -499,6 +518,16 @@ module Capybara
|
|
|
499
518
|
if @ctx
|
|
500
519
|
begin
|
|
501
520
|
@ctx.reset
|
|
521
|
+
# `@ctx.reset` swaps in a snapshot-fresh realm and `dispose_frame_realms`
|
|
522
|
+
# above tore down the visit's iframe realms — but V8 keeps those dead
|
|
523
|
+
# contexts as RECLAIMABLE GARBAGE: a per-frame realm (within_frame /
|
|
524
|
+
# `Isolate#create_context`) is a large GC root that V8's incremental
|
|
525
|
+
# GC won't collect between visits. Over a long run (esp. iframe-heavy
|
|
526
|
+
# pages) they pile toward the old-space cap, where the near-heap-limit
|
|
527
|
+
# GC thrashes instead of reclaiming. A full GC under pressure drops the
|
|
528
|
+
# used heap back to baseline (measured: ~450 MB -> ~150 MB, native
|
|
529
|
+
# contexts N -> 1). See `relieve_heap_pressure`.
|
|
530
|
+
relieve_heap_pressure
|
|
502
531
|
attach_host_fns(@ctx)
|
|
503
532
|
@ctx.eval('__csim_installWorker();')
|
|
504
533
|
return @ctx
|
|
@@ -520,14 +549,7 @@ module Capybara
|
|
|
520
549
|
# `ctx`), which rusty_racer's thread-confined isolates require. This
|
|
521
550
|
# cold path is only the rare reset-failure fallback, so the inline
|
|
522
551
|
# 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
|
|
552
|
+
dispose_ctx(old) if old
|
|
531
553
|
@ctx = build_and_track_ctx
|
|
532
554
|
end
|
|
533
555
|
|
|
@@ -536,6 +558,48 @@ module Capybara
|
|
|
536
558
|
# path is the same operation.
|
|
537
559
|
def reset_page = rebuild_ctx
|
|
538
560
|
|
|
561
|
+
# Memory-pressure threshold (MB) above which `rebuild_ctx` forces a full
|
|
562
|
+
# GC to reclaim dead per-frame realms (see the call site). Measured
|
|
563
|
+
# against used heap **+ external** (ArrayBuffer backing stores, image
|
|
564
|
+
# pixel buffers) — `used_heap_size` alone misses the external component,
|
|
565
|
+
# which on image-heavy specs is the bulk of the footprint. Default 1 GB:
|
|
566
|
+
# far above a normal single visit (~200-500 MB) so ordinary specs never
|
|
567
|
+
# trigger it, far below the 4 GB old-space cap so a multi-visit spec
|
|
568
|
+
# reclaims long before the near-heap-limit GC would thrash. `0` disables.
|
|
569
|
+
GC_PRESSURE_MB = (ENV['CSIM_V8_GC_PRESSURE_MB'] || '1024').to_i
|
|
570
|
+
|
|
571
|
+
# Opt-in heap accounting on every rebuild (CSIM_HEAP_DIAG). Resolved once
|
|
572
|
+
# at load (rule 3: don't re-read env per call); zero cost when off.
|
|
573
|
+
HEAP_DIAG = !ENV['CSIM_HEAP_DIAG'].nil?
|
|
574
|
+
|
|
575
|
+
# Forced full GC when V8-managed memory (used heap + external) crosses
|
|
576
|
+
# GC_PRESSURE_MB. The stat read is cheap (a counter snapshot every visit);
|
|
577
|
+
# the GC itself only fires once a multi-visit spec has actually piled up
|
|
578
|
+
# dead realms — measured at ~once per 25-50 iframe-heavy visits, reclaiming
|
|
579
|
+
# 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.
|
|
582
|
+
def relieve_heap_pressure
|
|
583
|
+
return unless GC_PRESSURE_MB.positive? && @ctx.heap_accounting?
|
|
584
|
+
s = @ctx.heap_statistics
|
|
585
|
+
over = (s[:used_heap_size].to_i + s[:external_memory].to_i) > GC_PRESSURE_MB * 1_048_576
|
|
586
|
+
if HEAP_DIAG
|
|
587
|
+
warn(format('[csim heap] used=%dMB ext=%dMB native_ctx=%d over=%s',
|
|
588
|
+
s[:used_heap_size].to_i >> 20, s[:external_memory].to_i >> 20,
|
|
589
|
+
s[:number_of_native_contexts].to_i, over))
|
|
590
|
+
end
|
|
591
|
+
return unless over
|
|
592
|
+
@ctx.low_memory_notification
|
|
593
|
+
if HEAP_DIAG
|
|
594
|
+
a = @ctx.heap_statistics
|
|
595
|
+
warn(format('[csim heap] -> after GC used=%dMB native_ctx=%d',
|
|
596
|
+
a[:used_heap_size].to_i >> 20, a[:number_of_native_contexts].to_i))
|
|
597
|
+
end
|
|
598
|
+
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.
|
|
601
|
+
end
|
|
602
|
+
|
|
539
603
|
# Built lazily on first use, on the calling (main) thread. There is no
|
|
540
604
|
# pool / background pre-warm: under warm-compile the steady-state visit
|
|
541
605
|
# reuses this one isolate via `Context#reset` (rebuild_ctx) and never
|
|
@@ -545,6 +609,7 @@ module Capybara
|
|
|
545
609
|
# every isolate confined to one thread for its whole life. The one-time
|
|
546
610
|
# synchronous build is ~3 ms.
|
|
547
611
|
def ctx
|
|
612
|
+
return @ctx if @disposed # don't resurrect a disposed runtime (closed window)
|
|
548
613
|
@ctx ||= build_and_track_ctx
|
|
549
614
|
end
|
|
550
615
|
|
|
@@ -555,11 +620,46 @@ module Capybara
|
|
|
555
620
|
c
|
|
556
621
|
end
|
|
557
622
|
|
|
623
|
+
# Terminate + dispose a tracked isolate and drop it from the at-exit
|
|
624
|
+
# `@@live` registry. Dispose FIRST and de-register only on success: if
|
|
625
|
+
# `dispose` raises (rescued), the isolate stays in `@@live` so the at_exit
|
|
626
|
+
# sweep retries it instead of leaking it un-disposed. Shared by the
|
|
627
|
+
# cold-rebuild fallback (`rebuild_ctx`) and `#dispose`.
|
|
628
|
+
def dispose_ctx(c)
|
|
629
|
+
return unless c
|
|
630
|
+
c.terminate rescue nil
|
|
631
|
+
c.dispose
|
|
632
|
+
@@live_lock.synchronize { @@live.delete(c) }
|
|
633
|
+
rescue StandardError
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Tear this runtime's isolate down for good. Each auxiliary window
|
|
637
|
+
# (`window.open` / a switched-into `target=_blank`) is its own Browser +
|
|
638
|
+
# V8Runtime + isolate; without this, closing the window reaped its
|
|
639
|
+
# background threads (Browser#dispose) but left the isolate ALIVE — the
|
|
640
|
+
# `@@live` at-exit registry holds a strong reference, so a bare GC never
|
|
641
|
+
# reclaimed it. Over a long suite those isolates (and their RSS)
|
|
642
|
+
# accumulated (measured: V8 isolate count 2 → 10, RSS ~2.7 → 6.6 GB across
|
|
643
|
+
# the Discourse suite). Idempotent; only ever called on teardown
|
|
644
|
+
# (Browser#dispose) — never on the per-test `reset_page` path, which
|
|
645
|
+
# reuses the isolate via `Context#reset`. The `ctx` getter stops rebuilding
|
|
646
|
+
# once `@disposed`, so a stray post-close call can't resurrect the isolate.
|
|
647
|
+
def dispose
|
|
648
|
+
return if @disposed
|
|
649
|
+
@disposed = true
|
|
650
|
+
dispose_frame_realms rescue nil
|
|
651
|
+
c, @ctx = @ctx, nil
|
|
652
|
+
dispose_ctx(c)
|
|
653
|
+
end
|
|
654
|
+
|
|
558
655
|
# Per-call wall-clock cap (ms). Off by default. Opt in via
|
|
559
656
|
# `CSIM_V8_CALL_TIMEOUT_MS=30000` for long-running suites where an
|
|
560
657
|
# occasional JS-side infinite loop would otherwise stall the whole
|
|
561
658
|
# run; the timeout converts the hang into a
|
|
562
|
-
# `RustyRacer::ScriptTerminatedError` on that one example
|
|
659
|
+
# `RustyRacer::ScriptTerminatedError` on that one example — whose
|
|
660
|
+
# `#message` / `#js_backtrace` (rusty >= 0.1.10) name the looping JS
|
|
661
|
+
# frame (function + source position), so an in-V8 hang is diagnosable
|
|
662
|
+
# from the failure alone, no live debugger attach needed. The
|
|
563
663
|
# terminate escalates through any nested frames (it is
|
|
564
664
|
# isolate-global by design), and the isolate itself stays healthy
|
|
565
665
|
# for subsequent calls — csim treats a terminated call as fatal to
|
|
@@ -599,17 +699,15 @@ module Capybara
|
|
|
599
699
|
# id (or nil on failure — then the bridge keeps its same-realm fallback).
|
|
600
700
|
# The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
|
|
601
701
|
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) }
|
|
702
|
+
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) {
|
|
703
|
+
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
704
|
})
|
|
605
705
|
# Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
|
|
606
706
|
# the bridge calls this to tear down the superseded one so it doesn't
|
|
607
707
|
# linger in @frame_realms and get re-drained on every poll tick.
|
|
608
708
|
# Disposing a non-executing child realm mid-callback is safe.
|
|
609
709
|
c.attach('__csim_disposeFrameRealm', ->(id) {
|
|
610
|
-
|
|
611
|
-
fr = frame_realms.delete(id)
|
|
612
|
-
fr.dispose rescue nil if fr
|
|
710
|
+
dispose_frame_realm(id) # also revokes the realm's blob URLs
|
|
613
711
|
nil
|
|
614
712
|
})
|
|
615
713
|
end
|
|
@@ -620,8 +718,27 @@ module Capybara
|
|
|
620
718
|
# state, point it at its own URL with the top frame as parent/top, then
|
|
621
719
|
# load its document (running its scripts in the realm). Tracked for
|
|
622
720
|
# event-loop draining + teardown.
|
|
623
|
-
|
|
721
|
+
# Browsers cap nested browsing-context depth; with eager frame building a
|
|
722
|
+
# self-referential or pathologically nested iframe (`<iframe src=self>`)
|
|
723
|
+
# would otherwise build realms without bound and stall the settle loop.
|
|
724
|
+
# Depth is one more than the parent realm's (the main frame is depth 0).
|
|
725
|
+
MAX_FRAME_DEPTH = 16
|
|
726
|
+
|
|
727
|
+
def frame_realm_depths = (@frame_realm_depths ||= {})
|
|
728
|
+
|
|
729
|
+
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
|
+
depth = (frame_realm_depths[parent_id] || 0) + 1
|
|
731
|
+
if depth > MAX_FRAME_DEPTH
|
|
732
|
+
@browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
|
|
733
|
+
return nil
|
|
734
|
+
end
|
|
624
735
|
realm = parent_ctx.create_context
|
|
736
|
+
# Record depth BEFORE loading the document: the frame's own scripts run
|
|
737
|
+
# during __csimLoadDocument below and may synchronously build NESTED frames
|
|
738
|
+
# (their create_frame_realm looks up this realm's depth as their parent's),
|
|
739
|
+
# so it must already be set or the nested depth undercounts and the cap
|
|
740
|
+
# never trips.
|
|
741
|
+
frame_realm_depths[realm.id] = depth
|
|
625
742
|
# Re-evaling the snapshot source would redefine snapshot globals (e.g.
|
|
626
743
|
# the `scrollX` accessor) and throw — re-entrantly. Only eval the
|
|
627
744
|
# source on a bare no-snapshot dev ctx, where the realm boots empty.
|
|
@@ -645,8 +762,22 @@ module Capybara
|
|
|
645
762
|
if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
|
|
646
763
|
var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
|
|
647
764
|
if (__parentWin) {
|
|
648
|
-
|
|
649
|
-
|
|
765
|
+
// Expose `parent`/`top` as THIS realm's WindowProxy for them (not the
|
|
766
|
+
// raw parent global) so `e.source === parent` holds and a frame's
|
|
767
|
+
// `parent.postMessage(...)` attributes the sender. `top` resolves
|
|
768
|
+
// through the parent's own top (already a proxy if the parent is a
|
|
769
|
+
// frame), unwrapped to its raw global then re-proxied for this realm.
|
|
770
|
+
var __NS = globalThis.#{HOST_NAMESPACE_NAME};
|
|
771
|
+
var __pf = globalThis.__csimFrameWindowProxyFor;
|
|
772
|
+
if (__pf && __NS && typeof __NS.contextOf === 'function') {
|
|
773
|
+
var __topRaw = __parentWin.top || __parentWin;
|
|
774
|
+
if (__topRaw && __topRaw.__csimRawWindow) __topRaw = __topRaw.__csimRawWindow;
|
|
775
|
+
globalThis.parent = __pf(__NS.contextOf(__parentWin)) || __parentWin;
|
|
776
|
+
globalThis.top = __pf(__NS.contextOf(__topRaw)) || __topRaw;
|
|
777
|
+
} else {
|
|
778
|
+
globalThis.parent = __parentWin;
|
|
779
|
+
globalThis.top = __parentWin.top || __parentWin;
|
|
780
|
+
}
|
|
650
781
|
}
|
|
651
782
|
}
|
|
652
783
|
JS
|
|
@@ -655,8 +786,42 @@ module Capybara
|
|
|
655
786
|
# HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
|
|
656
787
|
# JS string escaper — it mangles \a, \e, and binary bytes).
|
|
657
788
|
realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
|
|
789
|
+
# Set window.name from the container's `name` attribute BEFORE the document
|
|
790
|
+
# loads, so a frame whose load handler reads window.name to identify itself
|
|
791
|
+
# (declarative-shadow declarative-child-frame) sees it.
|
|
792
|
+
realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
|
|
793
|
+
# Seed the frame's document origin (opaque/inherited) BEFORE the document
|
|
794
|
+
# loads, so its load-time scripts read the right self.origin. nil → a
|
|
795
|
+
# real-URL frame whose origin is its own location origin.
|
|
796
|
+
realm.call('__csimSetDocumentOrigin', frame_doc_origin.to_s) unless frame_doc_origin.nil?
|
|
797
|
+
# The frame's location.origin (opaque "null" for about:blank / srcdoc /
|
|
798
|
+
# javascript:); decoupled from the location string so navigation is intact.
|
|
799
|
+
realm.call('__csimSetLocationOrigin', frame_location_origin.to_s) unless frame_location_origin.nil?
|
|
658
800
|
realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
|
|
801
|
+
# A `javascript:` URL frame: the initial empty document is now loaded and
|
|
802
|
+
# parent/top are wired, so evaluate the URL's script in the realm (global
|
|
803
|
+
# scope). Per HTML, only a STRING result navigates the frame to a new
|
|
804
|
+
# document built from it; any other result (incl. the common undefined)
|
|
805
|
+
# leaves the about:blank document, so only its side effects (e.g.
|
|
806
|
+
# `parent.foo()`) take effect. A throwing script is reported and left as a
|
|
807
|
+
# no-op rather than aborting the frame build.
|
|
808
|
+
unless js_url_source.nil?
|
|
809
|
+
begin
|
|
810
|
+
result = realm.eval(js_url_source.to_s)
|
|
811
|
+
realm.call('__csimLoadDocument', result, 'text/html') if result.is_a?(String)
|
|
812
|
+
rescue StandardError => e
|
|
813
|
+
@browser.log_console('warn', "javascript: URL frame threw: #{e.message}")
|
|
814
|
+
end
|
|
815
|
+
end
|
|
659
816
|
frame_realms[realm.id] = realm
|
|
817
|
+
# Fire the nested document's window `load`. The frame's inline scripts ran
|
|
818
|
+
# during __csimLoadDocument and registered their `window.onload` (the usual
|
|
819
|
+
# `window.onload = () => parent.postMessage(...)` a frame reports back
|
|
820
|
+
# through); without firing it, an eagerly-built frame that the parent never
|
|
821
|
+
# touches would never run its load handler. Safe if no handler is set
|
|
822
|
+
# (dispatches to an empty listener list). Guarded so a frame whose load
|
|
823
|
+
# handler throws doesn't abort the build.
|
|
824
|
+
realm.call('__csimFireWindowLoad') rescue nil
|
|
660
825
|
realm.id
|
|
661
826
|
rescue StandardError => e
|
|
662
827
|
@browser.log_console('warn', "frame realm load failed: #{e.message}")
|