capybara-simulated 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02f56bfd3215e67c4a8faa799352e0ae3c399557f928926cafcdc9dde7dd2b9c
4
- data.tar.gz: 12e88e441d93de921d0727bfae9e3728cc56d100f8e32e525e37e21a28f0e273
3
+ metadata.gz: 3b2b947ec8b6945ec441d61c70a2bf0155283696c7d8188a135c990850149f68
4
+ data.tar.gz: c7530db87c7b019f25db0370213c6965a4c3f44172284ef469f84e8617319291
5
5
  SHA512:
6
- metadata.gz: a1f22b4a5d7ffec1b33080982aa169b5b3356c014d49a399de4cf5b5a430021eec7fb1ba4d624b00769640ffdcd314e55df765afdd44ac90575c88cb137519c9
7
- data.tar.gz: 04d95fd0e7215800124d4d81de11247b814e352c0b511d550bb9bb1557a0af069c1261b52a570e82f6100cab90e07d627b18b7a02267f193dd92c4c98c92e2cd
6
+ metadata.gz: 1c3e5d6358892be026316a31531ed4ced5e66f52ac0dafcda51236977f8f94f40b5465e9653cce4ce2ad84cee0ef4d29b8fbe2d850cd94efb3f1a3ab47db3eb6
7
+ data.tar.gz: '099d9b40e65a21667a543721f26b1df60176a2651da945f175016d5d85bbd81b0296099a17bf23b2a415101387489573d6b487aecdf8255ee0a923935d0f9b10'
data/README.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  A lightweight Capybara driver that runs JavaScript against an
4
4
  in-process JS-resident DOM, with no Chrome. Forms submit through
5
- `Rack::MockRequest`, inline `<script>` and event handlers run,
6
- MutationObserver / custom elements / `<template>` / Shadow DOM /
7
- Trix / Stimulus / Turbo all work, and the Capybara DSL is unchanged.
5
+ `Rack::MockRequest`, inline `<script>` and event handlers run, and the
6
+ Capybara DSL is unchanged.
8
7
 
9
8
  The DOM lives entirely inside the JS engine — V8 via
