capybara-simulated 0.2.0 → 0.3.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 +23 -7
- data/lib/capybara/simulated/browser.rb +325 -6
- data/lib/capybara/simulated/js/bridge.bundle.js +211 -21
- data/lib/capybara/simulated/runtime_shared.rb +5 -0
- 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: 02f56bfd3215e67c4a8faa799352e0ae3c399557f928926cafcdc9dde7dd2b9c
|
|
4
|
+
data.tar.gz: 12e88e441d93de921d0727bfae9e3728cc56d100f8e32e525e37e21a28f0e273
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a1f22b4a5d7ffec1b33080982aa169b5b3356c014d49a399de4cf5b5a430021eec7fb1ba4d624b00769640ffdcd314e55df765afdd44ac90575c88cb137519c9
|
|
7
|
+
data.tar.gz: 04d95fd0e7215800124d4d81de11247b814e352c0b511d550bb9bb1557a0af069c1261b52a570e82f6100cab90e07d627b18b7a02267f193dd92c4c98c92e2cd
|
data/README.md
CHANGED
|
@@ -44,7 +44,9 @@ browser:
|
|
|
44
44
|
`elementFromPoint()` isn't implemented, so visual hit-testing,
|
|
45
45
|
coordinate drag-and-drop, and sticky-scroll math don't work.
|
|
46
46
|
- **Real networking** — `fetch` / XHR are synchronous through Rack: no
|
|
47
|
-
streaming, no concurrency
|
|
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.)
|
|
48
50
|
- **Screenshots**.
|
|
49
51
|
|
|
50
52
|
**`within_frame` / `switch_to_frame`** work on the V8 (rusty_racer) engine:
|
|
@@ -356,9 +358,21 @@ referenced page-specific DOM.
|
|
|
356
358
|
but there's no real network, no streaming, no `Request#body`
|
|
357
359
|
ReadableStream, and no concurrent requests. XHR is implemented
|
|
358
360
|
with the same Rack pass-through.
|
|
359
|
-
- **WebSocket
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
362
376
|
- **`within_frame` / `switch_to_frame`** work on the V8 engine: each
|
|
363
377
|
`<iframe>` runs in its own per-frame realm and the DSL routes finds,
|
|
364
378
|
reads, interactions, `evaluate_script`, and self-targeted navigation
|
|
@@ -376,9 +390,11 @@ referenced page-specific DOM.
|
|
|
376
390
|
across windows (delivered as a `message` event when the target window next
|
|
377
391
|
settles). Caveats: `target="_blank"` opens with no opener (modern-browser
|
|
378
392
|
no-opener default); cross-window `postMessage` data is JSON-shaped, not a
|
|
379
|
-
full structured clone (no `DataCloneError`, `undefined`→`null`)
|
|
380
|
-
|
|
381
|
-
|
|
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` /
|
|
382
398
|
pixel-exact `resize_to`) are no-ops — no layout engine.
|
|
383
399
|
|
|
384
400
|
## Architecture
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'date'
|
|
5
|
+
require 'digest'
|
|
5
6
|
require 'fileutils'
|
|
6
7
|
require 'json'
|
|
7
8
|
require 'net/http'
|
|
8
9
|
require 'openssl'
|
|
9
10
|
require 'rack/mock'
|
|
11
|
+
require 'securerandom'
|
|
10
12
|
require 'socket'
|
|
11
13
|
require 'thread'
|
|
12
14
|
require 'time'
|
|
@@ -273,6 +275,23 @@ module Capybara
|
|
|
273
275
|
@event_source_seq = 0
|
|
274
276
|
@event_source_threads = {}
|
|
275
277
|
@event_source_queue = Thread::Queue.new
|
|
278
|
+
# WebSocket — per-Browser handle counter, background frame-reader
|
|
279
|
+
# threads, the csim-side socket end of each connection (for writing
|
|
280
|
+
# client→server frames), and a Queue of lifecycle / message events
|
|
281
|
+
# awaiting delivery into the VM. Same model as SSE: the reader thread
|
|
282
|
+
# does the blocking socket read; the main thread drains the Queue in
|
|
283
|
+
# `settle` and dispatches via `__csim_deliverWebSocketEvents`. The
|
|
284
|
+
# connection rides the in-process `rack.hijack` socket Action Cable
|
|
285
|
+
# (and any Rack WebSocket middleware) takes over.
|
|
286
|
+
@websocket_seq = 0
|
|
287
|
+
@websocket_threads = {}
|
|
288
|
+
@websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
|
|
289
|
+
@websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
|
|
290
|
+
@websocket_queue = Thread::Queue.new
|
|
291
|
+
# All frame writes (the reader thread's pong replies + the main thread's
|
|
292
|
+
# send/close) go through one socket; serialise them so two threads can't
|
|
293
|
+
# interleave bytes into a corrupt frame.
|
|
294
|
+
@websocket_write_lock = Mutex.new
|
|
276
295
|
# Hijacked-XHR delivery — per-Browser handle counter,
|
|
277
296
|
# background threads, and a Queue of completed responses for
|
|
278
297
|
# Rack calls where the middleware used `rack.hijack` to hold
|
|
@@ -307,6 +326,15 @@ module Capybara
|
|
|
307
326
|
@transfer_buffer_lock = Mutex.new
|
|
308
327
|
@transfer_buffers = {}
|
|
309
328
|
@transfer_buffer_seq = 0
|
|
329
|
+
# Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
|
|
330
|
+
# `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
|
|
331
|
+
# crosses isolates by token (no byte copy), its source detached. A token
|
|
332
|
+
# parked but never imported pins its backing store PROCESS-WIDE, so we
|
|
333
|
+
# record every issued token (reported from JS, possibly on a worker
|
|
334
|
+
# thread — hence the lock) and `transferDrop` the lot on `reset!`
|
|
335
|
+
# (idempotent: an already-imported token no-ops).
|
|
336
|
+
@transfer_tokens = []
|
|
337
|
+
@transfer_tokens_lock = Mutex.new
|
|
310
338
|
# Cross-window `postMessage` inbox. Another window's `target.postMessage`
|
|
311
339
|
# routes through the Driver and lands here; this window drains it into a
|
|
312
340
|
# `message` event the next time it's active and settles/ticks. Plain
|
|
@@ -689,7 +717,7 @@ module Capybara
|
|
|
689
717
|
# drain that channel, without paying an unconditional drain on
|
|
690
718
|
# timer-driven runloop pages.
|
|
691
719
|
def async_io_pending?
|
|
692
|
-
worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
720
|
+
worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
693
721
|
end
|
|
694
722
|
|
|
695
723
|
# Single-slot cache for the most recent find_xpath / find_css /
|
|
@@ -1374,8 +1402,9 @@ module Capybara
|
|
|
1374
1402
|
deliver_worker_messages
|
|
1375
1403
|
deliver_hijacked_fetches
|
|
1376
1404
|
deliver_window_messages
|
|
1405
|
+
deliver_websocket_events
|
|
1377
1406
|
break if @runtime.settle_gen > start_gen
|
|
1378
|
-
break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1407
|
+
break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1379
1408
|
# ONE event-loop step replaces the old drain_microtasks(4)+drain_timers(32)
|
|
1380
1409
|
# pair: it fires due timers, runs a per-task microtask checkpoint (so
|
|
1381
1410
|
# chained .then / MutationObserver delivery interleave spec-correctly),
|
|
@@ -1388,13 +1417,14 @@ module Capybara
|
|
|
1388
1417
|
deliver_worker_messages
|
|
1389
1418
|
deliver_hijacked_fetches
|
|
1390
1419
|
deliver_window_messages
|
|
1420
|
+
deliver_websocket_events
|
|
1391
1421
|
break if @runtime.settle_gen > start_gen
|
|
1392
1422
|
# No progress this iter (no DOM/URL change observed) — the
|
|
1393
1423
|
# remaining timers are queued for the future; bail and let
|
|
1394
1424
|
# Capybara's wall-clock-driven poll loop drive the next tick
|
|
1395
1425
|
# via `tick_real_time`. SSE / Worker channels keep us in
|
|
1396
1426
|
# the loop as long as background threads have data queued.
|
|
1397
|
-
break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending?
|
|
1427
|
+
break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending? && !websocket_pending?
|
|
1398
1428
|
prev_gen = @runtime.settle_gen
|
|
1399
1429
|
end
|
|
1400
1430
|
@find_cache_dirty = true
|
|
@@ -1886,7 +1916,7 @@ module Capybara
|
|
|
1886
1916
|
# Background-thread work (workers, EventSource, MessageBus
|
|
1887
1917
|
# long-poll) keeps the settle loop alive even when settle_gen
|
|
1888
1918
|
# is otherwise idle.
|
|
1889
|
-
return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1919
|
+
return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1890
1920
|
if @timers_active
|
|
1891
1921
|
gen = @runtime.settle_gen
|
|
1892
1922
|
if @last_polled_gen.nil? || gen != @last_polled_gen
|
|
@@ -1917,7 +1947,7 @@ module Capybara
|
|
|
1917
1947
|
# `Kernel#sleep`) and by `Playwright::Page#wait_for_timeout` to step a
|
|
1918
1948
|
# precise virtual duration.
|
|
1919
1949
|
def tick_real_time(step_ms: nil)
|
|
1920
|
-
return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1950
|
+
return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1921
1951
|
# Re-entrancy guard. Capybara's `Result#each` triggers nested
|
|
1922
1952
|
# finds (visible? per element); the outermost tick has already
|
|
1923
1953
|
# advanced the clock, the inner calls would only re-drain
|
|
@@ -1947,6 +1977,7 @@ module Capybara
|
|
|
1947
1977
|
@find_cache_dirty = true if deliver_event_source_events > 0
|
|
1948
1978
|
@find_cache_dirty = true if deliver_hijacked_fetches > 0
|
|
1949
1979
|
@find_cache_dirty = true if deliver_window_messages > 0
|
|
1980
|
+
@find_cache_dirty = true if deliver_websocket_events > 0
|
|
1950
1981
|
ensure
|
|
1951
1982
|
@ticking = false
|
|
1952
1983
|
end
|
|
@@ -1990,7 +2021,7 @@ module Capybara
|
|
|
1990
2021
|
end
|
|
1991
2022
|
# (1) Background async (cheap Ruby-side checks, no V8 crossing) we must let
|
|
1992
2023
|
# land before jumping the clock: advance one fixed step, reset the guard.
|
|
1993
|
-
if worker_pending? || event_source_pending? || hijack_fetch_pending?
|
|
2024
|
+
if worker_pending? || event_source_pending? || hijack_fetch_pending? || websocket_pending?
|
|
1994
2025
|
@ff_transient_polls = 0
|
|
1995
2026
|
return POLL_TICK_STEP_MS
|
|
1996
2027
|
end
|
|
@@ -2203,7 +2234,11 @@ module Capybara
|
|
|
2203
2234
|
reset_event_sources
|
|
2204
2235
|
reset_hijacked_fetches
|
|
2205
2236
|
reset_workers
|
|
2237
|
+
reset_websockets
|
|
2206
2238
|
@window_inbox.clear
|
|
2239
|
+
# Free any zero-copy transfer backing stores that went unimported
|
|
2240
|
+
# (worker killed before draining its inbox, etc.) before the rebuild.
|
|
2241
|
+
drop_pending_transfers
|
|
2207
2242
|
@blob_registry_lock.synchronize { @blob_registry.clear }
|
|
2208
2243
|
# Drop volatile entries from the class-level HTTP asset cache
|
|
2209
2244
|
# so test-local DB state (TranslationOverride, etc.) reaches
|
|
@@ -2220,6 +2255,23 @@ module Capybara
|
|
|
2220
2255
|
invalidate_find_cache
|
|
2221
2256
|
end
|
|
2222
2257
|
|
|
2258
|
+
# Tear down an auxiliary window's Browser when its window closes (the
|
|
2259
|
+
# Driver calls this on close_window / reset!). Releases what a bare GC of
|
|
2260
|
+
# the isolate would NOT: live background threads (worker / SSE / hijacked-
|
|
2261
|
+
# fetch / WebSocket readers) and any parked zero-copy transfer backing
|
|
2262
|
+
# stores this window issued (the transfer registry is process-wide). Runs
|
|
2263
|
+
# while the runtime is still alive so the transferDrop call lands.
|
|
2264
|
+
def dispose
|
|
2265
|
+
drop_pending_transfers
|
|
2266
|
+
reset_workers
|
|
2267
|
+
reset_event_sources
|
|
2268
|
+
reset_hijacked_fetches
|
|
2269
|
+
reset_websockets
|
|
2270
|
+
@window_inbox.clear
|
|
2271
|
+
rescue StandardError
|
|
2272
|
+
nil
|
|
2273
|
+
end
|
|
2274
|
+
|
|
2223
2275
|
# ── Host-fn callbacks invoked by bridge.js ──────────────────
|
|
2224
2276
|
|
|
2225
2277
|
def rack_fetch_body(url)
|
|
@@ -2499,6 +2551,254 @@ module Capybara
|
|
|
2499
2551
|
@event_source_queue.clear
|
|
2500
2552
|
end
|
|
2501
2553
|
|
|
2554
|
+
# ── WebSocket (RFC6455 over in-process rack.hijack) ────────────
|
|
2555
|
+
#
|
|
2556
|
+
# A real browser's WebSocket connects, upgrades, and stays open for
|
|
2557
|
+
# bidirectional framing. Action Cable (and any Rack WebSocket
|
|
2558
|
+
# middleware) handles the upgrade by HIJACKING the connection and
|
|
2559
|
+
# speaking frames over that socket — the same in-process `rack.hijack`
|
|
2560
|
+
# mechanism the long-poll path already uses, but bidirectional. So we:
|
|
2561
|
+
# 1. build the upgrade request as a Rack env (the server reads the
|
|
2562
|
+
# handshake from the env, like websocket-driver's rack helper),
|
|
2563
|
+
# 2. hand the app a `Socket.pair` end via `rack.hijack` and call it,
|
|
2564
|
+
# 3. the app writes the 101 + frames to its end; we read/write ours.
|
|
2565
|
+
# The frame reader runs on a background thread (engine access stays on
|
|
2566
|
+
# the main thread — events drain into the VM at `settle`, like SSE).
|
|
2567
|
+
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
2568
|
+
private_constant :WS_GUID
|
|
2569
|
+
|
|
2570
|
+
def ws_open(url, protocols = nil)
|
|
2571
|
+
id = (@websocket_seq += 1)
|
|
2572
|
+
# ws:// → http://, wss:// → https:// for the Rack env; resolve relative
|
|
2573
|
+
# against the current document (Action Cable's consumer builds an
|
|
2574
|
+
# absolute ws URL, but be tolerant).
|
|
2575
|
+
http_url = url.to_s.sub(/\Awss/i, 'https').sub(/\Aws/i, 'http')
|
|
2576
|
+
target = resolve_against_current(http_url)
|
|
2577
|
+
key = SecureRandom.base64(16)
|
|
2578
|
+
csim_io, app_io = Socket.pair(:UNIX, :STREAM, 0)
|
|
2579
|
+
env = Rack::MockRequest.env_for(target, method: 'GET')
|
|
2580
|
+
apply_default_request_env(env, referer: @current_url)
|
|
2581
|
+
env['HTTP_UPGRADE'] = 'websocket'
|
|
2582
|
+
env['HTTP_CONNECTION'] = 'Upgrade'
|
|
2583
|
+
env['HTTP_SEC_WEBSOCKET_KEY'] = key
|
|
2584
|
+
env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
|
|
2585
|
+
list = Array(protocols).map(&:to_s).reject(&:empty?)
|
|
2586
|
+
env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
|
|
2587
|
+
env['rack.hijack?'] = true
|
|
2588
|
+
env['rack.hijack'] = -> { app_io }
|
|
2589
|
+
env['rack.hijack_io'] = app_io
|
|
2590
|
+
# The app hijacks + writes the 101 (synchronously, or on its own event
|
|
2591
|
+
# loop thread — Action Cable handles the upgrade on a separate thread, so
|
|
2592
|
+
# `@app.call` may return before the handshake bytes appear; the reader
|
|
2593
|
+
# blocks until they do). Run it on the main thread like the long-poll
|
|
2594
|
+
# hijack so we don't race a second concurrent `@app.call`. (No handshake
|
|
2595
|
+
# timeout: a server that never writes the 101 leaks the reader+socket
|
|
2596
|
+
# until `reset_websockets` — acceptable, real servers always respond.)
|
|
2597
|
+
@app.call(env)
|
|
2598
|
+
@websocket_sockets[id] = csim_io
|
|
2599
|
+
@websocket_app_sockets[id] = app_io
|
|
2600
|
+
accept = Digest::SHA1.base64digest(key + WS_GUID)
|
|
2601
|
+
queue = @websocket_queue
|
|
2602
|
+
@websocket_threads[id] = Thread.new do
|
|
2603
|
+
Thread.current.report_on_exception = false
|
|
2604
|
+
run_websocket_reader(id, csim_io, accept, queue)
|
|
2605
|
+
end
|
|
2606
|
+
id
|
|
2607
|
+
rescue StandardError => e
|
|
2608
|
+
# Nothing was registered for cleanup yet — close both pair ends here so
|
|
2609
|
+
# a failed upgrade (mis-routed URL, app error) doesn't leak fds.
|
|
2610
|
+
csim_io.close rescue nil
|
|
2611
|
+
app_io.close rescue nil
|
|
2612
|
+
@websocket_queue << {id: id, type: '__error', message: "#{e.class}: #{e.message}"}
|
|
2613
|
+
id
|
|
2614
|
+
end
|
|
2615
|
+
|
|
2616
|
+
# `binary` is set by the JS side (it knows whether `send` was given a
|
|
2617
|
+
# 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)
|
|
2620
|
+
sock = @websocket_sockets[id.to_i] or return
|
|
2621
|
+
ws_write_frame(sock, binary ? 0x2 : 0x1, data.to_s.b)
|
|
2622
|
+
nil
|
|
2623
|
+
rescue StandardError
|
|
2624
|
+
nil
|
|
2625
|
+
end
|
|
2626
|
+
|
|
2627
|
+
def ws_close(id, code = 1000, reason = '')
|
|
2628
|
+
sock = @websocket_sockets[id.to_i] or return
|
|
2629
|
+
# Send the close frame and let the close HANDSHAKE complete: the server
|
|
2630
|
+
# replies with its own close frame, which the reader thread surfaces as
|
|
2631
|
+
# the `__close` event (carrying the agreed code) before tearing the
|
|
2632
|
+
# socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
|
|
2633
|
+
payload = [code.to_i].pack('n') + reason.to_s.b
|
|
2634
|
+
ws_write_frame(sock, 0x8, payload) rescue nil
|
|
2635
|
+
nil
|
|
2636
|
+
end
|
|
2637
|
+
|
|
2638
|
+
def websocket_pending? = !@websocket_queue.empty?
|
|
2639
|
+
|
|
2640
|
+
def deliver_websocket_events
|
|
2641
|
+
return 0 if @websocket_threads.empty? && @websocket_queue.empty?
|
|
2642
|
+
events = drain_queue(@websocket_queue)
|
|
2643
|
+
return 0 if events.empty?
|
|
2644
|
+
@runtime.call('__csim_deliverWebSocketEvents', events)
|
|
2645
|
+
events.size
|
|
2646
|
+
end
|
|
2647
|
+
|
|
2648
|
+
def reset_websockets
|
|
2649
|
+
@websocket_threads.each_value(&:kill)
|
|
2650
|
+
@websocket_threads.clear
|
|
2651
|
+
# Close BOTH pair ends: csim's read/write end and the app's hijack end
|
|
2652
|
+
# (the app may abandon its end without closing it — e.g. its connection
|
|
2653
|
+
# thread was just killed), so neither leaks across tests.
|
|
2654
|
+
@websocket_sockets.each_value {|s| s.close rescue nil }
|
|
2655
|
+
@websocket_app_sockets.each_value {|s| s.close rescue nil }
|
|
2656
|
+
@websocket_sockets.clear
|
|
2657
|
+
@websocket_app_sockets.clear
|
|
2658
|
+
@websocket_queue.clear
|
|
2659
|
+
end
|
|
2660
|
+
|
|
2661
|
+
# Background-thread frame reader: verify the 101 handshake, then loop
|
|
2662
|
+
# decoding server→client frames into queue events until close / EOF.
|
|
2663
|
+
private def run_websocket_reader(id, sock, expected_accept, queue)
|
|
2664
|
+
ok, protocol = ws_read_handshake(sock, expected_accept)
|
|
2665
|
+
unless ok
|
|
2666
|
+
queue << {id: id, type: '__error', message: 'websocket handshake failed'}
|
|
2667
|
+
return
|
|
2668
|
+
end
|
|
2669
|
+
# Carry the negotiated subprotocol — Action Cable's client closes the
|
|
2670
|
+
# connection in its `onopen` unless `webSocket.protocol` is one it knows
|
|
2671
|
+
# (`actioncable-v1-json`).
|
|
2672
|
+
queue << {id: id, type: '__open', protocol: protocol}
|
|
2673
|
+
loop do
|
|
2674
|
+
frame = ws_read_message(sock, queue, id)
|
|
2675
|
+
break if frame.nil? # EOF
|
|
2676
|
+
opcode, payload = frame
|
|
2677
|
+
if opcode == :close
|
|
2678
|
+
code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
|
|
2679
|
+
reason = payload.bytesize > 2 ? RuntimeShared.utf8_text(payload[2..]) : ''
|
|
2680
|
+
queue << {id: id, type: '__close', code: code, reason: reason}
|
|
2681
|
+
break
|
|
2682
|
+
end
|
|
2683
|
+
# Binary frames cross to JS as raw bytes (wrap_binary) tagged so the
|
|
2684
|
+
# JS side decodes them per `binaryType`; text is UTF-8.
|
|
2685
|
+
if opcode == 0x2
|
|
2686
|
+
queue << {id: id, type: 'message', binary: true, data: @runtime.wrap_binary(payload)}
|
|
2687
|
+
else
|
|
2688
|
+
queue << {id: id, type: 'message', data: RuntimeShared.utf8_text(payload)}
|
|
2689
|
+
end
|
|
2690
|
+
end
|
|
2691
|
+
rescue StandardError => e
|
|
2692
|
+
queue << {id: id, type: '__close', code: 1006, reason: e.message.to_s[0, 120]}
|
|
2693
|
+
ensure
|
|
2694
|
+
# Only the socket (a local) is closed here — the `@websocket_*` hashes
|
|
2695
|
+
# are mutated solely on the main thread (`reset_websockets`) to avoid a
|
|
2696
|
+
# cross-thread Hash race; a closed entry just no-ops on the next access.
|
|
2697
|
+
sock.close rescue nil
|
|
2698
|
+
end
|
|
2699
|
+
|
|
2700
|
+
# Read + validate the 101 Switching Protocols response (status line +
|
|
2701
|
+
# headers up to the blank line). Returns `[accept_ok, negotiated_protocol]`
|
|
2702
|
+
# — the accept hash must match the handshake key, and the negotiated
|
|
2703
|
+
# `Sec-WebSocket-Protocol` (nil if none) is surfaced so `webSocket.protocol`
|
|
2704
|
+
# is set (Action Cable's client requires `actioncable-v1-json`).
|
|
2705
|
+
private def ws_read_handshake(sock, expected_accept)
|
|
2706
|
+
status = sock.gets
|
|
2707
|
+
return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
|
|
2708
|
+
accept_ok = false
|
|
2709
|
+
protocol = nil
|
|
2710
|
+
while (line = sock.gets)
|
|
2711
|
+
line = line.chomp
|
|
2712
|
+
break if line.empty?
|
|
2713
|
+
k, v = line.split(':', 2)
|
|
2714
|
+
next unless k
|
|
2715
|
+
key = k.strip.downcase
|
|
2716
|
+
val = v.to_s.strip
|
|
2717
|
+
accept_ok = true if key == 'sec-websocket-accept' && val == expected_accept
|
|
2718
|
+
# utf8_text: socket reads are BINARY, and a BINARY string marshals to a
|
|
2719
|
+
# JS Uint8Array — the protocol must reach JS as a real string so
|
|
2720
|
+
# `webSocket.protocol` compares equal to `actioncable-v1-json`.
|
|
2721
|
+
protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
|
|
2722
|
+
end
|
|
2723
|
+
[accept_ok, protocol]
|
|
2724
|
+
end
|
|
2725
|
+
|
|
2726
|
+
# Read one complete message (reassembling continuation frames), handling
|
|
2727
|
+
# interleaved control frames inline. Returns `[opcode, payload]` (opcode
|
|
2728
|
+
# 0x1 text / 0x2 binary, or `:close`), or nil on EOF.
|
|
2729
|
+
private def ws_read_message(sock, queue, id)
|
|
2730
|
+
data = +''.b
|
|
2731
|
+
msg_opcode = nil
|
|
2732
|
+
loop do
|
|
2733
|
+
hdr = ws_read_n(sock, 2) or return nil
|
|
2734
|
+
b0, b1 = hdr.bytes
|
|
2735
|
+
fin = (b0 & 0x80) != 0
|
|
2736
|
+
opcode = b0 & 0x0f
|
|
2737
|
+
masked = (b1 & 0x80) != 0
|
|
2738
|
+
len = b1 & 0x7f
|
|
2739
|
+
if len == 126
|
|
2740
|
+
ext = ws_read_n(sock, 2) or return nil
|
|
2741
|
+
len = ext.unpack1('n')
|
|
2742
|
+
elsif len == 127
|
|
2743
|
+
ext = ws_read_n(sock, 8) or return nil
|
|
2744
|
+
len = ext.unpack1('Q>')
|
|
2745
|
+
end
|
|
2746
|
+
mask = nil
|
|
2747
|
+
if masked
|
|
2748
|
+
mask = ws_read_n(sock, 4) or return nil
|
|
2749
|
+
end
|
|
2750
|
+
if len.zero?
|
|
2751
|
+
payload = ''.b
|
|
2752
|
+
else
|
|
2753
|
+
payload = ws_read_n(sock, len) or return nil
|
|
2754
|
+
end
|
|
2755
|
+
payload = ws_mask(payload, mask) if mask # server frames shouldn't be masked, but be defensive
|
|
2756
|
+
case opcode
|
|
2757
|
+
when 0x8 then return [:close, payload]
|
|
2758
|
+
when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
|
|
2759
|
+
when 0xA then next # pong → ignore
|
|
2760
|
+
when 0x0 then data << payload # continuation
|
|
2761
|
+
else msg_opcode = opcode; data << payload # 0x1 text / 0x2 binary
|
|
2762
|
+
end
|
|
2763
|
+
return [msg_opcode || opcode, data] if fin
|
|
2764
|
+
end
|
|
2765
|
+
end
|
|
2766
|
+
|
|
2767
|
+
# Read exactly `n` bytes, or nil if the stream ends first (EOF, or a short
|
|
2768
|
+
# read on a mid-frame close). `IO#read(n)` blocks for n bytes on a stream
|
|
2769
|
+
# socket and only returns nil / fewer at EOF, so a nil-or-short result is
|
|
2770
|
+
# a closed/broken connection — bail and let the caller surface a close.
|
|
2771
|
+
private def ws_read_n(sock, n)
|
|
2772
|
+
buf = sock.read(n)
|
|
2773
|
+
buf if buf && buf.bytesize == n
|
|
2774
|
+
end
|
|
2775
|
+
|
|
2776
|
+
# Write one frame. Client→server frames MUST be masked (RFC6455 §5.3);
|
|
2777
|
+
# csim is always the client, so every frame it writes is masked. Holds the
|
|
2778
|
+
# per-connection write lock so the reader thread's pong and the main
|
|
2779
|
+
# thread's send/close can't interleave bytes on the shared socket.
|
|
2780
|
+
private def ws_write_frame(sock, opcode, payload)
|
|
2781
|
+
payload = payload.to_s.b
|
|
2782
|
+
len = payload.bytesize
|
|
2783
|
+
out = [0x80 | opcode].pack('C')
|
|
2784
|
+
if len < 126
|
|
2785
|
+
out << [0x80 | len].pack('C')
|
|
2786
|
+
elsif len < 65_536
|
|
2787
|
+
out << [0x80 | 126, len].pack('Cn')
|
|
2788
|
+
else
|
|
2789
|
+
out << [0x80 | 127, len].pack('CQ>')
|
|
2790
|
+
end
|
|
2791
|
+
key = SecureRandom.random_bytes(4)
|
|
2792
|
+
out << key
|
|
2793
|
+
out << ws_mask(payload, key)
|
|
2794
|
+
@websocket_write_lock.synchronize { sock.write(out) }
|
|
2795
|
+
end
|
|
2796
|
+
|
|
2797
|
+
private def ws_mask(payload, key)
|
|
2798
|
+
kb = key.bytes
|
|
2799
|
+
payload.bytes.each_with_index.map {|byte, i| byte ^ kb[i & 3] }.pack('C*')
|
|
2800
|
+
end
|
|
2801
|
+
|
|
2502
2802
|
# ── Hijack-aware async XHR ─────────────────────────────────────
|
|
2503
2803
|
#
|
|
2504
2804
|
# Real browsers' long-poll keeps the request socket open across
|
|
@@ -2883,6 +3183,25 @@ module Capybara
|
|
|
2883
3183
|
@transfer_buffer_lock.synchronize { @transfer_buffers.delete(id.to_i) }
|
|
2884
3184
|
end
|
|
2885
3185
|
|
|
3186
|
+
# JS reports each zero-copy transfer token it mints (`RustyRacer.transferOut`)
|
|
3187
|
+
# so we can release any that go unimported. Callable from a worker thread.
|
|
3188
|
+
def transfer_token_issued(token)
|
|
3189
|
+
t = token.to_i
|
|
3190
|
+
@transfer_tokens_lock.synchronize { @transfer_tokens << t } if t > 0
|
|
3191
|
+
nil
|
|
3192
|
+
end
|
|
3193
|
+
|
|
3194
|
+
# Release every outstanding transfer token's backing store. The transfer
|
|
3195
|
+
# registry is process-wide (it bridges isolates) and survives isolate
|
|
3196
|
+
# teardown, so an unimported token would leak across the whole run; drop
|
|
3197
|
+
# them on `reset!` via the (still-live) main context — `transferDrop` is
|
|
3198
|
+
# idempotent, so dropping already-imported tokens is a harmless no-op.
|
|
3199
|
+
def drop_pending_transfers
|
|
3200
|
+
toks = @transfer_tokens_lock.synchronize { ts = @transfer_tokens; @transfer_tokens = []; ts }
|
|
3201
|
+
return if toks.empty?
|
|
3202
|
+
@runtime.call('__csimTransferDropAll', toks) rescue nil
|
|
3203
|
+
end
|
|
3204
|
+
|
|
2886
3205
|
# Wraps the raw bytes in whatever binary shape the ACTIVE runtime can
|
|
2887
3206
|
# marshal to a JS Uint8Array (V8: the BINARY-tagged string itself —
|
|
2888
3207
|
# tag-driven marshalling crosses it as a Uint8Array; QuickJS: base64
|
|
@@ -417,7 +417,7 @@
|
|
|
417
417
|
this._currentTarget = v;
|
|
418
418
|
}
|
|
419
419
|
});
|
|
420
|
-
var
|
|
420
|
+
var DOMException2 = class _DOMException extends Error {
|
|
421
421
|
static {
|
|
422
422
|
__name(this, "DOMException");
|
|
423
423
|
}
|
|
@@ -480,8 +480,8 @@
|
|
|
480
480
|
INVALID_NODE_TYPE_ERR: 24,
|
|
481
481
|
DATA_CLONE_ERR: 25
|
|
482
482
|
}).forEach(([k, v]) => {
|
|
483
|
-
Object.defineProperty(
|
|
484
|
-
Object.defineProperty(
|
|
483
|
+
Object.defineProperty(DOMException2, k, { value: v, enumerable: true });
|
|
484
|
+
Object.defineProperty(DOMException2.prototype, k, { value: v, enumerable: true });
|
|
485
485
|
});
|
|
486
486
|
var CustomEvent = class extends Event {
|
|
487
487
|
static {
|
|
@@ -4532,6 +4532,18 @@
|
|
|
4532
4532
|
return globalThis.__csim_transferStash(view) | 0;
|
|
4533
4533
|
}
|
|
4534
4534
|
__name(stashTransfer, "stashTransfer");
|
|
4535
|
+
function detachTransferables(transferList) {
|
|
4536
|
+
if (!Array.isArray(transferList)) return;
|
|
4537
|
+
for (const t of transferList) {
|
|
4538
|
+
if (t instanceof ArrayBuffer && typeof t.transfer === "function") {
|
|
4539
|
+
try {
|
|
4540
|
+
t.transfer();
|
|
4541
|
+
} catch (_) {
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
__name(detachTransferables, "detachTransferables");
|
|
4535
4547
|
|
|
4536
4548
|
// lib/capybara/simulated/js/src/workers.js
|
|
4537
4549
|
function hasWorkers() {
|
|
@@ -4540,28 +4552,51 @@
|
|
|
4540
4552
|
}
|
|
4541
4553
|
__name(hasWorkers, "hasWorkers");
|
|
4542
4554
|
var TRANSFER_STASH_MIN = 64 * 1024;
|
|
4543
|
-
function
|
|
4555
|
+
function transferSetFrom(transferList) {
|
|
4556
|
+
if (!transferList || !transferList.length) return null;
|
|
4557
|
+
const set = /* @__PURE__ */ new Set();
|
|
4558
|
+
for (const t of transferList) set.add(t instanceof ArrayBuffer ? t : t && t.buffer || t);
|
|
4559
|
+
return set;
|
|
4560
|
+
}
|
|
4561
|
+
__name(transferSetFrom, "transferSetFrom");
|
|
4562
|
+
function encode(data, transferSet) {
|
|
4563
|
+
const NS = globalThis.RustyRacer;
|
|
4564
|
+
const canTransfer = transferSet && NS && typeof NS.transferOut === "function";
|
|
4544
4565
|
return JSON.stringify(data, function(_key, value) {
|
|
4545
4566
|
const isU8 = value instanceof Uint8Array;
|
|
4546
4567
|
const isAB = !isU8 && value instanceof ArrayBuffer;
|
|
4547
4568
|
if (!isU8 && !isAB) return value;
|
|
4569
|
+
const type = isU8 ? "Uint8Array" : "ArrayBuffer";
|
|
4570
|
+
const buf = isU8 ? value.buffer : value;
|
|
4571
|
+
if (canTransfer && transferSet.has(buf)) {
|
|
4572
|
+
const token = NS.transferOut(value) | 0;
|
|
4573
|
+
if (token > 0) {
|
|
4574
|
+
if (globalThis.__csim_transferIssued) globalThis.__csim_transferIssued(token);
|
|
4575
|
+
return isU8 ? { __csimType: type, xfer: token, byteOffset: value.byteOffset, length: value.length } : { __csimType: type, xfer: token };
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
4548
4578
|
const view = isU8 ? value : new Uint8Array(value);
|
|
4549
4579
|
if (view.byteLength >= TRANSFER_STASH_MIN) {
|
|
4550
4580
|
const refId = stashTransfer(view);
|
|
4551
|
-
if (refId > 0) return { __csimType:
|
|
4581
|
+
if (refId > 0) return { __csimType: type, refId };
|
|
4552
4582
|
}
|
|
4553
|
-
return {
|
|
4554
|
-
__csimType: isU8 ? "Uint8Array" : "ArrayBuffer",
|
|
4555
|
-
b64: globalThis.btoa(bytesToLatin1(view))
|
|
4556
|
-
};
|
|
4583
|
+
return { __csimType: type, b64: globalThis.btoa(bytesToLatin1(view)) };
|
|
4557
4584
|
});
|
|
4558
4585
|
}
|
|
4559
4586
|
__name(encode, "encode");
|
|
4560
4587
|
function decode(s) {
|
|
4588
|
+
const NS = globalThis.RustyRacer;
|
|
4561
4589
|
return JSON.parse(s, function(_key, value) {
|
|
4562
4590
|
if (!value || typeof value !== "object") return value;
|
|
4563
4591
|
const tag = value.__csimType;
|
|
4564
4592
|
if (tag !== "Uint8Array" && tag !== "ArrayBuffer") return value;
|
|
4593
|
+
if (value.xfer != null && NS && typeof NS.transferIn === "function") {
|
|
4594
|
+
const ab = NS.transferIn(value.xfer);
|
|
4595
|
+
if (ab) {
|
|
4596
|
+
return tag === "ArrayBuffer" ? ab : new Uint8Array(ab, value.byteOffset || 0, value.length != null ? value.length : ab.byteLength);
|
|
4597
|
+
}
|
|
4598
|
+
return tag === "ArrayBuffer" ? new ArrayBuffer(0) : new Uint8Array(0);
|
|
4599
|
+
}
|
|
4565
4600
|
const u = fetchTransfer(value.refId) || latin1ToBytes(globalThis.atob(value.b64 || ""));
|
|
4566
4601
|
return tag === "ArrayBuffer" ? u.buffer : u;
|
|
4567
4602
|
});
|
|
@@ -4585,15 +4620,16 @@
|
|
|
4585
4620
|
this._handle = globalThis.__csim_workerSpawn(this.url) | 0;
|
|
4586
4621
|
if (this._handle > 0) byHandle.set(this._handle, this);
|
|
4587
4622
|
}
|
|
4588
|
-
postMessage(data,
|
|
4623
|
+
postMessage(data, transferList) {
|
|
4589
4624
|
if (this._handle <= 0) return;
|
|
4590
4625
|
let payload;
|
|
4591
4626
|
try {
|
|
4592
|
-
payload = encode(data);
|
|
4627
|
+
payload = encode(data, transferSetFrom(transferList));
|
|
4593
4628
|
} catch (_) {
|
|
4594
4629
|
payload = "null";
|
|
4595
4630
|
}
|
|
4596
4631
|
globalThis.__csim_workerPostToWorker(this._handle, payload);
|
|
4632
|
+
detachTransferables(transferList);
|
|
4597
4633
|
}
|
|
4598
4634
|
terminate() {
|
|
4599
4635
|
if (this._handle <= 0) return;
|
|
@@ -4658,14 +4694,15 @@
|
|
|
4658
4694
|
globalThis.DedicatedWorkerGlobalScope = globalThis.DedicatedWorkerGlobalScope || /* @__PURE__ */ __name(function DedicatedWorkerGlobalScope() {
|
|
4659
4695
|
}, "DedicatedWorkerGlobalScope");
|
|
4660
4696
|
if (typeof globalThis.postMessage !== "function") {
|
|
4661
|
-
globalThis.postMessage = function(data,
|
|
4697
|
+
globalThis.postMessage = function(data, transferList) {
|
|
4662
4698
|
let payload;
|
|
4663
4699
|
try {
|
|
4664
|
-
payload = encode(data);
|
|
4700
|
+
payload = encode(data, transferSetFrom(transferList));
|
|
4665
4701
|
} catch (_) {
|
|
4666
4702
|
payload = "null";
|
|
4667
4703
|
}
|
|
4668
4704
|
globalThis.__csim_workerPostMessage(payload);
|
|
4705
|
+
detachTransferables(transferList);
|
|
4669
4706
|
};
|
|
4670
4707
|
}
|
|
4671
4708
|
globalThis.importScripts = function(...urls) {
|
|
@@ -10843,8 +10880,8 @@
|
|
|
10843
10880
|
function windowNamedLookup(name) {
|
|
10844
10881
|
const doc = globalThis.document;
|
|
10845
10882
|
if (!doc) return void 0;
|
|
10846
|
-
const
|
|
10847
|
-
if (
|
|
10883
|
+
const byId3 = doc.getElementById && doc.getElementById(name);
|
|
10884
|
+
if (byId3) return byId3;
|
|
10848
10885
|
if (!WINDOW_NAME_VALUES.has(name)) return void 0;
|
|
10849
10886
|
let found;
|
|
10850
10887
|
walkSubtree(doc, (el) => {
|
|
@@ -13162,7 +13199,7 @@
|
|
|
13162
13199
|
|
|
13163
13200
|
// lib/capybara/simulated/js/src/abort.js
|
|
13164
13201
|
function defaultAbortReason() {
|
|
13165
|
-
return new
|
|
13202
|
+
return new DOMException2("signal is aborted without reason", "AbortError");
|
|
13166
13203
|
}
|
|
13167
13204
|
__name(defaultAbortReason, "defaultAbortReason");
|
|
13168
13205
|
var AbortSignal = class _AbortSignal extends EventTarget {
|
|
@@ -13194,7 +13231,7 @@
|
|
|
13194
13231
|
// AbortSignal.timeout(ms)}` is the canonical timeout idiom.
|
|
13195
13232
|
static timeout(ms) {
|
|
13196
13233
|
const s = new _AbortSignal();
|
|
13197
|
-
globalThis.setTimeout(() => s._markAborted(new
|
|
13234
|
+
globalThis.setTimeout(() => s._markAborted(new DOMException2("signal timed out", "TimeoutError")), Number(ms) || 0);
|
|
13198
13235
|
return s;
|
|
13199
13236
|
}
|
|
13200
13237
|
// Spec: returns a signal that aborts when any input signal aborts.
|
|
@@ -14137,6 +14174,109 @@
|
|
|
14137
14174
|
};
|
|
14138
14175
|
globalThis.EventSource = EventSource;
|
|
14139
14176
|
|
|
14177
|
+
// lib/capybara/simulated/js/src/websocket.js
|
|
14178
|
+
var byId2 = /* @__PURE__ */ new Map();
|
|
14179
|
+
globalThis.__csim_webSocketById = byId2;
|
|
14180
|
+
var WebSocket = class extends EventTarget {
|
|
14181
|
+
static {
|
|
14182
|
+
__name(this, "WebSocket");
|
|
14183
|
+
}
|
|
14184
|
+
constructor(url, protocols) {
|
|
14185
|
+
super();
|
|
14186
|
+
this.url = String(url);
|
|
14187
|
+
this.readyState = 0;
|
|
14188
|
+
this.bufferedAmount = 0;
|
|
14189
|
+
this.extensions = "";
|
|
14190
|
+
this.protocol = "";
|
|
14191
|
+
this.binaryType = "blob";
|
|
14192
|
+
this.onopen = null;
|
|
14193
|
+
this.onmessage = null;
|
|
14194
|
+
this.onclose = null;
|
|
14195
|
+
this.onerror = null;
|
|
14196
|
+
const list = protocols == null ? [] : Array.isArray(protocols) ? protocols.map(String) : [String(protocols)];
|
|
14197
|
+
this._id = globalThis.__csim_wsOpen(this.url, list) | 0;
|
|
14198
|
+
if (this._id > 0) byId2.set(this._id, this);
|
|
14199
|
+
}
|
|
14200
|
+
send(data) {
|
|
14201
|
+
if (this.readyState === 0) throw new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError");
|
|
14202
|
+
if (this.readyState !== 1) return;
|
|
14203
|
+
if (typeof data === "string") {
|
|
14204
|
+
globalThis.__csim_wsSend(this._id, data, false);
|
|
14205
|
+
return;
|
|
14206
|
+
}
|
|
14207
|
+
let bytes;
|
|
14208
|
+
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
|
14209
|
+
else if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
14210
|
+
else bytes = new Uint8Array(0);
|
|
14211
|
+
globalThis.__csim_wsSend(this._id, bytes, true);
|
|
14212
|
+
}
|
|
14213
|
+
close(code, reason) {
|
|
14214
|
+
if (this.readyState === 2 || this.readyState === 3) return;
|
|
14215
|
+
this.readyState = 2;
|
|
14216
|
+
if (this._id > 0) globalThis.__csim_wsClose(this._id, code == null ? 1e3 : code | 0, reason == null ? "" : String(reason));
|
|
14217
|
+
}
|
|
14218
|
+
};
|
|
14219
|
+
WebSocket.CONNECTING = 0;
|
|
14220
|
+
WebSocket.OPEN = 1;
|
|
14221
|
+
WebSocket.CLOSING = 2;
|
|
14222
|
+
WebSocket.CLOSED = 3;
|
|
14223
|
+
globalThis.__csim_deliverWebSocketEvents = function(events) {
|
|
14224
|
+
if (!events || !events.length) return 0;
|
|
14225
|
+
let delivered = 0;
|
|
14226
|
+
for (const e of events) {
|
|
14227
|
+
const ws = byId2.get(e.id | 0);
|
|
14228
|
+
if (!ws) continue;
|
|
14229
|
+
if (e.type === "__open") {
|
|
14230
|
+
if (ws.readyState === 0) {
|
|
14231
|
+
ws.readyState = 1;
|
|
14232
|
+
if (e.protocol) ws.protocol = String(e.protocol);
|
|
14233
|
+
dispatchWithOnHandler(ws, new Event("open"));
|
|
14234
|
+
delivered++;
|
|
14235
|
+
}
|
|
14236
|
+
continue;
|
|
14237
|
+
}
|
|
14238
|
+
if (e.type === "__close" || e.type === "__error") {
|
|
14239
|
+
if (e.type === "__error") {
|
|
14240
|
+
const err = new Event("error");
|
|
14241
|
+
if (e.message) try {
|
|
14242
|
+
err.message = String(e.message);
|
|
14243
|
+
} catch (_) {
|
|
14244
|
+
}
|
|
14245
|
+
dispatchWithOnHandler(ws, err);
|
|
14246
|
+
}
|
|
14247
|
+
if (ws.readyState !== 3) {
|
|
14248
|
+
ws.readyState = 3;
|
|
14249
|
+
const ev = new Event("close");
|
|
14250
|
+
try {
|
|
14251
|
+
ev.code = e.code == null ? 1006 : e.code | 0;
|
|
14252
|
+
ev.reason = e.reason == null ? "" : String(e.reason);
|
|
14253
|
+
ev.wasClean = e.type === "__close" && e.code != null;
|
|
14254
|
+
} catch (_) {
|
|
14255
|
+
}
|
|
14256
|
+
dispatchWithOnHandler(ws, ev);
|
|
14257
|
+
}
|
|
14258
|
+
byId2.delete(e.id | 0);
|
|
14259
|
+
delivered++;
|
|
14260
|
+
continue;
|
|
14261
|
+
}
|
|
14262
|
+
let data;
|
|
14263
|
+
if (e.binary) {
|
|
14264
|
+
const bytes = fetchedToBytes(e.data) || new Uint8Array(0);
|
|
14265
|
+
if (ws.binaryType === "arraybuffer") {
|
|
14266
|
+
data = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength ? bytes.buffer : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
14267
|
+
} else {
|
|
14268
|
+
data = typeof globalThis.Blob === "function" ? new globalThis.Blob([bytes]) : bytes.buffer;
|
|
14269
|
+
}
|
|
14270
|
+
} else {
|
|
14271
|
+
data = e.data == null ? "" : e.data;
|
|
14272
|
+
}
|
|
14273
|
+
dispatchWithOnHandler(ws, new MessageEvent("message", { data, origin: ws.url }));
|
|
14274
|
+
delivered++;
|
|
14275
|
+
}
|
|
14276
|
+
return delivered;
|
|
14277
|
+
};
|
|
14278
|
+
globalThis.WebSocket = WebSocket;
|
|
14279
|
+
|
|
14140
14280
|
// lib/capybara/simulated/js/src/request-body.js
|
|
14141
14281
|
function setContentTypeIfMissing(headers, value) {
|
|
14142
14282
|
if (!("Content-Type" in headers) && !("content-type" in headers)) {
|
|
@@ -17885,7 +18025,55 @@
|
|
|
17885
18025
|
}
|
|
17886
18026
|
};
|
|
17887
18027
|
globalThis.BroadcastChannel = BroadcastChannel;
|
|
18028
|
+
globalThis.__csimTransferDropAll = function(tokens) {
|
|
18029
|
+
const NS = globalThis.RustyRacer;
|
|
18030
|
+
if (!tokens || !NS || typeof NS.transferDrop !== "function") return;
|
|
18031
|
+
for (let i = 0; i < tokens.length; i++) NS.transferDrop(tokens[i]);
|
|
18032
|
+
};
|
|
17888
18033
|
var __csimWindowProxies = /* @__PURE__ */ new Map();
|
|
18034
|
+
function csimMaybeTransferOut(data, transfer) {
|
|
18035
|
+
if (!transfer || !transfer.length) return null;
|
|
18036
|
+
const NS = globalThis.RustyRacer;
|
|
18037
|
+
if (!NS || typeof NS.transferOut !== "function") return null;
|
|
18038
|
+
const isAB = data instanceof ArrayBuffer;
|
|
18039
|
+
const isView = !isAB && ArrayBuffer.isView(data);
|
|
18040
|
+
if (!isAB && !isView) return null;
|
|
18041
|
+
const buf = isAB ? data : data.buffer;
|
|
18042
|
+
let inList = false;
|
|
18043
|
+
for (let i = 0; i < transfer.length; i++) {
|
|
18044
|
+
const t = transfer[i];
|
|
18045
|
+
if (t === buf || t && t.buffer === buf) {
|
|
18046
|
+
inList = true;
|
|
18047
|
+
break;
|
|
18048
|
+
}
|
|
18049
|
+
}
|
|
18050
|
+
if (!inList) return null;
|
|
18051
|
+
const token = NS.transferOut(data) | 0;
|
|
18052
|
+
if (token <= 0) return null;
|
|
18053
|
+
if (globalThis.__csim_transferIssued) globalThis.__csim_transferIssued(token);
|
|
18054
|
+
return isAB ? { __csimXfer: token, kind: "ArrayBuffer" } : {
|
|
18055
|
+
__csimXfer: token,
|
|
18056
|
+
kind: data.constructor && data.constructor.name || "Uint8Array",
|
|
18057
|
+
byteOffset: data.byteOffset,
|
|
18058
|
+
length: data.length
|
|
18059
|
+
};
|
|
18060
|
+
}
|
|
18061
|
+
__name(csimMaybeTransferOut, "csimMaybeTransferOut");
|
|
18062
|
+
function csimMaybeTransferIn(data) {
|
|
18063
|
+
if (!data || typeof data !== "object" || data.__csimXfer == null) return data;
|
|
18064
|
+
const NS = globalThis.RustyRacer;
|
|
18065
|
+
if (!NS || typeof NS.transferIn !== "function") return data;
|
|
18066
|
+
const ab = NS.transferIn(data.__csimXfer);
|
|
18067
|
+
if (!ab) return new ArrayBuffer(0);
|
|
18068
|
+
if (data.kind === "ArrayBuffer") return ab;
|
|
18069
|
+
const Ctor = globalThis[data.kind] || globalThis.Uint8Array;
|
|
18070
|
+
try {
|
|
18071
|
+
return new Ctor(ab, data.byteOffset || 0, data.length);
|
|
18072
|
+
} catch (_) {
|
|
18073
|
+
return new Uint8Array(ab);
|
|
18074
|
+
}
|
|
18075
|
+
}
|
|
18076
|
+
__name(csimMaybeTransferIn, "csimMaybeTransferIn");
|
|
17889
18077
|
function csimWindowProxy(handle) {
|
|
17890
18078
|
if (handle == null || handle === "") return null;
|
|
17891
18079
|
let proxy = __csimWindowProxies.get(handle);
|
|
@@ -17925,8 +18113,10 @@
|
|
|
17925
18113
|
// lost — fine for the JSON-ish payloads postMessage carries in practice.
|
|
17926
18114
|
// `targetOrigin` is accepted but, like the single-origin model elsewhere,
|
|
17927
18115
|
// not enforced.
|
|
17928
|
-
postMessage(data, targetOrigin,
|
|
17929
|
-
|
|
18116
|
+
postMessage(data, targetOrigin, transfer) {
|
|
18117
|
+
const xfer = csimMaybeTransferOut(data, transfer);
|
|
18118
|
+
globalThis.__csimWindowPostMessage(handle, xfer || data, String(targetOrigin == null ? "*" : targetOrigin));
|
|
18119
|
+
detachTransferables(transfer);
|
|
17930
18120
|
},
|
|
17931
18121
|
get location() {
|
|
17932
18122
|
return location;
|
|
@@ -17972,7 +18162,7 @@
|
|
|
17972
18162
|
for (const ev of events) {
|
|
17973
18163
|
const source = ev && ev.sourceHandle ? csimWindowProxy(ev.sourceHandle) : null;
|
|
17974
18164
|
dispatchWithOnHandler(globalThis, new MessageEvent("message", {
|
|
17975
|
-
data: ev ? ev.data : void 0,
|
|
18165
|
+
data: csimMaybeTransferIn(ev ? ev.data : void 0),
|
|
17976
18166
|
origin: ev && ev.origin || "",
|
|
17977
18167
|
source,
|
|
17978
18168
|
lastEventId: "",
|
|
@@ -18966,7 +19156,7 @@
|
|
|
18966
19156
|
(function() {
|
|
18967
19157
|
"use strict";
|
|
18968
19158
|
globalThis.Event = Event;
|
|
18969
|
-
globalThis.DOMException =
|
|
19159
|
+
globalThis.DOMException = DOMException2;
|
|
18970
19160
|
globalThis.CustomEvent = CustomEvent;
|
|
18971
19161
|
globalThis.MouseEvent = MouseEvent;
|
|
18972
19162
|
globalThis.KeyboardEvent = KeyboardEvent;
|
|
@@ -66,6 +66,9 @@ module Capybara
|
|
|
66
66
|
'__csim_logConsole' => ->(b, *a) { b.log_console(a[0], a[1]); nil },
|
|
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
|
+
'__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 },
|
|
71
|
+
'__csim_wsClose' => ->(b, *a) { b.ws_close(a[0], a[1], a[2]); nil },
|
|
69
72
|
'__csim_rackFetchAsync' => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
|
|
70
73
|
'__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
|
|
71
74
|
# Cross-window references (window.open / opener / postMessage). Each
|
|
@@ -87,6 +90,8 @@ module Capybara
|
|
|
87
90
|
'__csim_blobUnregister' => ->(b, *a) { b.blob_unregister(a[0]); nil },
|
|
88
91
|
'__csim_transferStash' => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
|
|
89
92
|
'__csim_transferFetch' => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
|
|
93
|
+
# Zero-copy postMessage transfer-token bookkeeping (see Browser#drop_pending_transfers).
|
|
94
|
+
'__csim_transferIssued' => ->(b, *a) { b.transfer_token_issued(a[0]); nil },
|
|
90
95
|
'__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
|
|
91
96
|
'__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
|
|
92
97
|
# WebAuthn create / get raise `WebauthnState::Error` carrying
|