capybara-simulated 0.1.1 → 0.2.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.
@@ -17,9 +17,13 @@ module Capybara
17
17
  @browser = driver.current_browser
18
18
  @initial_node = @browser.lookup_node(handle)
19
19
  @context_gen = @browser.context_gen
20
+ # The frame realm this handle belongs to (nil = main document). Handle
21
+ # integers are per-realm, so a main-document node and a frame node can
22
+ # share an id — `==` includes the realm to keep them distinct.
23
+ @realm_id = @browser.current_realm_id
20
24
  end
21
25
 
22
- attr_reader :handle_id, :context_gen
26
+ attr_reader :handle_id, :context_gen, :realm_id
23
27
 
24
28
  # Tick the virtual clock unconditionally on the text path. Unlike
25
29
  # `Node#[]` (attribute reads), the text readers are NOT preceded by
@@ -211,13 +215,25 @@ module Capybara
211
215
  def ==(other)
212
216
  other.is_a?(Node) &&
213
217
  other.handle_id == @handle_id &&
214
- other.context_gen == @context_gen
218
+ other.context_gen == @context_gen &&
219
+ other.realm_id == @realm_id
215
220
  end
216
221
 
217
222
  private
218
223
 
219
224
  def browser = @browser
220
- def check_stale = browser.check_stale(handle_id, @initial_node, @context_gen)
225
+ def check_stale
226
+ # Handle integers are per-realm. If the browser is no longer switched
227
+ # to this node's frame (block exited, or a different frame entered),
228
+ # routing its handle through `dom_call` would hit the wrong realm's
229
+ # registry — so treat a cross-realm access as stale, matching how real
230
+ # browsers invalidate frame element references after `switch_to_frame`.
231
+ if @realm_id != browser.current_realm_id
232
+ raise Capybara::Simulated::StaleElement,
233
+ 'element belongs to a different browsing context than the active frame'
234
+ end
235
+ browser.check_stale(handle_id, @initial_node, @context_gen)
236
+ end
221
237
  end
222
238
  end
223
239
  end
@@ -68,6 +68,16 @@ module Capybara
68
68
  '__csim_eventSourceClose' => ->(b, *a) { b.event_source_close(a[0]); nil },
69
69
  '__csim_rackFetchAsync' => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
70
70
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
71
+ # Cross-window references (window.open / opener / postMessage). Each
72
+ # window is a separate VM, so these forward to the Driver to route to
73
+ # the target window's Browser.
74
+ '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1]) },
75
+ '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
76
+ '__csimWindowLocation' => ->(b, *a) { b.window_location_of(a[0]) },
77
+ '__csimWindowSetLocation' => ->(b, *a) { b.set_window_location(a[0], a[1]); nil },
78
+ '__csimWindowClosed' => ->(b, *a) { b.window_closed?(a[0]) },
79
+ '__csimWindowClose' => ->(b, *a) { b.close_child_window(a[0]); nil },
80
+ '__csimWindowOpener' => ->(b, *_) { b.opener_handle },
71
81
  '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0]) },
72
82
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
73
83
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
@@ -26,6 +26,13 @@ begin
26
26
  # media-optimization-worker hands a 317 MB raw RGBA frame from an
27
27
  # 8900×8900 fixture through the transfer-buffer path). Match
28
28
  # Discourse's own testem flag of 4 GB so the test fits.
29
+ #
30
+ # rusty_racer >= 0.1.4 installs a near-heap-limit callback on every isolate,
31
+ # so EXCEEDING this cap raises a catchable `RustyRacer::V8OutOfMemoryError`
32
+ # (and the isolate recovers) instead of V8 aborting the whole process with a
33
+ # fatal "Reached heap limit". So this value doubles as the memory backstop: a
34
+ # runaway script fails one example, it doesn't kill the run. No per-isolate
35
+ # `memory_limit` needed — the default protection catches at the cap.
29
36
  max_old_mb = (ENV['CSIM_V8_MAX_OLD_SPACE_MB'] || '4096').to_i
30
37
  RustyRacer::Platform.set_flags!('max-old-space-size': max_old_mb) if max_old_mb > 0
31
38
  # `CSIM_V8_PROF=1` turns on V8's tick-sampling profiler. Output
@@ -331,6 +338,32 @@ module Capybara
331
338
  result
332
339
  end
333
340
 
