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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 692a6a8f67870e2078ba815a0698831f3cec4ada4dc3d79d9af795156107e37b
4
- data.tar.gz: 734a4cfe94f4cd93367b95082619e10729cb4ebf1f83a890b4d87c78ce5fd3cf
3
+ metadata.gz: 02f56bfd3215e67c4a8faa799352e0ae3c399557f928926cafcdc9dde7dd2b9c
4
+ data.tar.gz: 12e88e441d93de921d0727bfae9e3728cc56d100f8e32e525e37e21a28f0e273
5
5
  SHA512:
6
- metadata.gz: 9c9bfde944aad7339e4d7153e2d49b3b6834662fc5bcaf945a63899ca580493d1c67bfbd625ce7e06683bdbfacab83fa8192af7f02a73d5228f080dc22fc1dda
7
- data.tar.gz: da6971ee07edd867e00baa4e6d382ff291d5d0c8c437084f6f4d7dba50ae5c0596e4726db9be58f1d7a6847340fccff847d06d0056fdb8de3847a703887a4c28
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, no WebSocket.
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, screenshots, and drag pixel coordinates** are out of
360
- scope by design use Selenium / Cuprite. (EventSource and Web
361
- Workers *are* implemented.)
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`); and only
380
- the active window's event loop runs, so a message is delivered when you
381
- switch to its window. Window viewport APIs (`maximize` / `fullscreen` /
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 DOMException = class _DOMException extends Error {
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(DOMException, k, { value: v, enumerable: true });
484
- Object.defineProperty(DOMException.prototype, k, { value: v, enumerable: true });
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 encode(data) {
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: isU8 ? "Uint8Array" : "ArrayBuffer", refId };
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, _transferList) {
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, _transferList) {
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 byId2 = doc.getElementById && doc.getElementById(name);
10847
- if (byId2) return byId2;
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 DOMException("signal is aborted without reason", "AbortError");
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 DOMException("signal timed out", "TimeoutError")), Number(ms) || 0);
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, _transfer) {
17929
- globalThis.__csimWindowPostMessage(handle, data, String(targetOrigin == null ? "*" : targetOrigin));
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 = 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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-simulated
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima