capybara-simulated 0.3.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 +108 -149
- data/exe/capybara-simulated +143 -0
- data/lib/capybara/simulated/browser.rb +690 -98
- data/lib/capybara/simulated/driver.rb +52 -6
- data/lib/capybara/simulated/js/bridge.bundle.js +12522 -6595
- 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 +27 -3
- 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 +193 -21
- 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
|
|
@@ -411,13 +426,24 @@ module Capybara
|
|
|
411
426
|
# keeps the new realm's `parent`/`top` wired to the owning realm. The
|
|
412
427
|
# Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
|
|
413
428
|
def reload_frame_realm(old_id, parent_id, url, body, content_type)
|
|
414
|
-
|
|
415
|
-
@realm_module_handles&.delete(old_id)
|
|
416
|
-
fr.dispose rescue nil
|
|
417
|
-
end
|
|
429
|
+
dispose_frame_realm(old_id)
|
|
418
430
|
create_frame_realm(ctx, url, body, content_type, parent_id)
|
|
419
431
|
end
|
|
420
432
|
|
|
433
|
+
# Tear down a single frame realm (e.g. a descendant frame destroyed when an
|
|
434
|
+
# ancestor frame re-navigates). No-op for nil/0/unknown ids.
|
|
435
|
+
def dispose_frame_realm(id)
|
|
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
|
|
440
|
+
@realm_module_handles&.delete(id)
|
|
441
|
+
@frame_realm_depths&.delete(id)
|
|
442
|
+
fr = frame_realms.delete(id)
|
|
443
|
+
fr.dispose rescue nil if fr
|
|
444
|
+
nil
|
|
445
|
+
end
|
|
446
|
+
|
|
421
447
|
# One native microtask checkpoint — a checkpoint runs the queue until
|
|
422
448
|
# empty, and rusty already performs one at the end of every top-level
|
|
423
449
|
# eval/call (V8's default kAuto policy), so a single explicit checkpoint
|
|
@@ -492,6 +518,16 @@ module Capybara
|
|
|
492
518
|
if @ctx
|
|
493
519
|
begin
|
|
494
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
|
|
495
531
|
attach_host_fns(@ctx)
|
|
496
532
|
@ctx.eval('__csim_installWorker();')
|
|
497
533
|
return @ctx
|
|
@@ -513,14 +549,7 @@ module Capybara
|
|
|
513
549
|
# `ctx`), which rusty_racer's thread-confined isolates require. This
|
|
514
550
|
# cold path is only the rare reset-failure fallback, so the inline
|
|
515
551
|
# teardown isn't on the steady-state path.
|
|
516
|
-
if old
|
|
517
|
-
@@live_lock.synchronize { @@live.delete(old) }
|
|
518
|
-
begin
|
|
519
|
-
old.terminate rescue nil
|
|
520
|
-
old.dispose
|
|
521
|
-
rescue StandardError
|
|
522
|
-
end
|
|
523
|
-
end
|
|
552
|
+
dispose_ctx(old) if old
|
|
524
553
|
@ctx = build_and_track_ctx
|
|
525
554
|
end
|
|
526
555
|
|
|
@@ -529,6 +558,48 @@ module Capybara
|
|
|
529
558
|
# path is the same operation.
|
|
530
559
|
def reset_page = rebuild_ctx
|
|
531
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
|
+
|
|
532
603
|
# Built lazily on first use, on the calling (main) thread. There is no
|
|
533
604
|
# pool / background pre-warm: under warm-compile the steady-state visit
|
|
534
605
|
# reuses this one isolate via `Context#reset` (rebuild_ctx) and never
|
|
@@ -538,6 +609,7 @@ module Capybara
|
|
|
538
609
|
# every isolate confined to one thread for its whole life. The one-time
|
|
539
610
|
# synchronous build is ~3 ms.
|
|
540
611
|
def ctx
|
|
612
|
+
return @ctx if @disposed # don't resurrect a disposed runtime (closed window)
|
|
541
613
|
@ctx ||= build_and_track_ctx
|
|
542
614
|
end
|
|
543
615
|
|
|
@@ -548,11 +620,46 @@ module Capybara
|
|
|
548
620
|
c
|
|
549
621
|
end
|
|
550
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
|
+
|
|
551
655
|
# Per-call wall-clock cap (ms). Off by default. Opt in via
|
|
552
656
|
# `CSIM_V8_CALL_TIMEOUT_MS=30000` for long-running suites where an
|
|
553
657
|
# occasional JS-side infinite loop would otherwise stall the whole
|
|
554
658
|
# run; the timeout converts the hang into a
|
|
555
|
-
# `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
|
|
556
663
|
# terminate escalates through any nested frames (it is
|
|
557
664
|
# isolate-global by design), and the isolate itself stays healthy
|
|
558
665
|
# for subsequent calls — csim treats a terminated call as fatal to
|
|
@@ -592,17 +699,15 @@ module Capybara
|
|
|
592
699
|
# id (or nil on failure — then the bridge keeps its same-realm fallback).
|
|
593
700
|
# The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
|
|
594
701
|
def attach_frame_realm_loader(c)
|
|
595
|
-
c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0) {
|
|
596
|
-
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) }
|
|
597
704
|
})
|
|
598
705
|
# Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
|
|
599
706
|
# the bridge calls this to tear down the superseded one so it doesn't
|
|
600
707
|
# linger in @frame_realms and get re-drained on every poll tick.
|
|
601
708
|
# Disposing a non-executing child realm mid-callback is safe.
|
|
602
709
|
c.attach('__csim_disposeFrameRealm', ->(id) {
|
|
603
|
-
|
|
604
|
-
fr = frame_realms.delete(id)
|
|
605
|
-
fr.dispose rescue nil if fr
|
|
710
|
+
dispose_frame_realm(id) # also revokes the realm's blob URLs
|
|
606
711
|
nil
|
|
607
712
|
})
|
|
608
713
|
end
|
|
@@ -613,8 +718,27 @@ module Capybara
|
|
|
613
718
|
# state, point it at its own URL with the top frame as parent/top, then
|
|
614
719
|
# load its document (running its scripts in the realm). Tracked for
|
|
615
720
|
# event-loop draining + teardown.
|
|
616
|
-
|
|
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
|
|
617
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
|
|
618
742
|
# Re-evaling the snapshot source would redefine snapshot globals (e.g.
|
|
619
743
|
# the `scrollX` accessor) and throw — re-entrantly. Only eval the
|
|
620
744
|
# source on a bare no-snapshot dev ctx, where the realm boots empty.
|
|
@@ -638,8 +762,22 @@ module Capybara
|
|
|
638
762
|
if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
|
|
639
763
|
var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
|
|
640
764
|
if (__parentWin) {
|
|
641
|
-
|
|
642
|
-
|
|
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
|
+
}
|
|
643
781
|
}
|
|
644
782
|
}
|
|
645
783
|
JS
|
|
@@ -648,8 +786,42 @@ module Capybara
|
|
|
648
786
|
# HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
|
|
649
787
|
# JS string escaper — it mangles \a, \e, and binary bytes).
|
|
650
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?
|
|
651
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
|
|
652
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
|
|
653
825
|
realm.id
|
|
654
826
|
rescue StandardError => e
|
|
655
827
|
@browser.log_console('warn', "frame realm load failed: #{e.message}")
|