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.
@@ -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