10
9
  [rusty_racer](https://github.com/ursm/rusty_racer) or QuickJS via
@@ -37,35 +36,15 @@ visual layout:
37
36
 
38
37
  **Reach for a real browser** (Selenium / Cuprite) **when** your tests need
39
38
  what this driver doesn't simulate **by design** — there's no rendering
40
- engine or real network stack, the same ground Selenium covers via a real
41
- browser:
42
-
43
- - **Pixel layout** — `getBoundingClientRect()` returns zeros and
44
- `elementFromPoint()` isn't implemented, so visual hit-testing,
45
- coordinate drag-and-drop, and sticky-scroll math don't work.
46
- - **Real networking** `fetch` / XHR are synchronous through Rack: no
47
- streaming, no HTTP concurrency. (`EventSource` and `WebSocket` *do*
48
- work they ride real reader threads / the in-process `rack.hijack`
49
- socket; see below.)
50
- - **Screenshots**.
51
-
52
- **`within_frame` / `switch_to_frame`** work on the V8 (rusty_racer) engine:
53
- each `<iframe>` runs its own scripts in its own per-frame realm, and the DSL
54
- routes finds, reads, interactions, `evaluate_script`, and self-targeted
55
- navigation (a link or form submit inside the frame) into the active frame —
56
- nested frames included. (QuickJS keeps a same-realm fallback, so
57
- `within_frame` is V8-only.)
58
-
59
- **Multiple windows / tabs** work on both engines: each window is its own
60
- Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
61
- shared). `open_new_window` / `within_window` / `switch_to_window` /
62
- `window_opened_by` drive them, and JS `window.open` opens a real window,
63
- `window.opener` points back to the opener, and `postMessage` is delivered
64
- across windows. (`target="_blank"` defaults to no-opener, matching modern
65
- browsers; cross-window `postMessage` data is JSON-shaped, not a full
66
- structured clone.)
67
-
68
- See [Known limits](#known-limits) for the full picture.
39
+ engine: **pixel layout** (`getBoundingClientRect()` returns zeros,
40
+ `elementFromPoint()` isn't implemented, so visual hit-testing, coordinate
41
+ drag-and-drop, and sticky-scroll math don't work) and **screenshots**.
42
+
43
+ Most of the rest runs in-process — including the things that usually mean
44
+ "you need a real browser": **`within_frame`**, **multiple windows / tabs**,
45
+ **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work.
46
+ Each has constraints (JS engine, settle-timing, no layout); see
47
+ [Capabilities & limits](#capabilities--limits).
69
48
 
70
49
  ## Status
71
50
 
@@ -78,10 +57,9 @@ Mastodon / Discourse) runs its full system suite against the driver in
78
57
  [capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world)
79
58
  as an integration check.
80
59
 
81
- The remaining gaps need a real layout engine (`elementFromPoint`,
82
- truthy `getBoundingClientRect`, viewport-clip visibility, `display:
83
- contents` table edge cases) — the same set Selenium escapes via
84
- screenshots and this driver deliberately doesn't simulate.
60
+ The remaining gaps are the layout / pixel-geometry features the driver
61
+ deliberately doesn't simulate the same set Selenium escapes via
62
+ screenshots (see [Capabilities & limits](#capabilities--limits)).
85
63
 
86
64
  ## Install
87
65
 
@@ -290,21 +268,6 @@ isolate whose context is reset to a clean realm per navigation
290
268
  snapshot-loaded VM out of a small pre-warmed pool. Either way, every
291
269
  navigation lands on a clean, warm JS context near-instantly.
292
270
 
293
- **Wall time is sensitive to whether the app uses Turbo Drive**,
294
- because navigation simulates real-browser semantics:
295
-
296
- | navigation source | what happens |
297
- |---|---|
298
- | `visit(...)`, `refresh`, programmatic `location.assign` | full reload — fresh JS Context, scripts re-evaluated |
299
- | link click *with Turbo Drive loaded* | Turbo intercepts, body-swap via JS, **JS context preserved** |
300
- | link click *without Turbo Drive* | full reload (anchor default action) |
301
- | form submit *with Turbo Drive loaded* | Turbo intercepts (turbo-frame or page-level), body-swap |
302
- | form submit *without Turbo Drive* | full reload |
303
-
304
- So Turbo Drive apps stay fast even with click-heavy tests; non-Turbo
305
- apps pay full-reload cost per click — exactly mirroring what the
306
- production site does.
307
-
308
271
  ### Library snapshot policy
309
272
 
310
273
  Per visit, `<script src>`-referenced libraries (jQuery, Stimulus,
@@ -335,67 +298,64 @@ referenced page-specific DOM.
335
298
  for 2 s; the callback fires once polling has advanced the clock past
336
299
  it.
337
300
 
338
- ## Known limits
339
-
340
- - **No layout engine.** `visible?` and `Node#style` consult the CSS
341
- cascade and the inline `style` attribute, but
342
- `getBoundingClientRect()` returns zeros and `elementFromPoint()`
343
- isn't implemented. Click offsets work for fixture-style absolute /
344
- relative positioning (ancestor-summed `top`/`left`); position-via-
345
- layout (Dragula drops, sticky-header scroll math) needs a real
346
- browser.
347
- - **`:hover` / `:focus-within`-gated content** is reachable two ways:
348
- call `element.hover` explicitly (we track the most-recently-hovered
349
- element and propagate `:hover` up its chain), or rely on the
350
- candidate-chain fallback (when stateless cascade reports
351
- `display: none`, we re-evaluate with the candidate itself in the
352
- `:hover` set). Symmetric peers — N rows each with `tr:hover .icon`
353
- revealing `.icon`, queried as bare `find('.icon')` — reveal all and
354
- Capybara raises `Capybara::Ambiguous`. Scope the test (`find('tr',
355
- text: 'foo').hover` then `find('.icon')`) — also more robust
356
- against real-browser flake.
357
- - **`fetch` is synchronous-via-Rack** — HTML / JSON round-trips work
358
- but there's no real network, no streaming, no `Request#body`
359
- ReadableStream, and no concurrent requests. XHR is implemented
360
- with the same Rack pass-through.
361
- - **WebSocket** works in-process: `new WebSocket(url)` rides the
362
- `rack.hijack` socket the Rack app hijacks, with a hand-rolled RFC6455
363
- client (handshake + subprotocol negotiation, masked client frames,
364
- ping/pong, close handshake). Frames deliver as `message` events when
365
- the page next settles, like SSE. **Action Cable** works end-to-end on
366
- this: the real `@rails/actioncable` consumer connects, subscribes, and
367
- receives server broadcasts (so `turbo_stream_from` live updates are
368
- reachable) — Action Cable hijacks the connection just as csim drives
369
- it. Caveats: server pushes land at settle (not instant); the app must
370
- use the **async / in-process** Cable adapter (a real Redis adapter
371
- would need real Redis); binary frames are V8-only (QuickJS corrupts
372
- raw bytes across the host boundary — text, hence Action Cable, is fine
373
- on both). `EventSource` and Web Workers are likewise implemented.
374
- - **Screenshots and drag pixel coordinates** are out of scope by
375
- design — use Selenium / Cuprite.
376
- - **`within_frame` / `switch_to_frame`** work on the V8 engine: each
377
- `<iframe>` runs in its own per-frame realm and the DSL routes finds,
378
- reads, interactions, `evaluate_script`, and self-targeted navigation
379
- (link / form submit) into the active frame (nested frames included) — the
380
- frame's realm is rebuilt from the fetched document, leaving the top page
381
- untouched. `_top` navigates the main page; a `_parent` target from a
382
- frame nested ≥2 levels falls back to navigating the main page, and
383
- cross-origin frame locality resolves against the main origin. QuickJS has
384
- no nested browsing context, so `within_frame` raises there.
385
- - **Multiple windows / tabs** work on both engines: each window is its own
301
+ ## Capabilities & limits
302
+
303
+ Most features run in-process; the notes below are mostly "works, but…",
304
+ followed by the short list of things that need a real browser **by design**.
305
+
306
+ ### Works, with constraints
307
+
308
+ - **`within_frame` / `switch_to_frame`** (V8 engine) each `<iframe>` runs
309
+ its own scripts in its own per-frame realm; the DSL routes finds, reads,
310
+ interactions, `evaluate_script`, and navigation into the active frame,
311
+ nested frames included the target frame's realm is rebuilt from the
312
+ fetched document, the top page untouched. QuickJS has no nested browsing
313
+ context, so `within_frame` raises there.
314
+ - **Multiple windows / tabs** (both engines) each window is its own
386
315
  Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
387
- shared across windows). `open_new_window` / `within_window` /
388
- `switch_to_window` / `window_opened_by` drive them; JS `window.open` opens
389
- a real window, `window.opener` links back, and `postMessage` is routed
390
- across windows (delivered as a `message` event when the target window next
391
- settles). Caveats: `target="_blank"` opens with no opener (modern-browser
392
- no-opener default); cross-window `postMessage` data is JSON-shaped, not a
393
- full structured clone (no `DataCloneError`, `undefined`→`null`) but a
394
- buffer in the `transfer` list moves **zero-copy** (its backing store crosses
395
- isolates by token and the source is detached); and only the active window's
396
- event loop runs, so a message is delivered when you switch to its window.
397
- Window viewport APIs (`maximize` / `fullscreen` /
398
- pixel-exact `resize_to`) are no-opsno layout engine.
316
+ shared). `open_new_window` / `within_window` / `switch_to_window` /
317
+ `window_opened_by` drive them; JS `window.open` opens a real window,
318
+ `window.opener` links back, and `postMessage` crosses windows. Only the
319
+ active window's event loop runs, so a message is delivered when you switch
320
+ to its window. `target="_blank"` opens with no opener (modern-browser
321
+ default). `postMessage` carries real structured data (not a lossy JSON
322
+ hop) `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all
323
+ round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing
324
+ store by token, source detached); only bare `undefined` collapses to `null`
325
+ (Ruby has no distinct `undefined`). Window viewport APIs (`maximize` /
326
+ `fullscreen` / pixel-exact `resize_to`) are no-ops (no layout engine).
327
+ - **WebSocket + Action Cable** `new WebSocket(url)` works in-process over
328
+ the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including
329
+ subprotocol negotiation). The real `@rails/actioncable` consumer connects,
330
+ subscribes, and receives broadcasts, so `turbo_stream_from` live updates
331
+ work. Constraints: server
332
+ pushes land at settle (not instant); the Cable app must use the **async /
333
+ in-process** adapter (a real Redis adapter needs real Redis); binary frames
334
+ are V8-only (QuickJS corrupts raw bytes across the host boundary — text,
335
+ hence Action Cable, works on both engines). `EventSource` and Web Workers
336
+ are likewise real (background reader threads draining at settle).
337
+ - **`fetch` / XHR** — synchronous through Rack: HTML / JSON round-trips work,
338
+ but there's no streaming, no `Request#body` ReadableStream, and no
339
+ concurrent requests.
340
+ - **`:hover` / `:focus-within`-gated content** — reachable two ways: call
341
+ `element.hover` explicitly (we track the most-recently-hovered element and
342
+ propagate `:hover` up its chain), or rely on the candidate-chain fallback
343
+ (when the stateless cascade reports `display: none`, we re-evaluate with the
344
+ candidate itself in the `:hover` set). Symmetric peers — N rows each with
345
+ `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` —
346
+ reveal all and Capybara raises `Capybara::Ambiguous`; scope the test
347
+ (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more
348
+ robust against real-browser flake.
349
+
350
+ ### Out of scope (by design — use Selenium / Cuprite)
351
+
352
+ - **Layout / pixel geometry.** `visible?` and `Node#style` consult the CSS
353
+ cascade and the inline `style` attribute, but `getBoundingClientRect()`
354
+ returns zeros and `elementFromPoint()` isn't implemented. Click offsets work
355
+ for fixture-style absolute / relative positioning (ancestor-summed
356
+ `top`/`left`); position-via-layout (Dragula drops, sticky-header scroll math,
357
+ viewport-clip visibility) needs a real browser.
358
+ - **Screenshots.**
399
359
 
400
360
  ## Architecture
401
361
 
@@ -429,39 +389,6 @@ referenced page-specific DOM.
429
389
  `(handle_id, context_gen)` pair so a handle from a pre-rebuild
430
390
  Context can't ghost into the next one.
431
391
 
432
- ## ES modules + importmap
433
-
434
- `<script type="module">` and `<script type="importmap">` work the
435
- same way they do in a real browser: bare specifiers resolve through
436
- the importmap, relative paths resolve against the importer's URL,
437
- and every load (including dynamic `import(...)`) routes back through
438
- the in-process Rack app. No bundling step, no Node toolchain.
439
-
440
- The standard importmap-rails layout works as-is:
441
-
442
- ```erb
443
- <%= javascript_importmap_tags %>
444
- <!-- emits:
445
- <script type="importmap">{ "imports": { "application": "/assets/application-...js", ... } }</script>
446
- <script type="module">import "application"</script>
447
- -->
448
- ```
449
-
450
- ## Hotwire (Stimulus + Turbo)
451
-
452
- Stimulus and Turbo work both via UMD (classic `<script src>`) and via
453
- the standard ESM bundles imported through importmap. For
454
- importmap-rails apps, no changes are needed:
455
-
456
- ```ruby
457
- # config/importmap.rb
458
- pin '@hotwired/stimulus'
459
- pin '@hotwired/turbo'
460
- ```
461
-
462
- `window.fetch` routes through Rack, so Turbo's frame fetch and
463
- link-action POSTs round-trip the test app.
464
-
465
392
  ## License
466
393
 
467
394
  [MIT](LICENSE).
@@ -531,13 +531,14 @@ module Capybara
531
531
  # the find cache (its keys aren't realm-qualified, and a switch is rare).
532
532
  #
533
533
  # Scope: finds, reads, interactions (click/fill_in/…), evaluate_script,
534
- # and a self-targeted navigation (a link / form submit whose default
535
- # action loads a new document) all route into the frame — the frame's
536
- # realm is rebuilt from the fetched document, leaving the top page
537
- # untouched (see `navigate_frame`). Out of scope: `_top` navigates the
538
- # main page (correct), but a `_parent` target from a frame nested ≥2
539
- # levels navigates the main page rather than the intermediate frame, and
540
- # cross-origin frame locality is resolved against the main page's origin.
534
+ # and navigation (a link / form submit whose default action loads a new
535
+ # document) all route into the frame — the target frame's realm is rebuilt
536
+ # from the fetched document, leaving the top page untouched (see
537
+ # `navigate_frame` / `frame_nav_target_entry`). A `_parent`-targeted link
538
+ # or form from a frame nested ≥2 levels rebuilds the intermediate parent
539
+ # frame; `_top` (and a one-level `_parent`, whose parent is the top
540
+ # context) navigate the main page. Cross-origin frame locality is resolved
541
+ # against the main page's origin.
541
542
  def switch_to_frame(target)
542
543
  invalidate_find_cache
543
544
  case target
@@ -601,16 +602,27 @@ module Capybara
601
602
  end
602
603
 
603
604
  # Does a link/form `target` load into the CURRENT frame? Empty or `_self`
604
- # do; `_top` / `_blank` / `_parent` / a named context do not. `_top`
605
- # correctly navigates the main page (it falls through to `navigate`).
606
- # `_parent` from a frame nested ≥2 levels would ideally navigate the
607
- # intermediate parent frame, not the top page — that ancestor-targeted
608
- # case isn't modelled yet (rare); it currently navigates the main page.
605
+ # do; `_top` / `_blank` / `_parent` / a named context do not.
609
606
  def frame_self_target?(target)
610
607
  t = target.to_s.downcase
611
608
  t.empty? || t == '_self'
612
609
  end
613
610
 
611
+ # Resolve a link/form `target` to the frame stack entry its navigation
612
+ # should rebuild, or nil when it targets the top page / a new context
613
+ # (the caller then falls through to a full-page `navigate` or aux window).
614
+ # Only meaningful inside a frame (`@current_realm_id` set):
615
+ # - `''` / `_self` → the current frame.
616
+ # - `_parent` → the intermediate parent frame, but only when nested ≥2
617
+ # levels deep; at one level the parent IS the top browsing context, so
618
+ # it returns nil and the full-page path handles it (same as `_top`).
619
+ def frame_nav_target_entry(target)
620
+ return nil unless @current_realm_id
621
+ return @frame_stack.last if frame_self_target?(target)
622
+ return @frame_stack[-2] if target.to_s.downcase == '_parent' && @frame_stack.size >= 2
623
+ nil
624
+ end
625
+
614
626
  def find_css(css, context_handle = nil)
615
627
  s = css.to_s
616
628
  return find_xpath(s, context_handle) if xpath_shaped?(s)
@@ -875,13 +887,14 @@ module Capybara
875
887
  when 'navigate'
876
888
  url = action['url'].to_s
877
889
  target = action['target'].to_s
878
- # Inside a frame, a self-targeted link navigates the FRAME, not the
879
- # top page: fetch + rebuild this frame's realm. A pure-fragment link
880
- # is already handled in-realm by the frame's own location JS.
881
- if @current_realm_id && frame_self_target?(target)
882
- unless pure_fragment_navigation?(url)
890
+ # Inside a frame, a frame-targeted link (self, or `_parent` of a
891
+ # ≥2-deep frame) navigates that FRAME, not the top page: fetch +
892
+ # rebuild its realm. A self-targeted pure-fragment link is already
893
+ # handled in-realm by the frame's own location JS, so skip it.
894
+ if (frame_entry = frame_nav_target_entry(target))
895
+ unless frame_entry.equal?(@frame_stack.last) && pure_fragment_navigation?(url)
883
896
  tick_real_time
884
- navigate_frame(resolve_against_current(url, use_base: true))
897
+ navigate_frame(resolve_against_current(url, use_base: true), entry: frame_entry)
885
898
  end
886
899
  # `target="_blank"` (or any non-_self/_top/_parent name) opens
887
900
  # in a new browsing context (its own Browser/VM); the primary
@@ -2105,15 +2118,15 @@ module Capybara
2105
2118
  URI.encode_www_form(fields)
2106
2119
  end
2107
2120
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2108
- # A form submitted inside a frame whose target is the frame itself
2109
- # navigates the FRAME, not the top page.
2110
- in_frame = !!@current_realm_id && frame_self_target?(spec['target'])
2121
+ # A form submitted inside a frame whose target is that frame (self, or a
2122
+ # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
2123
+ frame_entry = frame_nav_target_entry(spec['target'])
2111
2124
  if method == 'GET'
2112
2125
  uri = URI.parse(action_url)
2113
2126
  uri.query = body unless body.empty?
2114
- in_frame ? navigate_frame(uri.to_s) : navigate(uri.to_s)
2115
- elsif in_frame
2116
- navigate_frame_post(action_url, body, content_type || enctype)
2127
+ frame_entry ? navigate_frame(uri.to_s, entry: frame_entry) : navigate(uri.to_s)
2128
+ elsif frame_entry
2129
+ navigate_frame_post(action_url, body, content_type || enctype, entry: frame_entry)
2117
2130
  else
2118
2131
  navigate_post(action_url, body, content_type || enctype)
2119
2132
  end
@@ -2126,7 +2139,7 @@ module Capybara
2126
2139
  append_multipart_part(body, boundary, name, value.to_s)
2127
2140
  end
2128
2141
  file_inputs.each do |fi|
2129
- picks = @file_picks && @file_picks[fi['handle'].to_i] || []
2142
+ picks = file_pick_paths(fi)
2130
2143
  if picks.empty?
2131
2144
  append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
2132
2145
  else
@@ -2141,6 +2154,34 @@ module Capybara
2141
2154
  {content_type: "multipart/form-data; boundary=#{boundary}", body: body}
2142
2155
  end
2143
2156
 
2157
+ # The on-disk paths backing a file input's current selection. Each
2158
+ # selected File reports its host-backed source (`handle`/`index` → the
2159
+ # `@file_picks` slot recorded at `attach_file` time); this resolves bytes
2160
+ # even when JS moved a File onto a different input (`input.files =
2161
+ # dataTransfer.files`), whose own handle was never attached to. Falls back
2162
+ # to the input's own handle for older serializer payloads.
2163
+ #
2164
+ # Only host-backed Files (from `attach_file`) resolve here; a purely
2165
+ # in-memory `new File(['bytes'], …)` assigned via JS has no `@file_picks`
2166
+ # slot, so a CLASSIC (non-Turbo) submit drops its bytes — the fetch/XHR
2167
+ # path serializes those in JS (`serializeMultipart` → `blobBytes`) and is
2168
+ # unaffected. This matches the pre-existing behaviour and covers every
2169
+ # realistic upload (host-backed file submitted through Turbo or a plain
2170
+ # form).
2171
+ def file_pick_paths(fi)
2172
+ refs = fi['files']
2173
+ if refs.is_a?(Array) && !refs.empty?
2174
+ refs.filter_map {|ref|
2175
+ handle = ref['handle']
2176
+ next if handle.nil?
2177
+ picks = @file_picks && @file_picks[handle.to_i]
2178
+ picks && picks[ref['index'].to_i]
2179
+ }
2180
+ else
2181
+ (@file_picks && @file_picks[fi['handle'].to_i]) || []
2182
+ end
2183
+ end
2184
+
2144
2185
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
2145
2186
  body << "--#{boundary}\r\n"
2146
2187
  disposition = %[form-data; name="#{name}"]
@@ -2615,10 +2656,16 @@ module Capybara
2615
2656
 
2616
2657
  # `binary` is set by the JS side (it knows whether `send` was given a
2617
2658
  # string or an ArrayBuffer/view) → opcode 0x2 vs the text 0x1. Action
2618
- # Cable is text-only (JSON). The payload's bytes are written as-is.
2619
- def ws_send(id, data, binary = false)
2659
+ # Cable is text-only (JSON). `b64` is set when the bytes arrived base64-
2660
+ # encoded (the QuickJS binary path — raw bytes ≥0x80 don't survive its
2661
+ # host boundary); decode before framing.
2662
+ def ws_send(id, data, binary = false, b64 = false)
2620
2663
  sock = @websocket_sockets[id.to_i] or return
2621
- ws_write_frame(sock, binary ? 0x2 : 0x1, data.to_s.b)
2664
+ if binary
2665
+ ws_write_frame(sock, 0x2, b64 ? Base64.decode64(data.to_s) : data.to_s.b)
2666
+ else
2667
+ ws_write_frame(sock, 0x1, data.to_s.b)
2668
+ end
2622
2669
  nil
2623
2670
  rescue StandardError
2624
2671
  nil
@@ -3566,6 +3613,12 @@ module Capybara
3566
3613
  # the page, discarding all JS state.
3567
3614
  if pure_fragment_navigation?(url)
3568
3615
  update_current_hash(url)
3616
+ elsif @current_realm_id
3617
+ # A JS-driven `location.*` from inside a `within_frame` block
3618
+ # navigates the FRAME, not the top page (same as a self-targeted
3619
+ # link/form there). Gated on the realm, so the main-page path is
3620
+ # untouched.
3621
+ navigate_frame(url)
3569
3622
  else
3570
3623
  navigate(url)
3571
3624
  end
@@ -3724,11 +3777,11 @@ module Capybara
3724
3777
  # Mirrors `navigate` / `navigate_post`'s fetch + redirect-follow but
3725
3778
  # terminates in `reload_current_frame_realm` instead of a main-page boot.
3726
3779
 
3727
- def navigate_frame(url, depth: 0)
3780
+ def navigate_frame(url, depth: 0, entry: @frame_stack.last)
3728
3781
  raise 'too many redirects' if depth > 10
3729
3782
  invalidate_find_cache
3730
3783
  if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
3731
- reload_current_frame_realm('about:blank', '', 'text/html')
3784
+ reload_current_frame_realm('about:blank', '', 'text/html', entry: entry)
3732
3785
  return
3733
3786
  end
3734
3787
  env = Rack::MockRequest.env_for(url, method: 'GET')
@@ -3738,16 +3791,16 @@ module Capybara
3738
3791
  if (loc = redirect_location(status, headers))
3739
3792
  next_url = carry_fragment(url, resolve_against_current(loc))
3740
3793
  body.close if body.respond_to?(:close)
3741
- return navigate_frame(next_url, depth: depth + 1)
3794
+ return navigate_frame(next_url, depth: depth + 1, entry: entry)
3742
3795
  end
3743
3796
  if download_response?(headers)
3744
3797
  save_downloaded_response(url, headers, body)
3745
3798
  return
3746
3799
  end
3747
- reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers))
3800
+ reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers), entry: entry)
3748
3801
  end
