capybara-simulated 0.0.6 → 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.
- checksums.yaml +4 -4
- data/README.md +303 -158
- data/lib/capybara/simulated/asset_cache.rb +232 -0
- data/lib/capybara/simulated/browser.rb +3409 -845
- data/lib/capybara/simulated/driver.rb +341 -134
- data/lib/capybara/simulated/errors.rb +9 -5
- data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
- data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
- data/lib/capybara/simulated/node.rb +151 -163
- data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
- data/lib/capybara/simulated/runtime_shared.rb +183 -0
- data/lib/capybara/simulated/script_cache.rb +168 -0
- data/lib/capybara/simulated/sourcemap.rb +119 -0
- data/lib/capybara/simulated/stack_resolver.rb +97 -0
- data/lib/capybara/simulated/trace.rb +111 -0
- data/lib/capybara/simulated/v8_runtime.rb +987 -0
- data/lib/capybara/simulated/version.rb +3 -1
- data/lib/capybara/simulated/webauthn_state.rb +367 -0
- data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
- data/lib/capybara/simulated/worker_runtime.rb +30 -0
- data/lib/capybara/simulated.rb +31 -4
- data/lib/capybara-simulated.rb +2 -0
- data/vendor/js/vendor.bundle.js +13 -0
- metadata +24 -32
- data/vendor/esbuild-wasm/LICENSE.md +0 -21
- data/vendor/esbuild-wasm/bin/esbuild +0 -91
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +0 -2337
- data/vendor/esbuild-wasm/wasm_exec.js +0 -575
- data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
- data/vendor/js/bundle-modules.mjs +0 -168
- data/vendor/js/csim.bundle.js +0 -91560
- data/vendor/js/entry.mjs +0 -23
- data/vendor/js/prelude.js +0 -186
- data/vendor/js/runtime.js +0 -2174
|
@@ -1,1018 +1,3582 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
1
4
|
require 'date'
|
|
2
|
-
require '
|
|
5
|
+
require 'fileutils'
|
|
3
6
|
require 'json'
|
|
4
|
-
require '
|
|
5
|
-
require '
|
|
7
|
+
require 'net/http'
|
|
8
|
+
require 'openssl'
|
|
6
9
|
require 'rack/mock'
|
|
7
|
-
require '
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
#
|
|
64
|
-
# Capybara
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
# page
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
result =
|
|
149
|
-
|
|
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
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
886
|
+
v.is_a?(Date) ? v.strftime('%Y-%m-%d') : v.to_s
|
|
188
887
|
end
|
|
189
|
-
drain_async_timers
|
|
190
888
|
end
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
1293
|
+
def title
|
|
1294
|
+
tick_real_time
|
|
1295
|
+
@runtime.call('__csimDocumentTitle').to_s
|
|
221
1296
|
end
|
|
222
1297
|
|
|
223
|
-
def
|
|
224
|
-
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
#
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def
|
|
299
|
-
|
|
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
|
-
#
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
def
|
|
306
|
-
|
|
307
|
-
return
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
1577
|
+
def log_network(method, url, status) = @trace&.log_network(method, url, status)
|
|
320
1578
|
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
@
|
|
474
|
-
@
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
#
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
638
|
-
|
|
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
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
#
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
669
|
-
rescue
|
|
670
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
2912
|
+
raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
|
|
868
2913
|
rescue StandardError => e
|
|
869
|
-
|
|
2914
|
+
warn "[capybara-simulated] rack_fetch failed: #{e.class}: #{e.message[0, 200]}"
|
|
2915
|
+
nil
|
|
870
2916
|
end
|
|
871
2917
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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
|
-
#
|
|
906
|
-
#
|
|
907
|
-
#
|
|
908
|
-
#
|
|
909
|
-
#
|
|
910
|
-
def
|
|
911
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
954
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
3470
|
+
@cookies[name.strip] = value.to_s.strip
|
|
975
3471
|
end
|
|
976
|
-
|
|
977
|
-
value.map {|v| decode_script_result(v) }
|
|
978
|
-
else
|
|
979
|
-
value
|
|
980
|
-
end
|
|
3472
|
+
}
|
|
981
3473
|
end
|
|
982
3474
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
#
|
|
1003
|
-
#
|
|
1004
|
-
# `
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|