capybara-simulated 0.0.7 → 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -190
  35. data/vendor/js/runtime.js +0 -2208
@@ -1,1018 +1,3582 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
1
4
  require 'date'
2
- require 'digest'
5
+ require 'fileutils'
3
6
  require 'json'
4
- require 'mini_racer'
5
- require 'open3'
7
+ require 'net/http'
8
+ require 'openssl'
6
9
  require 'rack/mock'
7
- require 'securerandom'
10
+ require 'socket'
11
+ require 'thread'
8
12
  require 'time'
9
13
  require 'uri'
14
+ require_relative 'asset_cache'
15
+ require_relative 'errors'
16
+ require_relative 'stack_resolver'
17
+ require_relative 'trace'
18
+ require_relative 'webauthn_state'
10
19
 
11
20
  module Capybara
12
21
  module Simulated
13
22
  class Browser
14
- VENDOR_DIR = File.expand_path('../../../../vendor/js', __FILE__)
15
- DEFAULT_HOST = 'http://www.example.com'.freeze
16
-
17
- # The V8 isolate is shared across the lifetime of a Browser instance —
18
- # only the happy-dom Window is recreated per visit. Reset between
19
- # specs is via `reset!` on the Driver, which closes the Window and
20
- # clears tracked DOM handles, but keeps the (expensive) isolate alive.
21
- # On mini_racer + happy-dom the previously-denylisted scripts (notably
22
- # jQuery UI) load without hanging the runtime, and ready callbacks
23
- # depend on them succeeding so we no longer skip anything by default.
24
- EXTERNAL_SCRIPT_DENYLIST = /\A\z/.freeze
25
-
26
- attr_reader :app, :status_code, :response_headers
27
-
28
- # window.history.pushState/replaceState only updates the JS-side
29
- # location; mirror it onto the Ruby @current_url so Capybara reads
30
- # the new path. Before any real visit (or after reset_session!), we
31
- # have no @current_url, in which case we report `nil` rather than
32
- # leaking the synthetic happy-dom Window URL.
33
- def current_url
34
- return '' unless @current_url
35
- # Capybara's synchronize loop calls current_path/current_url while
36
- # waiting for navigation. Drive the virtual clock so timer-based
37
- # location updates (window.location.pathname = '...') eventually
38
- # fire under the wait budget.
39
- advance_virtual_clock
40
- check_location_change unless @in_navigate
41
- loc = (@ctx.eval('window && window.location ? window.location.href : null') rescue nil)
42
- loc && !loc.empty? ? loc.to_s : @current_url
43
- end
44
- attr_accessor :driver_for_results
45
-
46
- def initialize(app)
47
- @app = app
48
- @ctx = MiniRacer::Context.new
49
- @ctx.eval(File.read(File.join(VENDOR_DIR, 'prelude.js')))
50
- @ctx.eval(File.read(File.join(VENDOR_DIR, 'csim.bundle.js')))
51
- @ctx.eval(File.read(File.join(VENDOR_DIR, 'runtime.js')))
52
- @ctx.attach('__csim_fetch', method(:js_fetch))
53
-
54
- @history = []
55
- @history_index = -1
56
- @current_url = nil
57
- @status_code = nil
58
- @response_headers = {}
59
- @modal_responses = {alert: [], confirm: [], prompt: []}
60
- @cookie_jar = []
61
- end
62
-
63
- # Tear down all per-page state without throwing away the V8 isolate.
64
- # Capybara calls this between specs via Driver#reset!.
65
- def reset_state!
66
- @history = []
67
- @history_index = -1
68
- @current_url = nil
69
- @status_code = nil
70
- @response_headers = {}
71
- @modal_responses = {alert: [], confirm: [], prompt: []}
72
- @last_call_at = nil
73
- @cookie_jar = []
74
- # `loadHTML` clears all tracked node handles and recreates the
75
- # happy-dom Window for an empty document.
76
- call_runtime('loadHTML', '<!doctype html><html><body></body></html>', DEFAULT_HOST)
77
- call_runtime('setModalResponses', stringify_keys(@modal_responses))
78
- end
79
-
80
- # Explicit `visit` is a new navigation, not a follow-up drop the
81
- # referer the way browsers' address-bar navigation does, and resolve
82
- # the URL against the configured app host rather than the current
83
- # page (which may have a stale `<base href>`).
23
+ # Fallback origin for `visit('/foo')` and friends when no current
24
+ # page is loaded yet. Track Capybara's idea of the test server
25
+ # (`app_host` if set, else explicitly-configured `server_host` /
26
+ # `server_port`) so the host header reaching the Rack app matches
27
+ # what host-specific helpers expect Discourse's
28
+ # `setup_system_test` sets `SiteSetting.force_hostname =
29
+ # Capybara.server_host` / `port = Capybara.server_port`, and the
30
+ # request-tracker specs assert `event[:url] ==
31
+ # Discourse.base_url_no_prefix + path`, which derives from the
32
+ # same SiteSetting pair. We only consult `server_host` when it
33
+ # was *explicitly* set: Capybara's getter returns `'127.0.0.1'`
34
+ # when unset, but Rack::Test's hardcoded default origin is
35
+ # `www.example.com` and capybara's own shared specs hard-code
36
+ # that literal — fall back to it when no suite-side configuration
37
+ # is in play.
38
+ def self.default_host
39
+ return ::Capybara.app_host if ::Capybara.app_host
40
+ host = ::Capybara.server_host
41
+ return 'http://www.example.com' if host == '127.0.0.1'
42
+ port = ::Capybara.server_port.to_i
43
+ port > 0 ? "http://#{host}:#{port}" : "http://#{host}"
44
+ end
45
+
46
+ # Process-wide HTTP/1.1 response cache for `rack_fetch`. Real
47
+ # browsers (cuprite / selenium) reuse fetched assets across the
48
+ # suite — without this, Simulated re-fetches every <script src>
49
+ # on every visit (Redmine baseline: ~6× more requests than
50
+ # selenium). Honors `Cache-Control` / `Expires` / `ETag` /
51
+ # `Last-Modified` per RFC 9111.
52
+ @@asset_cache = AssetCache.new
53
+
54
+ attr_writer :timers_active
55
+
56
+ # Sticky window after timers finish: keep polling? true so a
57
+ # setTimeout firing mid-loop doesn't drop Capybara's synchronize
58
+ # before its own default_max_wait_time kicks in. Counted in poll
59
+ # calls (not wall time) for determinism under GC/load pressure.
60
+ # 1000 polls × Capybara's default 0.01 s retry_interval ≈ 10 s.
61
+ POLLING_GRACE_POLLS = 1000
62
+ # When `@timers_active` is true but `@runtime.settle_gen` hasn't
63
+ # bumped in this many consecutive polls, treat the page as
64
+ # observably idle and let Capybara's per-find timer give up. See
65
+ # `polling?` for the full rationale. 300 polls ≈ 3 s at
66
+ # Capybara's default 10 ms retry interval — long enough to ride
67
+ # through brief async idle windows during Discourse's
68
+ # ProseMirror editor boot (which sometimes pauses ~1 s mid-load
69
+ # while a webpack chunk + Glimmer reconcile complete) while
70
+ # still cutting the full 4 s wait on tests destined to fail.
71
+ IDLE_SETTLE_POLLS = 300
72
+ # Brief window after a Ruby-side navigate (context rebuild) so
73
+ # Capybara's outer synchronize gets one retry against the new
74
+ # context.
75
+ POST_NAV_POLL_GRACE_POLLS = 10
76
+ # Deterministic virtual-clock model (replaces the old wall-sync, where each
77
+ # tick advanced by REAL wall-elapsed and so coupled virtual time to JS/Ruby/GC
78
+ # speed — a faster `visible_text` shifted WHEN debounces fired, e.g. Avo
79
+ # actions_spec:464). Now each poll advances by a FIXED step; near-future
80
+ # timers on an otherwise-idle page are fast-forwarded to (horizon-gated).
81
+ #
82
+ # 100 ms is empirically the floor that lets a "commit debounce scheduled
83
+ # between two user actions" fire before the next action (Avo actions_spec:464's
84
+ # ~50-75 ms field-commit flips at step 10/50, fixed at >=75). Group-A
85
+ # transient-catch observability does NOT depend on this step — it comes from
86
+ # the `timer_wait_elapsed?` FREQUENCY gate (the first find after an action
87
+ # doesn't tick, so the pre-debounce state is observed regardless of step
88
+ # size), so a larger step completes Group-B without losing Group-A (verified
89
+ # green at 100 across gem 1579, WPT 660, Forem, Avo, :464 passing). Clamped
90
+ # >=1 so a `CSIM_POLL_TICK_STEP_MS=0` misconfig can't freeze the fixed-step path.
91
+ POLL_TICK_STEP_MS = [(ENV['CSIM_POLL_TICK_STEP_MS'] || '100').to_i, 1].max
92
+ # Horizon-gated fast-forward: when the page is observably idle (no timer due
93
+ # now, no background IO) but a timer is parked within this horizon, jump the
94
+ # virtual clock straight to it instead of waiting ~delay/step polls. A timer
95
+ # farther out (ahoy 1000 ms, session-timeout, analytics) is LEFT parked. 600
96
+ # clears every legit must-fire wait (Backburner/DTextField 500, refetch/chart
97
+ # <=200, image-grid 64) while staying BELOW ahoy's 1000. `=0` disables FF →
98
+ # pure deterministic fixed-step (the fallback model).
99
+ FF_HORIZON_MS = (ENV['CSIM_FF_HORIZON_MS'] || '600').to_i
100
+ # Transient guard: hold the page pre-debounce for this many consecutive idle
101
+ # polls before allowing a fast-forward, so "catch the DOM before the 200 ms
102
+ # debounce fires" tests (Discourse refetchForSearch / doubled-filter, Avo
103
+ # filters) still observe the intermediate state across several polls.
104
+ FF_TRANSIENT_GUARD_POLLS = (ENV['CSIM_FF_TRANSIENT_GUARD_POLLS'] || '6').to_i
105
+ SETTLE_DRAIN_MS = 32
106
+ SETTLE_MAX_ITER = 10
107
+ # Per-`run_loop_step` task cap (its `maxIter`). Bounds a self-rescheduling
108
+ # timer/microtask storm so one settle iter returns to Ruby; large enough
109
+ # for the heaviest legit chain (Mastodon hydrate, Turbo stream batch).
110
+ SETTLE_MAX_ITER_TASKS = 256
111
+ # Post-user-action virtual-clock advance. Default 0 — the
112
+ # wall-sync model (each tick_real_time advances by the wall
113
+ # ms elapsed since the last tick) lets Capybara's outer poll
114
+ # loop drive the clock at the same rate a real browser sees,
115
+ # so debounced chains complete naturally during polling
116
+ # without being pre-emptively flushed past the transient
117
+ # window real-browser tests rely on.
118
+ #
119
+ # `CSIM_USER_ACTION_DRAIN_MS=600` restores the pre-wall-sync
120
+ # burst behaviour: post-action, drain everything due in the next
121
+ # 600 ms of virtual time before returning. Costs the transient-
122
+ # state observability the wall-sync model preserves; recovers
123
+ # the ~5-10 % wall on action-heavy suites where Capybara would
124
+ # otherwise poll N times to catch a single debounce.
125
+ USER_ACTION_DRAIN_MS = (ENV['CSIM_USER_ACTION_DRAIN_MS'] || '0').to_i
126
+ # Sent on every driver-originated Rack call. `HTTP_USER_AGENT`
127
+ # must lead with `Mozilla/5.0` so server-side bot detectors
128
+ # (ahoy_matey's `Browser.new(ua).bot?`) treat us as a real
129
+ # client. `REMOTE_ADDR` has to be a non-empty, parseable IP —
130
+ # Devise's `trackable` mixin runs `IPAddr.new(request.remote_ip)`
131
+ # during `set_user`/sign-in, and an empty string trips
132
+ # `IPAddr::AddressFamilyError`.
133
+ # Keep `USER_AGENT` in sync with `navigator.userAgent` in
134
+ # `lib/capybara/simulated/js/bridge.js` — the JS side ships in the V8
135
+ # snapshot, so injecting from Ruby at boot would defeat snapshot
136
+ # warmth.
137
+ # Discourse's `non_crawler_user_agents` adds a "Rails Testing"
138
+ # bypass in test mode (see lib/crawler_detection.rb); without one
139
+ # of its bypass tokens here Discourse serves a no-JS crawler-only
140
+ # HTML view. Putting "Rails Testing" in the UA satisfies that
141
+ # without claiming a specific real-browser engine (which would
142
+ # send Turbo / Stimulus down chrome-specific code paths Avo's
143
+ # tests don't exercise).
144
+ USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 capybara-simulated'
145
+ # Approximate Chrome's resolution: when connecting to `localhost`,
146
+ # Linux glibc returns IPv6 (::1) first and the server sees the
147
+ # client at `::1`; for any literal IP (or a non-localhost name),
148
+ # the server keeps IPv4. Match that so Discourse system specs
149
+ # (`expect(event[:ip_address]).to eq('::1')`) line up with what
150
+ # they would see under selenium.
151
+ REMOTE_ADDR_IPV4 = '127.0.0.1'
152
+ REMOTE_ADDR_IPV6 = '::1'
153
+ def self.remote_addr_for(host)
154
+ bare = host.to_s.downcase.sub(/:\d+\z/, '').sub(/\A\[(.+)\]\z/, '\1')
155
+ bare == 'localhost' ? REMOTE_ADDR_IPV6 : REMOTE_ADDR_IPV4
156
+ end
157
+
158
+ def mime_type_for_path(path)
159
+ Rack::Mime.mime_type(File.extname(path.to_s), '')
160
+ end
161
+
162
+ def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil)
163
+ @app = app
164
+ @driver = driver
165
+ @runtime = build_runtime(js_engine)
166
+ # Per-poll clock decisions cached at construction (CLAUDE.md rule 3 — the
167
+ # runtime type + env are fixed for the session): the wall-sync escape
168
+ # hatch and whether the runtime exposes the fast-forward timer query.
169
+ @clock_wall = !ENV['CSIM_CLOCK_WALL'].nil?
170
+ @runtime_supports_ff = @runtime.respond_to?(:next_timer_delay_ms)
171
+ @current_url = nil
172
+ # Real browsers yield control between asynchronous URL
173
+ # transitions (XHR-driven model loads, then `replaceWith` to a
174
+ # child route), so Capybara polls catch the intermediate URL —
175
+ # e.g. Discourse's `/wizard` → `/wizard/steps/setup` flow holds
176
+ # at `/wizard` while `Wizard.load()` runs. Our env drains
177
+ # microtasks synchronously and only the final URL is reachable
178
+ # by the time Ruby regains control. Queue URLs we transitioned
179
+ # through; `current_url` shifts one out per call so a polling
180
+ # `assert_current_path` walks the same set the real browser
181
+ # would have observed.
182
+ @recent_urls = []
183
+ @recent_urls_last_push_at = nil
184
+ # The URL the page was at when the current user-action drain began.
185
+ # It's the *starting point* of the action, not an intermediate the
186
+ # action transitioned through, so a pushState/replaceState back to a
187
+ # fresh URL during the drain (a Turbo Drive Visit triggered by the
188
+ # action) must NOT queue it into `@recent_urls` — otherwise a one-shot
189
+ # `current_url` read after the action returns the pre-action URL
190
+ # instead of the navigated-to one (Avo filter `encoded_filters`).
191
+ @action_url_baseline = nil
192
+ # The URL of the page that navigated to the current document —
193
+ # HTTP `Referer` header on the response that loaded the page,
194
+ # exposed to JS as `document.referrer`. Tracked by `navigate`
195
+ # so post-auth flows (Discourse login: `cookie('destination_url',
196
+ # referrer)` when navigating from `/t/N` → `/login` via link
197
+ # click) can reconstruct the origin URL.
198
+ @current_referer = ''
199
+ # Cookies + localStorage are origin-shared in real browsers —
200
+ # the Driver injects the jars so aux windows (per-window VMs)
201
+ # see the same auth state and storage as the primary. Tests
202
+ # without a Driver (gem-internal callers) get fresh jars.
203
+ @cookies = cookies || {}
204
+ @local_storage = local_storage || {}
205
+ @session_storage = {}
206
+ @sticky_headers = {}
207
+ @timers_active = false
208
+ # Capybara config is set once per suite; cache the derived
209
+ # origin so the per-request fallback path doesn't re-dispatch
210
+ # `Capybara.app_host` / `server_host` / `server_port` on every
211
+ # rack call (CLAUDE.md: cache env decisions at construction).
212
+ @default_host = self.class.default_host
213
+ # Handle IDs are per-Context integer sequences: a handle from
214
+ # a pre-rebuild context could collide with a fresh node's id
215
+ # in the new context. Node captures this on construction;
216
+ # `check_stale` rejects on mismatch.
217
+ @context_gen = 0
218
+ @find_cache_dirty = true
219
+ @find_cache_kind = nil
220
+ @find_cache_arg = nil
221
+ @find_cache_ctx = nil
222
+ @find_cache_value = nil
223
+ @document_handle = 0
224
+ @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
+ @polling_grace = nil
226
+ @last_polled_gen = nil
227
+ @idle_settle_polls = 0
228
+ @ticking = false
229
+ @history = []
230
+ @history_idx = -1
231
+ @modal_handlers = []
232
+ # Geolocation override (CDP-ish). nil = no override configured →
233
+ # navigator.geolocation reports POSITION_UNAVAILABLE. Ruby-backed so
234
+ # it survives the per-call VM rebuilds, like web storage. Read by the
235
+ # `__csimGeolocationState` host fn.
236
+ @geolocation = nil
237
+ # Per-test action trace. `@trace` is the live recorder; `reset!`
238
+ # moves it to `@pending_trace` so an after-hook running after
239
+ # session reset still has access. `@trace_mode` is cached at
240
+ # construction so `record_action`'s hot path doesn't pay an
241
+ # ENV lookup.
242
+ #
243
+ # `CSIM_TRACE=off|on-failure|full` (default `on-failure`):
244
+ # - `off` — no recording at all; `record_action` early-exits.
245
+ # - `on-failure` — record kind/url/console/network in-memory;
246
+ # snapshot `dom_after` only on action error.
247
+ # - `full` — record + snapshot DOM after every action
248
+ # (debug-heavy).
249
+ # File output is orthogonal — `CSIM_TRACE_DIR=path` makes the
250
+ # test-runner hook persist the trace JSON there; unset means
251
+ # in-memory only (no files written without explicit opt-in).
252
+ @trace = nil
253
+ @pending_trace = nil
254
+ @recording_action = false
255
+ @trace_mode = parse_trace_mode(ENV['CSIM_TRACE'])
256
+ # EventSource (SSE) — per-Browser handle counter, background
257
+ # reader threads, and a thread-safe Queue of parsed events
258
+ # awaiting delivery into the VM. Threads do the long-lived
259
+ # HTTP read; the main thread polls the Queue in `settle` and
260
+ # dispatches via `__csim_deliverEventSourceEvents`.
261
+ @event_source_seq = 0
262
+ @event_source_threads = {}
263
+ @event_source_queue = Thread::Queue.new
264
+ # Hijacked-XHR delivery — per-Browser handle counter,
265
+ # background threads, and a Queue of completed responses for
266
+ # Rack calls where the middleware used `rack.hijack` to hold
267
+ # the connection open (the contract `message_bus`'s long-poll
268
+ # uses to push publishes immediately rather than waiting for
269
+ # the next client poll). Same shape as SSE: the thread reads
270
+ # the hijacked pipe; main settle drains the Queue and
271
+ # dispatches via `__csim_deliverHijackedFetches`.
272
+ @hijack_fetch_seq = 0
273
+ @hijack_fetch_threads = {}
274
+ @hijack_fetch_queue = Thread::Queue.new
275
+ # Web Workers — per-Browser handle counter, per-worker
276
+ # {thread, inbox} pair, and a shared outbox the main settle
277
+ # drains via `__csim_deliverWorkerMessages`. Each worker
278
+ # thread owns its own V8 Context / QuickJS VM (real isolate);
279
+ # cross-isolate messaging is JSON-marshalled.
280
+ @worker_seq = 0
281
+ @workers = {}
282
+ @worker_outbox = Thread::Queue.new
283
+ # Outstanding posts-to-worker; `polling?` stays true while > 0
284
+ # so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
285
+ # isn't starved by the settle_gen idle gate.
286
+ @worker_in_flight = 0
287
+ # Cross-isolate `blob:` store. Worker isolates can't see the
288
+ # main scope's `__csimBlobs` Map, so we mirror bytes here and
289
+ # workers resolve them through a host fn.
290
+ @blob_registry = {}
291
+ @blob_registry_lock = Mutex.new
292
+ # Postmessage transferable-buffer store. Large Uint8Array /
293
+ # ArrayBuffer payloads cross isolates as a Ruby-side byte ID
294
+ # rather than a JSON base64 string, so peak JS heap stays flat.
295
+ @transfer_buffer_lock = Mutex.new
296
+ @transfer_buffers = {}
297
+ @transfer_buffer_seq = 0
298
+ end
299
+
300
+ # Worker thread polling and termination intervals — split so a
301
+ # tuning change to one doesn't accidentally rebind the other.
302
+ WORKER_POLL_INTERVAL = 0.05
303
+ WORKER_TERMINATE_GRACE = 0.05
304
+ private_constant :WORKER_POLL_INTERVAL, :WORKER_TERMINATE_GRACE
305
+
306
+ # `js_engine` picks the JS runtime: `:v8` (rusty_racer, fastest
307
+ # per-spec) or `:quickjs` (quickjs.rb, smaller per-VM footprint —
308
+ # wins on parallelism). Both gems are soft dependencies; pass nil
309
+ # to auto-select whichever is installed.
310
+ ENGINE_GEM = {v8: %w[rusty_racer], quickjs: %w[quickjs]}.freeze
311
+ private_constant :ENGINE_GEM
312
+
313
+ def build_runtime(engine)
314
+ engine ||= detect_js_engine
315
+ case engine
316
+ when :v8
317
+ require_relative 'v8_runtime'
318
+ V8Runtime.new(self)
319
+ when :quickjs
320
+ require_relative 'quickjs_runtime'
321
+ QuickJSRuntime.new(self)
322
+ else
323
+ raise ArgumentError, "unknown CSIM_JS_ENGINE #{engine.inspect}; expected one of #{JS_ENGINES.inspect}"
324
+ end
325
+ end
326
+
327
+ # `CSIM_JS_ENGINE` forces the engine (overriding auto-detect); otherwise
328
+ # iterate `JS_ENGINES` in preference order — V8 first because JIT wins
329
+ # per-spec wall time, QuickJS second when only the smaller-footprint
330
+ # engine is installed.
331
+ private def detect_js_engine
332
+ if (env = ENV['CSIM_JS_ENGINE'].to_s) && !env.empty?
333
+ sym = env.to_sym
334
+ return sym if JS_ENGINES.include?(sym)
335
+ raise ArgumentError, "unknown CSIM_JS_ENGINE #{env.inspect}; expected one of #{JS_ENGINES.inspect}"
336
+ end
337
+ JS_ENGINES.find {|e| ENGINE_GEM.fetch(e).any? {|g| Gem.loaded_specs.key?(g) } } ||
338
+ raise(LoadError, "capybara-simulated needs a JS engine: add one of #{ENGINE_GEM.values.map {|gems| "`gem '#{gems.first}'`" }.join(' / ')} to your Gemfile")
339
+ end
340
+
341
+ # ── Capybara DSL surface ────────────────────────────────────
342
+
343
+ # Address-bar navigation: no Referer, and relative paths resolve
344
+ # against the host root (not the current page's directory).
84
345
  def visit(url)
85
- navigate(:get, resolve_visit_url(url), [], referer: false)
346
+ navigate(resolve_visit_url(url), referer: nil)
86
347
  end
87
348
 
349
+ URL_UNSAFE_CHARS = %r{[^!*'();:@&=+$,/?#\[\]A-Za-z0-9\-._~%]}n.freeze
350
+ private_constant :URL_UNSAFE_CHARS
351
+
88
352
  def resolve_visit_url(url)
89
- return @current_url if !url.nil? && !url.empty? && @current_url && url == @current_url
90
- url = '/' if url.nil? || url.empty?
91
- return url if url.start_with?('http://', 'https://')
92
- # Resolve relative paths against the app host's root (not the
93
- # currently displayed page), matching Capybara's `visit` semantics
94
- # for rack_test-style drivers.
95
- host = Capybara.app_host
96
- if host.nil?
97
- uri = URI.parse(@current_url || DEFAULT_HOST)
98
- host = "#{uri.scheme}://#{uri.host}#{uri.port ? ":#{uri.port}" : ''}/"
99
- end
100
- URI.join(host, url).to_s
353
+ s = url.to_s
354
+ unless s =~ %r{\A[a-z]+://}i
355
+ host_root = (begin URI.parse(@current_url) rescue nil end)&.tap {|u| u.path = ''; u.query = nil; u.fragment = nil }&.to_s || @default_host
356
+ host_root = host_root.sub(/\/+$/, '')
357
+ s = "/#{s}" unless s.start_with?('/')
358
+ s = "#{host_root}#{s}"
359
+ end
360
+ # Real browsers percent-encode characters that aren't legal in their
361
+ # URL position before issuing the request. Skip the escape pass when
362
+ # the input is already clean (the common case).
363
+ s.match?(URL_UNSAFE_CHARS) ? URI::DEFAULT_PARSER.escape(s, URL_UNSAFE_CHARS) : s
364
+ end
365
+
366
+ # Queued URLs older than this (real wall clock) are treated as
367
+ # stale and dropped on the next `current_url` read. Capybara's
368
+ # default polling interval is 50 ms, so a `have_current_path`
369
+ # walk runs through its iterations well under this threshold;
370
+ # a `page.current_url` read between unrelated user actions
371
+ # arrives long after the prior action's settle pushed
372
+ # intermediates, falls past the cutoff, and surfaces the
373
+ # current URL directly.
374
+ RECENT_URLS_STALE_AGE_MS = 250
375
+
376
+ def current_url
377
+ tick_real_time
378
+ # `tick_real_time` may have queued URL transitions via
379
+ # `record_url_transition`. A polling matcher
380
+ # (`have_current_path`) calls here once per ~50 ms iteration
381
+ # and shifts one entry per call so it walks the same
382
+ # intermediate-URL chain a real browser would have observed
383
+ # before microtasks all collapsed onto the final URL — the
384
+ # finish_installation_spec wizard chain depends on this for
385
+ # the `/wizard` step before the JS replaceWith to
386
+ # `/wizard/steps/setup` lands. A non-polling read
387
+ # (`topic_url = page.current_url` long after the prior
388
+ # action's settle) just wants the current URL; drop entries
389
+ # older than the polling-cadence window so they don't leak
390
+ # into an unrelated call (tags_spec:221's composer-submit
391
+ # leaves `/new-topic` queued, and the read happens minutes
392
+ # of test wall-clock later).
393
+ if @recent_urls_last_push_at && @recent_urls.any?
394
+ age_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - @recent_urls_last_push_at
395
+ @recent_urls.clear if age_ms > RECENT_URLS_STALE_AGE_MS
396
+ end
397
+ return @recent_urls.shift if @recent_urls.any?
398
+ @current_url || ''
399
+ end
400
+
401
+ # Called whenever `@current_url` is about to be set to a new
402
+ # value during a page-load drain or a settle tick driven by a
403
+ # user action; queues the prior URL for surface-via-
404
+ # `current_url` so a polling matcher walks the intermediate
405
+ # chain. Out-of-band JS-driven pushStates
406
+ # (`execute_script("history.pushState(...)")`) bypass the queue —
407
+ # they have no chain of microtask-driven transitions to walk,
408
+ # and the caller expects to read the new URL one-shot. Bounded
409
+ # to size 8 to guard against runaway chains; `current_url`'s
410
+ # staleness check drops the rest on any read past the polling-
411
+ # cadence window. Without the queue the finish_installation
412
+ # wizard chain's intermediate `/wizard` would be invisible:
413
+ # the JS-side `replaceWith` to `/wizard/steps/setup` lands
414
+ # during a tick, so by the time Capybara polls `@current_url`
415
+ # is already the final URL.
416
+ def record_url_transition(new_url)
417
+ return unless @ticking || @navigating
418
+ old = @current_url
419
+ return if old.nil? || old.to_s.empty?
420
+ return if old.to_s == new_url.to_s
421
+ # The URL the action started from is the starting point, not an
422
+ # intermediate it walked through — don't surface it to a polling
423
+ # (or one-shot) `current_url` as a step. Genuine mid-action
424
+ # intermediates (a load to /wizard, *then* a replaceState to
425
+ # /wizard/steps/setup) differ from the baseline and still queue.
426
+ return if @action_url_baseline && old.to_s == @action_url_baseline.to_s
427
+ @recent_urls << old.to_s
428
+ @recent_urls.shift while @recent_urls.size > 8
429
+ @recent_urls_last_push_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
430
+ end
431
+
432
+ def find_css(css, context_handle = nil)
433
+ s = css.to_s
434
+ return find_xpath(s, context_handle) if xpath_shaped?(s)
435
+ find_with_timer_fallback(:css, s, context_handle) do
436
+ @runtime.call('__csimQuery', context_handle || @document_handle, s).to_a
437
+ rescue StandardError => e
438
+ # Invalid selector → empty result. Callers that genuinely
439
+ # need the throw go through `evaluate_script`.
440
+ raise unless syntax_or_invalid_selector_error?(e)
441
+ []
442
+ end
101
443
  end
102
- def refresh
103
- return unless @current_url
104
- # Browsers re-issue the previous request as-is on F5 (after a
105
- # confirmation prompt for non-GETs, which our driver skips).
106
- method = @last_method || :get
107
- navigate(method, @current_url, @last_fields || [],
108
- enctype: @last_enctype || 'application/x-www-form-urlencoded',
109
- replace_history: true)
110
- end
111
- def go_back
112
- return if @history_index <= 0
113
- @history_index -= 1
114
- navigate(:get, @history[@history_index], [], history_move: true)
115
- end
116
- def go_forward
117
- return if @history_index >= @history.size - 1
118
- @history_index += 1
119
- navigate(:get, @history[@history_index], [], history_move: true)
120
- end
121
-
122
- def html = call_runtime('html')
123
- def title = call_runtime('title')
124
-
125
- def find_xpath(xpath, context_id = nil) = Array(call_runtime('findXPath', xpath, context_id))
126
- def find_css(css, context_id = nil) = Array(call_runtime('findCSS', css, context_id))
127
-
128
- def all_text(id) = call_runtime('allText', id).to_s
129
- def visible_text(id) = call_runtime('visibleText', id).to_s
130
- def inner_html(id) = call_runtime('innerHTML', id).to_s
131
- def outer_html(id) = call_runtime('outerHTML', id).to_s
132
- def tag_name(id) = call_runtime('tagName', id).to_s
133
- def attr(id, name) = call_runtime('attr', id, name.to_s)
134
- def prop(id, name) = call_runtime('prop', id, name.to_s)
135
- def value(id) = call_runtime('value', id)
136
- def visible?(id) = !!call_runtime('visible', id)
137
- def checked?(id) = !!call_runtime('checked', id)
138
- def selected?(id) = !!call_runtime('selected', id)
139
- def disabled?(id) = !!call_runtime('disabled', id)
140
- def readonly?(id) = !!call_runtime('readonly', id)
141
- def multiple?(id) = !!call_runtime('multiple', id)
142
- def path(id) = call_runtime('path', id).to_s
143
- def rect(id) = call_runtime('rect', id) || {}
144
-
145
- def set_value(id, value)
146
- # setValue may return a submit-descriptor when the user typed `\n`
147
- # into the only text input of a form (HTML implicit submission).
148
- result = call_runtime('setValue', id, format_set_value(value))
149
- follow(result) if result.is_a?(Hash) && result['action']
444
+
445
+ def find_first_css(css, context_handle = nil)
446
+ s = css.to_s
447
+ find_with_timer_fallback(:css_first, s, context_handle) do
448
+ h = @runtime.call('__csimQueryOne', context_handle || @document_handle, s).to_i
449
+ h.zero? ? nil : h
450
+ rescue StandardError => e
451
+ raise unless syntax_or_invalid_selector_error?(e)
452
+ nil
453
+ end
454
+ end
455
+
456
+ # JS-side selector parser throws a `DOMException('csim: …',
457
+ # 'SyntaxError')`. The JS engine surfaces it as a `…::SyntaxError`
458
+ # (QuickJS via dynamic-named class) or, under V8, a
459
+ # `RustyRacer::RuntimeError` whose message is `"SyntaxError: csim: …"`.
460
+ # Match the `csim: ` marker anywhere in the message (it's no longer at
461
+ # the start once the DOMException name is prefixed) or the class suffix,
462
+ # so neither gem becomes a hard dependency.
463
+ def syntax_or_invalid_selector_error?(e)
464
+ e.class.name.to_s.end_with?('::SyntaxError') ||
465
+ e.message.to_s.include?('csim: ')
466
+ end
467
+
468
+ def xpath_shaped?(s)
469
+ # Cheap probe: anything starting with `/` (absolute or relative
470
+ # XPath), `(` (grouped XPath like `(//a)[1]`), or `./` /
471
+ # `..` (XPath current-node + step) is XPath. We can't treat a
472
+ # bare leading `.` as XPath because CSS class selectors look
473
+ # exactly like that (`.contextual`); only the `./` form is
474
+ # unambiguous.
475
+ !!(s =~ %r{\A\s*(?:/|\(\s*/|\./|\.\.)})
476
+ end
477
+
478
+ # XPath is evaluated *inside* V8 against the live JS DOM via
479
+ # the xpathway engine (bundled, installed at snapshot build). One IPC per
480
+ # `find_xpath` no serialise + reparse round-trip.
481
+ def find_xpath(xpath, context_handle = nil)
482
+ xpath_str = xpath.to_s
483
+ find_with_timer_fallback(:xpath, xpath_str, context_handle) do
484
+ @runtime.call('__csimEvaluateXPath', xpath_str, context_handle || 0).to_a
485
+ end
486
+ end
487
+
488
+ def find_with_timer_fallback(kind, arg, ctx)
489
+ tick_real_time if timer_wait_elapsed?
490
+ result = cached_find(kind, arg, ctx) { yield }
491
+ # An empty result is the wait-for-it case: Capybara is retrying for
492
+ # an element that hasn't appeared yet. Re-tick so the next poll
493
+ # observes anything an active timer OR a background-IO channel
494
+ # (Worker / EventSource / a held long-poll publish) is about to
495
+ # deliver. Gating on `@timers_active` alone misses the held-poll
496
+ # case — a MessageBus subscription waiting on a cross-session
497
+ # publish has NO pending JS timer (the re-poll only schedules after
498
+ # the current poll returns), so `@timers_active` is false while
499
+ # `hijack_fetch_pending?` is true. Without `async_io_pending?` here
500
+ # the delivered message never reaches the DOM during find-polling
501
+ # (only `evaluate_script`, which ticks unconditionally, would see
502
+ # it). Non-empty results keep the fast path — no extra tick.
503
+ return result unless empty_find_result?(result) && (@timers_active || async_io_pending?)
504
+
505
+ tick_real_time
506
+ return result unless @find_cache_dirty
507
+
508
+ cached_find(kind, arg, ctx) { yield }
509
+ end
510
+
511
+ def empty_find_result?(result)
512
+ result.nil? || (result.respond_to?(:empty?) && result.empty?)
513
+ end
514
+
515
+ # Minimum wall-clock gap before find() re-ticks. The smoke
516
+ # contract is "first find returns the current DOM without
517
+ # firing pending timers" — apps assert `have_selector` on a
518
+ # `<div>` whose constructor schedules a `setTimeout(0)` to
519
+ # remove it, expecting to catch the div before removal. Keep
520
+ # this above one Ruby boundary so a single visit+find pair
521
+ # doesn't accidentally tick.
522
+ FIND_PRE_TICK_MIN_S = 0.05
523
+ def timer_wait_elapsed?
524
+ @timers_active &&
525
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_tick_ts) >= FIND_PRE_TICK_MIN_S
526
+ end
527
+
528
+ # Cheap O(1) gate: is there any non-timer async channel with traffic
529
+ # that `tick_real_time` would drain? `tick_real_time` itself runs
530
+ # exactly when `worker_pending? || event_source_pending? ||
531
+ # hijack_fetch_pending?` (plus `@timers_active`), and each of those
532
+ # predicates is a single `.empty?` / counter check. Reusing them
533
+ # here lets an attribute poll whose value is delivered only by a
534
+ # Worker / SSE / hijacked-fetch message (with no active timer) still
535
+ # drain that channel, without paying an unconditional drain on
536
+ # timer-driven runloop pages.
537
+ def async_io_pending?
538
+ worker_pending? || event_source_pending? || hijack_fetch_pending?
539
+ end
540
+
541
+ # Single-slot cache for the most recent find_xpath / find_css /
542
+ # find_first_css result. Capybara's `synchronize` retry loop
543
+ # re-issues the same find on every poll while waiting for an
544
+ # element to appear or disappear; if no DOM-mutating event has
545
+ # happened since the last call (no timer fired, no click / set /
546
+ # navigate), the result is guaranteed identical and we can skip
547
+ # the V8 round-trip + xpathway traversal.
548
+ def cached_find(kind, arg, ctx)
549
+ if !@find_cache_dirty &&
550
+ @find_cache_kind == kind &&
551
+ @find_cache_ctx == ctx &&
552
+ @find_cache_arg == arg
553
+ return @find_cache_value
554
+ end
555
+ result = yield
556
+ @find_cache_kind = kind
557
+ @find_cache_arg = arg
558
+ @find_cache_ctx = ctx
559
+ @find_cache_value = result
560
+ @find_cache_dirty = false
150
561
  result
151
562
  end
152
563
 
153
- # Capybara hands Date/DateTime/Time objects through to the driver as-is
154
- # but mini_racer can't serialise them. Pre-format them to the strings
155
- # an HTML5 input expects so they round-trip via JS.
156
- def format_set_value(value)
157
- case value
158
- when Array then value.map {|v| format_set_value(v) }
159
- when Date then value.strftime('%Y-%m-%d')
160
- when DateTime then value.strftime('%Y-%m-%dT%H:%M')
161
- when Time then value.strftime('%Y-%m-%dT%H:%M')
162
- else value
564
+ # Any operation that may have mutated the DOM (click, set,
565
+ # send_keys, navigate, hover, …) must call this so the next find
566
+ # falls through to a fresh V8 query. Timer drains that fire any
567
+ # callbacks also dirty (see `tick_real_time`).
568
+ def invalidate_find_cache
569
+ @find_cache_dirty = true
570
+ end
571
+
572
+ def text(handle) = @runtime.call('__csimText', handle).to_s
573
+ def tag(handle) = @runtime.call('__csimTag', handle).to_s
574
+ def attr(handle, name) = @runtime.call('__csimAttr', handle, name.to_s)
575
+ def inner_html(handle) = @runtime.call('__csimInnerHTML', handle).to_s
576
+ def outer_html(handle) = @runtime.call('__csimOuterHTML', handle).to_s
577
+ def file_input?(handle)
578
+ tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
579
+ end
580
+ def visible?(handle) = @runtime.call('__csimVisible', handle) ? true : false
581
+
582
+ # Capybara::Driver::Node surface — Node calls `check_stale`
583
+ # before each read, and that advances the virtual clock.
584
+ def all_text(handle) = text(handle)
585
+ def visible_text(handle) = @runtime.call('__csimVisibleText', handle).to_s
586
+ def tag_name(handle) = tag(handle)
587
+ def value(handle) = @runtime.call('__csimValue', handle)
588
+ def disabled?(handle) = @runtime.call('__csimDisabled', handle)
589
+ # HTML spec: `<option>.selected` IDL is true when the `selected`
590
+ # *attribute* is set OR when no sibling option has `selected` and
591
+ # this is the first non-disabled option of a single-select
592
+ # `<select>` (implicit default). Capybara's `have_select(selected:
593
+ # "Choose an option")` filter calls `selected?` on each option;
594
+ # without the implicit-default branch, a select with no explicit
595
+ # `<option selected>` reports no selected options and the matcher
596
+ # fails even though the first option *is* the currently chosen
597
+ # one in real browsers.
598
+ def option_selected?(h) = !!@runtime.call('__csimOptionSelected', h)
599
+ def shadow_root_handle(handle)
600
+ h = @runtime.call('__csimShadowRoot', handle).to_i
601
+ h.zero? ? nil : h
602
+ end
603
+ def computed_style(handle, names)
604
+ tick_real_time
605
+ result = @runtime.call('__csimComputedStyle', handle, names.map(&:to_s))
606
+ return names.to_h {|n| [n, ''] } unless result.is_a?(Hash)
607
+ result.transform_keys(&:to_s)
608
+ end
609
+ def node_path(handle) = @runtime.call('__csimNodePath', handle).to_s
610
+
611
+ def lookup_node(handle)
612
+ handle if @runtime.call('__csimAlive', handle)
613
+ end
614
+
615
+ def check_stale(handle, initial, gen = nil)
616
+ return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
617
+
618
+ tick_real_time
619
+ return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
620
+
621
+ raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
622
+ end
623
+
624
+ # `tick_real_time` may have rebuilt the DOM (Ember route hydration
625
+ # finishing on its first idle tick replaces server-rendered nodes
626
+ # with fresh ones). `Node` ran check_stale before calling here,
627
+ # but that was BEFORE the tick — re-verify after so Capybara
628
+ # catches the now-stale handle and retries the find. Otherwise
629
+ # `__csim*` lookups would return null and the operation would
630
+ # silently no-op (or, in the case of `__csimClickResolve`,
631
+ # dispatch on a detached node whose listeners no longer matter).
632
+ def ensure_alive_after_tick(handle)
633
+ return if @runtime.call('__csimAlive', handle)
634
+ raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
635
+ end
636
+
637
+ def click(handle, keys = [], **opts)
638
+ mark_action_baseline
639
+ tick_real_time
640
+ invalidate_find_cache
641
+ ensure_alive_after_tick(handle)
642
+ init = click_event_init(handle, keys, opts)
643
+ delay = opts[:delay].to_f
644
+ action =
645
+ if delay > 0
646
+ # Wall-sleep between mousedown and mouseup so click handlers
647
+ # reading `Date.now()` see the elapsed gap (selenium parity).
648
+ init['mouseDownOnly'] = true
649
+ partial = @runtime.call('__csimClickResolve', handle, init)
650
+ sleep delay
651
+ @runtime.call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
652
+ else
653
+ @runtime.call('__csimClickResolve', handle, init)
654
+ end
655
+ unless action.is_a?(Hash)
656
+ settle
657
+ # Drain the download intent the click chain may have queued.
658
+ # Avo's action-download path: form submit → Turbo applies a
659
+ # turbo-stream → `StreamActions.download` → file-saver's
660
+ # `saveAs` → `setTimeout(() => click(<a download>), 0)` →
661
+ # our dispatchEvent default-action sets
662
+ # `__csimPendingDownload`. Settle bails on the first
663
+ # observable change (the stream-render mutation), so the
664
+ # await-chain inside the stream's `connectedCallback`
665
+ # (`await nextRepaint(); await performAction()` →
666
+ # `setTimeout(click(a), 0)`) hasn't reached saveAs yet —
667
+ # nudge it forward with a few alternating microtask /
668
+ # timer drain rounds, then consume directly. (Can't route
669
+ # via `tick_real_time`: post-drain `@timers_active` is
670
+ # false and it bails before its own consume_pending_*
671
+ # drains.)
672
+ if @runtime.respond_to?(:drain_microtasks) && @runtime.respond_to?(:drain_timers)
673
+ # Most clicks don't queue any timers; bail as soon as a
674
+ # round drains nothing rather than burning the full 8 engine
675
+ # round-trips. Profile (Avo actions_spec / V8): the
676
+ # unconditional loop cost ~7.7 % of wall time.
677
+ 8.times do
678
+ @runtime.drain_microtasks
679
+ break if @runtime.drain_timers(50).to_i.zero?
680
+ end
681
+ end
682
+ consume_pending_download
683
+ # Discourse's `lib/click-track.js` preventDefaults link
684
+ # clicks and routes navigation through `DiscourseURL
685
+ # .redirectTo → window.location = href`, which our setter
686
+ # parks on `@pending_location`. Drain it here so attachment
687
+ # downloads from `click_link` complete inside the click
688
+ # action.
689
+ consume_pending_location
690
+ return
691
+ end
692
+ case action['kind']
693
+ when 'navigate'
694
+ url = action['url'].to_s
695
+ target = action['target'].to_s
696
+ # `target="_blank"` (or any non-_self/_top/_parent name) opens
697
+ # in a new browsing context. URL-only multi-window mode
698
+ # records the URL against a fresh aux handle; the primary
699
+ # stays put (per HTML spec — original window is unaffected).
700
+ if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
701
+ @driver.open_aux_window(resolve_against_current(url, use_base: true))
702
+ # In-page anchor links (`#frag` / current-page + `#frag`) move
703
+ # the hash but don't fetch a new document. Pure-fragment also
704
+ # short-circuits the `<a>`s test fixtures use as click sinks.
705
+ elsif pure_fragment_navigation?(url)
706
+ update_current_hash(url)
707
+ else
708
+ # Drain any work the click handler queued before the VM gets
709
+ # rebuilt — analytics libraries (Ahoy / segment / GA) queue
710
+ # the event into a setTimeout-driven flush and rely on the
711
+ # browser firing it before navigation tears their context
712
+ # down. Without this drain the tracking POST is lost on
713
+ # every internal link click.
714
+ tick_real_time
715
+ # Link clicks honour `<base href>` (HTML spec); `visit`
716
+ # does not — that's address-bar navigation.
717
+ navigate(resolve_against_current(url, use_base: true))
718
+ end
719
+ when 'submit'
720
+ # Drain any work the click handler queued before the form
721
+ # submission. Mastodon's logout flow: submit-button click
722
+ # fires the form handler, which kicks off an axios DELETE for
723
+ # `/auth/sign_out`; the response sets
724
+ # `window.location.href = '/auth/sign_in'`. Without the
725
+ # drain, we'd submit the form (no `action` attr → current
726
+ # URL, e.g. `/start`) before the XHR resolves, landing on
727
+ # the wrong page. Loop matches the navigate branch — bail
728
+ # as soon as a drain round fires nothing.
729
+ submit_baseline_url = @current_url
730
+ if @runtime.respond_to?(:drain_microtasks) && @runtime.respond_to?(:drain_timers)
731
+ 8.times do
732
+ @runtime.drain_microtasks
733
+ break if @runtime.drain_timers(50).to_i.zero?
734
+ end
735
+ end
736
+ # If the drain queued or consumed a `location.assign`, that
737
+ # navigation supersedes the form's default submit. Honour
738
+ # pending; if `@current_url` already changed mid-drain (the
739
+ # navigate landed during a timer fire), skip the form submit
740
+ # entirely — its form handle is in a stale VM by now.
741
+ if @pending_location
742
+ consume_pending_location
743
+ elsif @current_url != submit_baseline_url
744
+ # Already navigated; nothing more to do.
745
+ else
746
+ submit_form_handle(action['formHandle'], action['submitter'])
747
+ end
748
+ when 'download'
749
+ download_link(resolve_against_current(action['url'].to_s), action['filename'].to_s)
750
+ end
751
+ end
752
+
753
+ def download_link(url, filename_hint = '')
754
+ env = Rack::MockRequest.env_for(url, method: 'GET')
755
+ env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
756
+ env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
757
+ env['HTTP_COOKIE'] = document_cookie unless @cookies.empty?
758
+ env['HTTP_REFERER'] = @current_url unless @current_url.nil? || @current_url.empty?
759
+ status, headers, body = @app.call(env)
760
+ return unless status.to_i == 200
761
+ # Fall back to the link's `download="filename"` value or the
762
+ # URL path tail when Content-Disposition is absent — `<a download>`
763
+ # is the spec hook for naming a download independently of the
764
+ # response headers.
765
+ forced_headers = headers.dup
766
+ if content_disposition_header(forced_headers).to_s.empty?
767
+ name = filename_hint.empty? ? File.basename(URI.parse(url).path.to_s) : filename_hint
768
+ forced_headers['Content-Disposition'] = %(attachment; filename="#{name}") unless name.empty?
163
769
  end
770
+ save_downloaded_response(url, forced_headers, body)
771
+ end
772
+
773
+ def pure_fragment_navigation?(url)
774
+ return true if url.start_with?('#')
775
+ return false if @current_url.nil?
776
+ target = resolve_against_current(url)
777
+ a = URI.parse(target)
778
+ b = URI.parse(@current_url)
779
+ # Same-document iff everything but the fragment matches AND the
780
+ # fragment actually changes — `a.fragment != b.fragment` covers
781
+ # both adding/changing a fragment and *clearing* one (target has
782
+ # no fragment while the current URL does, e.g. `location.hash =
783
+ # ''`). The old `!a.fragment.nil?` missed the clearing case, so a
784
+ # hash-reset turned into a full document reload.
785
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port &&
786
+ a.path == b.path && a.query == b.query && a.fragment != b.fragment
787
+ rescue URI::InvalidURIError
788
+ false
164
789
  end
165
- def select_option(id) = call_runtime('selectOption', id)
166
- def unselect_option(id) = call_runtime('unselectOption', id)
167
790
 
168
- def click(id, modifiers = {})
169
- delay = modifiers.delete('delay')
170
- if delay&.positive?
171
- call_runtime('mouseDown', id, 0, modifiers)
172
- sleep(delay)
173
- result = follow(call_runtime('click', id, 0, modifiers, true))
791
+ def update_current_hash(url)
792
+ return if @current_url.nil?
793
+ new_url = resolve_against_current(url)
794
+ @current_url = new_url
795
+ # JS-driven same-document fragment navigations (anchor clicks AND
796
+ # `location.hash`/`href`/`assign` sets) are now handled entirely in
797
+ # the VM by `tryFragmentNavigate` — they update the JS location and
798
+ # fire `hashchange` there and never round-trip through here. This
799
+ # path remains only as a defensive fallback for a fragment URL that
800
+ # reaches the Ruby navigate/pending drain by some other route; keep
801
+ # the VM's location object in sync so its `location.href` getter
802
+ # doesn't read stale.
803
+ @runtime.call('__csimUpdateLocation', new_url) if @runtime.respond_to?(:call)
804
+ end
805
+
806
+ def set_value_with_events(handle, value)
807
+ mark_action_baseline
808
+ tick_real_time
809
+ invalidate_find_cache
810
+ ensure_alive_after_tick(handle)
811
+ # `attach_file` hands us a Pathname (or Array of Pathnames);
812
+ # the marshaller rejects non-primitive types. Coerce to a path-list
813
+ # form V8 can hold — the actual multipart upload happens later
814
+ # in `build_multipart_body` during form submission.
815
+ coerced = coerce_set_value(value)
816
+ # For date/time-shaped inputs we need the type-specific
817
+ # string. Probe the handle's `type` and re-format Date / Time
818
+ # accordingly — `Date.today` → `2026-05-13` (date input) is
819
+ # already right via to_s, but `Time` needs the input-type-
820
+ # specific format.
821
+ coerced = format_temporal_value(value, handle) if value.is_a?(Date) || value.is_a?(Time)
822
+ @file_picks ||= {}
823
+ # Capybara `attach_file` calls `Node#set` with a Pathname; some
824
+ # callers pass a String path through directly. When the target
825
+ # IS a file input, promote either form into the file-list path
826
+ # so `.files` / `@file_picks` reflect the chosen file.
827
+ coerced = [coerced.to_s] if value.is_a?(Pathname)
828
+ if !coerced.is_a?(Array) && coerced.is_a?(String) && file_input?(handle)
829
+ coerced = [coerced]
830
+ end
831
+ if coerced.is_a?(Array)
832
+ paths = coerced.reject(&:empty?)
833
+ @file_picks[handle] = paths
834
+ # Expose File-list metadata to the JS side BEFORE setting the
835
+ # value: __csimSetValue fires input + change synchronously,
836
+ # and Redmine's onchange="addInputFiles(this)" reads
837
+ # `inputEl.files` — if we set files after, the handler sees
838
+ # an empty FileList and tears down the input.
839
+ file_infos = paths.map {|p|
840
+ stat = (File.stat(p) rescue nil)
841
+ {
842
+ 'name' => File.basename(p),
843
+ 'size' => stat ? stat.size : 0,
844
+ # Real browsers tag the File with the MIME type they
845
+ # sniffed from the path / disk header. Uppy's image-type
846
+ # filter rejects files whose `type` is empty, so without
847
+ # this even a `logo.png` `attach_file` finishes uploading
848
+ # 0 bytes through the validator and the composer's
849
+ # `#file-uploading` flag stays set forever. Use the OS's
850
+ # extension-based guess (matches what selenium / Chromium
851
+ # do on these paths) and fall back to empty when the
852
+ # extension is unknown.
853
+ 'type' => mime_type_for_path(p),
854
+ 'lastModified' => stat ? (stat.mtime.to_f * 1000).to_i : 0
855
+ }
856
+ }
857
+ @runtime.call('__csimSetFiles', handle, file_infos)
858
+ # Mirror real browser: <input type=file>.value reflects only
859
+ # the filename of the first chosen file (security-faked path).
860
+ # __csimSetValue dispatches input + change synchronously.
861
+ js_value = paths.first ? File.basename(paths.first) : ''
862
+ @runtime.call('__csimSetValue', handle, js_value)
174
863
  else
175
- result = follow(call_runtime('click', id, 0, modifiers))
864
+ @runtime.call('__csimSetValue', handle, coerced)
865
+ end
866
+ drain_after_user_action
867
+ end
868
+
869
+ def coerce_set_value(v)
870
+ case v
871
+ when Pathname then v.to_s
872
+ when Array then v.map {|x| x.is_a?(Pathname) ? x.to_s : x.to_s }
873
+ else v
176
874
  end
177
- drain_async_timers
178
- result
179
875
  end
180
- def right_click(id, modifiers = {})
181
- delay = modifiers.delete('delay')
182
- if delay&.positive?
183
- call_runtime('mouseDown', id, 2, modifiers)
184
- sleep(delay)
185
- call_runtime('rightClick', id, modifiers, true)
876
+
877
+ def format_temporal_value(v, handle)
878
+ type = attr(handle, 'type').to_s.downcase
879
+ case type
880
+ when 'date' then v.respond_to?(:strftime) ? v.strftime('%Y-%m-%d') : v.to_s
881
+ when 'time' then v.respond_to?(:strftime) ? v.strftime('%H:%M') : v.to_s
882
+ when 'datetime-local' then v.respond_to?(:strftime) ? v.strftime('%Y-%m-%dT%H:%M') : v.to_s
883
+ when 'month' then v.respond_to?(:strftime) ? v.strftime('%Y-%m') : v.to_s
884
+ when 'week' then v.respond_to?(:strftime) ? v.strftime('%Y-W%V') : v.to_s
186
885
  else
187
- call_runtime('rightClick', id, modifiers)
886
+ v.is_a?(Date) ? v.strftime('%Y-%m-%d') : v.to_s
188
887
  end
189
- drain_async_timers
190
888
  end
191
- def double_click(id, modifiers = {})
192
- result = call_runtime('doubleClick', id, modifiers)
193
- drain_async_timers
194
- result
889
+
890
+ def file_picks_for(handle)
891
+ (@file_picks && @file_picks[handle]) || []
892
+ end
893
+
894
+ # JS-side `__HostBackedFile.text()` / `arrayBuffer()` route through
895
+ # this to read attached file bytes on demand — ActiveStorage's
896
+ # `DirectUpload` MD5-chunks the file via FileReader before
897
+ # POSTing to `/rails/active_storage/direct_uploads`. Returns
898
+ # the requested byte range as base64 so binary content
899
+ # survives the engine string boundary (same approach as
900
+ # `__csimReadBlobBase64`).
901
+ def read_file_pick(handle, index, start = nil, finish = nil)
902
+ paths = file_picks_for(handle.to_i)
903
+ path = paths && paths[index.to_i]
904
+ return nil unless path && File.exist?(path)
905
+ size = File.size(path)
906
+ s = [start.to_i, 0].max
907
+ e = finish.nil? ? size : [finish.to_i, size].min
908
+ return Base64.strict_encode64('') if e <= s
909
+ bytes = File.open(path, 'rb') do |f|
910
+ f.seek(s)
911
+ f.read(e - s)
912
+ end
913
+ Base64.strict_encode64(bytes || '')
914
+ end
915
+
916
+ def right_click(handle, keys = [], **opts)
917
+ mark_action_baseline
918
+ tick_real_time
919
+ invalidate_find_cache
920
+ ensure_alive_after_tick(handle)
921
+ init = {'bubbles' => true, 'cancelable' => true, 'button' => 2, 'which' => 3}.merge(click_event_init(handle, keys, opts))
922
+ @runtime.call('__csimDispatchEvent', handle, 'mousedown', init)
923
+ sleep opts[:delay].to_f if opts[:delay].to_f > 0
924
+ @runtime.call('__csimDispatchEvent', handle, 'mouseup', init)
925
+ @runtime.call('__csimDispatchEvent', handle, 'contextmenu', init)
926
+ end
927
+
928
+ # HTML5 drag-and-drop simulation. Capybara routes `Element#drop`
929
+ # here with a flat list of paths / Pathnames / Hashes; build a
930
+ # DataTransfer-shaped object and dispatch dragenter / dragover /
931
+ # drop in sequence.
932
+ def drop(handle, args)
933
+ tick_real_time
934
+ invalidate_find_cache
935
+ ensure_alive_after_tick(handle)
936
+ items = args.flat_map {|arg| drop_items(arg) }
937
+ @runtime.call('__csimDropOnto', handle, items)
938
+ end
939
+
940
+ # Element-to-element drag. Capybara's `Element#drag_to(target,
941
+ # delay: …)` lands here. Fires the HTML5 drag event sequence on
942
+ # the source / target pair (mousedown → dragstart → dragenter →
943
+ # dragover → drop → dragend) with a shared DataTransfer. Discourse
944
+ # sidebar reorder + Avo Sortable-shaped widgets read the
945
+ # `event.offsetY` to decide "above vs below"; without a layout
946
+ # engine we report 0, which routes drops above the target.
947
+ def drag_to(source_handle, target_handle, **_opts)
948
+ mark_action_baseline
949
+ tick_real_time
950
+ invalidate_find_cache
951
+ ensure_alive_after_tick(source_handle)
952
+ ensure_alive_after_tick(target_handle)
953
+ @runtime.call('__csimDragOnto', source_handle, target_handle)
954
+ drain_after_user_action
955
+ end
956
+ def drop_items(arg)
957
+ case arg
958
+ when Hash
959
+ arg.map {|type, value| {'kind' => 'string', 'type' => type.to_s, 'value' => value.to_s} }
960
+ when ->(x) { x.respond_to?(:to_path) }
961
+ path = arg.to_path
962
+ [{'kind' => 'file', 'name' => File.basename(path), 'path' => path}]
963
+ when String
964
+ [{'kind' => 'file', 'name' => File.basename(arg), 'path' => arg}]
965
+ else
966
+ []
967
+ end
195
968
  end
196
- def hover(id)
197
- call_runtime('hover', id).tap { drain_async_timers }
969
+
970
+ def double_click(handle, keys = [], **opts)
971
+ mark_action_baseline
972
+ tick_real_time
973
+ invalidate_find_cache
974
+ ensure_alive_after_tick(handle)
975
+ # UI Events spec: two full mousedown→mouseup→click chains
976
+ # before the trailing `dblclick`. Jspreadsheet (table-builder's
977
+ # `.jss_worksheet`) enters edit mode on the inner mousedown.
978
+ 2.times { @runtime.call('__csimClickResolve', handle, opts) }
979
+ init = {'bubbles' => true, 'cancelable' => true}.merge(click_event_init(handle, keys, opts))
980
+ @runtime.call('__csimDispatchEvent', handle, 'dblclick', init)
981
+ # Real browsers' default-action on dblclick selects the word
982
+ # under the cursor — ProseMirror / Tiptap "paste URL over
983
+ # selection wraps with link" tests rely on the word being
984
+ # selected before the paste.
985
+ @runtime.call('__csimSelectWordAt', handle)
986
+ settle
987
+ end
988
+
989
+ MODIFIER_KEYS = {
990
+ shift: 'shiftKey',
991
+ control: 'ctrlKey',
992
+ ctrl: 'ctrlKey',
993
+ alt: 'altKey',
994
+ option: 'altKey',
995
+ meta: 'metaKey',
996
+ command: 'metaKey'
997
+ }.freeze
998
+ MODIFIER_KEY_NAMES = MODIFIER_KEYS.keys.to_set.freeze
999
+ def modifier_flags(keys)
1000
+ Array(keys).each_with_object({}) {|k, h|
1001
+ field = MODIFIER_KEYS[k.is_a?(Symbol) ? k : k.to_sym]
1002
+ h[field] = true if field
1003
+ }
198
1004
  end
199
- def trigger(id, evt)
200
- call_runtime('trigger', id, evt.to_s).tap { drain_async_timers }
1005
+
1006
+ # Resolve click offset against the element's CSS-declared box.
1007
+ # `opts[:offset] == :center` means "x/y is relative to the
1008
+ # element's centre" (Capybara's w3c_click_offset semantics);
1009
+ # otherwise the offset is relative to the top-left. We don't run
1010
+ # a real layout engine — `__csimElementRect` reads
1011
+ # top / left / width / height from the cascade so tests that
1012
+ # declare those values via CSS see honest coordinates.
1013
+ def click_event_init(handle, keys, opts)
1014
+ out = modifier_flags(keys)
1015
+ has_xy = opts[:x] || opts[:y]
1016
+ center = opts[:offset] == :center || !has_xy
1017
+ if has_xy || center
1018
+ rect = @runtime.call('__csimElementRect', handle)
1019
+ base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1020
+ base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1021
+ out['clientX'] = base_x + opts[:x].to_f
1022
+ out['clientY'] = base_y + opts[:y].to_f
1023
+ end
1024
+ out
201
1025
  end
202
- def focus(id) = call_runtime('focus', id)
203
- def blur(id) = call_runtime('blur', id)
204
- def active_element = call_runtime('activeElement')
205
1026
 
206
- def drop(id, items)
207
- call_runtime('drop', id, items).tap { drain_async_timers }
1027
+ def hover(handle)
1028
+ mark_action_baseline
1029
+ tick_real_time
1030
+ invalidate_find_cache
1031
+ ensure_alive_after_tick(handle)
1032
+ # Set `document._hoverElement` so `:hover` pseudo-class matches
1033
+ # resolve against this element (Redmine's gantt tooltips +
1034
+ # context-menu submenus rely on CSS `:hover`). The host fn
1035
+ # call into `__csimSetHover` does the slot update on the JS
1036
+ # side AND fires `mouseover` / `mouseenter` — keeping the
1037
+ # state-set and dispatch on the same path avoids the
1038
+ # double-eval recursion the inlined `globalThis.document.
1039
+ # _hoverElement = ...` triggered (the eval string ran inside
1040
+ # a fresh microtask that re-entered the hover listeners).
1041
+ @runtime.call('__csimSetHover', handle)
1042
+ end
1043
+
1044
+ def dispatch_event(handle, type, init = {})
1045
+ tick_real_time
1046
+ invalidate_find_cache
1047
+ ensure_alive_after_tick(handle)
1048
+ @runtime.call('__csimDispatchEvent', handle, type.to_s, init)
1049
+ end
1050
+
1051
+ # Capybara's `send_keys` accepts Strings and Symbols (special
1052
+ # keys: `:enter`, `:tab`, `:backspace`, …) and Array combos
1053
+ # (modifier + key). We hand each item to JS as a tagged atom so
1054
+ # the bridge can fire proper KeyboardEvents with `key` / `code`
1055
+ # / `ctrlKey` / `metaKey` / `shiftKey` filled in — required by
1056
+ # libraries that gate behaviour on the modifier flags (Redmine's
1057
+ # jstoolbar reads `event.ctrlKey || event.metaKey` for Ctrl+B /
1058
+ # Cmd+B; quote-reply Stimulus controllers read `event.key`).
1059
+ # An Array combo is the canonical "modifier + key" pattern:
1060
+ # everything but the last entry is a modifier; the last entry
1061
+ # is the key being pressed (String char or Symbol special).
1062
+ def send_keys(handle, keys)
1063
+ mark_action_baseline
1064
+ tick_real_time
1065
+ invalidate_find_cache
1066
+ ensure_alive_after_tick(handle)
1067
+ # Selenium's contract: a bare modifier symbol (`:shift`) at the
1068
+ # top level "holds" the modifier from that point on. `:null`
1069
+ # releases all modifiers. We rewrite the atom stream so each
1070
+ # following character / key carries the accumulated modifiers.
1071
+ held = []
1072
+ atoms = keys.flat_map {|k|
1073
+ case k
1074
+ when Symbol
1075
+ if k == :null
1076
+ held = []; nil
1077
+ elsif MODIFIER_KEY_NAMES.include?(k)
1078
+ held = (held + [k.to_s]).uniq; nil
1079
+ else
1080
+ held.empty? ? {'kind' => 'key', 'name' => k.to_s}
1081
+ : {'kind' => 'combo', 'parts' => held + [k.to_s]}
1082
+ end
1083
+ when String
1084
+ held.empty? ? {'kind' => 'text', 'value' => k}
1085
+ : {'kind' => 'combo', 'parts' => held + [k]}
1086
+ when Array
1087
+ parts = k.map {|x| x.is_a?(Symbol) ? x.to_s : x.to_s }
1088
+ {'kind' => 'combo', 'parts' => parts}
1089
+ end
1090
+ }.compact
1091
+ # Contenteditable hosts (ProseMirror, Trix, Tiptap) reconcile
1092
+ # their view between chars; a batched `__csimSendKeys` queues
1093
+ # all `beforeinput` events on the same microtask round and PM
1094
+ # nukes the editor wrapper when its reconciler can't apply
1095
+ # them in order. Split multi-char text atoms into per-char
1096
+ # calls with a `settle` between so PM commits each transaction
1097
+ # before the next char arrives. Plain `<input>` / `<textarea>`
1098
+ # don't need this — keep the single batched call there.
1099
+ has_multichar_text = atoms.any? {|a| a['kind'] == 'text' && a['value'].to_s.length > 1 }
1100
+ if has_multichar_text && @runtime.call('__csimIsContentEditable', handle)
1101
+ per_char = atoms.flat_map {|a|
1102
+ next a unless a['kind'] == 'text' && a['value'].to_s.length > 1
1103
+ a['value'].to_s.each_char.map {|c| {'kind' => 'text', 'value' => c} }
1104
+ }
1105
+ head, *tail = per_char
1106
+ @runtime.call('__csimSendKeys', handle, [head])
1107
+ tail.each {|atom|
1108
+ tick_real_time
1109
+ @runtime.call('__csimSendKeys', handle, [atom])
1110
+ settle
1111
+ }
1112
+ else
1113
+ @runtime.call('__csimSendKeys', handle, atoms)
1114
+ end
1115
+ drain_after_user_action
1116
+ end
1117
+
1118
+ def select_option(handle)
1119
+ mark_action_baseline
1120
+ tick_real_time
1121
+ invalidate_find_cache
1122
+ @runtime.call('__csimSelectOption', handle)
1123
+ tick_real_time
1124
+ drain_after_user_action
1125
+ end
1126
+
1127
+ def unselect_option(handle)
1128
+ mark_action_baseline
1129
+ tick_real_time
1130
+ invalidate_find_cache
1131
+ # Single-select <select>s can't have a selection cleared per
1132
+ # HTML — Capybara surfaces this as `UnselectNotAllowed`. Ask
1133
+ # the JS side whether the option's parent select is `multiple`
1134
+ # before issuing the unselect; the answer doubles as the
1135
+ # "found the right ancestor" check.
1136
+ info = @runtime.call('__csimOptionContext', handle)
1137
+ if info.is_a?(Hash) && info['hasSelect'] && !info['multiple']
1138
+ raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.'
1139
+ end
1140
+ @runtime.call('__csimUnselectOption', handle)
1141
+ tick_real_time
1142
+ drain_after_user_action
1143
+ end
1144
+
1145
+ # Read the form-submit pending intent set by JS-side
1146
+ # `form.submit()` / `form.requestSubmit()`. Called by user-action
1147
+ # entry points (click is the primary, but a `<select onchange="$
1148
+ # ('#form').submit()">` pattern reaches here through
1149
+ # select_option). Without this hop the intent sits on the slot
1150
+ # forever and the form never actually navigates / POSTs.
1151
+ def consume_pending_form_submit
1152
+ pending = @runtime.call('__csimTakePendingFormSubmit')
1153
+ return unless pending.is_a?(Hash) && pending['formHandle']
1154
+ submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'])
1155
+ end
1156
+
1157
+ # Pin the URL the page is at as a user action BEGINS — the FIRST line of
1158
+ # every action entry (click / double_click / right_click / hover / set /
1159
+ # send_keys / select / unselect). A Turbo Drive Visit the action triggers
1160
+ # is async — its pushState may fire synchronously mid-action or in a LATER
1161
+ # find-poll tick (the test's `wait_for_loaded`) — so `record_url_transition`
1162
+ # uses this baseline to recognise the pre-action URL as the action's
1163
+ # starting point, not a walkable intermediate, and skip queuing it. Set at
1164
+ # action entry (NOT the tail drain, which runs after the pushState); must
1165
+ # precede the action's first `tick_real_time` so a deferred prior-page
1166
+ # timer firing in that tick is still measured against the pre-action URL.
1167
+ # Persists until the next action (so the async case is covered) and is
1168
+ # reset by `navigate` so a stale baseline can't leak across a document
1169
+ # boundary.
1170
+ def mark_action_baseline
1171
+ @action_url_baseline = @current_url
1172
+ end
1173
+
1174
+ # Every user-action entry point (set / send_keys / select / unselect)
1175
+ # ends in this trio: drain any pending form submit, drain any
1176
+ # pending Element.click anchor activation, then settle the page.
1177
+ # Missing one site silently breaks the Stimulus filter pattern that
1178
+ # wires `link.click()` into input/change/keypress listeners.
1179
+ def drain_after_user_action
1180
+ consume_pending_form_submit
1181
+ consume_pending_navigation
1182
+ settle
1183
+ # Settle bails on first observable change, but Backburner-style
1184
+ # 500 ms debounces park behind a setTimeout that hasn't fired
1185
+ # yet. Drain one 600 ms window so input → debounce → parent
1186
+ # state propagation completes before the next Capybara call.
1187
+ @runtime.drain_timers(USER_ACTION_DRAIN_MS) if @timers_active && @runtime.respond_to?(:drain_timers)
1188
+ end
1189
+
1190
+ # Yield on the first observable change. Each iter (a) drains
1191
+ # the chained-await/`.then` microtask queue a few rounds, (b)
1192
+ # checks the JS-side `__settleGen` counter — bumped on every
1193
+ # DOM mutation / URL change — and bails if it ticked, otherwise
1194
+ # (c) advances the virtual clock to fire rAF / setTimeout that
1195
+ # the chain is parked on. Capybara's outer polling loop drives
1196
+ # the next iter on the next find / has_? — matching real
1197
+ # browsers' "one paint = one observable moment" semantics.
1198
+ #
1199
+ # This makes a user-action settle as cheap as ~4 evals when
1200
+ # the click immediately mutates DOM, and lets `wait_for_*`
1201
+ # helpers catch transient states like "modal removed before
1202
+ # the redirect_to Visit's render rebuilds it" — exactly the
1203
+ # window real browsers paint at.
1204
+ def settle
1205
+ start_gen = @runtime.settle_gen
1206
+ prev_gen = start_gen
1207
+ SETTLE_MAX_ITER.times do
1208
+ deliver_event_source_events
1209
+ deliver_worker_messages
1210
+ deliver_hijacked_fetches
1211
+ break if @runtime.settle_gen > start_gen
1212
+ break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending?
1213
+ # ONE event-loop step replaces the old drain_microtasks(4)+drain_timers(32)
1214
+ # pair: it fires due timers, runs a per-task microtask checkpoint (so
1215
+ # chained .then / MutationObserver delivery interleave spec-correctly),
1216
+ # and runs the render phase — bailing INTERNALLY on the first settleGen
1217
+ # bump (yield_on_gen), which preserves the one-observable-boundary-per-poll
1218
+ # contract. maxMs 0 when no timer is active just flushes microtasks +
1219
+ # render for the work the deliveries above queued.
1220
+ @runtime.run_loop_step(@timers_active ? SETTLE_DRAIN_MS : 0, SETTLE_MAX_ITER_TASKS, yield_on_gen: true)
1221
+ deliver_event_source_events
1222
+ deliver_worker_messages
1223
+ deliver_hijacked_fetches
1224
+ break if @runtime.settle_gen > start_gen
1225
+ # No progress this iter (no DOM/URL change observed) — the
1226
+ # remaining timers are queued for the future; bail and let
1227
+ # Capybara's wall-clock-driven poll loop drive the next tick
1228
+ # via `tick_real_time`. SSE / Worker channels keep us in
1229
+ # the loop as long as background threads have data queued.
1230
+ break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending?
1231
+ prev_gen = @runtime.settle_gen
1232
+ end
1233
+ @find_cache_dirty = true
1234
+ end
1235
+
1236
+ # Read the anchor-navigation pending intent set by JS-side
1237
+ # `el.click()` (Element.prototype.click) on an `<a href>`. Avo's
1238
+ # boolean-filter / select-filter controllers respond to `input`
1239
+ # events by building the filtered URL and calling
1240
+ # `urlRedirectTarget.click()` on a hidden anchor; the click chain
1241
+ # starts from Ruby's `set_value_with_events` rather than
1242
+ # `click`, so without a parallel drain here the navigation stays
1243
+ # queued and the page never reloads.
1244
+ def consume_pending_navigation
1245
+ pending = @runtime.call('__csimTakePendingNavigation')
1246
+ return unless pending.is_a?(Hash) && pending['url']
1247
+ url = pending['url'].to_s
1248
+ target = pending['target'].to_s
1249
+ if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
1250
+ @driver.open_aux_window(resolve_against_current(url, use_base: true))
1251
+ elsif pure_fragment_navigation?(url)
1252
+ update_current_hash(url)
1253
+ else
1254
+ tick_real_time
1255
+ navigate(resolve_against_current(url, use_base: true))
1256
+ end
208
1257
  end
209
1258
 
210
- def submit(id)
211
- result = follow(call_runtime('submit', id))
212
- drain_async_timers
213
- result
1259
+ # `<a download>` clicked synthetically (file-saver's saveAs ships
1260
+ # a freshly-created anchor through `dispatchEvent(MouseEvent
1261
+ # 'click')`). The bridge queues `{url, filename}` on
1262
+ # __csimPendingDownload during the click default-action; we drain
1263
+ # here at every tick so the file lands in `downloads_directory`
1264
+ # before Capybara's `wait_for_download` polls.
1265
+ def consume_pending_download
1266
+ pending = @runtime.call('__csimTakePendingDownload')
1267
+ return unless pending.is_a?(Hash) && pending['url']
1268
+ url = pending['url'].to_s
1269
+ filename = pending['filename'].to_s
1270
+ if url.start_with?('blob:')
1271
+ b64 = @runtime.call('__csimReadBlobBase64', url)
1272
+ return if b64.nil?
1273
+ content = Base64.decode64(b64.to_s)
1274
+ name = filename.empty? ? 'download' : filename
1275
+ dir = downloads_directory
1276
+ FileUtils.mkdir_p(dir)
1277
+ File.binwrite(File.join(dir, name), content)
1278
+ else
1279
+ download_link(resolve_against_current(url, use_base: true), filename)
1280
+ end
214
1281
  end
215
1282
 
216
- def shadow_root(id) = call_runtime('shadowRoot', id)
1283
+ # `Node#submit(*)` (Capybara DSL) hits here. Find the enclosing
1284
+ # form, serialise, post.
1285
+ def submit_form(handle)
1286
+ tick_real_time
1287
+ invalidate_find_cache
1288
+ form_handle = @runtime.call('__csimAncestorForm', handle).to_i
1289
+ return if form_handle.zero?
1290
+ submit_form_handle(form_handle, nil)
1291
+ end
217
1292
 
218
- def send_keys(id, keys)
219
- directive = call_runtime('sendKeys', id, encode_keys(keys))
220
- follow(directive)
1293
+ def title
1294
+ tick_real_time
1295
+ @runtime.call('__csimDocumentTitle').to_s
221
1296
  end
222
1297
 
223
- def execute_script(code, args = [])
224
- call_runtime('executeScript', code.to_s, encode_script_args(args))
1298
+ def html
1299
+ tick_real_time
1300
+ @runtime.call('__csimDocumentHtml').to_s
1301
+ end
1302
+
1303
+ def status_code = (@last_response_status || 200)
1304
+ # Rack 3 lowercases header names; Capybara tests do `['Content-Type']`.
1305
+ def response_headers
1306
+ (@last_response_headers || {}).each_with_object({}) {|(k, v), h|
1307
+ h[k.to_s.split('-').map(&:capitalize).join('-')] = v
1308
+ }
1309
+ end
1310
+ def record_response(status, headers)
1311
+ @last_response_status = status
1312
+ @last_response_headers = headers.to_h
1313
+ end
1314
+
1315
+ def set_header(name, value) ; @sticky_headers[name.to_s] = value.to_s ; end
1316
+ # Capybara's `current_window.resize_to(w, h)` lands here; the
1317
+ # ahoy hamburger test (mobile breakpoint at 425×694) and any
1318
+ # responsive-utility-aware test (Tailwind `m:` show / hide,
1319
+ # bootstrap `.d-md-flex`, …) depends on this surfacing through
1320
+ # the JS-side `innerWidth` / `innerHeight` so the cascade's
1321
+ # `mediaMatches` and `matchMedia()` evaluate against the test's
1322
+ # chosen viewport instead of the 1024×768 default.
1323
+ # Sticky defaults applied at `reset!`. Used by the driver to
1324
+ # carry mobile viewport / user-agent across per-test resets —
1325
+ # without these the second mobile-tagged spec sees the desktop
1326
+ # default. The user-agent also flows into `navigator.userAgent`
1327
+ # on every VM rebuild so JS-side UA branches (Discourse's
1328
+ # `viewport_based_mobile_mode = false` path) resolve correctly.
1329
+ attr_reader :default_viewport, :default_user_agent
1330
+
1331
+ def default_viewport=(vp)
1332
+ @default_viewport = vp
1333
+ set_viewport(*vp) if vp
1334
+ end
1335
+
1336
+ def default_user_agent=(ua)
1337
+ @default_user_agent = ua
1338
+ push_user_agent_to_js if ua
1339
+ end
1340
+
1341
+ def push_user_agent_to_js
1342
+ ua = @default_user_agent or return
1343
+ return unless @runtime
1344
+ @runtime.eval("try { Object.defineProperty(navigator, 'userAgent', { value: #{ua.to_json}, configurable: true }); } catch (_) {}")
1345
+ end
1346
+
1347
+ def set_viewport(w, h)
1348
+ @viewport_width = w.to_i
1349
+ @viewport_height = h.to_i
1350
+ invalidate_find_cache
1351
+ @runtime.eval("globalThis.innerWidth = #{@viewport_width}; globalThis.innerHeight = #{@viewport_height};")
1352
+ # Recompute the cascade `@media` rules against the new
1353
+ # viewport so visibility checks (Capybara `visible?`,
1354
+ # `getComputedStyle().display`) re-reflect mobile-breakpoint
1355
+ # `display: none` / `display: block` flips. Without this the
1356
+ # cascade keeps the pre-resize hide-rule set.
1357
+ @runtime.call('__csimRebuildCascade') if @document_handle.to_i > 0
1358
+ # Fire `change` events on every live MediaQueryList whose
1359
+ # match state flipped, so libraries that hold `matchMedia(...)`
1360
+ # listeners (Discourse's `TrackedMediaQuery` powering the
1361
+ # viewport-based mobile/desktop class swap) reactively
1362
+ # re-render. The JS-side function iterates `_activeQueries`
1363
+ # and dispatches only on transitions — cheap no-op when no
1364
+ # query is open.
1365
+ @runtime.call('__csimViewportChanged') if @document_handle.to_i > 0
1366
+ # Re-fire a `resize` event so libraries that re-layout on
1367
+ # resize (responsive nav, sidebar collapse) see the new size.
1368
+ @runtime.eval("try { (globalThis.dispatchEvent || function(){})(new Event('resize')); } catch (_) {}")
225
1369
  nil
226
1370
  end
227
- def evaluate_script(code, args = [])
228
- decode_script_result(call_runtime('evaluate', code.to_s, encode_script_args(args)))
1371
+ def viewport_width ; @viewport_width || 1024 ; end
1372
+ def viewport_height ; @viewport_height || 768 ; end
1373
+ # Capybara-initiated `page.go_back` runs from Ruby, not inside a
1374
+ # JS call, so it's safe to rebuild the Context synchronously. The
1375
+ # `force:` flag bypasses the deferral that `history_go` uses to
1376
+ # avoid terminating the running JS context.
1377
+ def go_back ; history_go(-1, force: true) ; end
1378
+ def go_forward ; history_go(+1, force: true) ; end
1379
+
1380
+ # Move through the history stack by `delta`. Per HTML spec, a
1381
+ # same-document traversal (within a chain of pushState entries
1382
+ # rooted at a single navigation) updates `location` and fires
1383
+ # `popstate` with the entry's state — no full reload. A cross-
1384
+ # document traversal replays the entry (full navigate / re-POST).
1385
+ def history_go(delta, force: false)
1386
+ delta = delta.to_i
1387
+ return if delta == 0
1388
+ target = @history_idx + delta
1389
+ return if target < 0 || target >= @history.size
1390
+ if same_document_traversal?(@history_idx, target)
1391
+ # Pure pushState traversal — no VM rebuild, safe to run
1392
+ # inline; the popstate dispatch happens within the current
1393
+ # call's JS context.
1394
+ @history_idx = target
1395
+ entry = @history[target]
1396
+ @current_url = entry[:url]
1397
+ @runtime.call('__csimUpdateLocation', @current_url)
1398
+ @runtime.call('__csimDispatchPopState', entry[:state])
1399
+ elsif force
1400
+ # Ruby-driven (`page.go_back`) — no live JS call to interrupt,
1401
+ # safe to rebuild the Context synchronously.
1402
+ perform_history_traverse(target)
1403
+ else
1404
+ # JS-driven (`history.back()` from a page handler): replaying
1405
+ # the history entry synchronously would call `rebuild_ctx`
1406
+ # on the still-executing Context and terminate the current
1407
+ # call with `ScriptTerminatedError` (terminating the
1408
+ # in-flight call on the isolate). Stash the intent
1409
+ # and drain after the call returns — mirrors
1410
+ # `location_assign` / `location_reload`.
1411
+ @pending_history_traverse = target
1412
+ end
229
1413
  end
230
1414
 
231
- # Capybara's async-script contract: the user script receives a
232
- # callback as its final `arguments[N]` and must invoke it (possibly
233
- # after a setTimeout) with the result. We have no real event loop,
234
- # so after kicking the script off we drive the virtual clock in
235
- # small slices until the callback fires or the wait budget is up.
236
- ASYNC_POLL_STEP_MS = 50
237
- def evaluate_async_script(code, args = [])
238
- call_runtime('startAsync', code.to_s, encode_script_args(args))
239
- budget = (Capybara.default_max_wait_time || 2).to_f
240
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget
241
- loop do
242
- status = @ctx.call('__csim.pollAsync')
243
- if status['done']
244
- raise MiniRacer::RuntimeError, status['error'] if status['error']
245
- return decode_script_result(status['value'])
1415
+ def consume_pending_history_traverse
1416
+ return unless (target = @pending_history_traverse)
1417
+ @pending_history_traverse = nil
1418
+ perform_history_traverse(target)
1419
+ end
1420
+
1421
+ private def perform_history_traverse(target)
1422
+ @history_idx = target
1423
+ replay_history_entry(@history[target])
1424
+ end
1425
+
1426
+ # Same-document = every entry between `from` and `to` (inclusive)
1427
+ # is a `:push_state` entry (or the boundary just changed state on
1428
+ # the current URL). A `:visit` entry between them means we'd
1429
+ # cross a real navigation, which needs a fresh document.
1430
+ def same_document_traversal?(from, to)
1431
+ lo, hi = [from, to].sort
1432
+ ((lo + 1)..hi).all? {|i| @history[i] && @history[i][:kind] == :push_state }
1433
+ end
1434
+
1435
+ def record_history(entry)
1436
+ # Discard any forward-history tail (a real browser drops the
1437
+ # redo stack the moment you navigate after a `go_back`).
1438
+ @history = @history[0..@history_idx] if @history_idx + 1 < @history.size
1439
+ @history << entry.merge(kind: entry[:kind] || :visit)
1440
+ @history_idx = @history.size - 1
1441
+ end
1442
+ def replay_history_entry(entry)
1443
+ return unless entry
1444
+ if entry[:method] == :post
1445
+ navigate_post(entry[:url], entry[:body], entry[:content_type], from_history: true)
1446
+ else
1447
+ navigate(entry[:url], from_history: true)
1448
+ end
1449
+ end
1450
+ def active_element_handle
1451
+ tick_real_time
1452
+ h = @runtime.call('__csimActiveElement').to_i
1453
+ h.zero? ? nil : h
1454
+ end
1455
+ # Session-level keystroke. Tab / shift-tab cycle focus; everything
1456
+ # else is routed to the currently focused element (if any) as a
1457
+ # plain keydown/keyup pair.
1458
+ def send_session_keys(keys)
1459
+ # Walk the key list with running modifier state so a Selenium-
1460
+ # style `(:shift, :enter)` invocation reaches `Browser#send_keys`
1461
+ # as one combo atom (shift held over enter), while independent
1462
+ # non-modifier keys stay separate calls — each one settles
1463
+ # between dispatches so a dropdown highlight (Avo Tags input's
1464
+ # arrow navigation) commits before the next key fires. Tab /
1465
+ # backtab are focus-advance, dispatched out of band.
1466
+ held = []
1467
+ Array(keys).each do |k|
1468
+ sym = k.is_a?(Symbol) ? k : (k.respond_to?(:to_sym) ? k.to_sym : nil)
1469
+ if sym == :tab || sym == :backtab
1470
+ @runtime.call('__csimAdvanceFocus', sym == :backtab)
1471
+ elsif sym && MODIFIER_KEY_NAMES.include?(sym)
1472
+ held << sym
1473
+ else
1474
+ handle = active_element_handle
1475
+ handle = @document_handle if handle.nil? || handle.zero?
1476
+ atom = held.empty? ? k : (held + [k])
1477
+ send_keys(handle, [atom])
246
1478
  end
247
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
248
- @ctx.call('__csim.drainTimers', ASYNC_POLL_STEP_MS) rescue nil
249
1479
  end
250
- raise Capybara::ScriptTimeoutError, 'evaluate_async_script timed out'
251
1480
  end
252
1481
 
253
- def set_modal_responses(alerts: [], confirms: [], prompts: [])
254
- @modal_responses = {alert: Array(alerts), confirm: Array(confirms), prompt: Array(prompts)}
255
- call_runtime('setModalResponses', stringify_keys(@modal_responses))
1482
+ def send_session_key(key) = send_session_keys([key])
1483
+ attr_reader :trace, :pending_trace, :trace_mode
1484
+
1485
+ TRACE_MODES = {'off' => :off, 'on-failure' => :on_failure, 'full' => :full}.freeze
1486
+ private_constant :TRACE_MODES
1487
+
1488
+ def parse_trace_mode(raw)
1489
+ return :on_failure if raw.nil? || raw.empty?
1490
+ TRACE_MODES[raw] || raise(ArgumentError, "CSIM_TRACE must be one of #{TRACE_MODES.keys.join(', ')}; got #{raw.inspect}")
256
1491
  end
257
1492
 
258
- # Append a text-matched handler used by `accept_modal` /
259
- # `dismiss_modal`. JS-side modal stubs scan the handler stack in
260
- # registration order and pick the first whose text predicate
261
- # matches the firing message — which is what enables nested
262
- # `dismiss_confirm { accept_confirm { ... } }`.
263
- def add_modal_handler(type:, text: nil, response: true)
264
- encoded = encode_modal_handler(type, text, response)
265
- call_runtime('pushModalHandler', encoded)
1493
+ def start_trace(metadata = {})
1494
+ @trace = Trace.new(metadata: metadata)
1495
+ @runtime.call('__csimSetTraceActive', true)
266
1496
  end
267
1497
 
268
- def remove_modal_handler(type:, text: nil)
269
- call_runtime('popModalHandler', type.to_s, encode_modal_text(text))
1498
+ # Persist `trace` (defaults to live or pending) to `path` and
1499
+ # return the path. Doesn't clear — `clear_trace!` is the explicit
1500
+ # follow-up so a caller can inspect after writing if it wants.
1501
+ def finish_trace_to(path, trace = (@trace || @pending_trace))
1502
+ return nil unless trace
1503
+ trace.write_json(path)
270
1504
  end
271
1505
 
272
- def encode_modal_handler(type, text, response)
273
- {
274
- 'type' => type.to_s,
275
- 'text' => encode_modal_text(text),
276
- 'response' => response.is_a?(Symbol) ? response.to_s : response
277
- }
1506
+ def clear_trace!
1507
+ @trace = nil
1508
+ @pending_trace = nil
1509
+ @runtime.call('__csimSetTraceActive', false)
278
1510
  end
279
1511
 
280
- def encode_modal_text(text)
281
- case text
282
- when nil then nil
283
- when Regexp then {'regexp' => text.source, 'flags' => text.options}
284
- else text.to_s
1512
+ # Wraps a driver action so the trace records description, urls,
1513
+ # console / network activity, and (on action error / full mode)
1514
+ # a post-action DOM snapshot. Re-entrant: nested recorded actions
1515
+ # (label-click click, session send_keys → send_keys) let the
1516
+ # outer step own the boundary and the inner just yields.
1517
+ #
1518
+ # `description` is a String or Proc — Procs are lazy-evaluated
1519
+ # only when a step is actually being recorded, so the off-path
1520
+ # doesn't pay `describe_node_handle`'s V8 round-trip.
1521
+ def record_action(kind, description)
1522
+ # Off-mode: no autostart, only proceed if a trace was started
1523
+ # explicitly via `driver.start_tracing`. Hot path for users
1524
+ # who set CSIM_TRACE=off.
1525
+ return yield if @trace.nil? && @trace_mode == :off
1526
+ if @trace.nil?
1527
+ @trace = Trace.new(metadata: {auto_started_at: Time.now.utc.iso8601(3)})
1528
+ @runtime.call('__csimSetTraceActive', true)
1529
+ end
1530
+ return yield if @recording_action
1531
+ @recording_action = true
1532
+ desc = description.is_a?(Proc) ? description.call : description
1533
+ @trace.begin_step(kind, description: desc, url_before: @current_url)
1534
+ error = nil
1535
+ begin
1536
+ yield
1537
+ rescue => e
1538
+ error = {class: e.class.name, message: e.message}
1539
+ raise
1540
+ ensure
1541
+ # `full` mode serializes the document after every action; the
1542
+ # default `on_failure` mode only snapshots when an action
1543
+ # errored. The V8 round-trip + DOM serialize is the
1544
+ # expensive part of trace recording, so skipping it on the
1545
+ # happy path is the whole point of the default.
1546
+ dom = (error || @trace_mode == :full) ? html : nil
1547
+ @trace.finish_step(url_after: @current_url, dom_after: dom, error: error)
1548
+ @recording_action = false
285
1549
  end
286
1550
  end
287
- def drain_modal_queue
288
- captured = @captured_modals
289
- @captured_modals = nil
290
- out = Array(call_runtime('drainModalQueue'))
291
- captured ? captured + out : out
292
- end
293
1551
 
294
- # Long-lived inbox shared by nested `accept_modal` /
295
- # `dismiss_modal` blocks. Each driver call drains the JS-side queue
296
- # once and stashes unmatched modals here so the OUTER block can
297
- # still find its message after the inner one consumes its own.
298
- def modal_inbox
299
- @modal_inbox ||= []
1552
+ # Resolved once log_console fires for every page console.* line
1553
+ # (CLAUDE.md rule 3: no per-call ENV reads on hot paths).
1554
+ CONSOLE_STDERR = ENV['CSIM_CONSOLE_STDERR'] == '1'
1555
+
1556
+ def log_console(severity, message)
1557
+ # Diagnostic mirror: surface page console output on stderr regardless
1558
+ # of trace state (engine bring-up / CI triage).
1559
+ warn "[console:#{severity}] #{message.to_s[0, 300]}" if CONSOLE_STDERR
1560
+ return unless @trace
1561
+ @trace.log_console(severity, annotate_console_message(severity, message))
300
1562
  end
301
1563
 
302
- # Snapshot the JS-side modalQueue before a navigate wipes it, so a
303
- # subsequent `drain_modal_queue` still sees alerts that fired
304
- # synchronously from inside the click handler.
305
- def capture_pending_modals
306
- pending = Array(@ctx.call('__csim.drainModalQueue')) rescue []
307
- return if pending.empty?
308
- @captured_modals ||= []
309
- @captured_modals.concat(pending)
1564
+ # info/debug/log lines almost never carry stack traces keep them
1565
+ # out of the regex pass so per-call cost stays at the severity gate.
1566
+ ANNOTATABLE_SEVERITIES = %w[error warning warn].freeze
1567
+ def annotate_console_message(severity, message)
1568
+ return message unless ANNOTATABLE_SEVERITIES.include?(severity.to_s)
1569
+ return message unless message.is_a?(String) && message.include?('://')
1570
+ stack_resolver.annotate(message)
310
1571
  end
311
1572
 
312
- # Forced advance for callers (e.g. accept_modal) that need to age
313
- # out async setTimeout-driven side effects without waiting on the
314
- # wall-clock heuristic in advance_virtual_clock.
315
- def advance_virtual_clock_step(ms)
316
- @ctx.call('__csim.drainTimers', ms.to_i) rescue nil
1573
+ def stack_resolver
1574
+ @stack_resolver ||= StackResolver.new(self)
317
1575
  end
318
1576
 
319
- private
1577
+ def log_network(method, url, status) = @trace&.log_network(method, url, status)
320
1578
 
321
- # Pump only zero-delay timers (microtask-style fallouts of jQuery's
322
- # `$(fn)` and similar). Any non-zero setTimeout queued during the
323
- # interaction stays pending Capybara's synchronize loop will
324
- # advance the virtual clock as it retries.
325
- # After interaction events that may kick off Turbo / Stimulus async
326
- # rendering, drain enough virtual-clock to flush a `requestAnimationFrame`
327
- # (which our prelude maps to `setTimeout(..., 16)`) plus any chained
328
- # microtasks. Without this, `<turbo-stream>`'s async `connectedCallback`
329
- # leaves the DOM mid-render until the next call advances the clock.
330
- DRAIN_AFTER_INTERACTION_MS = 32
331
- def drain_async_timers
332
- @ctx.call('__csim.drainTimers', DRAIN_AFTER_INTERACTION_MS) rescue nil
333
- end
334
-
335
- def advance_virtual_clock
336
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
337
- if @last_call_at
338
- ms = ((now - @last_call_at) * 1000).to_i
339
- @ctx.call('__csim.drainTimers', ms) rescue nil if ms.positive?
340
- end
341
- @last_call_at = now
342
- end
343
-
344
- # Per call_runtime, mirror real wall-clock progress into the JS
345
- # virtual clock so setTimeout-style handlers fire as Capybara's
346
- # synchronize loop ages out the wait budget. Skip the bookkeeping
347
- # for the calls that should never trigger user code (timer drain,
348
- # page load setup, modal-response wiring).
349
- VIRTUAL_TICKLESS = %w[drainTimers loadHTML setModalResponses].freeze
350
-
351
- def call_runtime(method, *args)
352
- advance_virtual_clock unless VIRTUAL_TICKLESS.include?(method)
353
- result = @ctx.call("__csim.#{method}", *args)
354
- # Skip the implicit-navigation pickup when the runtime returned a
355
- # navigation directive: `follow` will run the actual visit through
356
- # Rack with the right method/body, and we don't want to clobber it
357
- # with a GET driven by happy-dom's stale window.location update.
358
- if !VIRTUAL_TICKLESS.include?(method) && !@in_navigate &&
359
- !(result.is_a?(Hash) && %w[navigate submit].include?(result['action']))
360
- check_location_change
361
- end
1579
+ # `tag#id.class` short description of the handle, for trace
1580
+ # `description` fields. One V8 round-trip; only paid when a step
1581
+ # is actively being recorded (`record_action` lazy-evaluates the
1582
+ # description Proc).
1583
+ def describe_node_handle(handle)
1584
+ return "handle=#{handle}" if handle.nil? || handle.zero?
1585
+ info = @runtime.call('__csimDescribeNode', handle)
1586
+ return "handle=#{handle}" unless info.is_a?(Hash)
1587
+ s = info['tag'].to_s
1588
+ s += "##{info['id']}" unless info['id'].to_s.empty?
1589
+ s += ".#{info['cls']}" unless info['cls'].to_s.empty?
1590
+ s
1591
+ end
1592
+ def evaluate_script(code, args = [])
1593
+ # Drain timers first so ready handlers (jQuery `$(handler)`,
1594
+ # framework `DOMContentLoaded` listeners) run before the
1595
+ # user's script. Without this, `execute_script` can fire
1596
+ # *before* the page's own setup code that the test expects
1597
+ # to be active.
1598
+ tick_real_time
1599
+ invalidate_find_cache
1600
+ result = @runtime.call('__csimEvalScript', code.to_s, marshal_args(args || []))
1601
+ drain_pending_navigation
362
1602
  result
363
- rescue MiniRacer::RuntimeError => e
364
- if e.message.to_s.include?('stale or unknown node handle')
365
- raise Capybara::Simulated::StaleElementReferenceError, e.message
366
- end
367
- raise
368
1603
  end
369
1604
 
370
- # Detect when user JS assigned to window.location and trigger an
371
- # actual visit through the Rack stack so subsequent finds operate
372
- # on the new page.
373
- def check_location_change
374
- return unless @current_url
375
- loc = (@ctx.eval('window && window.location ? window.location.href : null') rescue nil)
376
- return unless loc.is_a?(String) && !loc.empty?
377
- return if loc == @current_url
378
- # history.pushState / replaceState changed the URL but did not
379
- # trigger a fetch — adopt the new URL without re-fetching.
380
- if (@ctx.call('__csim.consumeHistoryPushed') rescue false)
381
- @current_url = loc
382
- return
383
- end
384
- a = URI.parse(@current_url) rescue nil
385
- b = URI.parse(loc) rescue nil
386
- return unless a && b
387
- # Same scheme/host/path/query → only the fragment changed; no fetch.
388
- if a.scheme == b.scheme && a.host == b.host && a.port == b.port &&
389
- a.path == b.path && a.query == b.query
390
- @current_url = loc
391
- return
392
- end
393
- # Trigger a real navigation under the Rack app.
394
- navigate(:get, loc, [], referer: @current_url)
1605
+ # Fire-and-forget variant: runs the script but never returns
1606
+ # its value to Ruby. Lets execute_script handle scripts whose
1607
+ # return is a complex JS object (jQuery chainable, DOM tree,
1608
+ # …) that the marshaller would recurse into.
1609
+ def execute_script(code, args = [])
1610
+ tick_real_time
1611
+ invalidate_find_cache
1612
+ @runtime.call('__csimExecScript', code.to_s, marshal_args(args || []))
1613
+ drain_pending_navigation
1614
+ nil
395
1615
  end
396
1616
 
397
- def follow(directive)
398
- return nil unless directive.is_a?(Hash)
399
- # Snapshot any modal recorded by the click handler before the
400
- # navigate clears the JS-side queue (loadHTML resets state).
401
- # Capybara's `accept_alert { click_link 'Alert page change' }`
402
- # otherwise loses the alert message because it fires synchronously
403
- # from the same click that triggers the navigation.
404
- capture_pending_modals if %w[navigate submit].include?(directive['action'])
405
- case directive['action']
406
- when 'navigate'
407
- target = resolve_url(directive['href'])
408
- # `<a href="/foo#bar">` from the same /foo page is just an
409
- # in-page anchor jump in real browsers — no request, no reload.
410
- if @current_url && same_path_anchor?(@current_url, target)
411
- @current_url = target
412
- return nil
413
- end
414
- navigate(:get, target)
415
- when 'submit'
416
- method = (directive['method'] || 'GET').downcase.to_sym
417
- target = directive['url'].to_s.empty? ? @current_url : directive['url']
418
- navigate(method, resolve_url(target), directive['fields'] || [], enctype: directive['enctype'])
419
- end
420
- end
421
-
422
- def navigate(method, url, fields = [], enctype: 'application/x-www-form-urlencoded',
423
- replace_history: false, history_move: false, referer: nil)
424
- # Avoid recursive location-change detection while we're in the
425
- # middle of installing the new page.
426
- outermost = !@in_navigate
427
- @in_navigate = true
428
- full_url = resolve_url(url)
429
- body = nil
430
- env_overrides = {}
431
-
432
- if %i[post put patch delete].include?(method)
433
- if enctype.to_s.start_with?('multipart/form-data')
434
- boundary = "----CapybaraSimulatedBoundary#{SecureRandom.hex(8)}"
435
- body = build_multipart_body(fields, boundary)
436
- env_overrides['CONTENT_TYPE'] = "multipart/form-data; boundary=#{boundary}"
437
- elsif fields.any? { |_, v| v.is_a?(Hash) && v['file'] }
438
- # Non-multipart form with a file input: real browsers submit
439
- # the file's basename as the field value.
440
- cleaned = fields.flat_map do |k, v|
441
- if v.is_a?(Hash) && v['file']
442
- paths = Array(v['paths'])
443
- paths.empty? ? [[k, '']] : paths.map { |p| [k, File.basename(p.to_s)] }
444
- else
445
- [[k, v]]
446
- end
447
- end
448
- body = URI.encode_www_form(cleaned)
449
- env_overrides['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
1617
+ # CDP-ish shim: override navigator.geolocation (like CDP's
1618
+ # `Emulation.setGeolocationOverride`). State is Ruby-backed on
1619
+ # `@geolocation`; the JS geolocation object reads it on every call via
1620
+ # the `__csimGeolocationState` host fn, so it survives the per-call VM
1621
+ # rebuilds (the same model web storage uses).
1622
+ #
1623
+ # set_geolocation(latitude: 35.6, longitude: 139.7)
1624
+ # set_geolocation(latitude: 1, longitude: 2, accuracy: 5, altitude: 10)
1625
+ # set_geolocation(denied: true) # report PERMISSION_DENIED
1626
+ # set_geolocation # clear -> report POSITION_UNAVAILABLE
1627
+ def set_geolocation(latitude: nil, longitude: nil, accuracy: 10, denied: false, **rest)
1628
+ @geolocation =
1629
+ if denied
1630
+ {'denied' => true}
1631
+ elsif latitude.nil? || longitude.nil?
1632
+ nil
450
1633
  else
451
- body = URI.encode_www_form(fields)
452
- env_overrides['CONTENT_TYPE'] = enctype
1634
+ {'coords' => {'latitude' => latitude, 'longitude' => longitude, 'accuracy' => accuracy}.merge(rest.transform_keys(&:to_s))}
453
1635
  end
454
- elsif fields.any?
455
- uri = URI.parse(full_url)
456
- uri.query = [uri.query.to_s, URI.encode_www_form(fields)].reject(&:empty?).join('&')
457
- full_url = uri.to_s
458
- end
459
1636
 
460
- ref = referer.nil? ? @current_url : (referer || nil)
461
- env_overrides['HTTP_REFERER'] = ref if ref
462
-
463
- cookie_header = build_cookie_header(full_url)
464
- env_overrides['HTTP_COOKIE'] = cookie_header unless cookie_header.empty?
465
-
466
- request = Rack::MockRequest.new(@app)
467
- response = request.request(method.to_s.upcase, full_url, env_overrides.merge(input: body || ''))
1637
+ # Re-deliver to any active watchPosition watchers, mirroring a real
1638
+ # browser firing the watch again when the location updates. The JS
1639
+ # side reads the fresh @geolocation via the host fn.
1640
+ execute_script('if (typeof globalThis.__csimGeoRefireWatches === "function") globalThis.__csimGeoRefireWatches();')
1641
+ nil
1642
+ end
468
1643
 
469
- ingest_set_cookie(response.headers, full_url)
1644
+ # Backs the `__csimGeolocationState` host fn. Returns the configured
1645
+ # geolocation override as a JSON string (or 'null' when none is set),
1646
+ # which the JS geolocation object reads on every getCurrentPosition /
1647
+ # watchPosition call.
1648
+ def geolocation_state_json
1649
+ JSON.generate(@geolocation)
1650
+ end
1651
+
1652
+ # Capybara passes Node instances directly as script args
1653
+ # (`session.evaluate_script('arguments[0].click()', some_node)`).
1654
+ # the marshaller can't pass a Ruby Node, so wrap as a sentinel
1655
+ # the JS side recognises and rehydrates via the handle registry.
1656
+ def marshal_args(args)
1657
+ args.map {|a|
1658
+ case a
1659
+ when Capybara::Simulated::Node then {'__elementHandle' => a.handle_id}
1660
+ when Array then marshal_args(a)
1661
+ when Hash then a.transform_values {|v| marshal_args([v]).first }
1662
+ else a
1663
+ end
1664
+ }
1665
+ end
1666
+ def evaluate_async_script(code, args = [])
1667
+ tick_real_time
1668
+ invalidate_find_cache
1669
+ @runtime.call('__evalAsyncScript', code.to_s, marshal_args(args || []))
1670
+ # Pump virtual time so any setTimeout-driven completion lands.
1671
+ # Capybara's polling can't help here — we're inside one session
1672
+ # call, not a retry loop.
1673
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
1674
+ Capybara.default_max_wait_time.to_f
1675
+ loop do
1676
+ result = @runtime.call('__pollAsyncResult')
1677
+ return result['value'] if result.is_a?(Hash) && result.key?('value')
1678
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1679
+ sleep 0.01
1680
+ tick_real_time
1681
+ end
1682
+ nil
1683
+ end
470
1684
 
471
- @current_url = full_url
472
- @status_code = response.status
473
- @response_headers = response.headers
474
- @last_method = method
475
- @last_fields = fields
476
- @last_enctype = enctype
1685
+ def current_path
1686
+ tick_real_time
1687
+ return '' if @current_url.nil? || @current_url.empty?
1688
+ URI.parse(@current_url).path
1689
+ rescue URI::InvalidURIError
1690
+ ''
1691
+ end
1692
+
1693
+ # Capybara polls find / has_? via `synchronize` while
1694
+ # `Driver#wait?` is true. We stay true while there's any scheduled
1695
+ # timer (`@timers_active` is flipped by the JS bridge's
1696
+ # `__setTimersActive` callback), plus a sticky grace window after
1697
+ # the last timer fires so a `setTimeout` firing mid-loop doesn't
1698
+ # drop us off polling before Capybara's own retry deadline.
1699
+ #
1700
+ # Settle-gen idle gate: a recurring `setInterval` from a framework
1701
+ # runloop (Ember / Glimmer) keeps `@timers_active` true forever
1702
+ # even when nothing observable is changing. Without a second
1703
+ # signal, Capybara waits the full `default_max_wait_time` on every
1704
+ # `has_css?` / `has_no_css?` that's destined to fail — which
1705
+ # Discourse's `CapybaraTimeoutExtension` reports as a "slow
1706
+ # spec" failure. Track `@runtime.settle_gen` across polls: when
1707
+ # it hasn't bumped for `IDLE_SETTLE_POLLS` calls, drop polling
1708
+ # even though timers are scheduled. `settle_gen` already bumps
1709
+ # on every DOM mutation / URL change (see __settleGen wiring),
1710
+ # so this only short-circuits genuinely idle loops.
1711
+ def polling?
1712
+ # Background-thread work (workers, EventSource, MessageBus
1713
+ # long-poll) keeps the settle loop alive even when settle_gen
1714
+ # is otherwise idle.
1715
+ return true if worker_pending? || event_source_pending? || hijack_fetch_pending?
1716
+ if @timers_active
1717
+ gen = @runtime.settle_gen
1718
+ if @last_polled_gen.nil? || gen != @last_polled_gen
1719
+ @last_polled_gen = gen
1720
+ @idle_settle_polls = 0
1721
+ @polling_grace = POLLING_GRACE_POLLS
1722
+ return true
1723
+ end
1724
+ @idle_settle_polls += 1
1725
+ return true if @idle_settle_polls < IDLE_SETTLE_POLLS
1726
+ # Treat as idle for this poll; if a fresh timer fires later
1727
+ # the next poll's settle_gen check will resume polling.
1728
+ false
1729
+ elsif @polling_grace && @polling_grace > 0
1730
+ @polling_grace -= 1
1731
+ true
1732
+ else
1733
+ false
1734
+ end
1735
+ end
477
1736
 
478
- unless history_move
479
- if replace_history
480
- @history[@history_index] = full_url if @history_index >= 0
481
- @history[@history_index] = full_url if @history_index < 0
482
- @history_index = 0 if @history_index < 0
1737
+ # Advance the virtual JS clock and fire timers that came due.
1738
+ # When `step_ms` is omitted, advance by `horizon_fast_forward_step` — a
1739
+ # DETERMINISTIC step (never wall-derived, so per-poll JS/Ruby/GC cost can't
1740
+ # shift when a timer fires): a fixed `POLL_TICK_STEP_MS` per poll, fast-
1741
+ # forwarding straight to a near-future timer when the page is otherwise idle.
1742
+ # Explicit `step_ms` is used by `SleepHook#advance_virtual_clock_ms` (from
1743
+ # `Kernel#sleep`) and by `Playwright::Page#wait_for_timeout` to step a
1744
+ # precise virtual duration.
1745
+ def tick_real_time(step_ms: nil)
1746
+ return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending?
1747
+ # Re-entrancy guard. Capybara's `Result#each` triggers nested
1748
+ # finds (visible? per element); the outermost tick has already
1749
+ # advanced the clock, the inner calls would only re-drain
1750
+ # already-fired timers.
1751
+ return if @ticking
1752
+ @ticking = true
1753
+ begin
1754
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1755
+ # Kept wall-anchored ONLY for `timer_wait_elapsed?` / FIND_PRE_TICK_MIN_S
1756
+ # (gates tick FREQUENCY for the smoke first-find-no-fire contract); the
1757
+ # step SIZE below is deterministic.
1758
+ @last_tick_ts = now
1759
+ effective_step = step_ms || horizon_fast_forward_step
1760
+ if @timers_active && effective_step > 0
1761
+ r = @runtime.run_loop_step(effective_step)
1762
+ # `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
1763
+ # delivered MutationObserver that mutated the DOM without firing a timer
1764
+ # (fired == 0) — a fired-count-only test would leave a stale find cache.
1765
+ @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
1766
+ end
1767
+ # Pull any pending Worker / EventSource messages into JS
1768
+ # state. Without this, `evaluate_script` after kicking off
1769
+ # a worker round-trip would see stale state — the inbox
1770
+ # outbox only drains during `settle`, which doesn't run
1771
+ # for direct `execute_script` / `evaluate_script` calls.
1772
+ @find_cache_dirty = true if deliver_worker_messages > 0
1773
+ @find_cache_dirty = true if deliver_event_source_events > 0
1774
+ @find_cache_dirty = true if deliver_hijacked_fetches > 0
1775
+ ensure
1776
+ @ticking = false
1777
+ end
1778
+ # Drain navigation intents queued by JS-side handlers that fired
1779
+ # during the drain (e.g. `setTimeout(() => location.pathname = X)`).
1780
+ # Outside the @ticking guard so the navigate's rebuild_ctx is
1781
+ # well-clear of the V8 call we just made.
1782
+ drain_pending_navigation
1783
+ # Same shape for `form.submit()` queued by a timer callback —
1784
+ # Forem's comment-edit form has an `onsubmit` handler that
1785
+ # `preventDefault`s, polls for the CSRF meta tag inside
1786
+ # `setInterval(…, 1)`, then calls `form.submit()` once the
1787
+ # meta is present. The click that originally fired the submit
1788
+ # event has already returned by the time the interval triggers,
1789
+ # so without this drain the intent sits on the slot forever
1790
+ # and the form never posts.
1791
+ consume_pending_form_submit
1792
+ # And for `<a download>` clicks (Avo's action-download chain
1793
+ # goes via file-saver's `saveAs` → synthetic dispatchEvent
1794
+ # on a freshly-created anchor with `download` + blob URL).
1795
+ consume_pending_download
1796
+ end
1797
+
1798
+ # This tick's deterministic virtual-clock advance (ms). Default is the fixed
1799
+ # `POLL_TICK_STEP_MS` — never wall-derived, so per-poll JS/Ruby/GC cost cannot
1800
+ # shift WHEN a timer fires (the wall-sync↔perf coupling this replaces). When
1801
+ # the page is observably idle (nothing runnable now, no background IO) but a
1802
+ # near-future timer is parked within `FF_HORIZON_MS`, fast-forward straight to
1803
+ # it — but only after the transient-guard window so pre-debounce states are
1804
+ # still observed across several polls. `FF_HORIZON_MS=0` ⇒ pure fixed-step.
1805
+ def horizon_fast_forward_step
1806
+ # Escape hatch to the legacy wall-sync clock (virtual advance = real
1807
+ # wall-elapsed per poll). The deterministic model decouples perf from
1808
+ # timing but can't match a real browser's wall-proportional cadence for
1809
+ # timing-fragile heavy-JS flows; `CSIM_CLOCK_WALL=1` restores wall-sync.
1810
+ if @clock_wall
1811
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1812
+ step = ((now - (@wall_clock_last || now)) * 1000).to_i.clamp(0, 1000)
1813
+ @wall_clock_last = now
1814
+ return step
1815
+ end
1816
+ # (1) Background async (cheap Ruby-side checks, no V8 crossing) we must let
1817
+ # land before jumping the clock: advance one fixed step, reset the guard.
1818
+ if worker_pending? || event_source_pending? || hijack_fetch_pending?
1819
+ @ff_transient_polls = 0
1820
+ return POLL_TICK_STEP_MS
1821
+ end
1822
+ # No fast-forward support on this runtime (e.g. a worker realm) → fixed step.
1823
+ return POLL_TICK_STEP_MS unless @runtime_supports_ff
1824
+ # ONE V8 crossing: `delay` = ms until the nearest timer; 0 = runnable now
1825
+ # (a rAF or a due-now timer — equivalent to `has_ready_timer?`), -1 = none.
1826
+ delay = @runtime.next_timer_delay_ms
1827
+ # (2) Runnable now → fixed step, reset guard (not a quiet pre-debounce window).
1828
+ if delay.zero?
1829
+ @ff_transient_polls = 0
1830
+ return POLL_TICK_STEP_MS
1831
+ end
1832
+ # (3) Nothing parked → nothing to fast-forward to.
1833
+ return POLL_TICK_STEP_MS if delay.negative?
1834
+ # (4) Beyond the horizon (ahoy 1000 / session-timeout / analytics): leave
1835
+ # parked, advance only at the fixed rate. Not a transient window.
1836
+ if delay > FF_HORIZON_MS
1837
+ @ff_transient_polls = 0
1838
+ return POLL_TICK_STEP_MS
1839
+ end
1840
+ # (5) Near-future timer, page idle: hold the pre-debounce window for the
1841
+ # guard so transient-catch tests observe the intermediate state.
1842
+ @ff_transient_polls = (@ff_transient_polls || 0) + 1
1843
+ return POLL_TICK_STEP_MS if @ff_transient_polls < FF_TRANSIENT_GUARD_POLLS
1844
+ # (6) Fast-forward: jump exactly to the next timer's due. `runLoopStepLocal`
1845
+ # breaks on strict `nextDue > limit`, so `limit = virtualNow + delay`
1846
+ # (== that timer's due) fires it — and ONLY it, not a timer 1 ms later.
1847
+ delay
1848
+ end
1849
+
1850
+ def advance_virtual_clock_ms(ms)
1851
+ ms = ms.to_i
1852
+ tick_real_time(step_ms: ms) if ms > 0
1853
+ end
1854
+
1855
+ # Re-sync the Ruby-side timer mirror with a freshly-rebuilt JS
1856
+ # context. Clear `@timers_active` and the `@polling_grace` grace
1857
+ # window so the previous page's pending-timer state doesn't leak
1858
+ # into the next test, leaving `Driver#wait?` true and dragging
1859
+ # every failing matcher through the full `default_max_wait_time`
1860
+ # retry loop.
1861
+ def reset_timer_state
1862
+ @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1863
+ @wall_clock_last = @last_tick_ts # CSIM_CLOCK_WALL escape hatch: don't replay the prev page's gap
1864
+ @timers_active = false
1865
+ @polling_grace = nil
1866
+ @last_polled_gen = nil
1867
+ @idle_settle_polls = 0
1868
+ @ff_transient_polls = 0
1869
+ @context_gen += 1
1870
+ end
1871
+
1872
+ attr_reader :context_gen
1873
+
1874
+ # Pulls the serialised form-state out of JS, encodes it, and
1875
+ # drives the Rack app via `navigate` (for GET) or a POST.
1876
+ def submit_form_handle(form_handle, submitter_handle)
1877
+ invalidate_find_cache
1878
+ spec = @runtime.call('__csimFormSerialize', form_handle, submitter_handle || 0)
1879
+ return unless spec.is_a?(Hash)
1880
+ action = spec['action'].to_s
1881
+ method = spec['method'].to_s.upcase
1882
+ method = 'GET' if method.empty?
1883
+ fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
1884
+ file_inputs = spec['fileInputs'] || []
1885
+ enctype = spec['enctype'].to_s
1886
+ multipart = enctype.start_with?('multipart/form-data')
1887
+ content_type = nil
1888
+ body =
1889
+ if multipart
1890
+ built = build_multipart_body(fields, file_inputs)
1891
+ content_type = built[:content_type]
1892
+ built[:body]
483
1893
  else
484
- @history = @history[0..@history_index] + [full_url]
485
- @history_index = @history.size - 1
1894
+ # Non-multipart: file inputs contribute the filename only.
1895
+ file_inputs.each do |fi|
1896
+ picks = @file_picks && @file_picks[fi['handle'].to_i] || []
1897
+ fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
1898
+ end
1899
+ URI.encode_www_form(fields)
486
1900
  end
1901
+ action_url = action.empty? ? (@current_url || @default_host) : resolve_against_current(action)
1902
+ if method == 'GET'
1903
+ uri = URI.parse(action_url)
1904
+ uri.query = body unless body.empty?
1905
+ navigate(uri.to_s)
1906
+ else
1907
+ navigate_post(action_url, body, content_type || enctype)
487
1908
  end
1909
+ end
488
1910
 
489
- if (300..399).cover?(response.status) && response.headers['location']
490
- # Preserve the original fragment across redirects when the target
491
- # location does not include one — browsers do this per RFC 7231.
492
- target = response.headers['location']
493
- orig_frag = URI.parse(full_url).fragment rescue nil
494
- if orig_frag && !orig_frag.empty?
495
- tgt = URI.parse(target) rescue nil
496
- target = "#{target}##{orig_frag}" if tgt && (tgt.fragment.nil? || tgt.fragment.empty?)
1911
+ def build_multipart_body(fields, file_inputs)
1912
+ boundary = "csim-#{SecureRandom.hex(8)}"
1913
+ body = String.new.force_encoding(Encoding::ASCII_8BIT)
1914
+ fields.each do |name, value|
1915
+ append_multipart_part(body, boundary, name, value.to_s)
1916
+ end
1917
+ file_inputs.each do |fi|
1918
+ picks = @file_picks && @file_picks[fi['handle'].to_i] || []
1919
+ if picks.empty?
1920
+ append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
1921
+ else
1922
+ picks.each do |path|
1923
+ append_multipart_part(body, boundary, fi['name'].to_s, File.binread(path),
1924
+ filename: File.basename(path),
1925
+ content_type: Rack::Mime.mime_type(File.extname(path)))
1926
+ end
497
1927
  end
498
- if [307, 308].include?(response.status)
499
- return navigate(method, target, fields, enctype: enctype, referer: ref)
1928
+ end
1929
+ body << "--#{boundary}--\r\n"
1930
+ {content_type: "multipart/form-data; boundary=#{boundary}", body: body}
1931
+ end
1932
+
1933
+ def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
1934
+ body << "--#{boundary}\r\n"
1935
+ disposition = %[form-data; name="#{name}"]
1936
+ disposition += %[; filename="#{filename}"] if filename
1937
+ body << "Content-Disposition: #{disposition}\r\n"
1938
+ body << "Content-Type: #{content_type}\r\n" if content_type
1939
+ body << "\r\n"
1940
+ body << content.to_s.b
1941
+ body << "\r\n"
1942
+ end
1943
+
1944
+ def navigate_post(url, body, content_type, depth: 0, from_history: false)
1945
+ raise 'too many redirects' if depth > 10
1946
+ invalidate_find_cache
1947
+ record_history({method: :post, url: url, body: body, content_type: content_type}) unless from_history || depth > 0
1948
+ env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
1949
+ env['CONTENT_TYPE'] = content_type.empty? ? 'application/x-www-form-urlencoded' : content_type
1950
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
1951
+ apply_default_request_env(env, referer: @current_url)
1952
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
1953
+ merge_set_cookie(headers)
1954
+ if (loc = redirect_location(status, headers))
1955
+ next_url = resolve_against_current(loc)
1956
+ resp_body.close if resp_body.respond_to?(:close)
1957
+ # HTTP semantics: 301/302/303 → method becomes GET; 307/308
1958
+ # require the method (and body) to be preserved.
1959
+ if [307, 308].include?(status)
1960
+ return navigate_post(next_url, body, content_type, depth: depth + 1)
1961
+ else
1962
+ return navigate(next_url, depth: depth + 1)
500
1963
  end
501
- return navigate(:get, target, referer: ref)
502
1964
  end
1965
+ if download_response?(headers)
1966
+ save_downloaded_response(url, headers, resp_body)
1967
+ return
1968
+ end
1969
+ @current_url = url
1970
+ record_response(status, headers)
1971
+ html = read_rack_body(resp_body)
1972
+ # Same rebuild-on-full-load contract as `navigate`. POST
1973
+ # responses (form submissions that don't redirect, AJAX-less
1974
+ # data-remote replies) replace the page; we follow real-browser
1975
+ # semantics and bring up a fresh VM rather than papering over
1976
+ # the previous one's state.
1977
+ boot_response_into_ctx(html)
1978
+ end
1979
+
1980
+ def reset!
1981
+ @cookies.clear
1982
+ @local_storage.clear
1983
+ @session_storage.clear
1984
+ @sticky_headers.clear
1985
+ # The driver-side resize buffer has to clear too — without
1986
+ # this the previous test's `driver.resize(425, …)` leaks into
1987
+ # the next test's default viewport and any cascade rule that
1988
+ # gates on `(min-width: …)` reports the wrong answer for the
1989
+ # whole new test (Forem's comment-actions dropdown is
1990
+ # mobile-collapsed-by-default). The exception is the
1991
+ # `default_viewport` channel — drivers built for a mobile
1992
+ # session (Discourse's `playwright_mobile_chrome`) need to
1993
+ # stay mobile across resets, not snap back to desktop on the
1994
+ # next mobile-tagged test.
1995
+ if @default_viewport
1996
+ @viewport_width = @default_viewport[0]
1997
+ @viewport_height = @default_viewport[1]
1998
+ else
1999
+ @viewport_width = nil
2000
+ @viewport_height = nil
2001
+ end
2002
+ @current_url = nil
2003
+ @document_handle = 0
2004
+ @history.clear
2005
+ @history_idx = -1
2006
+ @file_picks = {} if @file_picks
2007
+ # Hand the live trace off to `@pending_trace` so an after-hook
2008
+ # running after `reset_session!` (Capybara's per-test teardown
2009
+ # order) still finds it. Anything stuck in `@pending_trace`
2010
+ # from a prior test is dropped — better than fusing two
2011
+ # tests' actions into one record.
2012
+ @pending_trace = @trace
2013
+ @trace = nil
2014
+ @recording_action = false
2015
+ # Kill any open SSE reader threads — the new VM has no JS-side
2016
+ # EventSource instances to dispatch into, and the old handles
2017
+ # would collide on the fresh handle counter the bridge starts
2018
+ # from after `reset_page`. Same shape for worker threads.
2019
+ reset_event_sources
2020
+ reset_hijacked_fetches
2021
+ reset_workers
2022
+ @blob_registry_lock.synchronize { @blob_registry.clear }
2023
+ # Drop volatile entries from the class-level HTTP asset cache
2024
+ # so test-local DB state (TranslationOverride, etc.) reaches
2025
+ # the app on subsequent visits. Fingerprinted assets
2026
+ # (`Cache-Control: immutable`) survive: their URLs are content-
2027
+ # addressable so a stale entry can't shadow a later test.
2028
+ @@asset_cache.clear_volatile if @@asset_cache.respond_to?(:clear_volatile)
2029
+ @runtime.reset_page
2030
+ # Per-visit ctx rebuild drops the JS-side trace-active flag,
2031
+ # so re-flip it if we're carrying a pending trace into the
2032
+ # next visit.
2033
+ @runtime.call('__csimSetTraceActive', false)
2034
+ reset_timer_state
2035
+ invalidate_find_cache
2036
+ end
2037
+
2038
+ # ── Host-fn callbacks invoked by bridge.js ──────────────────
2039
+
2040
+ def rack_fetch_body(url)
2041
+ result = rack_fetch('GET', url, '', {}, 'follow')
2042
+ return nil unless result && result['status'].to_i < 400
2043
+ result['body'].to_s
2044
+ end
2045
+
2046
+ # Fetch a source body and report how long it stays safely reusable per its
2047
+ # OWN response headers — an absolute freshness deadline (Time), or nil when
2048
+ # the response is not durably cacheable (no-store / no-cache / max-age=0 /
2049
+ # dynamic with no freshness). This lets a loader persist the body across
2050
+ # visits and skip the round-trip next time, driven by the server's cache
2051
+ # directives (RFC 9111 §5.2.2 / §4.2.2 heuristic) — NOT a URL-shape guess.
2052
+ # `clear_volatile` drops the body from the volatile per-visit asset cache,
2053
+ # but a content-hashed asset's source is content-stable while fresh, so a
2054
+ # loader's own cross-visit cache can hold it for `fresh_until`. Used by
2055
+ # the external-asset cache (`external_asset_source`, scripts +
2056
+ # stylesheets); name is generic.
2057
+ def durable_source(url)
2058
+ body = rack_fetch_body(url)
2059
+ return [nil, nil] unless body
2060
+ entry = @@asset_cache.lookup(url)
2061
+ fresh_until = entry && entry.fresh? && entry.max_age ? entry.stored_at + entry.max_age : nil
2062
+ [body, fresh_until]
2063
+ end
2064
+
2065
+ # Cross-visit cache of external asset bodies (classic `<script src>` bundles
2066
+ # AND linked `<link rel=stylesheet>` CSS), url → [body, fresh_until]. A
2067
+ # fresh VM per visit (`reset_page` → `clear_volatile`) would otherwise
2068
+ # re-fetch the same fingerprinted app assets (avo.base.js, avo.base.css, …)
2069
+ # on every visit — a real browser HTTP-caches them once. Safety: only
2070
+ # responses the server marks durably cacheable (`fresh_until` from max-age)
2071
+ # are stored, and these are content-stable assets at content-hashed URLs
2072
+ # (a change yields a new URL = cache miss), so a stale body can't shadow a
2073
+ # later test. Survives `clear_volatile` (that is the point); size-capped.
2074
+ @@asset_src = {}
2075
+ @@asset_src_lock = Mutex.new
2076
+ ASSET_SRC_MAX = 4096
2077
+
2078
+ # Body of an external durably-cacheable asset (classic script or stylesheet),
2079
+ # served from the cross-visit cache when still fresh, else fetched (which
2080
+ # read-throughs the per-visit asset cache) and cached iff durably cacheable.
2081
+ # Returns nil on 4xx / fetch failure so the JS caller skips it exactly as the
2082
+ # old `__rackFetch` branch did.
2083
+ def external_asset_source(url)
2084
+ key = resolve_against_current(url.to_s)
2085
+ return nil unless key.is_a?(String)
2086
+ @@asset_src_lock.synchronize do
2087
+ if (e = @@asset_src[key])
2088
+ return e[0] if e[1].nil? || Time.now < e[1]
2089
+ @@asset_src.delete(key)
2090
+ end
2091
+ end
2092
+ # `durable_source` already does the spec-compliant fetch + header-driven
2093
+ # freshness (RFC 9111 max-age → absolute deadline); reuse it instead of
2094
+ # re-deriving `fresh_until` here.
2095
+ body, fresh_until = durable_source(key)
2096
+ return nil unless body
2097
+ # Script / stylesheet source is TEXT, but the raw Rack / binread body
2098
+ # arrives BINARY-tagged (see `RuntimeShared.utf8_text`).
2099
+ body = RuntimeShared.utf8_text(body)
2100
+ if fresh_until
2101
+ @@asset_src_lock.synchronize do
2102
+ @@asset_src.clear if @@asset_src.size >= ASSET_SRC_MAX
2103
+ @@asset_src[key] = [body, fresh_until]
2104
+ end
2105
+ end
2106
+ body
2107
+ end
2108
+
2109
+ # Native ESM entry point. QuickJS uses its `vm.module_loader`;
2110
+ # V8 uses `Context#compile_module` + `Module#instantiate` /
2111
+ # `#evaluate` + `Context#dynamic_import_resolver=`. Both runtimes
2112
+ # expose `eval_esm_module`.
2113
+ def eval_esm_module(url, src = nil)
2114
+ @runtime.eval_esm_module(url, src)
2115
+ end
2116
+
2117
+ # ── EventSource (SSE) ──────────────────────────────────────────
2118
+ #
2119
+ # Mastodon (and any app using Server-Sent Events) opens an
2120
+ # `EventSource` to a streaming endpoint and expects pushed events
2121
+ # to fire `message`/typed listeners on the live instance. Our
2122
+ # implementation:
2123
+ # 1. JS-side `new EventSource(url)` calls `__csim_eventSourceOpen`
2124
+ # which returns an integer handle and spawns a Ruby thread.
2125
+ # 2. The thread holds a chunked-read HTTP connection open and
2126
+ # parses the SSE event-stream wire format, pushing each
2127
+ # `{id:, type:, data:, lastEventId:}` (or `{type: '__open'}`
2128
+ # / `{type: '__error', message:}` sentinel) onto a
2129
+ # thread-safe queue.
2130
+ # 3. Settle's drain loop calls `deliver_event_source_events`
2131
+ # which polls the queue and hands the batch to
2132
+ # `__csim_deliverEventSourceEvents` for dispatch.
2133
+ # rusty_racer / quickjs.rb VMs are single-threaded; only the main
2134
+ # thread ever enters the VM. Background threads only touch the
2135
+ # Queue. `reset!` and per-visit context rebuilds kill all open
2136
+ # threads — the new VM gets a fresh handle space.
2137
+ def event_source_open(url)
2138
+ id = (@event_source_seq += 1)
2139
+ queue = @event_source_queue
2140
+ thread = Thread.new do
2141
+ Thread.current.report_on_exception = false
2142
+ run_event_source_reader(id, url.to_s, queue)
2143
+ end
2144
+ @event_source_threads[id] = thread
2145
+ id
2146
+ end
503
2147
 
504
- load_html(response.body)
505
- ensure
506
- @in_navigate = false if outermost
507
- end
508
-
509
- def load_html(html)
510
- # loadHTML rebuilds the happy-dom Window with @current_url so
511
- # window.location matches Ruby's view of the page after a real
512
- # navigation.
513
- call_runtime('loadHTML', wrap_fragment_html(html.to_s), @current_url.to_s)
514
- call_runtime('setModalResponses', stringify_keys(@modal_responses))
515
- load_external_scripts
516
- call_runtime('runInlineScripts')
517
- load_module_scripts
518
- call_runtime('syncWindowGlobals')
519
- # Drain only the immediate (zero-delay) timers queued during page
520
- # load — typically jQuery's `$(fn)` ready callbacks. Anything
521
- # genuinely delayed waits for the synchronize loop to age it in.
522
- call_runtime('drainTimers', 0)
523
- @last_call_at = nil
2148
+ def event_source_close(id)
2149
+ thread = @event_source_threads.delete(id.to_i)
2150
+ thread&.kill
524
2151
  nil
525
2152
  end
526
2153
 
527
- # Rails apps pin module dependencies via `<script type="importmap">`
528
- # and load them via `<script type="module">`. mini_racer has no ESM
529
- # loader, so we shell out to esbuild (via vendor/js/bundle-modules.mjs)
530
- # to compile the entire dependency graph into a single IIFE bundle
531
- # that runs in the same isolate as inline scripts. Pre-fetches every
532
- # reachable module through Rack so the bundler never makes a real
533
- # network call.
534
- MODULE_BUNDLER_SCRIPT = File.join(VENDOR_DIR, 'bundle-modules.mjs').freeze
535
- MODULE_IMPORT_RX = /(?:^|[\s;{(=])(?:import|export)(?:\s*\(|[^'"\n;]*?\bfrom\s*)?\s*['"]([^'"]+)['"]/m.freeze
536
-
537
- def load_module_scripts
538
- details = call_runtime('moduleScriptDetails')
539
- return if details.nil?
540
- importmap = parse_importmap_from_details(details)
541
- entries = Array(details['entries'])
542
- return if importmap.empty? && entries.empty?
543
-
544
- # Importmap + module-script entries are page-content-derived, so
545
- # keying on them alone covers different pages of the same Rails app
546
- # the bundle output is identical when the inputs are. Including
547
- # `@current_url` only suppressed cache hits across visits without
548
- # buying any correctness; build_module_bundle was the single biggest
549
- # cost in the suite (52% of wall time on a hot test).
550
- cache_key = Digest::SHA256.hexdigest(JSON.dump([importmap, entries]))
551
- @module_bundle_cache ||= {}
552
- bundle = @module_bundle_cache[cache_key] ||=
553
- build_module_bundle(importmap, entries)
554
- return unless bundle
555
- @ctx.eval(bundle)
556
- # Resolve any dynamic imports queued during the bundle's static
557
- # evaluation phase. The registry is populated only after the
558
- # bundle body runs, so deferred resolution is the only way to
559
- # honour `import("controllers/foo")` calls fired by code like
560
- # stimulus-loading's eagerLoad.
561
- @ctx.eval('typeof __csim_drain_imports === "function" && __csim_drain_imports()')
562
- call_runtime('syncWindowGlobals')
563
- end
564
-
565
- def parse_importmap_from_details(details)
566
- raw = details['importmap']
567
- return {} if raw.nil? || raw.to_s.strip.empty?
568
- JSON.parse(raw)
569
- rescue JSON::ParserError
570
- {}
2154
+ def event_source_poll
2155
+ drain_queue(@event_source_queue)
2156
+ end
2157
+
2158
+ def event_source_pending? = !@event_source_queue.empty?
2159
+
2160
+ # Drain any queued events into the VM. Cheap when no SSE
2161
+ # connection is active (no threads → no queue items → empty
2162
+ # return). Returns the number of events delivered so settle can
2163
+ # tell whether progress was made.
2164
+ def deliver_event_source_events
2165
+ return 0 if @event_source_threads.empty? && @event_source_queue.empty?
2166
+ events = event_source_poll
2167
+ return 0 if events.empty?
2168
+ @runtime.call('__csim_deliverEventSourceEvents', events)
2169
+ events.size
2170
+ end
2171
+
2172
+ # Background-thread entry point. Resolves the URL (relative
2173
+ # paths against current page), opens a raw TCP / TLS socket,
2174
+ # speaks just enough HTTP/1.1 to make the request, and reads
2175
+ # chunked SSE bodies. Net::HTTP would be more natural but
2176
+ # WebMock's `disable_net_connect!(allow_localhost: true)`
2177
+ # routes Net::HTTP through an adapter that buffers chunked
2178
+ # responses until the body completes — which never happens on a
2179
+ # long-lived event stream. TCPSocket is below WebMock's hook
2180
+ # surface so this stays a real network read.
2181
+ private def run_event_source_reader(id, url, queue)
2182
+ target = resolve_against_current(url)
2183
+ uri = URI(target)
2184
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
2185
+ queue << {id: id, type: '__error', message: "unsupported scheme: #{uri.scheme.inspect}"}
2186
+ return
2187
+ end
2188
+ socket = open_event_source_socket(uri)
2189
+ send_event_source_request(socket, uri)
2190
+ status_line = socket.gets
2191
+ unless status_line
2192
+ queue << {id: id, type: '__error', message: 'empty response'}
2193
+ return
2194
+ end
2195
+ code = status_line[%r{HTTP/[\d.]+\s+(\d+)}, 1].to_i
2196
+ chunked = false
2197
+ while (line = socket.gets) && line.strip != ''
2198
+ chunked = true if line =~ /\Atransfer-encoding:\s*chunked/i
2199
+ end
2200
+ if code >= 400
2201
+ queue << {id: id, type: '__error', message: "HTTP #{code}"}
2202
+ return
2203
+ end
2204
+ queue << {id: id, type: '__open'}
2205
+ read_event_source_body(socket, id, queue, chunked: chunked)
2206
+ rescue EOFError, Errno::ECONNRESET
2207
+ # Server closed mid-stream — normal lifecycle, not an error
2208
+ # worth surfacing.
2209
+ rescue StandardError => e
2210
+ queue << {id: id, type: '__error', message: "#{e.class}: #{e.message}"}
2211
+ ensure
2212
+ begin
2213
+ socket.close if socket && !socket.closed?
2214
+ rescue StandardError
2215
+ # socket might have been closed concurrently (reset! killed
2216
+ # the thread); the leak is harmless.
2217
+ end
571
2218
  end
572
2219
 
573
- def build_module_bundle(importmap, entries)
574
- sources = prefetch_module_sources(importmap, entries)
575
- payload = JSON.dump(
576
- 'importmap' => importmap,
577
- 'baseUrl' => @current_url || DEFAULT_HOST,
578
- 'entries' => entries,
579
- 'sources' => sources
580
- )
581
- out, err, status = Open3.capture3('node', MODULE_BUNDLER_SCRIPT, stdin_data: payload)
582
- unless status.success?
583
- warn "[capybara-simulated] module bundler failed: #{err.to_s[0, 400]}"
584
- return nil
2220
+ private def open_event_source_socket(uri)
2221
+ if uri.is_a?(URI::HTTPS)
2222
+ require 'openssl'
2223
+ tcp = TCPSocket.new(uri.host, uri.port)
2224
+ ctx = OpenSSL::SSL::SSLContext.new
2225
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
2226
+ ssl.sync_close = true
2227
+ ssl.hostname = uri.host
2228
+ ssl.connect
2229
+ ssl
2230
+ else
2231
+ TCPSocket.new(uri.host, uri.port)
585
2232
  end
586
- out
587
2233
  end
588
2234
 
589
- # Walk the module graph by fetching each entry/import via Rack and
590
- # extracting nested `import`/`export ... from` references with a
591
- # regex. We don't try to be clever about live-binding semantics —
592
- # esbuild handles that. We just need every reachable URL in the
593
- # `sources` map before the bundler runs.
594
- def prefetch_module_sources(importmap, entries)
595
- base = @current_url || DEFAULT_HOST
596
- seen = {}
597
- queue = []
598
- entries.each do |e|
599
- if (src = e['src'] || e[:src])
600
- url = resolve_module_specifier(src, base, importmap, base)
601
- queue << url if url
602
- elsif (inline = e['inline'] || e[:inline])
603
- extract_module_specifiers(inline.to_s).each do |spec|
604
- url = resolve_module_specifier(spec, base, importmap, base)
605
- queue << url if url
606
- end
2235
+ private def send_event_source_request(socket, uri)
2236
+ host_header = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
2237
+ lines = [
2238
+ "GET #{uri.request_uri} HTTP/1.1",
2239
+ "Host: #{host_header}",
2240
+ 'Accept: text/event-stream',
2241
+ 'Accept-Encoding: identity',
2242
+ 'Cache-Control: no-store',
2243
+ 'Connection: keep-alive'
2244
+ ]
2245
+ # Forward the host-cookie jar so the streaming server can
2246
+ # authenticate the user the same way the browser would. The
2247
+ # jar is a flat name=value map (no per-host scoping); reuse
2248
+ # the canonical `document_cookie` serialiser the Rack path
2249
+ # uses, so we don't drift if its format changes.
2250
+ cookies = document_cookie
2251
+ lines << "Cookie: #{cookies}" unless cookies.empty?
2252
+ socket.write(lines.join("\r\n") << "\r\n\r\n")
2253
+ socket.flush
2254
+ end
2255
+
2256
+ private def read_event_source_body(socket, id, queue, chunked:)
2257
+ buffer = String.new
2258
+ loop do
2259
+ if chunked
2260
+ size_line = socket.gets
2261
+ break unless size_line
2262
+ size = size_line.strip.to_i(16)
2263
+ break if size.zero?
2264
+ buffer << socket.read(size).to_s
2265
+ socket.read(2) # trailing CRLF
2266
+ else
2267
+ buffer << socket.readpartial(4096)
607
2268
  end
608
- end
609
- # Pre-fetch every importmap pin too, even if no static import
610
- # reaches it. The bundler statically embeds them all so dynamic
611
- # `import("controllers/foo_controller")` calls (rewritten to a
612
- # synchronous registry lookup) resolve at runtime.
613
- (importmap['imports'] || importmap[:imports] || {}).each do |spec, _|
614
- next if spec.to_s.end_with?('/')
615
- url = resolve_module_specifier(spec.to_s, base, importmap, base)
616
- queue << url if url
617
- end
618
- until queue.empty?
619
- url = queue.shift
620
- next if seen.key?(url)
621
- body = fetch_resource(url)
622
- # Rack hands us ASCII-8BIT strings; the bundler's JSON.dump
623
- # trips a "UTF-8 string passed as BINARY" warning under json 2.x
624
- # (and is a hard error in json 3). JS source is always Unicode,
625
- # so retag without copying bytes.
626
- body = body.dup.force_encoding(Encoding::UTF_8) if body
627
- seen[url] = body || ''
628
- next if body.nil? || body.empty?
629
- extract_module_specifiers(body).each do |spec|
630
- next_url = resolve_module_specifier(spec, url, importmap, base)
631
- queue << next_url if next_url && !seen.key?(next_url)
2269
+ while (idx = buffer.index("\n\n") || buffer.index("\r\n\r\n"))
2270
+ sep_len = buffer[idx, 4] == "\r\n\r\n" ? 4 : 2
2271
+ raw_event = buffer[0...idx]
2272
+ buffer = buffer[(idx + sep_len)..]
2273
+ event = parse_sse_event(raw_event)
2274
+ queue << {id: id, **event} if event
632
2275
  end
633
2276
  end
634
- seen
635
2277
  end
636
2278
 
637
- def extract_module_specifiers(source)
638
- source.to_s.scan(MODULE_IMPORT_RX).flatten.compact
2279
+ private def parse_sse_event(block)
2280
+ type = nil
2281
+ data = []
2282
+ last_id = nil
2283
+ block.each_line do |line|
2284
+ line = line.chomp
2285
+ next if line.empty? || line.start_with?(':')
2286
+ if (idx = line.index(':'))
2287
+ field = line[0...idx]
2288
+ value = line[(idx + 1)..]
2289
+ value = value[1..] if value.start_with?(' ')
2290
+ else
2291
+ field = line
2292
+ value = ''
2293
+ end
2294
+ case field
2295
+ when 'event' then type = value
2296
+ when 'data' then data << value
2297
+ when 'id' then last_id = value
2298
+ end
2299
+ end
2300
+ return nil if data.empty? && type.nil?
2301
+ # SSE is a UTF-8 TEXT protocol (the spec decodes the stream as UTF-8),
2302
+ # but these strings are slices of the BINARY socket buffer (see
2303
+ # `RuntimeShared.utf8_text`).
2304
+ {
2305
+ type: RuntimeShared.utf8_text(type || 'message'),
2306
+ data: RuntimeShared.utf8_text(data.join("\n")),
2307
+ lastEventId: last_id && RuntimeShared.utf8_text(last_id)
2308
+ }
639
2309
  end
640
2310
 
641
- def resolve_module_specifier(spec, importer, importmap, base)
642
- return spec if spec.start_with?('http://', 'https://')
643
- if spec.start_with?('/')
644
- return URI.join(base, spec).to_s
2311
+ def reset_event_sources
2312
+ @event_source_threads.each_value(&:kill)
2313
+ @event_source_threads.clear
2314
+ @event_source_queue.clear
2315
+ end
2316
+
2317
+ # ── Hijack-aware async XHR ─────────────────────────────────────
2318
+ #
2319
+ # Real browsers' long-poll keeps the request socket open across
2320
+ # the entire user-interactive session, so a server-side
2321
+ # `MessageBus.publish` (or any other middleware writing through
2322
+ # `rack.hijack`) lands on the open connection and the client
2323
+ # gets the response when the server is ready. Our default
2324
+ # `__rackFetch` is purely sync — the middleware's hijack path
2325
+ # never engaged, so MessageBus's `subscribe(channel, -1)` +
2326
+ # `__status` reset chain dropped any publish that landed
2327
+ # between two scheduled polls.
2328
+ #
2329
+ # `rack_fetch_async` runs the Rack call with a `rack.hijack`
2330
+ # lambda installed. The lambda is invoked iff the middleware
2331
+ # actually hijacks; we detect that and spawn a background
2332
+ # thread to read from the pipe until the middleware closes its
2333
+ # end (a publish landed via `notify_clients`, or
2334
+ # `cleanup_timer` fired the empty-`[]` close after
2335
+ # `long_polling_interval`). Non-hijacking responses queue
2336
+ # immediately on the same thread — no thread spawn, no
2337
+ # backpressure beyond the existing sync `__rackFetch` cost.
2338
+ #
2339
+ # The contract is generic: any middleware that follows the
2340
+ # Rack hijack protocol works, not just `message_bus`. JS-side
2341
+ # XHR's async path routes every request here; sync XHRs
2342
+ # (`xhr.open(_, _, false)`, deprecated) stay on `__rackFetch`
2343
+ # because the hijack contract can't satisfy a synchronous XHR
2344
+ # response anyway.
2345
+ # Returns either a response hash (immediate — middleware didn't
2346
+ # hijack) or a `{'handle' => N}` token (deferred — middleware
2347
+ # hijacked the connection and a background thread is reading
2348
+ # the pipe). The JS-side XHR checks the return shape to pick
2349
+ # between inline processing and waiting for `__csim_
2350
+ # deliverHijackedFetches`.
2351
+ def rack_fetch_async(method, url, body, headers_json)
2352
+ headers = begin
2353
+ JSON.parse(headers_json.to_s)
2354
+ rescue JSON::ParserError
2355
+ {}
645
2356
  end
646
- if spec.start_with?('./', '../')
647
- return URI.join(importer, spec).to_s
648
- end
649
- imports = (importmap['imports'] || importmap[:imports] || {})
650
- if imports.key?(spec)
651
- return resolve_module_specifier(imports[spec], importer, importmap, base)
652
- end
653
- # Trailing-slash mapping per the importmap spec.
654
- match = imports.keys.find {|k| k.end_with?('/') && spec.start_with?(k) }
655
- if match
656
- return resolve_module_specifier(imports[match] + spec[match.length..], importer, importmap, base)
2357
+ # `rack_fetch` already handles redirects, cookie merge, the
2358
+ # asset cache shortcut, and download detection — keep async
2359
+ # XHRs on that single source of truth. The new behaviour is
2360
+ # the hijack hook for long-poll-shaped requests: install
2361
+ # `rack.hijack` so the middleware can hold the connection
2362
+ # open until something publishes through it.
2363
+ #
2364
+ # We can't unconditionally install the hijack env keys: some
2365
+ # downstream Discourse middleware paths take a different
2366
+ # streaming branch when `rack.hijack?` is truthy (even
2367
+ # without ever invoking the lambda) and the response then
2368
+ # re-renders the page in a slightly different order, racing
2369
+ # subsequent Capybara `find`s into StaleElement. Restrict
2370
+ # the hook to URLs that look like the long-poll endpoints
2371
+ # we actually need it for (`/message-bus/{id}/poll` today;
2372
+ # extend as new patterns surface).
2373
+ read_io = nil
2374
+ env_extras =
2375
+ if HIJACK_AWARE_URL_PATTERNS.any? {|re| re.match?(url.to_s) }
2376
+ {
2377
+ 'rack.hijack?' => true,
2378
+ 'rack.hijack' => lambda {
2379
+ read_io, write_io = IO.pipe
2380
+ write_io
2381
+ }
2382
+ }
2383
+ end
2384
+ resp = rack_fetch(method, url, body, headers, 'follow', env_extras: env_extras)
2385
+ return resp || {'status' => 0, 'headers' => {}, 'body' => ''} unless read_io
2386
+ id = (@hijack_fetch_seq += 1)
2387
+ @hijack_fetch_threads[id] = Thread.new do
2388
+ Thread.current.report_on_exception = false
2389
+ run_hijacked_pipe_read(id, read_io, @hijack_fetch_queue)
657
2390
  end
2391
+ {'handle' => id}
2392
+ end
2393
+
2394
+ # URLs whose middleware needs `rack.hijack` to hold the
2395
+ # connection open. Only enable hijack for these so the
2396
+ # `rack.hijack?` capability check doesn't perturb the response
2397
+ # path on unrelated requests.
2398
+ HIJACK_AWARE_URL_PATTERNS = [
2399
+ %r{/message-bus/[^/]+/poll(?:\?|$)}
2400
+ ].freeze
2401
+ private_constant :HIJACK_AWARE_URL_PATTERNS
2402
+
2403
+ def rack_fetch_async_abort(id)
2404
+ thread = @hijack_fetch_threads.delete(id.to_i)
2405
+ thread&.kill
658
2406
  nil
659
2407
  end
660
2408
 
661
- def load_external_scripts
662
- srcs = Array(call_runtime('externalScriptSources'))
663
- srcs.each do |src|
664
- next if src.nil? || src.empty?
665
- next if src.match?(EXTERNAL_SCRIPT_DENYLIST)
666
- body = nil
2409
+ def hijack_fetch_pending? = !@hijack_fetch_threads.empty? || !@hijack_fetch_queue.empty?
2410
+
2411
+ def deliver_hijacked_fetches
2412
+ return 0 if @hijack_fetch_threads.empty? && @hijack_fetch_queue.empty?
2413
+ responses = drain_queue(@hijack_fetch_queue)
2414
+ return 0 if responses.empty?
2415
+ @runtime.call('__csim_deliverHijackedFetches', responses)
2416
+ responses.size
2417
+ end
2418
+
2419
+ def reset_hijacked_fetches
2420
+ @hijack_fetch_threads.each_value(&:kill)
2421
+ @hijack_fetch_threads.clear
2422
+ @hijack_fetch_queue.clear
2423
+ end
2424
+
2425
+ # MessageBus's `long_polling_interval` defaults to 25 s — its
2426
+ # `cleanup_timer` fires after that interval, closing the
2427
+ # hijacked connection with an empty `[]` write. Pick a slightly
2428
+ # larger wall cap so the close reaches us before our pipe read
2429
+ # gives up. Other hijack-using middleware likely behaves
2430
+ # similarly; if any need much longer waits, this becomes a per-
2431
+ # request option.
2432
+ HIJACK_PIPE_MAX_WAIT_S = 30
2433
+
2434
+ private def run_hijacked_pipe_read(id, read_io, queue)
2435
+ buf = String.new
2436
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HIJACK_PIPE_MAX_WAIT_S
2437
+ loop do
2438
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
2439
+ break if remaining <= 0
2440
+ ready, = IO.select([read_io], nil, nil, remaining)
2441
+ break unless ready
667
2442
  begin
668
- body = fetch_resource(src)
669
- rescue StandardError => e
670
- warn "[capybara-simulated] failed to fetch script #{src.inspect}: #{e.class}: #{e.message[0, 120]}"
2443
+ buf << read_io.read_nonblock(8192)
2444
+ rescue EOFError, Errno::EPIPE, Errno::ECONNRESET
2445
+ break
2446
+ rescue IO::WaitReadable
671
2447
  next
672
2448
  end
673
- next if body.nil?
674
- begin
675
- @ctx.eval(body)
676
- call_runtime('syncWindowGlobals')
677
- rescue MiniRacer::RuntimeError => e
678
- warn "[capybara-simulated] script #{src.inspect} raised: #{e.class}: #{e.message[0, 200]}"
679
- end
680
- end
681
- end
682
-
683
- def wrap_fragment_html(html)
684
- return html if html.empty?
685
- return html if html.match?(/\A\s*(?:<!doctype\s|<\?xml|<html\b)/i)
686
- "<!doctype html><html><body>#{html}</body></html>"
687
- end
688
-
689
- MIME_TYPES = {
690
- 'txt' => 'text/plain',
691
- 'html' => 'text/html',
692
- 'htm' => 'text/html',
693
- 'css' => 'text/css',
694
- 'js' => 'application/javascript',
695
- 'json' => 'application/json',
696
- 'xml' => 'application/xml',
697
- 'png' => 'image/png',
698
- 'jpg' => 'image/jpeg',
699
- 'jpeg' => 'image/jpeg',
700
- 'gif' => 'image/gif',
701
- 'svg' => 'image/svg+xml',
702
- 'pdf' => 'application/pdf'
703
- }.freeze
2449
+ end
2450
+ queue << {'handle' => id, **parse_hijacked_http_response(buf)}
2451
+ rescue StandardError => e
2452
+ queue << {'handle' => id, 'status' => 0, 'headers' => {}, 'body' => '', 'error' => "#{e.class}: #{e.message}"}
2453
+ ensure
2454
+ @hijack_fetch_threads.delete(id)
2455
+ begin
2456
+ read_io.close unless read_io.closed?
2457
+ rescue StandardError
2458
+ # pipe already closed by the middleware; ignore.
2459
+ end
2460
+ end
704
2461
 
705
- def build_multipart_body(fields, boundary)
706
- parts = []
707
- fields.each do |name, value|
708
- if value.is_a?(Hash) && value['file']
709
- paths = Array(value['paths'])
710
- if paths.empty?
711
- parts << multipart_file_part(name, '', '', 'application/octet-stream', boundary)
712
- else
713
- paths.each do |path|
714
- content = File.binread(path) rescue ''
715
- filename = File.basename(path.to_s)
716
- ext = File.extname(filename).delete('.').downcase
717
- ctype = MIME_TYPES[ext] || 'application/octet-stream'
718
- parts << multipart_file_part(name, content, filename, ctype, boundary)
719
- end
720
- end
2462
+ # Hijacked middleware writes raw HTTP/1.1 over the socket:
2463
+ # `HTTP/1.1 200 OK\r\nheader: value\r\n...\r\n\r\nbody`.
2464
+ private def parse_hijacked_http_response(buf)
2465
+ status = 200
2466
+ headers = {}
2467
+ sep_idx = buf.index("\r\n\r\n") || buf.index("\n\n")
2468
+ head, body =
2469
+ if sep_idx
2470
+ sep_len = buf[sep_idx, 4] == "\r\n\r\n" ? 4 : 2
2471
+ [buf[0...sep_idx], buf[(sep_idx + sep_len)..]]
721
2472
  else
722
- parts << multipart_text_part(name, value.to_s, boundary)
2473
+ [buf, '']
2474
+ end
2475
+ head.split(/\r?\n/).each_with_index do |line, i|
2476
+ if i == 0 && (m = line.match(%r{\AHTTP/[\d.]+\s+(\d+)}))
2477
+ status = m[1].to_i
2478
+ elsif (idx = line.index(':'))
2479
+ k = line[0...idx].strip.downcase
2480
+ v = line[(idx + 1)..].to_s.strip
2481
+ # Slices of the BINARY socket buffer (see `RuntimeShared.utf8_text`).
2482
+ headers[RuntimeShared.utf8_text(k)] = RuntimeShared.utf8_text(v)
723
2483
  end
724
2484
  end
725
- parts.join + "--#{boundary}--\r\n"
2485
+ # The held-poll body is TEXT (long-poll JSON); the socket read is
2486
+ # BINARY-tagged.
2487
+ body = RuntimeShared.utf8_text(body.to_s)
2488
+ {'status' => status, 'headers' => headers, 'body' => body}
726
2489
  end
727
2490
 
728
- def multipart_text_part(name, value, boundary)
729
- "--#{boundary}\r\n" \
730
- "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" \
731
- "#{value}\r\n"
2491
+ private def normalize_response_headers(headers)
2492
+ return {} unless headers
2493
+ out = {}
2494
+ headers.each {|k, v| out[k.to_s.downcase] = v.to_s }
2495
+ out
732
2496
  end
733
2497
 
734
- def multipart_file_part(name, content, filename, ctype, boundary)
735
- "--#{boundary}\r\n" \
736
- "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
737
- "Content-Type: #{ctype}\r\n\r\n" \
738
- "#{content}\r\n"
2498
+ # ── Web Workers ────────────────────────────────────────────────
2499
+ #
2500
+ # `new Worker(url)` in JS lands in `worker_spawn`. The Ruby
2501
+ # thread it spawns owns a fresh V8 Context / QuickJS VM (true
2502
+ # isolate, separate microtask queue and timer table), evals the
2503
+ # worker script there, and runs an event loop draining timers,
2504
+ # microtasks, and the inbox queue from the main thread. Each
2505
+ # worker's `__csim_workerPostMessage` host fn closes over its
2506
+ # handle and routes outgoing messages onto a shared outbox the
2507
+ # main settle drains.
2508
+ def worker_spawn(url)
2509
+ handle = (@worker_seq += 1)
2510
+ inbox = Thread::Queue.new
2511
+ outbox = @worker_outbox
2512
+ engine_class = @runtime.class
2513
+ target = resolve_against_current(url.to_s)
2514
+ # Resolve the worker script body on the main thread before
2515
+ # handing off to the worker. `blob:` URLs need the main VM's
2516
+ # blob registry; calling into the main runtime from a
2517
+ # non-owning thread SEGVs (V8 isolates are thread-
2518
+ # bound; quickjs.rb's VM is similarly per-thread).
2519
+ body = fetch_worker_script(target)
2520
+ thread = Thread.new do
2521
+ Thread.current.report_on_exception = false
2522
+ run_worker(handle, target, body, inbox, outbox, engine_class)
2523
+ end
2524
+ @workers[handle] = {thread: thread, inbox: inbox}
2525
+ handle
2526
+ end
2527
+
2528
+ def worker_post_to_worker(handle, data)
2529
+ w = @workers[handle.to_i]
2530
+ return unless w
2531
+ @worker_in_flight += 1
2532
+ w[:inbox] << data.to_s
2533
+ end
2534
+
2535
+ def worker_terminate(handle)
2536
+ w = @workers.delete(handle.to_i)
2537
+ return unless w
2538
+ w[:inbox] << :terminate
2539
+ # Most clean shutdowns are <10 ms; the kill is the fallback
2540
+ # for blocked workers.
2541
+ w[:thread].join(WORKER_TERMINATE_GRACE)
2542
+ w[:thread].kill if w[:thread].alive?
2543
+ # A blocked worker that never returned messages leaves
2544
+ # `@worker_in_flight` permanently > 0; reset when no workers
2545
+ # remain so `polling?` can short-circuit again.
2546
+ @worker_in_flight = 0 if @workers.empty?
2547
+ end
2548
+
2549
+ def deliver_worker_messages
2550
+ return 0 if @workers.empty? && @worker_outbox.empty?
2551
+ events = drain_queue(@worker_outbox)
2552
+ return 0 if events.empty?
2553
+ # `__error` postbacks don't correspond to a prior post, so
2554
+ # bottom out at zero.
2555
+ @worker_in_flight = [0, @worker_in_flight - events.size].max
2556
+ @runtime.call('__csim_deliverWorkerMessages', events)
2557
+ events.size
2558
+ end
2559
+
2560
+ def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0
2561
+
2562
+ # ── Image decode (libvips) ─────────────────────────────────────
2563
+ #
2564
+ # Called by the JS bridge whenever a Canvas / OffscreenCanvas
2565
+ # path needs raw RGBA pixels — `drawImage(image, …)` whose
2566
+ # source is an HTMLImageElement / Blob / ImageBitmap with
2567
+ # encoded bytes still on the wire. ruby-vips decodes any format
2568
+ # libvips supports (PNG, JPEG, WebP, GIF, …) into a contiguous
2569
+ # row-major RGBA buffer. Returns `{width, height, refId}` — the
2570
+ # raw bytes land in the transfer-buffer registry so the JS side
2571
+ # fetches them as a `Uint8Array` (tag-driven binary marshalling) rather
2572
+ # than building a 423 MB latin-1 + base64 intermediate for the
2573
+ # 8900×8900 frames Discourse uploads exercise. Optional
2574
+ # `max_w`/`max_h` lets the caller pre-shrink for cheap OCR-style
2575
+ # "downscale before pixel-touch" flows.
2576
+ def decode_image(b64_bytes, max_w = nil, max_h = nil)
2577
+ host_image_op('decode_image') {
2578
+ require 'vips' unless defined?(Vips)
2579
+ bytes = Base64.decode64(b64_bytes.to_s)
2580
+ # `access: :sequential` keeps libvips from applying the
2581
+ # source's ICC profile mid-stream (changes RGBA values by ±2
2582
+ # vs raw decode). `colourspace('srgb')` is the same ICC
2583
+ # transform Chrome's createImageBitmap runs, but rounding
2584
+ # differs by a few ulp; only convert when libvips reports
2585
+ # a non-sRGB interpretation, otherwise trust the bytes.
2586
+ img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
2587
+ img = img.colourspace('srgb') unless img.interpretation == :srgb || img.interpretation == :rgb
2588
+ img = img.bandjoin(255) if img.bands < 4
2589
+ if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
2590
+ (img.width > max_w.to_i || img.height > max_h.to_i)
2591
+ shrink_x = img.width.to_f / max_w.to_i
2592
+ shrink_y = img.height.to_f / max_h.to_i
2593
+ shrink = [shrink_x, shrink_y].max
2594
+ img = img.resize(1.0 / shrink) if shrink > 1
2595
+ end
2596
+ raw = img.write_to_memory
2597
+ {'width' => img.width, 'height' => img.height, 'refId' => transfer_buffer_stash(raw)}
2598
+ }
739
2599
  end
740
2600
 
741
- # ---- minimal cookie jar -------------------------------------------
742
- # Stores Set-Cookie response headers and replays the matching cookies
743
- # on subsequent requests. Just enough surface area to satisfy
744
- # Capybara specs that probe set/clear/path-scoped behaviour.
745
- def build_cookie_header(url)
746
- uri = URI.parse(url) rescue nil
747
- return '' unless uri
748
- now = Time.now
749
- @cookie_jar.reject! { |c| c[:expires] && c[:expires] < now }
750
- matching = @cookie_jar.select do |c|
751
- domain_matches?(c[:domain], uri.host) && path_matches?(c[:path], uri.path)
752
- end
753
- matching.map { |c| "#{c[:name]}=#{c[:value]}" }.join('; ')
2601
+ private def host_image_op(name)
2602
+ yield
2603
+ rescue LoadError, StandardError => e
2604
+ warn "[capybara-simulated] #{name} failed: #{e.class}: #{e.message[0, 200]}"
2605
+ nil
754
2606
  end
755
2607
 
756
- def ingest_set_cookie(headers, url)
757
- uri = URI.parse(url) rescue nil
758
- return unless uri
759
- raw = headers['set-cookie'] || headers['Set-Cookie']
760
- return unless raw
761
- Array(raw).each do |line|
762
- line.to_s.split(/\n/).each {|l| absorb_cookie(l, uri) }
2608
+ def reset_workers
2609
+ @workers.each_value do |w|
2610
+ w[:inbox] << :terminate
2611
+ w[:thread].kill
763
2612
  end
2613
+ @workers.clear
2614
+ @worker_outbox.clear
2615
+ @worker_in_flight = 0
2616
+ @transfer_buffer_lock.synchronize {
2617
+ @transfer_buffers.clear
2618
+ @transfer_buffer_seq = 0
2619
+ }
2620
+ end
2621
+
2622
+ def blob_register(url, body_b64)
2623
+ @blob_registry_lock.synchronize { @blob_registry[url.to_s] = body_b64.to_s }
2624
+ nil
2625
+ end
2626
+
2627
+ def blob_resolve(url)
2628
+ @blob_registry_lock.synchronize { @blob_registry[url.to_s] }
2629
+ end
2630
+
2631
+ def blob_unregister(url)
2632
+ @blob_registry_lock.synchronize { @blob_registry.delete(url.to_s) }
2633
+ nil
764
2634
  end
765
2635
 
766
- def absorb_cookie(line, uri)
767
- parts = line.split(/;\s*/)
768
- return if parts.empty?
769
- name, value = parts.shift.split('=', 2)
770
- return unless name && !name.empty?
771
- cookie = {
772
- name: name.strip,
773
- value: (value || '').strip,
774
- domain: uri.host,
775
- path: '/',
776
- expires: nil,
777
- secure: false,
778
- http_only: false
2636
+ # ── postMessage transferable-buffer registry ───────────────────
2637
+ #
2638
+ # Large Uint8Array / ArrayBuffer payloads cross isolates by ID;
2639
+ # rusty_racer marshals typed arrays as ASCII-8BIT Strings so no
2640
+ # JS-side latin-1 / base64 intermediate is built. Without this
2641
+ # the 317 MB raw frames in Discourse's media-optimization-worker
2642
+ # peak >4 GB of JS strings before the worker even sees them.
2643
+ def transfer_buffer_stash(bytes)
2644
+ s = bytes.to_s
2645
+ s = s.dup.force_encoding(Encoding::ASCII_8BIT) unless s.encoding == Encoding::ASCII_8BIT
2646
+ @transfer_buffer_lock.synchronize {
2647
+ id = (@transfer_buffer_seq += 1)
2648
+ @transfer_buffers[id] = s
2649
+ id
779
2650
  }
780
- parts.each do |attr|
781
- k, v = attr.split('=', 2)
782
- case (k || '').downcase
783
- when 'domain' then cookie[:domain] = v.to_s.sub(/\A\./, '')
784
- when 'path' then cookie[:path] = v.to_s
785
- when 'expires' then cookie[:expires] = (Time.parse(v) rescue nil)
786
- when 'max-age'
787
- secs = v.to_i
788
- cookie[:expires] = Time.now + secs if secs.positive?
789
- cookie[:expires] = Time.at(0) if secs <= 0
790
- when 'secure' then cookie[:secure] = true
791
- when 'httponly' then cookie[:http_only] = true
2651
+ end
2652
+
2653
+ def transfer_buffer_fetch(id)
2654
+ @transfer_buffer_lock.synchronize { @transfer_buffers.delete(id.to_i) }
2655
+ end
2656
+
2657
+ # Wraps the raw bytes in whatever binary shape the ACTIVE runtime can
2658
+ # marshal to a JS Uint8Array (V8: the BINARY-tagged string itself —
2659
+ # tag-driven marshalling crosses it as a Uint8Array; QuickJS: base64
2660
+ # that the JS shim's `fetchedToBytes` atob's — it has no binary
2661
+ # marshaller). Asked of the runtime so each engine picks its shape.
2662
+ def transfer_buffer_fetch_for_js(id)
2663
+ bytes = transfer_buffer_fetch(id)
2664
+ return nil unless bytes
2665
+ @runtime.wrap_binary(bytes)
2666
+ end
2667
+
2668
+ # ── Video decode (ffprobe + ffmpeg) ────────────────────────────
2669
+ #
2670
+ # Called from the JS bridge when a `<video>` element's `src` is
2671
+ # assigned a `blob:` URL. ffprobe extracts dimensions + duration,
2672
+ # ffmpeg extracts the first frame as raw RGBA. JS caches both so
2673
+ # `canvas.drawImage(video, …)` blits like any ImageBitmap.
2674
+ def decode_video_frame(b64_bytes)
2675
+ host_image_op('decode_video_frame') {
2676
+ bytes = Base64.decode64(b64_bytes.to_s)
2677
+ next nil if bytes.empty?
2678
+ require 'tempfile'
2679
+ require 'json'
2680
+ Tempfile.create(['csim-video', '.bin'], binmode: true) do |f|
2681
+ f.write(bytes)
2682
+ f.flush
2683
+ info = ffprobe_stream(f.path) or break nil
2684
+ width = info['width'].to_i
2685
+ height = info['height'].to_i
2686
+ break nil if width <= 0 || height <= 0
2687
+ raw = ffmpeg_first_frame_rgba(f.path)
2688
+ duration = (info['duration'] || info.dig('format_duration')).to_f
2689
+ result = {'width' => width, 'height' => height, 'duration' => duration}
2690
+ result['refId'] = transfer_buffer_stash(raw) if raw && !raw.empty?
2691
+ result
792
2692
  end
2693
+ }
2694
+ end
2695
+
2696
+ private def ffprobe_stream(path)
2697
+ json = IO.popen(
2698
+ ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
2699
+ '-show_entries', 'stream=width,height,duration:format=duration',
2700
+ '-of', 'json', path],
2701
+ 'r', err: File::NULL,
2702
+ &:read
2703
+ )
2704
+ return nil unless $?.success?
2705
+ parsed = JSON.parse(json) rescue {}
2706
+ info = parsed.dig('streams', 0) || {}
2707
+ info['format_duration'] = parsed.dig('format', 'duration')
2708
+ info
2709
+ end
2710
+
2711
+ private def ffmpeg_first_frame_rgba(path)
2712
+ raw = IO.popen(
2713
+ ['ffmpeg', '-loglevel', 'error', '-i', path,
2714
+ '-frames:v', '1', '-f', 'image2pipe',
2715
+ '-vcodec', 'rawvideo', '-pix_fmt', 'rgba', '-'],
2716
+ 'rb', &:read
2717
+ )
2718
+ $?.success? ? raw : nil
2719
+ end
2720
+
2721
+ # ── Image encode (libvips) ─────────────────────────────────────
2722
+ #
2723
+ # `canvas.toBlob`'s Ruby end. The pixel buffer comes in via the
2724
+ # transfer registry (so JS doesn't build a megabyte-scale b64
2725
+ # intermediate); the encoded image goes back the same way. Returns
2726
+ # `{refId, type}` or nil on encoder failure.
2727
+ MIME_TO_VIPS_EXT = {
2728
+ 'image/jpeg' => '.jpg',
2729
+ 'image/jpg' => '.jpg',
2730
+ 'image/webp' => '.webp',
2731
+ 'image/png' => '.png'
2732
+ }.freeze
2733
+ private_constant :MIME_TO_VIPS_EXT
2734
+
2735
+ def encode_image(pixels_ref, width, height, mime_type = 'image/png', quality = 90)
2736
+ host_image_op('encode_image') {
2737
+ require 'vips' unless defined?(Vips)
2738
+ raw = transfer_buffer_fetch(pixels_ref).to_s
2739
+ w = width.to_i
2740
+ h = height.to_i
2741
+ next nil if w <= 0 || h <= 0 || raw.bytesize < w * h * 4
2742
+ img = Vips::Image.new_from_memory_copy(raw, w, h, 4, :uchar)
2743
+ ext = MIME_TO_VIPS_EXT[mime_type.to_s.downcase] || '.png'
2744
+ opts = (ext == '.jpg' || ext == '.webp') ? {Q: quality.to_i} : {}
2745
+ {'refId' => transfer_buffer_stash(img.write_to_buffer(ext, **opts))}
2746
+ }
2747
+ end
2748
+
2749
+ def webauthn = (@webauthn ||= WebauthnState.new)
2750
+
2751
+ # Worker thread entry. Builds an isolate via the engine class's
2752
+ # `build_worker` factory, evaluates the worker script, then
2753
+ # loops draining microtasks + timers + inbox until `:terminate`
2754
+ # lands or an exception propagates.
2755
+ private def run_worker(handle, url, body, inbox, outbox, engine_class)
2756
+ raise "worker script not found: #{url}" unless body
2757
+ # The worker SCRIPT is text; the Rack-fetched body arrives
2758
+ # BINARY-tagged (see `RuntimeShared.utf8_text`).
2759
+ body = RuntimeShared.utf8_text(body)
2760
+ post_back = ->(data) { outbox << {handle: handle, kind: 'message', data: data.to_s} }
2761
+ rt = engine_class.build_worker(self, post_back)
2762
+ # Set the worker's `self.location.href` so webpack /
2763
+ # rollup public-path derivation + `new URL(rel, import.meta.url)`
2764
+ # resolve chunks against the worker's own origin rather than
2765
+ # the snapshot-time `http://placeholder/`.
2766
+ rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
2767
+ rt.eval(body)
2768
+ loop do
2769
+ msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
2770
+ break if msg == :terminate
2771
+ rt.call('__csim_workerOnMessage', msg) if msg
2772
+ rt.drain_microtasks
2773
+ rt.drain_timers if rt.has_ready_timer?
793
2774
  end
794
- cookie[:path] = '/' if cookie[:path].nil? || cookie[:path].empty?
795
- # Replace existing cookie with same (name, domain, path).
796
- @cookie_jar.reject! do |c|
797
- c[:name] == cookie[:name] && c[:domain] == cookie[:domain] && c[:path] == cookie[:path]
798
- end
799
- if cookie[:expires] && cookie[:expires] < Time.now
800
- # already expired skip storing
801
- else
802
- @cookie_jar << cookie
803
- end
2775
+ rescue StandardError => e
2776
+ outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
2777
+ ensure
2778
+ rt&.dispose
2779
+ end
2780
+
2781
+ # Bundlers that ship a worker inline as a Blob (Tesseract,
2782
+ # Webpack `?worker` imports, Vite worker chunks) construct
2783
+ # `new Worker(blobURL)`. Rack can't parse `blob:` so short-
2784
+ # circuit to the JS-side blob registry instead. Http(s) URLs
2785
+ # fall through to the regular Rack path.
2786
+ private def fetch_worker_script(url)
2787
+ return rack_fetch_body(url) unless url.to_s.start_with?('blob:')
2788
+ b64 = @runtime.call('__csimReadBlobBase64', url)
2789
+ return nil unless b64
2790
+ Base64.decode64(b64.to_s)
2791
+ end
2792
+
2793
+ # `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
2794
+ # because the worker thread has nothing else to do while idle,
2795
+ # and `worker_post_to_worker` wakes the wait immediately.
2796
+ private def pop_with_timeout(queue, seconds)
2797
+ queue.pop(timeout: seconds)
2798
+ rescue ThreadError
2799
+ nil
804
2800
  end
805
2801
 
806
- def domain_matches?(cookie_domain, host)
807
- return true if cookie_domain.nil? || cookie_domain.empty?
808
- return true if cookie_domain == host
809
- host.to_s.end_with?(".#{cookie_domain}")
2802
+ # Drain everything currently in a Thread::Queue without
2803
+ # blocking. Shared between `event_source_poll` and
2804
+ # `deliver_worker_messages`.
2805
+ private def drain_queue(queue)
2806
+ out = []
2807
+ loop do
2808
+ out << queue.pop(true)
2809
+ rescue ThreadError
2810
+ break
2811
+ end
2812
+ out
810
2813
  end
811
2814
 
812
- def path_matches?(cookie_path, request_path)
813
- cookie_path = '/' if cookie_path.nil? || cookie_path.empty?
814
- return true if cookie_path == '/'
815
- return true if request_path == cookie_path
816
- return true if request_path.start_with?(cookie_path + '/')
817
- return true if cookie_path.end_with?('/') && request_path.start_with?(cookie_path)
818
- false
2815
+ # JS-side `ingestImportmaps` calls this through the host fn so
2816
+ # Ruby-side `resolve_module_specifier` agrees with the bare-
2817
+ # specifier map shipped by `<script type="importmap">`.
2818
+ def set_importmap(json)
2819
+ @importmap = JSON.parse(json.to_s)
2820
+ rescue JSON::ParserError
2821
+ @importmap = {'imports' => {}, 'scopes' => {}}
819
2822
  end
820
2823
 
821
- def fetch_resource(url)
822
- full_url = resolve_url(url)
823
- request = Rack::MockRequest.new(@app)
824
- response = request.request('GET', full_url, {})
825
- return nil unless response.status.between?(200, 299)
826
- response.body
2824
+ def resolve_module_specifier(specifier, base_url)
2825
+ @importmap ||= {'imports' => {}, 'scopes' => {}}
2826
+ if (mapped = @importmap['imports'][specifier])
2827
+ return resolve_against(mapped, base_url)
2828
+ end
2829
+ if specifier.start_with?('/', './', '../') || specifier.match?(%r{\A[a-z]+://}i)
2830
+ return resolve_against(specifier, base_url)
2831
+ end
2832
+ specifier
2833
+ end
2834
+
2835
+ def resolve_against(url, base)
2836
+ return url if url =~ %r{\A[a-z]+://}i
2837
+ # quickjs.rb's module_loader passes the importer for nested
2838
+ # relative imports; if the importer was an inline-script
2839
+ # pseudo-name (no scheme), fall through to the page URL.
2840
+ base = nil unless base.is_a?(String) && base =~ %r{\A[a-z]+://}i
2841
+ eff = base || @current_url || @default_host
2842
+ # Memo of `URI.join(eff, url)` — a pure function of (effective base, url).
2843
+ # A heavy ESM app re-resolves the same ~80 module specifiers against the
2844
+ # same base on every visit (a fresh VM re-instantiates the whole module
2845
+ # graph); Ruby's URI parser was a measured ~11% of per-visit wall. The
2846
+ # Browser persists across a suite's visits, so this instance-level memo
2847
+ # (same scope/threading assumptions as @importmap / @current_url) turns
2848
+ # all but the first visit's resolves into hash hits.
2849
+ cache = (@resolve_against_cache ||= {})
2850
+ cache[[eff, url]] ||= begin
2851
+ URI.join(eff, url).to_s
2852
+ rescue URI::InvalidURIError, URI::BadURIError
2853
+ url
2854
+ end
827
2855
  end
828
2856
 
829
- # JS-facing `fetch` implementation, attached to the V8 context as
830
- # `__csim_fetch`. Routes the request through the configured Rack app
831
- # using the active cookie jar and follows up to 20 redirects, mirroring
832
- # how `navigate` runs full-page requests. Headers come back joined into
833
- # a hash so the JS shim can reconstruct a `Headers` object.
834
- FETCH_REDIRECT_LIMIT = 20
835
- def js_fetch(method, url, headers, body)
2857
+ MAX_FETCH_REDIRECTS = 20
2858
+ # URLs we won't even try to route through Rack: anything that
2859
+ # isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
2860
+ # like V8's `<snapshot>` that sourcemap libraries pull out of
2861
+ # error stacks and feed straight to `fetch()` / `xhr.open()`.
2862
+ def rack_fetch(method, url, body, headers, redirect_mode, env_extras: nil)
2863
+ target = resolve_against_current(url.to_s)
2864
+ return nil unless target.is_a?(String) && target.match?(%r{\Ahttps?://}i)
836
2865
  method = (method || 'GET').to_s.upcase
837
- headers = (headers || {}).each_with_object({}) {|(k, v), m| m[k.to_s] = v.to_s }
838
- full_url = resolve_url(url)
839
2866
  redirected = false
840
- FETCH_REDIRECT_LIMIT.times do
841
- env = fetch_env_for(headers, full_url)
842
- input = body.to_s
843
- request = Rack::MockRequest.new(@app)
844
- response = request.request(method, full_url, env.merge(input: input))
845
- ingest_set_cookie(response.headers, full_url)
846
- if (300..399).cover?(response.status) && response.headers['location']
847
- target = response.headers['location']
848
- full_url = resolve_url(target)
849
- unless [307, 308].include?(response.status)
850
- method = 'GET'
851
- body = ''
852
- end
853
- headers = headers.reject {|k, _| %w[content-type content-length].include?(k.downcase) } if method == 'GET'
2867
+ # JS-side base64-encodes Blob/File bodies (raw bytes survive
2868
+ # the engine's UTF-8 string boundary that way); decode before
2869
+ # handing to Rack so the upload PUT lands intact.
2870
+ if headers.is_a?(Hash) && headers['X-Csim-Body-B64'].to_s == '1'
2871
+ body = Base64.decode64(body.to_s)
2872
+ headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
2873
+ end
2874
+ MAX_FETCH_REDIRECTS.times do
2875
+ # GET-only cache shortcut (RFC 9111). Fresh hit → skip @app.call
2876
+ # entirely; stale-but-revalidatable → fall through with conditional
2877
+ # headers added so the server can return 304.
2878
+ cache_entry = method == 'GET' ? @@asset_cache.lookup(target) : nil
2879
+ if cache_entry&.fresh?
2880
+ log_network(method, target, cache_entry.status)
2881
+ return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
2882
+ end
2883
+
2884
+ env = Rack::MockRequest.env_for(target, method: method, input: body || '')
2885
+ apply_request_headers(env, headers) if headers
2886
+ apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
2887
+ apply_default_request_env(env, referer: @current_url, force: false)
2888
+ env.merge!(env_extras) if env_extras
2889
+ status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
2890
+ merge_set_cookie(resp_headers)
2891
+ log_network(method, target, status)
2892
+ if status == 304 && cache_entry
2893
+ resp_body.close if resp_body.respond_to?(:close)
2894
+ @@asset_cache.refresh(cache_entry, resp_headers)
2895
+ return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
2896
+ end
2897
+ if redirect_mode != 'manual' && (loc = redirect_location(status, resp_headers))
2898
+ raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
854
2899
  redirected = true
2900
+ preserve = [307, 308].include?(status)
2901
+ next_url = resolve_against(loc, target)
2902
+ target = carry_fragment(target, next_url)
2903
+ method = 'GET' unless preserve
2904
+ body = nil unless preserve
2905
+ resp_body.close if resp_body.respond_to?(:close)
855
2906
  next
856
2907
  end
857
- return {
858
- 'ok' => response.status.between?(200, 299),
859
- 'status' => response.status,
860
- 'statusText' => '',
861
- 'headers' => normalize_response_headers(response.headers),
862
- 'body' => response.body.to_s,
863
- 'finalUrl' => full_url,
864
- 'redirected' => redirected
865
- }
2908
+ body_str = read_rack_body(resp_body)
2909
+ @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET'
2910
+ return response_hash(status, resp_headers, body_str, target, redirected)
866
2911
  end
867
- {'error' => 'too many redirects'}
2912
+ raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
868
2913
  rescue StandardError => e
869
- {'error' => "#{e.class}: #{e.message}"}
2914
+ warn "[capybara-simulated] rack_fetch failed: #{e.class}: #{e.message[0, 200]}"
2915
+ nil
870
2916
  end
871
2917
 
872
- def fetch_env_for(headers, full_url)
873
- env = {}
874
- headers.each do |k, v|
875
- name = k.to_s
876
- if name.downcase == 'content-type'
877
- env['CONTENT_TYPE'] = v.to_s
878
- elsif name.downcase == 'content-length'
879
- env['CONTENT_LENGTH'] = v.to_s
880
- else
881
- env["HTTP_#{name.upcase.tr('-', '_')}"] = v.to_s
2918
+ # CGI convention: `Content-Type` and `Content-Length` land in env
2919
+ # *without* the HTTP_ prefix. Rails / Rack params parsing reads
2920
+ # `CONTENT_TYPE` and dispatches JSON / multipart parsers off it;
2921
+ # sending it as `HTTP_CONTENT_TYPE` lets the request through but
2922
+ # with the default `text/plain`, so JSON bodies from
2923
+ # `@rails/request.js` never deserialise and the server reads an
2924
+ # empty params hash.
2925
+ def apply_request_headers(env, headers)
2926
+ headers.each {|k, v|
2927
+ name = k.to_s.upcase.tr('-', '_')
2928
+ case name
2929
+ when 'CONTENT_TYPE', 'CONTENT_LENGTH' then env[name] = v.to_s
2930
+ else env["HTTP_#{name}"] = v.to_s
882
2931
  end
883
- end
884
- if @current_url
885
- env['HTTP_REFERER'] ||= @current_url
886
- end
887
- cookie_header = build_cookie_header(full_url)
888
- env['HTTP_COOKIE'] = cookie_header unless cookie_header.empty?
889
- env
2932
+ }
890
2933
  end
891
2934
 
892
- def normalize_response_headers(headers)
893
- out = {}
894
- headers.each do |name, value|
895
- val = value.is_a?(Array) ? value.join("\n") : value.to_s
896
- out[name.to_s] = val
897
- end
2935
+ # Content types whose bytes are already representable in the
2936
+ # UTF-8 string that ships back to JS — base64 wouldn't add
2937
+ # anything and `Base64.strict_encode64` is ~1 % of suite wall
2938
+ # time on Discourse. Binary types (images, octet-stream,
2939
+ # gzipped traineddata, etc.) still need `body_b64` because V8 /
2940
+ # QuickJS mangle bytes 0x80-0xFF over the UTF-8 string boundary.
2941
+ # `fetch.js#_decodeBytes` and `xhr.js` both fall back to the
2942
+ # text body when `body_b64` is absent.
2943
+ TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
2944
+
2945
+ def response_hash(status, headers, body, url, redirected)
2946
+ raw = body.to_s
2947
+ hdrs = stringify(headers)
2948
+ is_text = text_response?(hdrs)
2949
+ # `body` crosses as TEXT — `responseText` semantics: the bytes decoded
2950
+ # as UTF-8 with invalid sequences replaced (a leading BOM selects the
2951
+ # encoding per the HTML "decode" algorithm and is removed). The real
2952
+ # bytes for binary consumers ride `body_b64`; the Rack body arrives
2953
+ # BINARY-tagged (see `RuntimeShared.utf8_text`).
2954
+ text = RuntimeShared.utf8_text(is_text ? decode_response_bom(raw) : raw)
2955
+ out = {
2956
+ 'status' => status,
2957
+ 'headers' => hdrs,
2958
+ 'body' => text,
2959
+ 'url' => url,
2960
+ 'redirected' => redirected,
2961
+ 'type' => 'basic'
2962
+ }
2963
+ out['body_b64'] = Base64.strict_encode64(raw) unless is_text
898
2964
  out
899
2965
  end
900
2966
 
901
- def stringify_keys(hash)
902
- hash.each_with_object({}) {|(k, v), m| m[k.to_s] = v }
2967
+ # Strip + decode a single leading byte-order mark, mapping the body to a
2968
+ # UTF-8 Ruby string. No BOM return the bytes untouched (the hot path:
2969
+ # just a 2–3 byte prefix check). One BOM is consumed; any further BOMs are
2970
+ # ordinary U+FEFF characters in the decoded text (per spec the parser does
2971
+ # not strip them again).
2972
+ def decode_response_bom(s)
2973
+ b = s.b
2974
+ if b.start_with?("\xEF\xBB\xBF".b)
2975
+ b.byteslice(3..).force_encoding(Encoding::UTF_8)
2976
+ elsif b.start_with?("\xFF\xFE".b) || b.start_with?("\xFE\xFF".b)
2977
+ # Generic UTF-16: the BOM picks endianness and is dropped by the decoder.
2978
+ # Replace malformed units rather than raising (a truncated/odd-length
2979
+ # body still yields readable UTF-8 instead of falling back to raw bytes).
2980
+ b.force_encoding(Encoding::UTF_16).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
2981
+ else
2982
+ s
2983
+ end
2984
+ rescue StandardError
2985
+ s
2986
+ end
2987
+
2988
+ def text_response?(headers)
2989
+ ct = (headers['content-type'] || headers['Content-Type']).to_s.downcase
2990
+ return false if ct.empty?
2991
+ TEXT_CONTENT_TYPE_PREFIXES.any? {|p| ct.start_with?(p) }
2992
+ end
2993
+
2994
+ # Rack response bodies must respond to `each` (or be an Array of
2995
+ # strings). `to_s` on a streaming body returns the inspect form,
2996
+ # not the bytes — which silently shipped 43-byte `#<Rack::Files…>`
2997
+ # strings to the JS engine for big assets like jquery.js.
2998
+ def read_rack_body(body)
2999
+ buf = +''
3000
+ body.each {|chunk| buf << chunk.to_s } if body.respond_to?(:each)
3001
+ body.close if body.respond_to?(:close)
3002
+ buf
3003
+ end
3004
+
3005
+ # Defer the navigation: doing it from inside the running V8 call
3006
+ # would dispose the Context mid-call. tick_real_time drains
3007
+ # after the call returns. Same pattern as `__csimPendingFormSubmit`.
3008
+ def location_assign(url)
3009
+ @pending_location = resolve_against_current(url.to_s)
3010
+ end
3011
+ def consume_pending_location
3012
+ return unless (url = @pending_location)
3013
+ @pending_location = nil
3014
+ # A `location.href`/`assign`/`hash` set to a same-document
3015
+ # fragment (e.g. `location.hash = ''`) is NOT a document fetch —
3016
+ # move the hash without rebuilding the VM, matching the anchor-
3017
+ # click navigate branch. Without this a hash assignment reloaded
3018
+ # the page, discarding all JS state.
3019
+ if pure_fragment_navigation?(url)
3020
+ update_current_hash(url)
3021
+ else
3022
+ navigate(url)
3023
+ end
903
3024
  end
904
-
905
- # Mirror Selenium's send_keys conventions. Top-level Symbols name a
906
- # special key; an Array names a "modifier+key" combo where every
907
- # element except the last is a held-down modifier. Modifier symbols
908
- # at the top level stay held for everything that follows in the
909
- # same call (Capybara's "hold modifiers" behaviour).
910
- def encode_keys(keys)
911
- Array(keys).flat_map {|k| encode_key(k) }
3025
+ # Mirror of `location_assign`'s deferral for `location.reload()`:
3026
+ # the JS call lands here from `__locationReload`; running
3027
+ # `browser.refresh` directly would `navigate` (rebuilding the
3028
+ # Context) while we're still inside the V8 call, which V8
3029
+ # terminates with a `ScriptTerminatedError`. Stash the intent
3030
+ # and drain it from `tick_real_time` after the call returns.
3031
+ def location_reload ; @pending_reload = true ; end
3032
+ def consume_pending_reload
3033
+ return unless @pending_reload
3034
+ @pending_reload = false
3035
+ refresh
3036
+ end
3037
+ def drain_pending_navigation
3038
+ consume_pending_location
3039
+ consume_pending_reload
3040
+ consume_pending_history_traverse
3041
+ end
3042
+ # POST-after-POST resubmits with the original body; GET-after-GET
3043
+ # is just a re-GET. Replay the current history entry.
3044
+ def refresh
3045
+ replay_history_entry(@history[@history_idx])
3046
+ end
3047
+ # `history.pushState(state, '', '/path')` ships the URL through
3048
+ # `__setCurrentUrl` and lands here. Tab controllers / SPA frameworks
3049
+ # pass a relative path; resolve it against the existing absolute
3050
+ # `@current_url` so subsequent `resolve_against_current(href)`
3051
+ # calls (e.g. click_link to a relative href) don't hit
3052
+ # `URI::BadURIError: both URI are relative`.
3053
+ # `history.replaceState(state, _, url)` updates the current entry
3054
+ # in place rather than appending. Both the state and (when given)
3055
+ # the URL are mirrored on Ruby's slot so a subsequent back to
3056
+ # this entry restores the same state.
3057
+ def history_state(url, state = nil)
3058
+ if url
3059
+ resolved = resolve_against_current(url.to_s)
3060
+ record_url_transition(resolved)
3061
+ @current_url = resolved
3062
+ end
3063
+ return if @history_idx < 0
3064
+ @history[@history_idx] = (@history[@history_idx] || {}).merge(
3065
+ url: @current_url,
3066
+ state: state,
3067
+ kind: @history[@history_idx] ? @history[@history_idx][:kind] : :push_state
3068
+ )
912
3069
  end
913
-
914
- def encode_key(k)
915
- case k
916
- when String then [k]
917
- when Symbol then [encode_special(k)]
918
- when Array
919
- modifiers = k[0..-2].map {|m| symbol_to_key_name(m) }
920
- tail = k.last
921
- [{'combo' => modifiers, 'tail' => encode_key(tail)}]
922
- else [k.to_s]
3070
+ # `history.pushState(state, _, url)` from SPA navigation (Turbo
3071
+ # Visit, InstantClick, …) appends a new browser-history entry.
3072
+ # Mirror that on the Ruby side so `Capybara#go_back` traverses
3073
+ # within the pushState chain (fires `popstate`) and only crosses
3074
+ # to a real reload when the back hits a `:visit` boundary.
3075
+ def history_push(url, state = nil)
3076
+ resolved = resolve_against_current(url.to_s)
3077
+ record_url_transition(resolved)
3078
+ @current_url = resolved
3079
+ record_history({method: :get, url: resolved, state: state, kind: :push_state})
3080
+ end
3081
+
3082
+ # Total history entries (after forward-tail truncation), surfaced
3083
+ # to JS `history.length` via the `__historyLength` host fn.
3084
+ def history_length
3085
+ [@history.size, 1].max
3086
+ end
3087
+ # `document.cookie` is TEXT; jar entries parsed out of Rack's Set-Cookie
3088
+ # headers can carry the BINARY tag, which would make the joined string
3089
+ # cross into JS as a Uint8Array (`document.cookie.match is not a
3090
+ # function`). Cookies are ASCII per RFC 6265.
3091
+ def document_cookie
3092
+ RuntimeShared.utf8_text(@cookies.map {|k, v| "#{k}=#{v}" }.join('; '))
3093
+ end
3094
+ def current_referer ; @current_referer.to_s ; end
3095
+ def write_document_cookie(s)
3096
+ return if s.nil? || s.empty?
3097
+ name, rest = s.split('=', 2)
3098
+ return if name.nil? || name.empty?
3099
+ parts = (rest || '').split(';').map(&:strip)
3100
+ value = parts.shift.to_s
3101
+ if cookie_deletion?(parts)
3102
+ @cookies.delete(name.strip)
3103
+ else
3104
+ @cookies[name.strip] = value
923
3105
  end
924
3106
  end
925
3107
 
926
- def encode_special(sym)
927
- {'special' => symbol_to_key_name(sym)}
3108
+ # Web Storage host-fn shims. The Ruby-side hashes survive
3109
+ # `rebuild_ctx` between visits, so apps that cache user data in
3110
+ # `localStorage` on page A (Forem's `browserStoreCache('set')`
3111
+ # inside fetchBaseData) see it on page B — without this, every
3112
+ # visit boots into a JS-side Map that starts empty and the
3113
+ # first-call branches that hinge on cached user data (the
3114
+ # onboarding task-card render, `initializeLocalStorageRender`,
3115
+ # etc.) silently skip.
3116
+ def storage_get(kind, key)
3117
+ store(kind)[key.to_s]
3118
+ end
3119
+ def storage_set(kind, key, value)
3120
+ store(kind)[key.to_s] = value.to_s
3121
+ nil
3122
+ end
3123
+ def storage_remove(kind, key)
3124
+ store(kind).delete(key.to_s)
3125
+ nil
3126
+ end
3127
+ def storage_clear(kind)
3128
+ store(kind).clear
3129
+ nil
3130
+ end
3131
+ def storage_key(kind, index)
3132
+ store(kind).keys[index.to_i]
3133
+ end
3134
+ def storage_length(kind)
3135
+ store(kind).size
3136
+ end
3137
+ private def store(kind)
3138
+ kind.to_s == 'session' ? @session_storage : @local_storage
3139
+ end
3140
+ # Push a one-shot handler onto the modal-dialog stack — the next
3141
+ # modal that fires consumes the topmost handler. Block exit pops
3142
+ # in case the dialog never fired.
3143
+ def with_modal(handler)
3144
+ @modal_handlers.push(handler)
3145
+ yield if block_given?
3146
+ ensure
3147
+ @modal_handlers.delete(handler)
3148
+ end
3149
+
3150
+ # JS-side `alert(...)` / `confirm(...)` / `prompt(...)` route here.
3151
+ # If no handler is pushed (typical of apps under test), accept
3152
+ # the dialog (Rails system-test default) so `data-turbo-confirm`
3153
+ # / similar progress without an explicit `accept_confirm` in
3154
+ # the test.
3155
+ def handle_modal(type, message, default_value)
3156
+ handler = @modal_handlers.pop
3157
+ if handler
3158
+ handler.call(type, message, default_value)
3159
+ else
3160
+ case type.to_s
3161
+ when 'alert' then nil
3162
+ when 'confirm' then true
3163
+ when 'prompt' then default_value.to_s
3164
+ end
3165
+ end
928
3166
  end
929
3167
 
930
- def symbol_to_key_name(sym)
931
- case sym
932
- when :enter, :return then 'Enter'
933
- when :tab then 'Tab'
934
- when :backspace then 'Backspace'
935
- when :delete then 'Delete'
936
- when :escape then 'Escape'
937
- when :space then ' '
938
- when :left then 'ArrowLeft'
939
- when :right then 'ArrowRight'
940
- when :up then 'ArrowUp'
941
- when :down then 'ArrowDown'
942
- when :home then 'Home'
943
- when :end then 'End'
944
- when :page_up then 'PageUp'
945
- when :page_down then 'PageDown'
946
- when :shift, :control, :alt, :meta, :command, :ctrl
947
- {shift: 'Shift', control: 'Control', ctrl: 'Control',
948
- alt: 'Alt', meta: 'Meta', command: 'Meta'}[sym]
949
- else sym.to_s.capitalize
3168
+ private
3169
+
3170
+ # Fetch via the Rack app and hand the body to V8 for parsing.
3171
+ # Only follows 3xx redirects up to a small depth.
3172
+ def navigate(url, depth: 0, referer: @current_url, from_history: false)
3173
+ raise 'too many redirects' if depth > 10
3174
+ invalidate_find_cache
3175
+ # Capture the entry referer (the page initiating this navigation,
3176
+ # e.g. clicked link's host page) at depth 0 — internal redirects
3177
+ # at deeper depths don't replace the user-visible referrer.
3178
+ # A full-document navigate also clears the pushState transition
3179
+ # queue: any URLs we'd queued during the prior page's lifetime
3180
+ # are stale once we cross a real document boundary. The pinned
3181
+ # user-action baseline is likewise scoped to the prior document —
3182
+ # drop it so it can't match (and wrongly suppress) a transition on
3183
+ # the new page.
3184
+ if depth == 0
3185
+ @current_referer = referer.to_s
3186
+ @recent_urls.clear if @recent_urls
3187
+ @recent_urls_last_push_at = nil
3188
+ @action_url_baseline = nil
3189
+ end
3190
+ # While navigate is in progress (and the loaded page's bootstrap
3191
+ # JS is running synchronously inside __csimLoadDocument), any
3192
+ # `history.pushState`/`replaceState` chain belongs to that load
3193
+ # — record intermediates so a polling matcher can walk them.
3194
+ prior_navigating = @navigating
3195
+ @navigating = true unless from_history
3196
+ begin
3197
+ record_history({method: :get, url: url}) unless from_history || depth > 0
3198
+ env = Rack::MockRequest.env_for(url, method: 'GET')
3199
+ apply_default_request_env(env, referer: referer)
3200
+ status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
3201
+ merge_set_cookie(headers)
3202
+ if (loc = redirect_location(status, headers))
3203
+ next_url = resolve_against_current(loc)
3204
+ # Per RFC 7231: if the original request URL had a fragment
3205
+ # and the redirect target doesn't specify one, preserve
3206
+ # the original fragment in the final URL.
3207
+ next_url = carry_fragment(url, next_url)
3208
+ body.close if body.respond_to?(:close)
3209
+ return navigate(next_url, depth: depth + 1)
3210
+ end
3211
+ # Track the navigated URL even for download-shaped responses
3212
+ # so an aux window opened on a binary asset (PDF / image
3213
+ # opened via `target=_blank`) still reports `current_url`
3214
+ # correctly to within_window assertions.
3215
+ @current_url = url
3216
+ if download_response?(headers)
3217
+ save_downloaded_response(url, headers, body)
3218
+ return
3219
+ end
3220
+ record_response(status, headers)
3221
+ html = read_rack_body(body)
3222
+ # Full-reload navigation rebuilds the JS Context from the warm
3223
+ # snapshot. Per-visit fresh VM avoids partial-reset drift
3224
+ # (jQuery `.ready`, rails-ujs `_rails_loaded`, accumulated
3225
+ # `$(document).on(...)` delegates) — snapshot warmup keeps the
3226
+ # rebuild itself cheap; app-bundle re-eval dominates.
3227
+ boot_response_into_ctx(html)
3228
+ ensure
3229
+ @navigating = prior_navigating
950
3230
  end
951
3231
  end
952
3232
 
953
- def encode_script_args(args)
954
- Array(args).map {|v| encode_script_arg(v) }
3233
+ # Rebuild the JS Context and load `html` into it. Called from
3234
+ # every code path that handles a full-page Rack response (`navigate`
3235
+ # for GETs, `navigate_post` for POSTs). `__csimLoadDocument` walks
3236
+ # importmaps + module scripts during `runInlineScripts`; the bridge
3237
+ # pushes the importmap back via `__csim_pushImportmap` before any
3238
+ # module loads so resolver lookups agree with the JS side.
3239
+ #
3240
+ # The post-nav grace bridges Capybara's outer-synchronize gap when
3241
+ # the new page has no scripts of its own to flip `@timers_active`
3242
+ # (e.g. Avo's `redirect_to main_app.hey_path` → static view). Kept
3243
+ # small (~10 retry intervals) so failing-assertion paths don't pay
3244
+ # for the wait.
3245
+ def boot_response_into_ctx(html)
3246
+ # Before discarding the OUTGOING page's VM, flush its DUE-NOW init work so
3247
+ # persistent side effects (localStorage / cookies) survive into the next
3248
+ # page. forem's login redirect kicks off `fetchBaseData` — a
3249
+ # `setTimeout(0)` (fetch.js) whose `.then` writes `current_user` to
3250
+ # localStorage — but the interactive gen-yield `settle` bails on the first
3251
+ # init mutation before that due-now fetch fires; without this flush the
3252
+ # cache write is lost on rebuild and the next page (which reads it
3253
+ # synchronously to reveal logged-in UI) renders as logged-out. `maxMs: 0`
3254
+ # fires only ALREADY-due timers (the setTimeout(0) + its `.then` chain),
3255
+ # NOT delayed timers — so the lazy wall-sync timer model is preserved (a
3256
+ # freshly-loaded, not-yet-navigated-away page keeps its own pending
3257
+ # setTimeout(0)s untouched; smoke_spec "queries DOM before advancing
3258
+ # pending timers"). A real browser lets the outgoing page's in-flight init
3259
+ # finish before the next document loads; this is the in-process analogue.
3260
+ flush_outgoing_page_init if @timers_active
3261
+ @runtime.rebuild_ctx
3262
+ reset_timer_state
3263
+ # The DOCUMENT is text; the Rack body arrives BINARY-tagged (see
3264
+ # `RuntimeShared.utf8_text`). Charset-header-driven decode is the
3265
+ # fuller story; UTF-8 + scrub matches observable browser behavior
3266
+ # for the suites we run.
3267
+ html = RuntimeShared.utf8_text(html)
3268
+ opts = {
3269
+ 'traceActive' => !@trace.nil?,
3270
+ 'timezone' => ENV['TZ'].to_s,
3271
+ 'timeTravelOffsetMs' => ((Time.now.to_f - Process.clock_gettime(Process::CLOCK_REALTIME)) * 1000).to_i,
3272
+ 'url' => @current_url.to_s,
3273
+ 'html' => html
3274
+ }
3275
+ # Carry the response content type so the JS side can pick the XML vs
3276
+ # HTML parser (XHTML / XML / SVG documents parse case-sensitively, with
3277
+ # no html/head/body skeleton, and report `isHtmlDocument` false).
3278
+ ct = (@last_response_headers || {}).find {|k, _| k.to_s.downcase == 'content-type' }&.last
3279
+ ct = ct.first if ct.is_a?(Array)
3280
+ opts['contentType'] = ct.to_s if ct && !ct.to_s.empty?
3281
+ if @viewport_width && @viewport_height
3282
+ opts['viewportW'] = @viewport_width
3283
+ opts['viewportH'] = @viewport_height
3284
+ end
3285
+ opts['userAgent'] = @default_user_agent if @default_user_agent
3286
+ @document_handle = @runtime.call('__csimBootContext', opts).to_i
3287
+ @polling_grace = POST_NAV_POLL_GRACE_POLLS
3288
+ end
3289
+
3290
+ # Run one due-now event-loop step on the OUTGOING page (see
3291
+ # `boot_response_into_ctx`). The outgoing page's timers may call
3292
+ # `location.* / history.* / reload`, which only STASH a Ruby-side intent —
3293
+ # but we are navigating away, so those intents are moot and must NOT leak
3294
+ # into the page we are about to load (otherwise the next find's
3295
+ # `tick_real_time` would consume a stray `@pending_location` and navigate
3296
+ # off the freshly-loaded page). Snapshot/restore the nav-intent slots to
3297
+ # keep the flush transparent; swallow any throw so a flaky outgoing-page
3298
+ # timer can't abort loading the next page (the page it would affect is
3299
+ # being discarded on the very next line).
3300
+ def flush_outgoing_page_init
3301
+ saved_location = @pending_location
3302
+ saved_reload = @pending_reload
3303
+ saved_traverse = @pending_history_traverse
3304
+ begin
3305
+ @runtime.run_loop_step(0, SETTLE_MAX_ITER_TASKS, yield_on_gen: false)
3306
+ rescue StandardError
3307
+ # Outgoing page is discarded next line; its flush error is moot.
3308
+ ensure
3309
+ @pending_location = saved_location
3310
+ @pending_reload = saved_reload
3311
+ @pending_history_traverse = saved_traverse
3312
+ end
955
3313
  end
956
- def encode_script_arg(value)
957
- case value
958
- when Capybara::Simulated::Node
959
- {'__csim_handle' => value.handle_id}
960
- when Array
961
- value.map {|v| encode_script_arg(v) }
962
- when Hash
963
- value.each_with_object({}) {|(k, v), m| m[k.to_s] = encode_script_arg(v) }
3314
+
3315
+ # `Content-Disposition: attachment` (or any explicit filename
3316
+ # in inline form) is the canonical "save to disk" signal that
3317
+ # browsers honour. Tests that exercise CSV / PDF / etc. exports
3318
+ # use Redmine's `downloaded_file` helper to read the bytes
3319
+ # back from `tmp/downloads/`; routing the response through the
3320
+ # save path here keeps the page state unchanged (no rebuild),
3321
+ # mirroring what a real browser does after a download.
3322
+ def content_disposition_header(headers)
3323
+ headers['content-disposition'] || headers['Content-Disposition']
3324
+ end
3325
+
3326
+ def download_response?(headers)
3327
+ cd = content_disposition_header(headers)
3328
+ cd && cd.to_s.match?(/(?:^|;)\s*(attachment|filename\s*=)/i)
3329
+ end
3330
+
3331
+ def save_downloaded_response(url, headers, body)
3332
+ cd = content_disposition_header(headers).to_s
3333
+ m = cd.match(/filename\*?\s*=\s*(?:"([^"]+)"|([^;]+))/i)
3334
+ filename = (m && (m[1] || m[2]) || '').strip
3335
+ filename = File.basename(URI.parse(url.to_s).path.to_s) if filename.empty?
3336
+ filename = 'download' if filename.empty?
3337
+ dir = downloads_directory
3338
+ FileUtils.mkdir_p(dir)
3339
+ File.binwrite(File.join(dir, filename), read_rack_body(body))
3340
+ end
3341
+
3342
+ def downloads_directory
3343
+ ENV['CSIM_DOWNLOADS_DIR'] || Capybara.save_path || File.join(Dir.pwd, 'tmp', 'downloads')
3344
+ end
3345
+
3346
+ # Stamps the default headers every driver-originated request
3347
+ # carries: UA, REMOTE_ADDR, cookies, referer, and any sticky
3348
+ # headers a previous response asked us to echo. `force: false`
3349
+ # (the rack_fetch path) preserves any value the caller already
3350
+ # set on `env`, so JS-supplied `XHR.setRequestHeader` /
3351
+ # `fetch(..., {headers: ...})` overrides win.
3352
+ DEFAULT_HTTP_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'.freeze
3353
+
3354
+ def apply_default_request_env(env, referer:, force: true)
3355
+ # `Rack::MockRequest.env_for` populates `SERVER_NAME` /
3356
+ # `SERVER_PORT` from the URL but leaves `HTTP_HOST` nil. Read
3357
+ # SERVER_NAME so the IPv4/IPv6 loopback choice still matches
3358
+ # what real Chrome would have done after DNS resolution.
3359
+ if force
3360
+ env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
3361
+ env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
3362
+ @sticky_headers.each {|k, v| env["HTTP_#{k.upcase.tr('-', '_')}"] = v }
964
3363
  else
965
- value
3364
+ env['HTTP_USER_AGENT'] ||= (@default_user_agent || USER_AGENT)
3365
+ env['REMOTE_ADDR'] ||= self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
3366
+ @sticky_headers.each {|k, v| env["HTTP_#{k.upcase.tr('-', '_')}"] ||= v }
3367
+ end
3368
+ # Browsers always send an `Accept` header; Rack::MockRequest
3369
+ # leaves it nil, which Rails reads as `Mime::HTML` *only* in
3370
+ # its formats list. Controllers with only `format.turbo_stream`
3371
+ # (Avo's actions / flash-render path) then raise
3372
+ # `ActionController::UnknownFormat`. Send the same
3373
+ # wildcard-trailing Accept Chromium / Firefox use so the
3374
+ # server can negotiate — HTML-only routes still pick html,
3375
+ # both-available pick the first registered.
3376
+ env['HTTP_ACCEPT'] ||= DEFAULT_HTTP_ACCEPT
3377
+ env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
3378
+ env['HTTP_COOKIE'] = document_cookie unless @cookies.empty?
3379
+ end
3380
+
3381
+ # Cross-host hop (e.g. Discourse's `discourse_connect` flow
3382
+ # redirecting to a real WEBrick on `localhost:9100`) must cross
3383
+ # the wire — Rails' router doesn't have routes for the external
3384
+ # server's paths, and the external server's redirect back to the
3385
+ # app host needs to come through @app again. Real-browser drivers
3386
+ # get this for free; we have to detect the boundary.
3387
+ def dispatch_rack_or_http(url, env, method: 'GET', body: nil)
3388
+ return @app.call(env) if url_is_local?(url)
3389
+ # External fetch: if the network is blocked (WebMock) or the
3390
+ # host is unreachable, fall back to @app — Rails will 404 or
3391
+ # otherwise handle it, matching the pre-cross-host behavior
3392
+ # for tests that route through an external OAuth provider URL
3393
+ # without intending the call to land.
3394
+ net_http_fetch(url, env, method: method, body: body) || @app.call(env)
3395
+ end
3396
+
3397
+ # Path-only or fragment-only URLs are always against the current
3398
+ # origin. For absolute URLs, compare host:port to the cached
3399
+ # parsed @current_url (or default_host on first navigate).
3400
+ def url_is_local?(url)
3401
+ s = url.to_s
3402
+ return true if s.empty? || s.start_with?('/', '#', '?')
3403
+ uri = safe_uri(s)
3404
+ return true if uri.nil? || uri.host.nil?
3405
+ ref = current_url_uri || safe_uri(@default_host.to_s)
3406
+ return true unless ref&.host
3407
+ uri.host == ref.host && effective_port(uri) == effective_port(ref)
3408
+ end
3409
+
3410
+ def safe_uri(s)
3411
+ URI.parse(s) rescue nil
3412
+ end
3413
+
3414
+ def current_url_uri
3415
+ return nil if @current_url.nil?
3416
+ return @current_url_uri if @current_url_uri_cached_for.equal?(@current_url)
3417
+ @current_url_uri_cached_for = @current_url
3418
+ @current_url_uri = safe_uri(@current_url)
3419
+ end
3420
+
3421
+ def effective_port(uri)
3422
+ uri.port || (uri.scheme == 'https' ? 443 : 80)
3423
+ end
3424
+
3425
+ # Returns a Rack-shaped triple, or `nil` if the network attempt
3426
+ # failed for any reason — the caller falls back to @app.call so
3427
+ # WebMock-blocked external URLs (Discourse's OAuth provider
3428
+ # redirects) still round-trip through Rails like before. Cookies
3429
+ # are origin-scoped: ours don't go out. No redirect-follow either
3430
+ # — navigate / rack_fetch's loop chooses per hop.
3431
+ def net_http_fetch(url, env, method: 'GET', body: nil)
3432
+ uri = URI.parse(url.to_s)
3433
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 30) do |http|
3434
+ req = Net::HTTP.const_get(method.to_s.capitalize).new(uri.request_uri)
3435
+ env.each_pair do |k, v|
3436
+ next unless k.is_a?(String) && k.start_with?('HTTP_') && k != 'HTTP_COOKIE' && k != 'HTTP_HOST'
3437
+ req[k.sub(/\AHTTP_/, '').split('_').map(&:capitalize).join('-')] = v.to_s
3438
+ end
3439
+ req['Content-Type'] = env['CONTENT_TYPE'] if env['CONTENT_TYPE']
3440
+ req.body = body if body && !body.empty?
3441
+ resp = http.request(req)
3442
+ headers = {}
3443
+ resp.each_capitalized {|k, v| (headers[k] ||= []) << v }
3444
+ headers = headers.transform_values {|vs| vs.length == 1 ? vs.first : vs.join("\n") }
3445
+ [resp.code.to_i, headers, [resp.body || '']]
966
3446
  end
3447
+ rescue SystemExit, Interrupt, NoMemoryError
3448
+ raise
3449
+ rescue Exception # WebMock::NetConnectNotAllowedError descends from Exception, not StandardError
3450
+ nil
967
3451
  end
968
- def decode_script_result(value)
969
- case value
970
- when Hash
971
- if value['__csim_handle']
972
- Capybara::Simulated::Node.new(@driver_for_results, value['__csim_handle'])
3452
+
3453
+ def merge_set_cookie(headers)
3454
+ sc = headers['set-cookie'] || headers['Set-Cookie']
3455
+ return if sc.nil? || sc.empty?
3456
+ # Rack 2 returns multiple Set-Cookie headers as a single
3457
+ # newline-separated string; Rack 3 returns an Array. Treat both
3458
+ # uniformly — splitting first means the second cookie in a
3459
+ # multi-cookie response (Rails' session cookie alongside the
3460
+ # remember_user_token) doesn't get silently dropped.
3461
+ lines = sc.is_a?(Array) ? sc : sc.split("\n")
3462
+ lines.each {|line|
3463
+ parts = line.split(';').map(&:strip)
3464
+ pair = parts.shift.to_s
3465
+ name, value = pair.split('=', 2)
3466
+ next if name.nil? || name.empty?
3467
+ if cookie_deletion?(parts)
3468
+ @cookies.delete(name.strip)
973
3469
  else
974
- value.transform_values {|v| decode_script_result(v) }
3470
+ @cookies[name.strip] = value.to_s.strip
975
3471
  end
976
- when Array
977
- value.map {|v| decode_script_result(v) }
978
- else
979
- value
980
- end
3472
+ }
981
3473
  end
982
3474
 
983
- def same_path_anchor?(from_url, to_url)
984
- a = URI.parse(from_url) rescue nil
985
- b = URI.parse(to_url) rescue nil
986
- return false unless a && b
987
- a.scheme == b.scheme && a.host == b.host && a.port == b.port &&
988
- a.path == b.path && a.query == b.query &&
989
- b.fragment && !b.fragment.empty?
3475
+ # Real browsers treat `Set-Cookie: foo=; Max-Age=0` (or an
3476
+ # `Expires=<past>`) as a delete instruction and drop the cookie
3477
+ # entirely. ahoy_matey's controller uses this exact pattern to
3478
+ # invalidate `ahoy_visit` / `ahoy_visitor` when it decides to
3479
+ # mint a new visit. Without honoring the delete, the empty value
3480
+ # sits in the jar; the next `getCookie('ahoy_visit')` returns
3481
+ # `""` (truthy-ish but useless), and ahoy.js stamps the event
3482
+ # with `visit_token: ""` — the server then rejects the POST.
3483
+ def cookie_deletion?(attrs)
3484
+ attrs.any? {|attr|
3485
+ k, v = attr.split('=', 2)
3486
+ case k.to_s.downcase
3487
+ when 'max-age'
3488
+ v.to_s.strip.to_i <= 0
3489
+ when 'expires'
3490
+ (Time.parse(v.to_s) < Time.now rescue false)
3491
+ end
3492
+ }
990
3493
  end
991
3494
 
992
- def resolve_url(url)
993
- return @current_url if url.nil? || url.empty?
994
- if url.start_with?('http://', 'https://')
995
- url
996
- else
997
- base = current_base_url || @current_url || (Capybara.app_host || DEFAULT_HOST)
998
- URI.join(base, url).to_s
3495
+ # Header names/values are TEXT (RFC 9110: field values are ASCII); Rack
3496
+ # hands them over BINARY-tagged (see `RuntimeShared.utf8_text`).
3497
+ def stringify(headers)
3498
+ out = {}
3499
+ headers.each do |k, v|
3500
+ out[k.to_s] = RuntimeShared.utf8_text(v.is_a?(Array) ? v.join(',') : v.to_s)
999
3501
  end
3502
+ out
3503
+ end
3504
+
3505
+ def redirect_location(status, headers)
3506
+ return nil unless (300..399).include?(status.to_i)
3507
+ headers['location'] || headers['Location']
3508
+ end
3509
+
3510
+ def resolve_against_current(url, use_base: false)
3511
+ return url if url =~ %r{\A[a-z]+://}i
3512
+ base =
3513
+ if use_base && (bh = base_href) && !bh.empty?
3514
+ # The document's `<base href>` takes precedence over the
3515
+ # request URL when the page's own links / form actions are
3516
+ # being resolved — HTML's base-tag semantics. `visit` skips
3517
+ # this branch so an address-bar navigation reaches the URL
3518
+ # the test typed.
3519
+ URI.join(@current_url || @default_host, bh).to_s
3520
+ else
3521
+ @current_url || @default_host
3522
+ end
3523
+ URI.join(base, url.to_s).to_s
3524
+ rescue URI::InvalidURIError, URI::BadURIError
3525
+ url
3526
+ end
3527
+
3528
+ def base_href
3529
+ @runtime.call('__csimBaseHref').to_s
3530
+ end
3531
+
3532
+ def carry_fragment(from_url, to_url)
3533
+ from = URI.parse(from_url.to_s)
3534
+ to = URI.parse(to_url.to_s)
3535
+ return to_url if to.fragment || from.fragment.nil? || from.fragment.empty?
3536
+ to.fragment = from.fragment
3537
+ to.to_s
3538
+ rescue URI::InvalidURIError
3539
+ to_url
1000
3540
  end
1001
3541
 
1002
- # Returns the URL implied by a `<base href>` element on the active
1003
- # page, or nil. happy-dom doesn't apply base href when computing
1004
- # `a.href`, so the driver does its own resolution.
1005
- def current_base_url
1006
- href = @ctx.eval(<<~JS) rescue nil
1007
- (() => {
1008
- const b = document && document.querySelector && document.querySelector('base[href]');
1009
- return b ? String(b.getAttribute('href') || '') : null;
1010
- })()
1011
- JS
1012
- return nil if href.nil? || href.empty?
1013
- return href if href.start_with?('http://', 'https://')
1014
- URI.join(@current_url || (Capybara.app_host || DEFAULT_HOST), href).to_s
3542
+ # Trace-wrap layer: prepended so the canonical method bodies above
3543
+ # stay un-instrumented and a no-trace caller pays only the
3544
+ # `record_action` early-exit. `super` forwards to the real impl
3545
+ # within the `record_action` block, which handles begin/finish
3546
+ # step bookkeeping + on-failure DOM snapshot.
3547
+ module RecordedActions
3548
+ def visit(url)
3549
+ record_action(:visit, "visit #{url}") { super }
3550
+ end
3551
+ def refresh
3552
+ record_action(:refresh, 'refresh') { super }
3553
+ end
3554
+ def go_back
3555
+ record_action(:go_back, 'go_back') { super }
3556
+ end
3557
+ def go_forward
3558
+ record_action(:go_forward, 'go_forward') { super }
3559
+ end
3560
+ def click(handle, keys = [], **opts)
3561
+ record_action(:click, -> { "click #{describe_node_handle(handle)}" }) { super }
3562
+ end
3563
+ def set_value_with_events(handle, value)
3564
+ record_action(:set, -> { "set #{describe_node_handle(handle)} = #{value.inspect[0, 80]}" }) { super }
3565
+ end
3566
+ def send_keys(handle, keys)
3567
+ record_action(:send_keys, -> { "send_keys #{describe_node_handle(handle)} #{keys.inspect[0, 80]}" }) { super }
3568
+ end
3569
+ def select_option(handle)
3570
+ record_action(:select, -> { "select #{describe_node_handle(handle)}" }) { super }
3571
+ end
3572
+ def unselect_option(handle)
3573
+ record_action(:unselect, -> { "unselect #{describe_node_handle(handle)}" }) { super }
3574
+ end
3575
+ def submit_form(handle)
3576
+ record_action(:submit, -> { "submit #{describe_node_handle(handle)}" }) { super }
3577
+ end
1015
3578
  end
3579
+ prepend RecordedActions
1016
3580
  end
1017
3581
  end
1018
3582
  end