3749
3802
 
3750
- def navigate_frame_post(url, body, content_type, depth: 0)
3803
+ def navigate_frame_post(url, body, content_type, depth: 0, entry: @frame_stack.last)
3751
3804
  raise 'too many redirects' if depth > 10
3752
3805
  invalidate_find_cache
3753
3806
  env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
@@ -3761,31 +3814,53 @@ module Capybara
3761
3814
  resp_body.close if resp_body.respond_to?(:close)
3762
3815
  # 301/302/303 → GET; 307/308 preserve method + body (same as navigate_post).
3763
3816
  if [307, 308].include?(status)
3764
- return navigate_frame_post(next_url, body, content_type, depth: depth + 1)
3817
+ return navigate_frame_post(next_url, body, content_type, depth: depth + 1, entry: entry)
3765
3818
  else
3766
- return navigate_frame(next_url, depth: depth + 1)
3819
+ return navigate_frame(next_url, depth: depth + 1, entry: entry)
3767
3820
  end
3768
3821
  end
3769
3822
  if download_response?(headers)
3770
3823
  save_downloaded_response(url, headers, resp_body)
3771
3824
  return
3772
3825
  end
3773
- reload_current_frame_realm(url.to_s, read_rack_body(resp_body), response_content_type(headers))
3774
- end
3775
-
3776
- # Tear down the active frame's realm and rebuild it from `html`, then
3777
- # re-point the iframe element at the new realm. The iframe lives in the
3778
- # PARENT realm, so the rebind host fn runs there.
3779
- def reload_current_frame_realm(url, html, content_type)
3780
- entry = @frame_stack.last
3826
+ reload_current_frame_realm(url.to_s, read_rack_body(resp_body), response_content_type(headers), entry: entry)
3827
+ end
3828
+
3829
+ # Tear down a frame's realm and rebuild it from `html`, then re-point the
3830
+ # iframe element at the new realm. The iframe lives in the PARENT realm, so
3831
+ # the rebind host fn runs there. `entry` defaults to the active frame; a
3832
+ # `_parent`-targeted navigation passes an ancestor entry instead — every
3833
+ # frame below it in the stack is destroyed along with the ancestor's old
3834
+ # document, so we dispose those realms and leave `@current_realm_id` on the
3835
+ # (now-gone) current frame, surfacing StaleElement for the rest of the open
3836
+ # `within_frame` block. Its `ensure` pops back to `entry`, whose `realm_id`
3837
+ # we've updated to the rebuilt realm.
3838
+ #
3839
+ # Teardown reaches the realms on the entered `@frame_stack` (the ones a
3840
+ # find could route into). Like the self-nav path, descendant realms of the
3841
+ # rebuilt frame that were entered-then-popped earlier (so they no longer
3842
+ # sit on the stack) aren't disposed here — they linger, unreferenced and
3843
+ # un-stepped, until the next full-page rebuild's `dispose_frame_realms`. A
3844
+ # bounded per-test leak, only reachable by re-entering a sibling subframe
3845
+ # before an ancestor `_parent` nav; not worth a JS descendant walk on this
3846
+ # path's perf budget.
3847
+ def reload_current_frame_realm(url, html, content_type, entry: @frame_stack.last)
3781
3848
  return unless entry
3782
3849
  old_id = entry[:realm_id]
3783
3850
  parent = entry[:parent_realm_id]
3784
3851
  new_id = @runtime.reload_frame_realm(old_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
3785
3852
  return if new_id.zero?
3786
3853
  rebind_frame_realm(parent, entry[:iframe_handle], old_id, new_id)
3787
- entry[:realm_id] = new_id
3788
- @current_realm_id = new_id
3854
+ if entry.equal?(@frame_stack.last)
3855
+ entry[:realm_id] = new_id
3856
+ @current_realm_id = new_id
3857
+ else
3858
+ # Match by object identity (the branch was chosen by `equal?`); index
3859
+ # by `==` could collide if two entries were ever structurally equal.
3860
+ idx = @frame_stack.index {|e| e.equal?(entry) }
3861
+ @frame_stack[(idx + 1)..].each {|descendant| @runtime.dispose_frame_realm(descendant[:realm_id]) }
3862
+ entry[:realm_id] = new_id
3863
+ end
3789
3864
  invalidate_find_cache
3790
3865
  settle
3791
3866
  end
@@ -1798,7 +1798,7 @@
1798
1798
  globalThis.__isVisibleNode = isVisibleNode;
1799
1799
  globalThis.__isLaidOutNode = isLaidOutNode;
1800
1800
  function selfHidden(el, ignoreVisibility = false) {
1801
- if (el._attrs.hidden != null) return true;
1801
+ const hidden = el._attrs.hidden != null;
1802
1802
  if (el._tag === "dialog" && el._attrs.open == null) return true;
1803
1803
  const inline = inlineHideDecl(el);
1804
1804
  if (inline && !state.hasImportantHideRule) {
@@ -1808,7 +1808,7 @@
1808
1808
  const visibilitySettled = ignoreVisibility || inline.visibility != null;
1809
1809
  if (displaySettled && visibilitySettled) return false;
1810
1810
  }
1811
- return matchesAnyHideRule(el, ignoreVisibility, inline);
1811
+ return matchesAnyHideRule(el, ignoreVisibility, inline, hidden);
1812
1812
  }
1813
1813
  __name(selfHidden, "selfHidden");
1814
1814
  function inlineHideDecl(el) {
@@ -2297,13 +2297,22 @@
2297
2297
  }
2298
2298
  __name(forEachCandidateRule, "forEachCandidateRule");
2299
2299
  var LAYOUT_PROPS = [...CAPTURED_PROPS, "text-transform", "white-space"];
2300
+ var IMPORTANT_RE = /\s*!\s*important\s*$/i;
2301
+ function splitImportant(value) {
2302
+ if (typeof value !== "string" || value.indexOf("!") < 0) return { value, important: false };
2303
+ return IMPORTANT_RE.test(value) ? { value: value.replace(IMPORTANT_RE, "").trim(), important: true } : { value, important: false };
2304
+ }
2305
+ __name(splitImportant, "splitImportant");
2300
2306
  function parseInlineLayout(el) {
2301
2307
  const out = {};
2302
2308
  const s = el._attrs && el._attrs.style;
2303
2309
  if (!s) return out;
2304
2310
  for (const part of String(s).split(";")) {
2305
2311
  const m = /^\s*(top|left|width|height)\s*:\s*([^;]+?)\s*$/.exec(part);
2306
- if (m) out[m[1]] = { value: m[2], important: /\s+!important\s*$/.test(m[2]) };
2312
+ if (m) {
2313
+ const d = splitImportant(m[2]);
2314
+ out[m[1]] = { value: d.value, important: d.important };
2315
+ }
2307
2316
  }
2308
2317
  return out;
2309
2318
  }
@@ -2343,8 +2352,8 @@
2343
2352
  return candSource >= current.source;
2344
2353
  }
2345
2354
  __name(winsProp, "winsProp");
2346
- function matchesAnyHideRule(el, ignoreVisibility = false, inline = null) {
2347
- if (state.hideRules.length === 0 && !inline) return false;
2355
+ function matchesAnyHideRule(el, ignoreVisibility = false, inline = null, hidden = false) {
2356
+ if (state.hideRules.length === 0 && !inline) return hidden;
2348
2357
  let bestD = null, bestV = null;
2349
2358
  if (inline) {
2350
2359
  if (inline.display != null) {
@@ -2367,6 +2376,7 @@
2367
2376
  });
2368
2377
  }
2369
2378
  if (bestD && bestD.value === "none") return true;
2379
+ if (hidden && bestD == null) return true;
2370
2380
  if (bestV && (bestV.value === "hidden" || bestV.value === "collapse")) return true;
2371
2381
  return false;
2372
2382
  }
@@ -2523,11 +2533,11 @@
2523
2533
  }
2524
2534
  __name(cascadedProperty, "cascadedProperty");
2525
2535
  function parseInlinePropertyValue(style, prop) {
2526
- const re = new RegExp("(?:^|;)\\s*" + prop + "\\s*:\\s*([^;!]+?)\\s*(?:!important)?\\s*(?:;|$)", "i");
2536
+ const re = new RegExp("(?:^|;)\\s*" + prop + "\\s*:\\s*([^;!]+?)\\s*(!\\s*important)?\\s*(?:;|$)", "i");
2527
2537
  const m = re.exec(String(style));
2528
2538
  if (!m) return null;
2529
2539
  const lower = prop === "display" || prop === "visibility" || prop === "text-transform" || prop === "white-space";
2530
- return { value: lower ? m[1].toLowerCase() : m[1], important: /!important/i.test(style) };
2540
+ return { value: lower ? m[1].toLowerCase() : m[1], important: m[2] != null };
2531
2541
  }
2532
2542
  __name(parseInlinePropertyValue, "parseInlinePropertyValue");
2533
2543
  function rulesIndexHas(prop) {
@@ -4817,6 +4827,10 @@
4817
4827
  this.lastModified = i.lastModified || Date.now();
4818
4828
  }
4819
4829
  };
4830
+ function utf8Latin1(s) {
4831
+ return bytesToLatin1(new globalThis.TextEncoder().encode(String(s)));
4832
+ }
4833
+ __name(utf8Latin1, "utf8Latin1");
4820
4834
  function serializeMultipart(formData) {
4821
4835
  const boundary = "----csimFormBoundary" + Math.random().toString(36).slice(2);
4822
4836
  let body = "";
@@ -4825,13 +4839,13 @@
4825
4839
  if (value instanceof Blob) {
4826
4840
  const filename = value.name != null ? String(value.name) : "blob";
4827
4841
  const contentType = value.type || "application/octet-stream";
4828
- body += 'Content-Disposition: form-data; name="' + key + '"; filename="' + filename + '"\r\n';
4842
+ body += 'Content-Disposition: form-data; name="' + utf8Latin1(key) + '"; filename="' + utf8Latin1(filename) + '"\r\n';
4829
4843
  body += "Content-Type: " + contentType + "\r\n\r\n";
4830
4844
  body += blobBytes(value);
4831
4845
  body += "\r\n";
4832
4846
  } else {
4833
- body += 'Content-Disposition: form-data; name="' + key + '"\r\n\r\n';
4834
- body += String(value) + "\r\n";
4847
+ body += 'Content-Disposition: form-data; name="' + utf8Latin1(key) + '"\r\n\r\n';
4848
+ body += utf8Latin1(value) + "\r\n";
4835
4849
  }
4836
4850
  });
4837
4851
  body += "--" + boundary + "--\r\n";
@@ -5487,7 +5501,8 @@
5487
5501
  get(_t, prop) {
5488
5502
  if (prop === "cssText") return el._attrs.style || "";
5489
5503
  if (prop === "getPropertyValue") return (name) => readCssProp(el, String(name));
5490
- if (prop === "setProperty") return (n, v) => writeCssProp(el, String(n), String(v));
5504
+ if (prop === "getPropertyPriority") return (name) => readCssPriority(el, String(name));
5505
+ if (prop === "setProperty") return (n, v, priority) => writeCssProp(el, String(n), String(v), priority);
5491
5506
  if (prop === "removeProperty") return (name) => removeCssProp(el, String(name));
5492
5507
  if (prop === "length") return Object.keys(parsedDecls(el)).length;
5493
5508
  if (typeof prop !== "string") return void 0;
@@ -5504,7 +5519,7 @@
5504
5519
  return true;
5505
5520
  },
5506
5521
  has(_t, prop) {
5507
- if (prop === "cssText" || prop === "getPropertyValue" || prop === "setProperty" || prop === "removeProperty" || prop === "length") return true;
5522
+ if (prop === "cssText" || prop === "getPropertyValue" || prop === "getPropertyPriority" || prop === "setProperty" || prop === "removeProperty" || prop === "length") return true;
5508
5523
  return readCssProp(el, camelToKebab(String(prop))) !== "";
5509
5524
  }
5510
5525
  };
@@ -5524,17 +5539,34 @@
5524
5539
  return el._declCache;
5525
5540
  }
5526
5541
  __name(parsedDecls, "parsedDecls");
5542
+ var IMPORTANT_SUFFIX_RE = /\s*!\s*important\s*$/i;
5543
+ function stripImportant(v) {
5544
+ if (typeof v !== "string" || v.indexOf("!") < 0) return v;
5545
+ return v.replace(IMPORTANT_SUFFIX_RE, "").trim();
5546
+ }
5547
+ __name(stripImportant, "stripImportant");
5527
5548
  function readCssProp(el, name) {
5528
5549
  const decls = parsedDecls(el);
5529
- return decls[name] != null ? decls[name] : "";
5550
+ return decls[name] != null ? stripImportant(decls[name]) : "";
5530
5551
  }
5531
5552
  __name(readCssProp, "readCssProp");
5532
- function writeCssProp(el, name, value) {
5553
+ function readCssPriority(el, name) {
5554
+ const v = parsedDecls(el)[name];
5555
+ return v != null && splitImportant(v).important ? "important" : "";
5556
+ }
5557
+ __name(readCssPriority, "readCssPriority");
5558
+ function writeCssProp(el, name, value, priority) {
5533
5559
  const decls = parseStyleDecls(el._attrs.style || "");
5534
5560
  if (value === "" || value == null) {
5535
5561
  delete decls[name];
5536
5562
  } else {
5537
- decls[name] = String(value);
5563
+ let v = String(value);
5564
+ if (/^\s*important\s*$/i.test(String(priority == null ? "" : priority))) {
5565
+ v = stripImportant(v) + " !important";
5566
+ } else if (priority != null && priority !== "") {
5567
+ return;
5568
+ }
5569
+ decls[name] = v;
5538
5570
  }
5539
5571
  el.setAttribute("style", serializeStyleDecls(decls));
5540
5572
  }
@@ -5657,10 +5689,9 @@
5657
5689
  const inlineStyle = el._attrs.style;
5658
5690
  if (inlineStyle) {
5659
5691
  const m = /(^|;|\s)display\s*:\s*([^;]+)/i.exec(inlineStyle);
5660
- if (m) return m[2].trim();
5692
+ if (m) return stripImportant(m[2].trim());
5661
5693
  }
5662
- if (el._attrs.hidden != null) return "none";
5663
- if (matchesAnyHideRule(el)) return "none";
5694
+ if (matchesAnyHideRule(el, true, null, el._attrs.hidden != null)) return "none";
5664
5695
  return DEFAULT_DISPLAY[el._tag] || "block";
5665
5696
  }
5666
5697
  __name(computedDisplayFor, "computedDisplayFor");
@@ -5668,7 +5699,7 @@
5668
5699
  const inlineStyle = el._attrs.style;
5669
5700
  if (inlineStyle) {
5670
5701
  const m = /(^|;|\s)visibility\s*:\s*([^;]+)/i.exec(inlineStyle);
5671
- if (m) return m[2].trim();
5702
+ if (m) return stripImportant(m[2].trim());
5672
5703
  }
5673
5704
  return "";
5674
5705
  }
@@ -10067,6 +10098,15 @@
10067
10098
  };
10068
10099
  return list;
10069
10100
  }
10101
+ // HTML lets you assign a `FileList` to a file input programmatically — the
10102
+ // canonical pattern is `input.files = dataTransfer.files` (drag-drop libraries,
10103
+ // and the kamalog `attach-images` Stimulus controller, do exactly this). Accept
10104
+ // any array-like of File objects; null/undefined clears the selection.
10105
+ set files(value) {
10106
+ if (this._tag !== "input") return;
10107
+ if ((this._attrs.type || "").toLowerCase() !== "file") return;
10108
+ this._files = value == null ? [] : Array.from(value);
10109
+ }
10070
10110
  get innerHTML() {
10071
10111
  return serializeChildren(this);
10072
10112
  }
@@ -14201,14 +14241,18 @@
14201
14241
  if (this.readyState === 0) throw new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError");
14202
14242
  if (this.readyState !== 1) return;
14203
14243
  if (typeof data === "string") {
14204
- globalThis.__csim_wsSend(this._id, data, false);
14244
+ globalThis.__csim_wsSend(this._id, data, false, false);
14205
14245
  return;
14206
14246
  }
14207
14247
  let bytes;
14208
14248
  if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
14209
14249
  else if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
14210
14250
  else bytes = new Uint8Array(0);
14211
- globalThis.__csim_wsSend(this._id, bytes, true);
14251
+ if (globalThis.RustyRacer) {
14252
+ globalThis.__csim_wsSend(this._id, bytes, true, false);
14253
+ } else {
14254
+ globalThis.__csim_wsSend(this._id, globalThis.btoa(bytesToLatin1(bytes)), true, true);
14255
+ }
14212
14256
  }
14213
14257
  close(code, reason) {
14214
14258
  if (this.readyState === 2 || this.readyState === 3) return;
@@ -14658,6 +14702,18 @@
14658
14702
  if (spec && Array.isArray(spec.fields)) {
14659
14703
  for (const pair of spec.fields) this._entries.push([String(pair[0]), String(pair[1])]);
14660
14704
  }
14705
+ if (spec && Array.isArray(spec.fileInputs)) {
14706
+ const File2 = globalThis.File;
14707
+ for (const fi of spec.fileInputs) {
14708
+ const el = lookup(fi.handle);
14709
+ const files = el && el.files;
14710
+ if (files && files.length) {
14711
+ for (const f of files) this._entries.push([String(fi.name), f]);
14712
+ } else if (File2) {
14713
+ this._entries.push([String(fi.name), new File2([], "", { type: "application/octet-stream" })]);
14714
+ }
14715
+ }
14716
+ }
14661
14717
  if (submitter && submitter._attrs && submitter._attrs.name) {
14662
14718
  this._entries.push([String(submitter._attrs.name), String(submitter._attrs.value || "")]);
14663
14719
  }
@@ -15686,7 +15742,12 @@
15686
15742
  continue;
15687
15743
  }
15688
15744
  if (type === "file") {
15689
- fileInputs.push({ name, handle: f._id });
15745
+ const files = (f._files || []).map((file) => ({
15746
+ name: String(file.name || ""),
15747
+ handle: file && file._csimHost ? file._handle : null,
15748
+ index: file && file._csimHost ? file._index : null
15749
+ }));
15750
+ fileInputs.push({ name, handle: f._id, files });
15690
15751
  continue;
15691
15752
  }
15692
15753
  fields.push([name, f._attrs.value != null ? f._attrs.value : ""]);
@@ -17919,7 +17980,16 @@
17919
17980
  this.dropEffect = "none";
17920
17981
  this.effectAllowed = "all";
17921
17982
  this.types = [];
17922
- this.files = [];
17983
+ }
17984
+ // `files` is the FileList view of the file-kind items — derived so it stays in
17985
+ // sync however items were added (`items.add(file)`, drag-drop construction, …).
17986
+ get files() {
17987
+ const out = [];
17988
+ for (const it of this.items) if (it.kind === "file" && it._file) out.push(it._file);
17989
+ out.item = function(i) {
17990
+ return this[i] || null;
17991
+ };
17992
+ return out;
17923
17993
  }
17924
17994
  getData(type) {
17925
17995
  for (const it of this.items) if (it.kind === "string" && it.type === type) return it._value;
@@ -19921,7 +19991,6 @@
19921
19991
  if (it.kind === "file") {
19922
19992
  const file = { name: it.name, type: "", size: 0 };
19923
19993
  dt.items.push(new globalThis.DataTransferItem("file", "application/octet-stream", null, file));
19924
- dt.files.push(file);
19925
19994
  if (!dt.types.includes("Files")) dt.types.push("Files");
19926
19995
  } else {
19927
19996
  dt.items.push(new globalThis.DataTransferItem("string", it.type, it.value, null));
@@ -19988,7 +20057,6 @@
19988
20057
  function __isTabbable(el) {
19989
20058
  if (!el || el.nodeType !== NODE_ELEMENT) return false;
19990
20059
  if (el._attrs.disabled != null) return false;
19991
- if (el._attrs.hidden != null) return false;
19992
20060
  if (selfHidden(el)) return false;
19993
20061
  const ti = el._attrs.tabindex;
19994
20062
  if (ti != null) {
@@ -67,7 +67,7 @@ module Capybara
67
67
  '__csim_eventSourceOpen' => ->(b, *a) { b.event_source_open(a[0]) },
68
68
  '__csim_eventSourceClose' => ->(b, *a) { b.event_source_close(a[0]); nil },
69
69
  '__csim_wsOpen' => ->(b, *a) { b.ws_open(a[0], a[1]) },
70
- '__csim_wsSend' => ->(b, *a) { b.ws_send(a[0], a[1], a[2]); nil },
70
+ '__csim_wsSend' => ->(b, *a) { b.ws_send(a[0], a[1], a[2], a[3]); nil },
71
71
  '__csim_wsClose' => ->(b, *a) { b.ws_close(a[0], a[1], a[2]); nil },
72
72
  '__csim_rackFetchAsync' => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
73
73
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
@@ -411,13 +411,20 @@ module Capybara
411
411
  # keeps the new realm's `parent`/`top` wired to the owning realm. The
412
412
  # Browser then re-points the iframe element at the new id (`__csimRebindFrameRealm`).
413
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
414
+ dispose_frame_realm(old_id)
418
415
  create_frame_realm(ctx, url, body, content_type, parent_id)
419
416
  end
420
417
 
418
+ # Tear down a single frame realm (e.g. a descendant frame destroyed when an
419
+ # ancestor frame re-navigates). No-op for nil/0/unknown ids.
420
+ def dispose_frame_realm(id)
421
+ return if id.nil? || id.zero?
422
+ @realm_module_handles&.delete(id)
423
+ fr = frame_realms.delete(id)
424
+ fr.dispose rescue nil if fr
425
+ nil
426
+ end
427
+
421
428
  # One native microtask checkpoint — a checkpoint runs the queue until
422
429
  # empty, and rusty already performs one at the end of every top-level
423
430
  # eval/call (V8's default kAuto policy), so a single explicit checkpoint
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima