capybara-simulated 0.0.2
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 +7 -0
- data/LICENSE +20 -0
- data/README.md +225 -0
- data/lib/capybara/simulated/browser.rb +1012 -0
- data/lib/capybara/simulated/driver.rb +191 -0
- data/lib/capybara/simulated/errors.rb +9 -0
- data/lib/capybara/simulated/node.rb +235 -0
- data/lib/capybara/simulated/version.rb +5 -0
- data/lib/capybara/simulated.rb +10 -0
- data/lib/capybara-simulated.rb +1 -0
- data/vendor/esbuild-wasm/LICENSE.md +21 -0
- data/vendor/esbuild-wasm/bin/esbuild +91 -0
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +2337 -0
- data/vendor/esbuild-wasm/wasm_exec.js +575 -0
- data/vendor/esbuild-wasm/wasm_exec_node.js +40 -0
- data/vendor/js/bundle-modules.mjs +168 -0
- data/vendor/js/csim.bundle.js +101015 -0
- data/vendor/js/entry.mjs +8 -0
- data/vendor/js/prelude.js +186 -0
- data/vendor/js/runtime.js +2054 -0
- metadata +106 -0
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'mini_racer'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'rack/mock'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'time'
|
|
9
|
+
require 'uri'
|
|
10
|
+
|
|
11
|
+
module Capybara
|
|
12
|
+
module Simulated
|
|
13
|
+
class Browser
|
|
14
|
+
VENDOR_DIR = File.expand_path('../../../../vendor/js', __FILE__)
|
|
15
|
+
DEFAULT_HOST = 'http://www.example.com'.freeze
|
|
16
|
+
|
|
17
|
+
# The V8 isolate is shared across the lifetime of a Browser instance —
|
|
18
|
+
# only the happy-dom Window is recreated per visit. Reset between
|
|
19
|
+
# specs is via `reset!` on the Driver, which closes the Window and
|
|
20
|
+
# clears tracked DOM handles, but keeps the (expensive) isolate alive.
|
|
21
|
+
# On mini_racer + happy-dom the previously-denylisted scripts (notably
|
|
22
|
+
# jQuery UI) load without hanging the runtime, and ready callbacks
|
|
23
|
+
# depend on them succeeding so we no longer skip anything by default.
|
|
24
|
+
EXTERNAL_SCRIPT_DENYLIST = /\A\z/.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :app, :status_code, :response_headers
|
|
27
|
+
|
|
28
|
+
# window.history.pushState/replaceState only updates the JS-side
|
|
29
|
+
# location; mirror it onto the Ruby @current_url so Capybara reads
|
|
30
|
+
# the new path. Before any real visit (or after reset_session!), we
|
|
31
|
+
# have no @current_url, in which case we report `nil` rather than
|
|
32
|
+
# leaking the synthetic happy-dom Window URL.
|
|
33
|
+
def current_url
|
|
34
|
+
return '' unless @current_url
|
|
35
|
+
# Capybara's synchronize loop calls current_path/current_url while
|
|
36
|
+
# waiting for navigation. Drive the virtual clock so timer-based
|
|
37
|
+
# location updates (window.location.pathname = '...') eventually
|
|
38
|
+
# fire under the wait budget.
|
|
39
|
+
advance_virtual_clock
|
|
40
|
+
check_location_change unless @in_navigate
|
|
41
|
+
loc = (@ctx.eval('window && window.location ? window.location.href : null') rescue nil)
|
|
42
|
+
loc && !loc.empty? ? loc.to_s : @current_url
|
|
43
|
+
end
|
|
44
|
+
attr_accessor :driver_for_results
|
|
45
|
+
|
|
46
|
+
def initialize(app)
|
|
47
|
+
@app = app
|
|
48
|
+
@ctx = MiniRacer::Context.new
|
|
49
|
+
@ctx.eval(File.read(File.join(VENDOR_DIR, 'prelude.js')))
|
|
50
|
+
@ctx.eval(File.read(File.join(VENDOR_DIR, 'csim.bundle.js')))
|
|
51
|
+
@ctx.eval(File.read(File.join(VENDOR_DIR, 'runtime.js')))
|
|
52
|
+
@ctx.attach('__csim_fetch', method(:js_fetch))
|
|
53
|
+
|
|
54
|
+
@history = []
|
|
55
|
+
@history_index = -1
|
|
56
|
+
@current_url = nil
|
|
57
|
+
@status_code = nil
|
|
58
|
+
@response_headers = {}
|
|
59
|
+
@modal_responses = {alert: [], confirm: [], prompt: []}
|
|
60
|
+
@cookie_jar = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Tear down all per-page state without throwing away the V8 isolate.
|
|
64
|
+
# Capybara calls this between specs via Driver#reset!.
|
|
65
|
+
def reset_state!
|
|
66
|
+
@history = []
|
|
67
|
+
@history_index = -1
|
|
68
|
+
@current_url = nil
|
|
69
|
+
@status_code = nil
|
|
70
|
+
@response_headers = {}
|
|
71
|
+
@modal_responses = {alert: [], confirm: [], prompt: []}
|
|
72
|
+
@last_call_at = nil
|
|
73
|
+
@cookie_jar = []
|
|
74
|
+
# `loadHTML` clears all tracked node handles and recreates the
|
|
75
|
+
# happy-dom Window for an empty document.
|
|
76
|
+
call_runtime('loadHTML', '<!doctype html><html><body></body></html>', DEFAULT_HOST)
|
|
77
|
+
call_runtime('setModalResponses', stringify_keys(@modal_responses))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Explicit `visit` is a new navigation, not a follow-up — drop the
|
|
81
|
+
# referer the way browsers' address-bar navigation does, and resolve
|
|
82
|
+
# the URL against the configured app host rather than the current
|
|
83
|
+
# page (which may have a stale `<base href>`).
|
|
84
|
+
def visit(url)
|
|
85
|
+
navigate(:get, resolve_visit_url(url), [], referer: false)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_visit_url(url)
|
|
89
|
+
return @current_url if !url.nil? && !url.empty? && @current_url && url == @current_url
|
|
90
|
+
url = '/' if url.nil? || url.empty?
|
|
91
|
+
return url if url.start_with?('http://', 'https://')
|
|
92
|
+
# Resolve relative paths against the app host's root (not the
|
|
93
|
+
# currently displayed page), matching Capybara's `visit` semantics
|
|
94
|
+
# for rack_test-style drivers.
|
|
95
|
+
host = Capybara.app_host
|
|
96
|
+
if host.nil?
|
|
97
|
+
uri = URI.parse(@current_url || DEFAULT_HOST)
|
|
98
|
+
host = "#{uri.scheme}://#{uri.host}#{uri.port ? ":#{uri.port}" : ''}/"
|
|
99
|
+
end
|
|
100
|
+
URI.join(host, url).to_s
|
|
101
|
+
end
|
|
102
|
+
def refresh
|
|
103
|
+
return unless @current_url
|
|
104
|
+
# Browsers re-issue the previous request as-is on F5 (after a
|
|
105
|
+
# confirmation prompt for non-GETs, which our driver skips).
|
|
106
|
+
method = @last_method || :get
|
|
107
|
+
navigate(method, @current_url, @last_fields || [],
|
|
108
|
+
enctype: @last_enctype || 'application/x-www-form-urlencoded',
|
|
109
|
+
replace_history: true)
|
|
110
|
+
end
|
|
111
|
+
def go_back
|
|
112
|
+
return if @history_index <= 0
|
|
113
|
+
@history_index -= 1
|
|
114
|
+
navigate(:get, @history[@history_index], [], history_move: true)
|
|
115
|
+
end
|
|
116
|
+
def go_forward
|
|
117
|
+
return if @history_index >= @history.size - 1
|
|
118
|
+
@history_index += 1
|
|
119
|
+
navigate(:get, @history[@history_index], [], history_move: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def html = call_runtime('html')
|
|
123
|
+
def title = call_runtime('title')
|
|
124
|
+
|
|
125
|
+
def find_xpath(xpath, context_id = nil) = Array(call_runtime('findXPath', xpath, context_id))
|
|
126
|
+
def find_css(css, context_id = nil) = Array(call_runtime('findCSS', css, context_id))
|
|
127
|
+
|
|
128
|
+
def all_text(id) = call_runtime('allText', id).to_s
|
|
129
|
+
def visible_text(id) = call_runtime('visibleText', id).to_s
|
|
130
|
+
def inner_html(id) = call_runtime('innerHTML', id).to_s
|
|
131
|
+
def outer_html(id) = call_runtime('outerHTML', id).to_s
|
|
132
|
+
def tag_name(id) = call_runtime('tagName', id).to_s
|
|
133
|
+
def attr(id, name) = call_runtime('attr', id, name.to_s)
|
|
134
|
+
def prop(id, name) = call_runtime('prop', id, name.to_s)
|
|
135
|
+
def value(id) = call_runtime('value', id)
|
|
136
|
+
def visible?(id) = !!call_runtime('visible', id)
|
|
137
|
+
def checked?(id) = !!call_runtime('checked', id)
|
|
138
|
+
def selected?(id) = !!call_runtime('selected', id)
|
|
139
|
+
def disabled?(id) = !!call_runtime('disabled', id)
|
|
140
|
+
def readonly?(id) = !!call_runtime('readonly', id)
|
|
141
|
+
def multiple?(id) = !!call_runtime('multiple', id)
|
|
142
|
+
def path(id) = call_runtime('path', id).to_s
|
|
143
|
+
def rect(id) = call_runtime('rect', id) || {}
|
|
144
|
+
|
|
145
|
+
def set_value(id, value)
|
|
146
|
+
# setValue may return a submit-descriptor when the user typed `\n`
|
|
147
|
+
# into the only text input of a form (HTML implicit submission).
|
|
148
|
+
result = call_runtime('setValue', id, format_set_value(value))
|
|
149
|
+
follow(result) if result.is_a?(Hash) && result['action']
|
|
150
|
+
result
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Capybara hands Date/DateTime/Time objects through to the driver as-is
|
|
154
|
+
# but mini_racer can't serialise them. Pre-format them to the strings
|
|
155
|
+
# an HTML5 input expects so they round-trip via JS.
|
|
156
|
+
def format_set_value(value)
|
|
157
|
+
case value
|
|
158
|
+
when Array then value.map {|v| format_set_value(v) }
|
|
159
|
+
when Date then value.strftime('%Y-%m-%d')
|
|
160
|
+
when DateTime then value.strftime('%Y-%m-%dT%H:%M')
|
|
161
|
+
when Time then value.strftime('%Y-%m-%dT%H:%M')
|
|
162
|
+
else value
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
def select_option(id) = call_runtime('selectOption', id)
|
|
166
|
+
def unselect_option(id) = call_runtime('unselectOption', id)
|
|
167
|
+
|
|
168
|
+
def click(id, modifiers = {})
|
|
169
|
+
delay = modifiers.delete('delay')
|
|
170
|
+
if delay&.positive?
|
|
171
|
+
call_runtime('mouseDown', id, 0, modifiers)
|
|
172
|
+
sleep(delay)
|
|
173
|
+
result = follow(call_runtime('click', id, 0, modifiers, true))
|
|
174
|
+
else
|
|
175
|
+
result = follow(call_runtime('click', id, 0, modifiers))
|
|
176
|
+
end
|
|
177
|
+
drain_async_timers
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
def right_click(id, modifiers = {})
|
|
181
|
+
delay = modifiers.delete('delay')
|
|
182
|
+
if delay&.positive?
|
|
183
|
+
call_runtime('mouseDown', id, 2, modifiers)
|
|
184
|
+
sleep(delay)
|
|
185
|
+
call_runtime('rightClick', id, modifiers, true)
|
|
186
|
+
else
|
|
187
|
+
call_runtime('rightClick', id, modifiers)
|
|
188
|
+
end
|
|
189
|
+
drain_async_timers
|
|
190
|
+
end
|
|
191
|
+
def double_click(id, modifiers = {})
|
|
192
|
+
result = call_runtime('doubleClick', id, modifiers)
|
|
193
|
+
drain_async_timers
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
def hover(id)
|
|
197
|
+
call_runtime('hover', id).tap { drain_async_timers }
|
|
198
|
+
end
|
|
199
|
+
def trigger(id, evt)
|
|
200
|
+
call_runtime('trigger', id, evt.to_s).tap { drain_async_timers }
|
|
201
|
+
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
|
+
|
|
206
|
+
def drop(id, items)
|
|
207
|
+
call_runtime('drop', id, items).tap { drain_async_timers }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def submit(id)
|
|
211
|
+
result = follow(call_runtime('submit', id))
|
|
212
|
+
drain_async_timers
|
|
213
|
+
result
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def shadow_root(id) = call_runtime('shadowRoot', id)
|
|
217
|
+
|
|
218
|
+
def send_keys(id, keys)
|
|
219
|
+
directive = call_runtime('sendKeys', id, encode_keys(keys))
|
|
220
|
+
follow(directive)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def execute_script(code, args = [])
|
|
224
|
+
call_runtime('executeScript', code.to_s, encode_script_args(args))
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
def evaluate_script(code, args = [])
|
|
228
|
+
decode_script_result(call_runtime('evaluate', code.to_s, encode_script_args(args)))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Capybara's async-script contract: the user script receives a
|
|
232
|
+
# callback as its final `arguments[N]` and must invoke it (possibly
|
|
233
|
+
# after a setTimeout) with the result. We have no real event loop,
|
|
234
|
+
# so after kicking the script off we drive the virtual clock in
|
|
235
|
+
# small slices until the callback fires or the wait budget is up.
|
|
236
|
+
ASYNC_POLL_STEP_MS = 50
|
|
237
|
+
def evaluate_async_script(code, args = [])
|
|
238
|
+
call_runtime('startAsync', code.to_s, encode_script_args(args))
|
|
239
|
+
budget = (Capybara.default_max_wait_time || 2).to_f
|
|
240
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget
|
|
241
|
+
loop do
|
|
242
|
+
status = @ctx.call('__csim.pollAsync')
|
|
243
|
+
if status['done']
|
|
244
|
+
raise MiniRacer::RuntimeError, status['error'] if status['error']
|
|
245
|
+
return decode_script_result(status['value'])
|
|
246
|
+
end
|
|
247
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
248
|
+
@ctx.call('__csim.drainTimers', ASYNC_POLL_STEP_MS) rescue nil
|
|
249
|
+
end
|
|
250
|
+
raise Capybara::ScriptTimeoutError, 'evaluate_async_script timed out'
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def set_modal_responses(alerts: [], confirms: [], prompts: [])
|
|
254
|
+
@modal_responses = {alert: Array(alerts), confirm: Array(confirms), prompt: Array(prompts)}
|
|
255
|
+
call_runtime('setModalResponses', stringify_keys(@modal_responses))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Append a text-matched handler used by `accept_modal` /
|
|
259
|
+
# `dismiss_modal`. JS-side modal stubs scan the handler stack in
|
|
260
|
+
# registration order and pick the first whose text predicate
|
|
261
|
+
# matches the firing message — which is what enables nested
|
|
262
|
+
# `dismiss_confirm { accept_confirm { ... } }`.
|
|
263
|
+
def add_modal_handler(type:, text: nil, response: true)
|
|
264
|
+
encoded = encode_modal_handler(type, text, response)
|
|
265
|
+
call_runtime('pushModalHandler', encoded)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def remove_modal_handler(type:, text: nil)
|
|
269
|
+
call_runtime('popModalHandler', type.to_s, encode_modal_text(text))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def encode_modal_handler(type, text, response)
|
|
273
|
+
{
|
|
274
|
+
'type' => type.to_s,
|
|
275
|
+
'text' => encode_modal_text(text),
|
|
276
|
+
'response' => response.is_a?(Symbol) ? response.to_s : response
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def encode_modal_text(text)
|
|
281
|
+
case text
|
|
282
|
+
when nil then nil
|
|
283
|
+
when Regexp then {'regexp' => text.source, 'flags' => text.options}
|
|
284
|
+
else text.to_s
|
|
285
|
+
end
|
|
286
|
+
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
|
+
|
|
294
|
+
# Long-lived inbox shared by nested `accept_modal` /
|
|
295
|
+
# `dismiss_modal` blocks. Each driver call drains the JS-side queue
|
|
296
|
+
# once and stashes unmatched modals here so the OUTER block can
|
|
297
|
+
# still find its message after the inner one consumes its own.
|
|
298
|
+
def modal_inbox
|
|
299
|
+
@modal_inbox ||= []
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Snapshot the JS-side modalQueue before a navigate wipes it, so a
|
|
303
|
+
# subsequent `drain_modal_queue` still sees alerts that fired
|
|
304
|
+
# synchronously from inside the click handler.
|
|
305
|
+
def capture_pending_modals
|
|
306
|
+
pending = Array(@ctx.call('__csim.drainModalQueue')) rescue []
|
|
307
|
+
return if pending.empty?
|
|
308
|
+
@captured_modals ||= []
|
|
309
|
+
@captured_modals.concat(pending)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Forced advance for callers (e.g. accept_modal) that need to age
|
|
313
|
+
# out async setTimeout-driven side effects without waiting on the
|
|
314
|
+
# wall-clock heuristic in advance_virtual_clock.
|
|
315
|
+
def advance_virtual_clock_step(ms)
|
|
316
|
+
@ctx.call('__csim.drainTimers', ms.to_i) rescue nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
# Pump only zero-delay timers (microtask-style fallouts of jQuery's
|
|
322
|
+
# `$(fn)` and similar). Any non-zero setTimeout queued during the
|
|
323
|
+
# interaction stays pending — Capybara's synchronize loop will
|
|
324
|
+
# advance the virtual clock as it retries.
|
|
325
|
+
# After interaction events that may kick off Turbo / Stimulus async
|
|
326
|
+
# rendering, drain enough virtual-clock to flush a `requestAnimationFrame`
|
|
327
|
+
# (which our prelude maps to `setTimeout(..., 16)`) plus any chained
|
|
328
|
+
# microtasks. Without this, `<turbo-stream>`'s async `connectedCallback`
|
|
329
|
+
# leaves the DOM mid-render until the next call advances the clock.
|
|
330
|
+
DRAIN_AFTER_INTERACTION_MS = 32
|
|
331
|
+
def drain_async_timers
|
|
332
|
+
@ctx.call('__csim.drainTimers', DRAIN_AFTER_INTERACTION_MS) rescue nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def advance_virtual_clock
|
|
336
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
337
|
+
if @last_call_at
|
|
338
|
+
ms = ((now - @last_call_at) * 1000).to_i
|
|
339
|
+
@ctx.call('__csim.drainTimers', ms) rescue nil if ms.positive?
|
|
340
|
+
end
|
|
341
|
+
@last_call_at = now
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Per call_runtime, mirror real wall-clock progress into the JS
|
|
345
|
+
# virtual clock so setTimeout-style handlers fire as Capybara's
|
|
346
|
+
# synchronize loop ages out the wait budget. Skip the bookkeeping
|
|
347
|
+
# for the calls that should never trigger user code (timer drain,
|
|
348
|
+
# page load setup, modal-response wiring).
|
|
349
|
+
VIRTUAL_TICKLESS = %w[drainTimers loadHTML setModalResponses].freeze
|
|
350
|
+
|
|
351
|
+
def call_runtime(method, *args)
|
|
352
|
+
advance_virtual_clock unless VIRTUAL_TICKLESS.include?(method)
|
|
353
|
+
result = @ctx.call("__csim.#{method}", *args)
|
|
354
|
+
# Skip the implicit-navigation pickup when the runtime returned a
|
|
355
|
+
# navigation directive: `follow` will run the actual visit through
|
|
356
|
+
# Rack with the right method/body, and we don't want to clobber it
|
|
357
|
+
# with a GET driven by happy-dom's stale window.location update.
|
|
358
|
+
if !VIRTUAL_TICKLESS.include?(method) && !@in_navigate &&
|
|
359
|
+
!(result.is_a?(Hash) && %w[navigate submit].include?(result['action']))
|
|
360
|
+
check_location_change
|
|
361
|
+
end
|
|
362
|
+
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
|
+
end
|
|
369
|
+
|
|
370
|
+
# Detect when user JS assigned to window.location and trigger an
|
|
371
|
+
# actual visit through the Rack stack so subsequent finds operate
|
|
372
|
+
# on the new page.
|
|
373
|
+
def check_location_change
|
|
374
|
+
return unless @current_url
|
|
375
|
+
loc = (@ctx.eval('window && window.location ? window.location.href : null') rescue nil)
|
|
376
|
+
return unless loc.is_a?(String) && !loc.empty?
|
|
377
|
+
return if loc == @current_url
|
|
378
|
+
# history.pushState / replaceState changed the URL but did not
|
|
379
|
+
# trigger a fetch — adopt the new URL without re-fetching.
|
|
380
|
+
if (@ctx.call('__csim.consumeHistoryPushed') rescue false)
|
|
381
|
+
@current_url = loc
|
|
382
|
+
return
|
|
383
|
+
end
|
|
384
|
+
a = URI.parse(@current_url) rescue nil
|
|
385
|
+
b = URI.parse(loc) rescue nil
|
|
386
|
+
return unless a && b
|
|
387
|
+
# Same scheme/host/path/query → only the fragment changed; no fetch.
|
|
388
|
+
if a.scheme == b.scheme && a.host == b.host && a.port == b.port &&
|
|
389
|
+
a.path == b.path && a.query == b.query
|
|
390
|
+
@current_url = loc
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
# Trigger a real navigation under the Rack app.
|
|
394
|
+
navigate(:get, loc, [], referer: @current_url)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def follow(directive)
|
|
398
|
+
return nil unless directive.is_a?(Hash)
|
|
399
|
+
# Snapshot any modal recorded by the click handler before the
|
|
400
|
+
# navigate clears the JS-side queue (loadHTML resets state).
|
|
401
|
+
# Capybara's `accept_alert { click_link 'Alert page change' }`
|
|
402
|
+
# otherwise loses the alert message because it fires synchronously
|
|
403
|
+
# from the same click that triggers the navigation.
|
|
404
|
+
capture_pending_modals if %w[navigate submit].include?(directive['action'])
|
|
405
|
+
case directive['action']
|
|
406
|
+
when 'navigate'
|
|
407
|
+
target = resolve_url(directive['href'])
|
|
408
|
+
# `<a href="/foo#bar">` from the same /foo page is just an
|
|
409
|
+
# in-page anchor jump in real browsers — no request, no reload.
|
|
410
|
+
if @current_url && same_path_anchor?(@current_url, target)
|
|
411
|
+
@current_url = target
|
|
412
|
+
return nil
|
|
413
|
+
end
|
|
414
|
+
navigate(:get, target)
|
|
415
|
+
when 'submit'
|
|
416
|
+
method = (directive['method'] || 'GET').downcase.to_sym
|
|
417
|
+
target = directive['url'].to_s.empty? ? @current_url : directive['url']
|
|
418
|
+
navigate(method, resolve_url(target), directive['fields'] || [], enctype: directive['enctype'])
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def navigate(method, url, fields = [], enctype: 'application/x-www-form-urlencoded',
|
|
423
|
+
replace_history: false, history_move: false, referer: nil)
|
|
424
|
+
# Avoid recursive location-change detection while we're in the
|
|
425
|
+
# middle of installing the new page.
|
|
426
|
+
outermost = !@in_navigate
|
|
427
|
+
@in_navigate = true
|
|
428
|
+
full_url = resolve_url(url)
|
|
429
|
+
body = nil
|
|
430
|
+
env_overrides = {}
|
|
431
|
+
|
|
432
|
+
if %i[post put patch delete].include?(method)
|
|
433
|
+
if enctype.to_s.start_with?('multipart/form-data')
|
|
434
|
+
boundary = "----CapybaraSimulatedBoundary#{SecureRandom.hex(8)}"
|
|
435
|
+
body = build_multipart_body(fields, boundary)
|
|
436
|
+
env_overrides['CONTENT_TYPE'] = "multipart/form-data; boundary=#{boundary}"
|
|
437
|
+
elsif fields.any? { |_, v| v.is_a?(Hash) && v['file'] }
|
|
438
|
+
# Non-multipart form with a file input: real browsers submit
|
|
439
|
+
# the file's basename as the field value.
|
|
440
|
+
cleaned = fields.flat_map do |k, v|
|
|
441
|
+
if v.is_a?(Hash) && v['file']
|
|
442
|
+
paths = Array(v['paths'])
|
|
443
|
+
paths.empty? ? [[k, '']] : paths.map { |p| [k, File.basename(p.to_s)] }
|
|
444
|
+
else
|
|
445
|
+
[[k, v]]
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
body = URI.encode_www_form(cleaned)
|
|
449
|
+
env_overrides['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
|
|
450
|
+
else
|
|
451
|
+
body = URI.encode_www_form(fields)
|
|
452
|
+
env_overrides['CONTENT_TYPE'] = enctype
|
|
453
|
+
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
|
+
|
|
460
|
+
ref = referer.nil? ? @current_url : (referer || nil)
|
|
461
|
+
env_overrides['HTTP_REFERER'] = ref if ref
|
|
462
|
+
|
|
463
|
+
cookie_header = build_cookie_header(full_url)
|
|
464
|
+
env_overrides['HTTP_COOKIE'] = cookie_header unless cookie_header.empty?
|
|
465
|
+
|
|
466
|
+
request = Rack::MockRequest.new(@app)
|
|
467
|
+
response = request.request(method.to_s.upcase, full_url, env_overrides.merge(input: body || ''))
|
|
468
|
+
|
|
469
|
+
ingest_set_cookie(response.headers, full_url)
|
|
470
|
+
|
|
471
|
+
@current_url = full_url
|
|
472
|
+
@status_code = response.status
|
|
473
|
+
@response_headers = response.headers
|
|
474
|
+
@last_method = method
|
|
475
|
+
@last_fields = fields
|
|
476
|
+
@last_enctype = enctype
|
|
477
|
+
|
|
478
|
+
unless history_move
|
|
479
|
+
if replace_history
|
|
480
|
+
@history[@history_index] = full_url if @history_index >= 0
|
|
481
|
+
@history[@history_index] = full_url if @history_index < 0
|
|
482
|
+
@history_index = 0 if @history_index < 0
|
|
483
|
+
else
|
|
484
|
+
@history = @history[0..@history_index] + [full_url]
|
|
485
|
+
@history_index = @history.size - 1
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
if (300..399).cover?(response.status) && response.headers['location']
|
|
490
|
+
# Preserve the original fragment across redirects when the target
|
|
491
|
+
# location does not include one — browsers do this per RFC 7231.
|
|
492
|
+
target = response.headers['location']
|
|
493
|
+
orig_frag = URI.parse(full_url).fragment rescue nil
|
|
494
|
+
if orig_frag && !orig_frag.empty?
|
|
495
|
+
tgt = URI.parse(target) rescue nil
|
|
496
|
+
target = "#{target}##{orig_frag}" if tgt && (tgt.fragment.nil? || tgt.fragment.empty?)
|
|
497
|
+
end
|
|
498
|
+
if [307, 308].include?(response.status)
|
|
499
|
+
return navigate(method, target, fields, enctype: enctype, referer: ref)
|
|
500
|
+
end
|
|
501
|
+
return navigate(:get, target, referer: ref)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
load_html(response.body)
|
|
505
|
+
ensure
|
|
506
|
+
@in_navigate = false if outermost
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def load_html(html)
|
|
510
|
+
# loadHTML rebuilds the happy-dom Window with @current_url so
|
|
511
|
+
# window.location matches Ruby's view of the page after a real
|
|
512
|
+
# navigation.
|
|
513
|
+
call_runtime('loadHTML', wrap_fragment_html(html.to_s), @current_url.to_s)
|
|
514
|
+
call_runtime('setModalResponses', stringify_keys(@modal_responses))
|
|
515
|
+
load_external_scripts
|
|
516
|
+
call_runtime('runInlineScripts')
|
|
517
|
+
load_module_scripts
|
|
518
|
+
call_runtime('syncWindowGlobals')
|
|
519
|
+
# Drain only the immediate (zero-delay) timers queued during page
|
|
520
|
+
# load — typically jQuery's `$(fn)` ready callbacks. Anything
|
|
521
|
+
# genuinely delayed waits for the synchronize loop to age it in.
|
|
522
|
+
call_runtime('drainTimers', 0)
|
|
523
|
+
@last_call_at = nil
|
|
524
|
+
nil
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Rails apps pin module dependencies via `<script type="importmap">`
|
|
528
|
+
# and load them via `<script type="module">`. mini_racer has no ESM
|
|
529
|
+
# loader, so we shell out to esbuild (via vendor/js/bundle-modules.mjs)
|
|
530
|
+
# to compile the entire dependency graph into a single IIFE bundle
|
|
531
|
+
# that runs in the same isolate as inline scripts. Pre-fetches every
|
|
532
|
+
# reachable module through Rack so the bundler never makes a real
|
|
533
|
+
# network call.
|
|
534
|
+
MODULE_BUNDLER_SCRIPT = File.join(VENDOR_DIR, 'bundle-modules.mjs').freeze
|
|
535
|
+
MODULE_IMPORT_RX = /(?:^|[\s;{(=])(?:import|export)(?:\s*\(|[^'"\n;]*?\bfrom\s*)?\s*['"]([^'"]+)['"]/m.freeze
|
|
536
|
+
|
|
537
|
+
def load_module_scripts
|
|
538
|
+
details = call_runtime('moduleScriptDetails')
|
|
539
|
+
return if details.nil?
|
|
540
|
+
importmap = parse_importmap_from_details(details)
|
|
541
|
+
entries = Array(details['entries'])
|
|
542
|
+
return if importmap.empty? && entries.empty?
|
|
543
|
+
|
|
544
|
+
cache_key = Digest::SHA256.hexdigest(JSON.dump([importmap, entries, @current_url]))
|
|
545
|
+
@module_bundle_cache ||= {}
|
|
546
|
+
bundle = @module_bundle_cache[cache_key] ||=
|
|
547
|
+
build_module_bundle(importmap, entries)
|
|
548
|
+
return unless bundle
|
|
549
|
+
@ctx.eval(bundle)
|
|
550
|
+
# Resolve any dynamic imports queued during the bundle's static
|
|
551
|
+
# evaluation phase. The registry is populated only after the
|
|
552
|
+
# bundle body runs, so deferred resolution is the only way to
|
|
553
|
+
# honour `import("controllers/foo")` calls fired by code like
|
|
554
|
+
# stimulus-loading's eagerLoad.
|
|
555
|
+
@ctx.eval('typeof __csim_drain_imports === "function" && __csim_drain_imports()')
|
|
556
|
+
call_runtime('syncWindowGlobals')
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def parse_importmap_from_details(details)
|
|
560
|
+
raw = details['importmap']
|
|
561
|
+
return {} if raw.nil? || raw.to_s.strip.empty?
|
|
562
|
+
JSON.parse(raw)
|
|
563
|
+
rescue JSON::ParserError
|
|
564
|
+
{}
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def build_module_bundle(importmap, entries)
|
|
568
|
+
sources = prefetch_module_sources(importmap, entries)
|
|
569
|
+
payload = JSON.dump(
|
|
570
|
+
'importmap' => importmap,
|
|
571
|
+
'baseUrl' => @current_url || DEFAULT_HOST,
|
|
572
|
+
'entries' => entries,
|
|
573
|
+
'sources' => sources
|
|
574
|
+
)
|
|
575
|
+
out, err, status = Open3.capture3('node', MODULE_BUNDLER_SCRIPT, stdin_data: payload)
|
|
576
|
+
unless status.success?
|
|
577
|
+
warn "[capybara-simulated] module bundler failed: #{err.to_s[0, 400]}"
|
|
578
|
+
return nil
|
|
579
|
+
end
|
|
580
|
+
out
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Walk the module graph by fetching each entry/import via Rack and
|
|
584
|
+
# extracting nested `import`/`export ... from` references with a
|
|
585
|
+
# regex. We don't try to be clever about live-binding semantics —
|
|
586
|
+
# esbuild handles that. We just need every reachable URL in the
|
|
587
|
+
# `sources` map before the bundler runs.
|
|
588
|
+
def prefetch_module_sources(importmap, entries)
|
|
589
|
+
base = @current_url || DEFAULT_HOST
|
|
590
|
+
seen = {}
|
|
591
|
+
queue = []
|
|
592
|
+
entries.each do |e|
|
|
593
|
+
if (src = e['src'] || e[:src])
|
|
594
|
+
url = resolve_module_specifier(src, base, importmap, base)
|
|
595
|
+
queue << url if url
|
|
596
|
+
elsif (inline = e['inline'] || e[:inline])
|
|
597
|
+
extract_module_specifiers(inline.to_s).each do |spec|
|
|
598
|
+
url = resolve_module_specifier(spec, base, importmap, base)
|
|
599
|
+
queue << url if url
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
# Pre-fetch every importmap pin too, even if no static import
|
|
604
|
+
# reaches it. The bundler statically embeds them all so dynamic
|
|
605
|
+
# `import("controllers/foo_controller")` calls (rewritten to a
|
|
606
|
+
# synchronous registry lookup) resolve at runtime.
|
|
607
|
+
(importmap['imports'] || importmap[:imports] || {}).each do |spec, _|
|
|
608
|
+
next if spec.to_s.end_with?('/')
|
|
609
|
+
url = resolve_module_specifier(spec.to_s, base, importmap, base)
|
|
610
|
+
queue << url if url
|
|
611
|
+
end
|
|
612
|
+
until queue.empty?
|
|
613
|
+
url = queue.shift
|
|
614
|
+
next if seen.key?(url)
|
|
615
|
+
body = fetch_resource(url)
|
|
616
|
+
# Rack hands us ASCII-8BIT strings; the bundler's JSON.dump
|
|
617
|
+
# trips a "UTF-8 string passed as BINARY" warning under json 2.x
|
|
618
|
+
# (and is a hard error in json 3). JS source is always Unicode,
|
|
619
|
+
# so retag without copying bytes.
|
|
620
|
+
body = body.dup.force_encoding(Encoding::UTF_8) if body
|
|
621
|
+
seen[url] = body || ''
|
|
622
|
+
next if body.nil? || body.empty?
|
|
623
|
+
extract_module_specifiers(body).each do |spec|
|
|
624
|
+
next_url = resolve_module_specifier(spec, url, importmap, base)
|
|
625
|
+
queue << next_url if next_url && !seen.key?(next_url)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
seen
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def extract_module_specifiers(source)
|
|
632
|
+
source.to_s.scan(MODULE_IMPORT_RX).flatten.compact
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def resolve_module_specifier(spec, importer, importmap, base)
|
|
636
|
+
return spec if spec.start_with?('http://', 'https://')
|
|
637
|
+
if spec.start_with?('/')
|
|
638
|
+
return URI.join(base, spec).to_s
|
|
639
|
+
end
|
|
640
|
+
if spec.start_with?('./', '../')
|
|
641
|
+
return URI.join(importer, spec).to_s
|
|
642
|
+
end
|
|
643
|
+
imports = (importmap['imports'] || importmap[:imports] || {})
|
|
644
|
+
if imports.key?(spec)
|
|
645
|
+
return resolve_module_specifier(imports[spec], importer, importmap, base)
|
|
646
|
+
end
|
|
647
|
+
# Trailing-slash mapping per the importmap spec.
|
|
648
|
+
match = imports.keys.find {|k| k.end_with?('/') && spec.start_with?(k) }
|
|
649
|
+
if match
|
|
650
|
+
return resolve_module_specifier(imports[match] + spec[match.length..], importer, importmap, base)
|
|
651
|
+
end
|
|
652
|
+
nil
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def load_external_scripts
|
|
656
|
+
srcs = Array(call_runtime('externalScriptSources'))
|
|
657
|
+
srcs.each do |src|
|
|
658
|
+
next if src.nil? || src.empty?
|
|
659
|
+
next if src.match?(EXTERNAL_SCRIPT_DENYLIST)
|
|
660
|
+
body = nil
|
|
661
|
+
begin
|
|
662
|
+
body = fetch_resource(src)
|
|
663
|
+
rescue StandardError => e
|
|
664
|
+
warn "[capybara-simulated] failed to fetch script #{src.inspect}: #{e.class}: #{e.message[0, 120]}"
|
|
665
|
+
next
|
|
666
|
+
end
|
|
667
|
+
next if body.nil?
|
|
668
|
+
begin
|
|
669
|
+
@ctx.eval(body)
|
|
670
|
+
call_runtime('syncWindowGlobals')
|
|
671
|
+
rescue MiniRacer::RuntimeError => e
|
|
672
|
+
warn "[capybara-simulated] script #{src.inspect} raised: #{e.class}: #{e.message[0, 200]}"
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def wrap_fragment_html(html)
|
|
678
|
+
return html if html.empty?
|
|
679
|
+
return html if html.match?(/\A\s*(?:<!doctype\s|<\?xml|<html\b)/i)
|
|
680
|
+
"<!doctype html><html><body>#{html}</body></html>"
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
MIME_TYPES = {
|
|
684
|
+
'txt' => 'text/plain',
|
|
685
|
+
'html' => 'text/html',
|
|
686
|
+
'htm' => 'text/html',
|
|
687
|
+
'css' => 'text/css',
|
|
688
|
+
'js' => 'application/javascript',
|
|
689
|
+
'json' => 'application/json',
|
|
690
|
+
'xml' => 'application/xml',
|
|
691
|
+
'png' => 'image/png',
|
|
692
|
+
'jpg' => 'image/jpeg',
|
|
693
|
+
'jpeg' => 'image/jpeg',
|
|
694
|
+
'gif' => 'image/gif',
|
|
695
|
+
'svg' => 'image/svg+xml',
|
|
696
|
+
'pdf' => 'application/pdf'
|
|
697
|
+
}.freeze
|
|
698
|
+
|
|
699
|
+
def build_multipart_body(fields, boundary)
|
|
700
|
+
parts = []
|
|
701
|
+
fields.each do |name, value|
|
|
702
|
+
if value.is_a?(Hash) && value['file']
|
|
703
|
+
paths = Array(value['paths'])
|
|
704
|
+
if paths.empty?
|
|
705
|
+
parts << multipart_file_part(name, '', '', 'application/octet-stream', boundary)
|
|
706
|
+
else
|
|
707
|
+
paths.each do |path|
|
|
708
|
+
content = File.binread(path) rescue ''
|
|
709
|
+
filename = File.basename(path.to_s)
|
|
710
|
+
ext = File.extname(filename).delete('.').downcase
|
|
711
|
+
ctype = MIME_TYPES[ext] || 'application/octet-stream'
|
|
712
|
+
parts << multipart_file_part(name, content, filename, ctype, boundary)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
else
|
|
716
|
+
parts << multipart_text_part(name, value.to_s, boundary)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
parts.join + "--#{boundary}--\r\n"
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def multipart_text_part(name, value, boundary)
|
|
723
|
+
"--#{boundary}\r\n" \
|
|
724
|
+
"Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" \
|
|
725
|
+
"#{value}\r\n"
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def multipart_file_part(name, content, filename, ctype, boundary)
|
|
729
|
+
"--#{boundary}\r\n" \
|
|
730
|
+
"Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
|
|
731
|
+
"Content-Type: #{ctype}\r\n\r\n" \
|
|
732
|
+
"#{content}\r\n"
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# ---- minimal cookie jar -------------------------------------------
|
|
736
|
+
# Stores Set-Cookie response headers and replays the matching cookies
|
|
737
|
+
# on subsequent requests. Just enough surface area to satisfy
|
|
738
|
+
# Capybara specs that probe set/clear/path-scoped behaviour.
|
|
739
|
+
def build_cookie_header(url)
|
|
740
|
+
uri = URI.parse(url) rescue nil
|
|
741
|
+
return '' unless uri
|
|
742
|
+
now = Time.now
|
|
743
|
+
@cookie_jar.reject! { |c| c[:expires] && c[:expires] < now }
|
|
744
|
+
matching = @cookie_jar.select do |c|
|
|
745
|
+
domain_matches?(c[:domain], uri.host) && path_matches?(c[:path], uri.path)
|
|
746
|
+
end
|
|
747
|
+
matching.map { |c| "#{c[:name]}=#{c[:value]}" }.join('; ')
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def ingest_set_cookie(headers, url)
|
|
751
|
+
uri = URI.parse(url) rescue nil
|
|
752
|
+
return unless uri
|
|
753
|
+
raw = headers['set-cookie'] || headers['Set-Cookie']
|
|
754
|
+
return unless raw
|
|
755
|
+
Array(raw).each do |line|
|
|
756
|
+
line.to_s.split(/\n/).each {|l| absorb_cookie(l, uri) }
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def absorb_cookie(line, uri)
|
|
761
|
+
parts = line.split(/;\s*/)
|
|
762
|
+
return if parts.empty?
|
|
763
|
+
name, value = parts.shift.split('=', 2)
|
|
764
|
+
return unless name && !name.empty?
|
|
765
|
+
cookie = {
|
|
766
|
+
name: name.strip,
|
|
767
|
+
value: (value || '').strip,
|
|
768
|
+
domain: uri.host,
|
|
769
|
+
path: '/',
|
|
770
|
+
expires: nil,
|
|
771
|
+
secure: false,
|
|
772
|
+
http_only: false
|
|
773
|
+
}
|
|
774
|
+
parts.each do |attr|
|
|
775
|
+
k, v = attr.split('=', 2)
|
|
776
|
+
case (k || '').downcase
|
|
777
|
+
when 'domain' then cookie[:domain] = v.to_s.sub(/\A\./, '')
|
|
778
|
+
when 'path' then cookie[:path] = v.to_s
|
|
779
|
+
when 'expires' then cookie[:expires] = (Time.parse(v) rescue nil)
|
|
780
|
+
when 'max-age'
|
|
781
|
+
secs = v.to_i
|
|
782
|
+
cookie[:expires] = Time.now + secs if secs.positive?
|
|
783
|
+
cookie[:expires] = Time.at(0) if secs <= 0
|
|
784
|
+
when 'secure' then cookie[:secure] = true
|
|
785
|
+
when 'httponly' then cookie[:http_only] = true
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
cookie[:path] = '/' if cookie[:path].nil? || cookie[:path].empty?
|
|
789
|
+
# Replace existing cookie with same (name, domain, path).
|
|
790
|
+
@cookie_jar.reject! do |c|
|
|
791
|
+
c[:name] == cookie[:name] && c[:domain] == cookie[:domain] && c[:path] == cookie[:path]
|
|
792
|
+
end
|
|
793
|
+
if cookie[:expires] && cookie[:expires] < Time.now
|
|
794
|
+
# already expired — skip storing
|
|
795
|
+
else
|
|
796
|
+
@cookie_jar << cookie
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def domain_matches?(cookie_domain, host)
|
|
801
|
+
return true if cookie_domain.nil? || cookie_domain.empty?
|
|
802
|
+
return true if cookie_domain == host
|
|
803
|
+
host.to_s.end_with?(".#{cookie_domain}")
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def path_matches?(cookie_path, request_path)
|
|
807
|
+
cookie_path = '/' if cookie_path.nil? || cookie_path.empty?
|
|
808
|
+
return true if cookie_path == '/'
|
|
809
|
+
return true if request_path == cookie_path
|
|
810
|
+
return true if request_path.start_with?(cookie_path + '/')
|
|
811
|
+
return true if cookie_path.end_with?('/') && request_path.start_with?(cookie_path)
|
|
812
|
+
false
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def fetch_resource(url)
|
|
816
|
+
full_url = resolve_url(url)
|
|
817
|
+
request = Rack::MockRequest.new(@app)
|
|
818
|
+
response = request.request('GET', full_url, {})
|
|
819
|
+
return nil unless response.status.between?(200, 299)
|
|
820
|
+
response.body
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# JS-facing `fetch` implementation, attached to the V8 context as
|
|
824
|
+
# `__csim_fetch`. Routes the request through the configured Rack app
|
|
825
|
+
# using the active cookie jar and follows up to 20 redirects, mirroring
|
|
826
|
+
# how `navigate` runs full-page requests. Headers come back joined into
|
|
827
|
+
# a hash so the JS shim can reconstruct a `Headers` object.
|
|
828
|
+
FETCH_REDIRECT_LIMIT = 20
|
|
829
|
+
def js_fetch(method, url, headers, body)
|
|
830
|
+
method = (method || 'GET').to_s.upcase
|
|
831
|
+
headers = (headers || {}).each_with_object({}) {|(k, v), m| m[k.to_s] = v.to_s }
|
|
832
|
+
full_url = resolve_url(url)
|
|
833
|
+
redirected = false
|
|
834
|
+
FETCH_REDIRECT_LIMIT.times do
|
|
835
|
+
env = fetch_env_for(headers, full_url)
|
|
836
|
+
input = body.to_s
|
|
837
|
+
request = Rack::MockRequest.new(@app)
|
|
838
|
+
response = request.request(method, full_url, env.merge(input: input))
|
|
839
|
+
ingest_set_cookie(response.headers, full_url)
|
|
840
|
+
if (300..399).cover?(response.status) && response.headers['location']
|
|
841
|
+
target = response.headers['location']
|
|
842
|
+
full_url = resolve_url(target)
|
|
843
|
+
unless [307, 308].include?(response.status)
|
|
844
|
+
method = 'GET'
|
|
845
|
+
body = ''
|
|
846
|
+
end
|
|
847
|
+
headers = headers.reject {|k, _| %w[content-type content-length].include?(k.downcase) } if method == 'GET'
|
|
848
|
+
redirected = true
|
|
849
|
+
next
|
|
850
|
+
end
|
|
851
|
+
return {
|
|
852
|
+
'ok' => response.status.between?(200, 299),
|
|
853
|
+
'status' => response.status,
|
|
854
|
+
'statusText' => '',
|
|
855
|
+
'headers' => normalize_response_headers(response.headers),
|
|
856
|
+
'body' => response.body.to_s,
|
|
857
|
+
'finalUrl' => full_url,
|
|
858
|
+
'redirected' => redirected
|
|
859
|
+
}
|
|
860
|
+
end
|
|
861
|
+
{'error' => 'too many redirects'}
|
|
862
|
+
rescue StandardError => e
|
|
863
|
+
{'error' => "#{e.class}: #{e.message}"}
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def fetch_env_for(headers, full_url)
|
|
867
|
+
env = {}
|
|
868
|
+
headers.each do |k, v|
|
|
869
|
+
name = k.to_s
|
|
870
|
+
if name.downcase == 'content-type'
|
|
871
|
+
env['CONTENT_TYPE'] = v.to_s
|
|
872
|
+
elsif name.downcase == 'content-length'
|
|
873
|
+
env['CONTENT_LENGTH'] = v.to_s
|
|
874
|
+
else
|
|
875
|
+
env["HTTP_#{name.upcase.tr('-', '_')}"] = v.to_s
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
if @current_url
|
|
879
|
+
env['HTTP_REFERER'] ||= @current_url
|
|
880
|
+
end
|
|
881
|
+
cookie_header = build_cookie_header(full_url)
|
|
882
|
+
env['HTTP_COOKIE'] = cookie_header unless cookie_header.empty?
|
|
883
|
+
env
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def normalize_response_headers(headers)
|
|
887
|
+
out = {}
|
|
888
|
+
headers.each do |name, value|
|
|
889
|
+
val = value.is_a?(Array) ? value.join("\n") : value.to_s
|
|
890
|
+
out[name.to_s] = val
|
|
891
|
+
end
|
|
892
|
+
out
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def stringify_keys(hash)
|
|
896
|
+
hash.each_with_object({}) {|(k, v), m| m[k.to_s] = v }
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# Mirror Selenium's send_keys conventions. Top-level Symbols name a
|
|
900
|
+
# special key; an Array names a "modifier+key" combo where every
|
|
901
|
+
# element except the last is a held-down modifier. Modifier symbols
|
|
902
|
+
# at the top level stay held for everything that follows in the
|
|
903
|
+
# same call (Capybara's "hold modifiers" behaviour).
|
|
904
|
+
def encode_keys(keys)
|
|
905
|
+
Array(keys).flat_map {|k| encode_key(k) }
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def encode_key(k)
|
|
909
|
+
case k
|
|
910
|
+
when String then [k]
|
|
911
|
+
when Symbol then [encode_special(k)]
|
|
912
|
+
when Array
|
|
913
|
+
modifiers = k[0..-2].map {|m| symbol_to_key_name(m) }
|
|
914
|
+
tail = k.last
|
|
915
|
+
[{'combo' => modifiers, 'tail' => encode_key(tail)}]
|
|
916
|
+
else [k.to_s]
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def encode_special(sym)
|
|
921
|
+
{'special' => symbol_to_key_name(sym)}
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def symbol_to_key_name(sym)
|
|
925
|
+
case sym
|
|
926
|
+
when :enter, :return then 'Enter'
|
|
927
|
+
when :tab then 'Tab'
|
|
928
|
+
when :backspace then 'Backspace'
|
|
929
|
+
when :delete then 'Delete'
|
|
930
|
+
when :escape then 'Escape'
|
|
931
|
+
when :space then ' '
|
|
932
|
+
when :left then 'ArrowLeft'
|
|
933
|
+
when :right then 'ArrowRight'
|
|
934
|
+
when :up then 'ArrowUp'
|
|
935
|
+
when :down then 'ArrowDown'
|
|
936
|
+
when :home then 'Home'
|
|
937
|
+
when :end then 'End'
|
|
938
|
+
when :page_up then 'PageUp'
|
|
939
|
+
when :page_down then 'PageDown'
|
|
940
|
+
when :shift, :control, :alt, :meta, :command, :ctrl
|
|
941
|
+
{shift: 'Shift', control: 'Control', ctrl: 'Control',
|
|
942
|
+
alt: 'Alt', meta: 'Meta', command: 'Meta'}[sym]
|
|
943
|
+
else sym.to_s.capitalize
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def encode_script_args(args)
|
|
948
|
+
Array(args).map {|v| encode_script_arg(v) }
|
|
949
|
+
end
|
|
950
|
+
def encode_script_arg(value)
|
|
951
|
+
case value
|
|
952
|
+
when Capybara::Simulated::Node
|
|
953
|
+
{'__csim_handle' => value.handle_id}
|
|
954
|
+
when Array
|
|
955
|
+
value.map {|v| encode_script_arg(v) }
|
|
956
|
+
when Hash
|
|
957
|
+
value.each_with_object({}) {|(k, v), m| m[k.to_s] = encode_script_arg(v) }
|
|
958
|
+
else
|
|
959
|
+
value
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
def decode_script_result(value)
|
|
963
|
+
case value
|
|
964
|
+
when Hash
|
|
965
|
+
if value['__csim_handle']
|
|
966
|
+
Capybara::Simulated::Node.new(@driver_for_results, value['__csim_handle'])
|
|
967
|
+
else
|
|
968
|
+
value.transform_values {|v| decode_script_result(v) }
|
|
969
|
+
end
|
|
970
|
+
when Array
|
|
971
|
+
value.map {|v| decode_script_result(v) }
|
|
972
|
+
else
|
|
973
|
+
value
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def same_path_anchor?(from_url, to_url)
|
|
978
|
+
a = URI.parse(from_url) rescue nil
|
|
979
|
+
b = URI.parse(to_url) rescue nil
|
|
980
|
+
return false unless a && b
|
|
981
|
+
a.scheme == b.scheme && a.host == b.host && a.port == b.port &&
|
|
982
|
+
a.path == b.path && a.query == b.query &&
|
|
983
|
+
b.fragment && !b.fragment.empty?
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def resolve_url(url)
|
|
987
|
+
return @current_url if url.nil? || url.empty?
|
|
988
|
+
if url.start_with?('http://', 'https://')
|
|
989
|
+
url
|
|
990
|
+
else
|
|
991
|
+
base = current_base_url || @current_url || (Capybara.app_host || DEFAULT_HOST)
|
|
992
|
+
URI.join(base, url).to_s
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# Returns the URL implied by a `<base href>` element on the active
|
|
997
|
+
# page, or nil. happy-dom doesn't apply base href when computing
|
|
998
|
+
# `a.href`, so the driver does its own resolution.
|
|
999
|
+
def current_base_url
|
|
1000
|
+
href = @ctx.eval(<<~JS) rescue nil
|
|
1001
|
+
(() => {
|
|
1002
|
+
const b = document && document.querySelector && document.querySelector('base[href]');
|
|
1003
|
+
return b ? String(b.getAttribute('href') || '') : null;
|
|
1004
|
+
})()
|
|
1005
|
+
JS
|
|
1006
|
+
return nil if href.nil? || href.empty?
|
|
1007
|
+
return href if href.start_with?('http://', 'https://')
|
|
1008
|
+
URI.join(@current_url || (Capybara.app_host || DEFAULT_HOST), href).to_s
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
1012
|
+
end
|