341
+ # Route a host-fn call into a specific frame realm's context — or the
342
+ # main context when `realm_id` is nil/0. Each frame realm is a full
343
+ # bridge with its OWN handle registry + `document`, so a node / query
344
+ # op on a frame node (a `within_frame` body) must execute in that
345
+ # realm; running it in the main context would dereference the handle
346
+ # against the wrong registry. Callers (`Browser#dom_call`) gate on
347
+ # `frame_realm_alive?` first, so a disposed realm surfaces as a stale
348
+ # element rather than silently mis-resolving against the main registry.
349
+ def realm_call(realm_id, name, *args)
350
+ return call(name, *args) if realm_id.nil? || realm_id.zero?
351
+ fr = frame_realms[realm_id]
352
+ return call(name, *args) unless fr
353
+ result = fr.call(name, *args)
354
+ ScriptCache.warm_pending!
355
+ result
356
+ end
357
+
358
+ # Is `realm_id` a live frame realm? A frame removed / re-navigated
359
+ # mid-block disposes its realm (`__csim_disposeFrameRealm`) while the
360
+ # Browser's `@current_realm_id` may still point at it; the Browser uses
361
+ # this to raise a stale-element instead of running a frame handle op
362
+ # against the main registry.
363
+ def frame_realm_alive?(realm_id)
364
+ !(realm_id.nil? || realm_id.zero?) && frame_realms.key?(realm_id)
365
+ end
366
+
334
367
  # bridge.js owns the virtual clock; Ruby still drives it because
335
368
  # Capybara's polling cadence is wall-clock-anchored. Use `call`
336
369
  # (function reference) rather than `eval` (string compile) — the
@@ -370,6 +403,21 @@ module Capybara
370
403
  @frame_realms.clear
371
404
  end
372
405
 
406
+ # Frame-scoped navigation: tear down the realm `old_id` and build a fresh
407
+ # one for the same `<iframe>` from the just-fetched document, returning the
408
+ # new realm's context id. A new context (not an in-place document reset) is
409
+ # the right model — it drops the prior frame document's timers / listeners
410
+ # / module state, exactly like the main page's per-visit rebuild. `parent_id`
411
+ # keeps the new realm's `parent`/`top` wired to the owning realm. The
412
+ # Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
413
+ def reload_frame_realm(old_id, parent_id, url, body, content_type)
414
+ if (fr = frame_realms.delete(old_id))
415
+ @realm_module_handles&.delete(old_id)
416
+ fr.dispose rescue nil
417
+ end
418
+ create_frame_realm(ctx, url, body, content_type, parent_id)
419
+ end
420
+
373
421
  # One native microtask checkpoint — a checkpoint runs the queue until
374
422
  # empty, and rusty already performs one at the end of every top-level
375
423
  # eval/call (V8's default kAuto policy), so a single explicit checkpoint
@@ -536,15 +584,16 @@ module Capybara
536
584
  attach_frame_realm_loader(c)
537
585
  end
538
586
 
539
- # The bridge calls `__csim_createFrameRealm(url, body, contentType)` (from
540
- # `iframe.contentWindow`'s getter) to spin up a real per-iframe realm. This
587
+ # The bridge calls `__csim_createFrameRealm(url, body, contentType, parentId)`
588
+ # (from `iframe.contentWindow`'s getter) to spin up a real per-iframe realm
589
+ # whose `parent`/`top` point at the realm `parentId` identifies. This
541
590
  # runs re-entrantly inside the main ctx's eval — rusty services nested
542
591
  # requests while a host callback is in flight. Returns the realm's context
543
592
  # id (or nil on failure — then the bridge keeps its same-realm fallback).
544
593
  # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
545
594
  def attach_frame_realm_loader(c)
546
- c.attach('__csim_createFrameRealm', ->(url, body, content_type) {
547
- RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type) }
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) }
548
597
  })
549
598
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
550
599
  # the bridge calls this to tear down the superseded one so it doesn't
@@ -564,7 +613,7 @@ module Capybara
564
613
  # state, point it at its own URL with the top frame as parent/top, then
565
614
  # load its document (running its scripts in the realm). Tracked for
566
615
  # event-loop draining + teardown.
567
- def create_frame_realm(parent_ctx, url, body, content_type)
616
+ def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0)
568
617
  realm = parent_ctx.create_context
569
618
  # Re-evaling the snapshot source would redefine snapshot globals (e.g.
570
619
  # the `scrollX` accessor) and throw — re-entrantly. Only eval the
@@ -580,12 +629,18 @@ module Capybara
580
629
  attach_run_script_with_cache(realm)
581
630
  attach_realm_esm_entry(realm)
582
631
  reseed_realm_js(realm)
583
- # Wire parent/top to the main realm (context id 0). No user data in
584
- # this eval only the literal namespace.
632
+ # Wire `parent` / `top` to the realm that owns this iframe (its context
633
+ # id passed from `__csimFrameWindow`), BEFORE the frame's scripts run —
634
+ # `top` propagates up the chain (the main realm's `top` is itself). A
635
+ # nested frame thus reaches its TRUE parent, not unconditionally the
636
+ # main frame. `parent_id` is an integer the marshaller carries verbatim.
585
637
  realm.eval(<<~JS)
586
638
  if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
587
- var __topWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(0);
588
- globalThis.parent = __topWin; globalThis.top = __topWin;
639
+ var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
640
+ if (__parentWin) {
641
+ globalThis.parent = __parentWin;
642
+ globalThis.top = __parentWin.top || __parentWin;
643
+ }
589
644
  }
590
645
  JS
591
646
  # Pass the URL + document body as call ARGUMENTS, not interpolated into
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.1.1'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-simulated
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima