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 +4 -4
- data/README.md +71 -144
- data/lib/capybara/simulated/browser.rb +120 -45
- data/lib/capybara/simulated/js/bridge.bundle.js +93 -25
- data/lib/capybara/simulated/runtime_shared.rb +1 -1
- data/lib/capybara/simulated/v8_runtime.rb +11 -4
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b2b947ec8b6945ec441d61c70a2bf0155283696c7d8188a135c990850149f68
|
|
4
|
+
data.tar.gz: c7530db87c7b019f25db0370213c6965a4c3f44172284ef469f84e8617319291
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
##
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
388
|
-
`
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
#
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
#
|
|
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.
|
|
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
|
|
879
|
-
#
|
|
880
|
-
#
|
|
881
|
-
|
|
882
|
-
|
|
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
|
|
2109
|
-
# navigates the FRAME, not the top page.
|
|
2110
|
-
|
|
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
|
-
|
|
2115
|
-
elsif
|
|
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 =
|
|
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).
|
|
2619
|
-
|
|
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
|
-
|
|
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
|
|
3777
|
-
#
|
|
3778
|
-
#
|
|
3779
|
-
|
|
3780
|
-
|
|
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
|
|
3788
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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*(
|
|
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:
|
|
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 +=
|
|
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 === "
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|