capybara-simulated 0.5.0 → 0.6.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 +58 -218
- data/lib/capybara/simulated/browser.rb +966 -148
- data/lib/capybara/simulated/driver.rb +220 -17
- data/lib/capybara/simulated/js/bridge.bundle.js +5384 -921
- data/lib/capybara/simulated/quickjs_runtime.rb +17 -6
- data/lib/capybara/simulated/runtime_shared.rb +32 -5
- data/lib/capybara/simulated/v8_runtime.rb +157 -32
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +12 -9
- metadata +1 -1
|
@@ -72,11 +72,23 @@ module Capybara
|
|
|
72
72
|
# Browser still has its own sessionStorage + DOM + JS VM.
|
|
73
73
|
@cookies = {}
|
|
74
74
|
@local_storage = {}
|
|
75
|
+
# Capture the universal-server flag ONCE, at session construction — the WPT
|
|
76
|
+
# runner sets CSIM_LOCAL_ALL_HOSTS only while building the session, then
|
|
77
|
+
# restores it. Every window (incl. aux windows opened later) inherits this so
|
|
78
|
+
# cross-origin iframes eager-build consistently across the whole session.
|
|
79
|
+
@all_hosts_local = ENV['CSIM_LOCAL_ALL_HOSTS'] == '1'
|
|
75
80
|
@browser = build_window_browser
|
|
76
81
|
@browser.window_handle = PRIMARY_HANDLE
|
|
77
82
|
@aux_windows = [] # [{handle:, browser:, name:, opener:}, …]
|
|
78
83
|
@active_handle = nil
|
|
79
84
|
@next_window_seq = 0
|
|
85
|
+
# Driver-level blob URL partition map: url => {browser:, site:}. A blob URL's
|
|
86
|
+
# storage partition is its creating context's top-level SITE; another window
|
|
87
|
+
# can resolve the blob only from the same partition (and same origin, which
|
|
88
|
+
# the blob: URL embeds). Bytes aren't copied here — they're read back from the
|
|
89
|
+
# creating Browser's own store, so this stays a light reference map.
|
|
90
|
+
@blob_partitions = {}
|
|
91
|
+
@blob_partitions_lock = Mutex.new
|
|
80
92
|
@owner_thread = Thread.current
|
|
81
93
|
@@live_lock.synchronize { @@live << WeakRef.new(self) }
|
|
82
94
|
@browser.default_viewport = viewport if viewport
|
|
@@ -85,10 +97,11 @@ module Capybara
|
|
|
85
97
|
|
|
86
98
|
private def build_window_browser
|
|
87
99
|
Browser.new(@app,
|
|
88
|
-
driver:
|
|
89
|
-
js_engine:
|
|
90
|
-
cookies:
|
|
91
|
-
local_storage:
|
|
100
|
+
driver: self,
|
|
101
|
+
js_engine: @js_engine,
|
|
102
|
+
cookies: @cookies,
|
|
103
|
+
local_storage: @local_storage,
|
|
104
|
+
all_hosts_local: @all_hosts_local)
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
# Per-test trace recording. Mirrors capybara-playwright-driver's
|
|
@@ -203,16 +216,73 @@ module Capybara
|
|
|
203
216
|
# `wait? = false` synchronize path.
|
|
204
217
|
def wait? = current_browser.polling?
|
|
205
218
|
|
|
219
|
+
# Run one real-cadence event-loop frame and return the loop's observable
|
|
220
|
+
# state. Drives "advance the page one frame" without the full poll tick
|
|
221
|
+
# `evaluate_script` would incur per read; the wpt_runner uses it to drain a
|
|
222
|
+
# page to completion at browser cadence.
|
|
223
|
+
#
|
|
224
|
+
# EVERY live window steps, not just the active one: an auxiliary window is a
|
|
225
|
+
# separate VM, but the cross-context orchestration the dispatcher framework
|
|
226
|
+
# builds on (a popup running an executor that polls a shared queue while the
|
|
227
|
+
# opener waits) needs those background windows to make progress autonomously.
|
|
228
|
+
# The active window's state leads; each aux window folds its progress / raf /
|
|
229
|
+
# async / nearest-timer in so the caller keeps pumping while any window works.
|
|
230
|
+
def run_event_loop_frame(frame_ms)
|
|
231
|
+
state = current_browser.run_event_loop_frame(frame_ms)
|
|
232
|
+
# Iterate a SNAPSHOT: a window's drain can open or close windows mid-loop (a
|
|
233
|
+
# popup spawning another, or window.close disposing one). Re-check each window
|
|
234
|
+
# is still open before stepping it, and isolate a per-window failure so one
|
|
235
|
+
# dead/half-torn-down VM can't abort the whole frame pump.
|
|
236
|
+
@aux_windows.dup.each do |w|
|
|
237
|
+
b = w[:browser]
|
|
238
|
+
next if b.equal?(current_browser)
|
|
239
|
+
next unless @aux_windows.include?(w) # closed earlier this loop → skip
|
|
240
|
+
begin
|
|
241
|
+
state = merge_frame_state(state, b.run_event_loop_frame(frame_ms))
|
|
242
|
+
rescue StandardError
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
state
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Fold an aux window's frame state into the running aggregate: any window that
|
|
250
|
+
# progressed / has a queued rAF / has an async channel in flight keeps the
|
|
251
|
+
# whole loop live, and `next_timer` becomes the nearest pending timer across
|
|
252
|
+
# all windows (-1 only when every window is timer-idle).
|
|
253
|
+
private def merge_frame_state(a, b)
|
|
254
|
+
an = a['next_timer'].to_f
|
|
255
|
+
bn = b['next_timer'].to_f
|
|
256
|
+
merged =
|
|
257
|
+
if an.negative? then bn
|
|
258
|
+
elsif bn.negative? then an
|
|
259
|
+
else [an, bn].min
|
|
260
|
+
end
|
|
261
|
+
{
|
|
262
|
+
'raf' => a['raf'] || b['raf'],
|
|
263
|
+
'async' => a['async'] || b['async'],
|
|
264
|
+
'next_timer' => merged,
|
|
265
|
+
'progressed' => a['progressed'] || b['progressed']
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Clock-free read of a JS expression in the active browsing context (no
|
|
270
|
+
# virtual-time advance, unlike evaluate_script) — for polling page state
|
|
271
|
+
# between event-loop frames without perturbing the clock.
|
|
272
|
+
def peek_script(expr) = current_browser.peek_script(expr)
|
|
273
|
+
|
|
206
274
|
def visit(path) = current_browser.visit(path)
|
|
207
275
|
def refresh = current_browser.refresh
|
|
208
276
|
def reset!
|
|
209
277
|
@aux_windows.each {|w| w[:browser].dispose rescue nil }
|
|
210
278
|
@aux_windows.clear
|
|
211
279
|
@active_handle = nil
|
|
280
|
+
@blob_partitions_lock.synchronize { @blob_partitions.clear }
|
|
212
281
|
browser.reset!
|
|
213
282
|
end
|
|
214
283
|
def go_back = current_browser.go_back
|
|
215
284
|
def go_forward = current_browser.go_forward
|
|
285
|
+
def reset_history! = current_browser.reset_history!
|
|
216
286
|
def current_url = current_browser.current_url || ''
|
|
217
287
|
def html = current_browser.html
|
|
218
288
|
def title = current_browser.title
|
|
@@ -264,10 +334,22 @@ module Capybara
|
|
|
264
334
|
# matches an existing window navigates that window instead of opening a
|
|
265
335
|
# new one (HTML window-name targeting); `opener_handle` records the
|
|
266
336
|
# opener so the new window's `window.opener` resolves back to it.
|
|
267
|
-
def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil)
|
|
337
|
+
def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil)
|
|
268
338
|
name = name.to_s
|
|
339
|
+
# A blob: URL opened from a different storage partition is forced noopener
|
|
340
|
+
# (cross-partition-navigation), overriding an explicit rel=opener — the new
|
|
341
|
+
# top-level window is the blob's own partition (so the blob still loads), but
|
|
342
|
+
# the opener relationship is severed.
|
|
343
|
+
opener = false if opener && url.to_s.start_with?('blob:') && source && cross_partition_blob?(url, source)
|
|
344
|
+
# A `<form target>` keeps its opener by default (unlike a `target=_blank`
|
|
345
|
+
# LINK, which is noopener) — resolve the opener handle from the source.
|
|
346
|
+
opener_handle ||= handle_for(source) if opener && source
|
|
269
347
|
if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
|
|
270
|
-
|
|
348
|
+
if post
|
|
349
|
+
existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer)
|
|
350
|
+
else
|
|
351
|
+
navigate_window(existing[:browser], url, source: source)
|
|
352
|
+
end
|
|
271
353
|
return existing[:handle]
|
|
272
354
|
end
|
|
273
355
|
@next_window_seq += 1
|
|
@@ -279,12 +361,18 @@ module Capybara
|
|
|
279
361
|
# (with its opener) must exist before `visit` runs those scripts.
|
|
280
362
|
@aux_windows << {handle: handle, browser: aux, name: name, opener: opener_handle}
|
|
281
363
|
if url && !url.empty?
|
|
364
|
+
if post
|
|
365
|
+
# A `<form target=_blank method=post>` loads the new window via POST,
|
|
366
|
+
# carrying the opener's URL as referrer (unless rel=noreferrer → '').
|
|
367
|
+
aux.navigate_post(url, post[:body], post[:content_type], referer: referrer)
|
|
282
368
|
# A blob: URL isn't rack-navigable and its bytes live in the OPENER's
|
|
283
369
|
# isolate — load the document directly from a click-time snapshot (a
|
|
284
370
|
# deferred target=_blank nav may revoke the URL first) or, failing that,
|
|
285
371
|
# the opener's blob store.
|
|
286
|
-
|
|
287
|
-
|
|
372
|
+
elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
|
|
373
|
+
# A form submission carries a referrer (the opener's URL) unless the
|
|
374
|
+
# form opted out via rel=noreferrer (referrer: '').
|
|
375
|
+
aux.visit(url, referer: referrer)
|
|
288
376
|
end
|
|
289
377
|
end
|
|
290
378
|
handle
|
|
@@ -304,22 +392,94 @@ module Capybara
|
|
|
304
392
|
|
|
305
393
|
# `window.open(url, name)` from the `opener` window's JS. Resolves the URL
|
|
306
394
|
# against the opener's document and records the opener relationship.
|
|
307
|
-
def open_window_from_js(opener_browser, url, name)
|
|
395
|
+
def open_window_from_js(opener_browser, url, name, opener_realm_id = 0)
|
|
308
396
|
resolved = url.to_s.empty? ? nil : opener_browser.resolve_document_url(url)
|
|
397
|
+
# Opening a blob: URL whose storage partition differs from the opener's
|
|
398
|
+
# top-level site is forced NOOPENER (cross-partition-navigation): the new
|
|
399
|
+
# auxiliary window is its own top-level context in the blob's partition, so the
|
|
400
|
+
# blob still loads — but there is no opener relationship and `window.open`
|
|
401
|
+
# returns null. Same-partition keeps the normal opener.
|
|
402
|
+
if resolved.to_s.start_with?('blob:') && cross_partition_blob?(resolved, opener_browser)
|
|
403
|
+
open_aux_window(resolved, name: name, source: opener_browser) # no opener_handle ⇒ window.opener null
|
|
404
|
+
return nil # window.open(...) === null
|
|
405
|
+
end
|
|
406
|
+
# Same-origin window → a realm in the opener's isolate (shared heap); the
|
|
407
|
+
# returned realm-id context becomes a native WindowProxy on the JS side, so
|
|
408
|
+
# cross-window scripting/adoption need no cross-isolate RPC. The opener's realm
|
|
409
|
+
# id wires the popup's window.opener. Falls through to the separate-VM aux path
|
|
410
|
+
# (cross-origin, or a URL we don't yet realm-load).
|
|
411
|
+
if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id))
|
|
412
|
+
return rid
|
|
413
|
+
end
|
|
309
414
|
open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser)
|
|
310
415
|
end
|
|
311
416
|
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
|
|
315
|
-
|
|
417
|
+
# The storage-partition site a blob: URL was created in (its creator's top-level
|
|
418
|
+
# site), or nil for an unknown / revoked / unpartitioned URL.
|
|
419
|
+
def blob_partition_site_of(url)
|
|
420
|
+
e = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
|
|
421
|
+
e && e[:site]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Is this blob: URL in a different storage partition than `accessor`'s top-level
|
|
425
|
+
# site? Unknown blobs (no entry) are treated as same-partition (no extra gating
|
|
426
|
+
# beyond the existing same-origin behaviour).
|
|
427
|
+
def cross_partition_blob?(url, accessor)
|
|
428
|
+
site = blob_partition_site_of(url)
|
|
429
|
+
!site.nil? && site != accessor.blob_partition_site
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Resolve a blob: URL's bytes from whichever Browser created it (the bytes live
|
|
433
|
+
# in the creator's isolate, not necessarily the navigator's). Used to load a blob
|
|
434
|
+
# document into a TOP-LEVEL window (window.open / a window navigation): that new
|
|
435
|
+
# context is the blob's own partition, so no partition gate applies here — the
|
|
436
|
+
# cross-partition rule for windows is the noopener severing above, and for nested
|
|
437
|
+
# frames it is enforced at the frame-navigation site. Falls back to the
|
|
438
|
+
# accessor's own store for an unpartitioned URL (worker / legacy path).
|
|
439
|
+
def blob_bytes_for(url, accessor)
|
|
440
|
+
entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
|
|
441
|
+
creator = entry ? entry[:browser] : accessor
|
|
442
|
+
creator.respond_to?(:read_blob_for_window) ? creator.read_blob_for_window(url) : nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Record / drop a blob URL's storage partition (called by Browser#blob_register /
|
|
446
|
+
# #blob_unregister). `site` is the creating context's top-level site.
|
|
447
|
+
def register_blob_partition(url, browser, site)
|
|
448
|
+
@blob_partitions_lock.synchronize { @blob_partitions[url.to_s] = {browser: browser, site: site.to_s} }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def unregister_blob_partition(url)
|
|
452
|
+
@blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# A user `URL.revokeObjectURL(url)` from `source`. Storage-partitioned: a revoke
|
|
456
|
+
# from a different top-level site than the blob's is a NO-OP (cross-partition.https
|
|
457
|
+
# "shouldn't be revocable from a cross-partition iframe/worker"). A same-partition
|
|
458
|
+
# revoke drops the Driver entry AND invalidates the blob in the CREATOR's isolate
|
|
459
|
+
# (the blob may have been created in another window), so every window stops
|
|
460
|
+
# resolving it. Returns false when vetoed (caller leaves its local copy intact).
|
|
461
|
+
def revoke_blob_partitioned(url, source)
|
|
462
|
+
entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
|
|
463
|
+
return false if entry && entry[:site] != source.blob_partition_site
|
|
464
|
+
@blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
|
|
465
|
+
creator = entry && entry[:browser]
|
|
466
|
+
creator.drop_local_blob(url.to_s) if creator && creator.respond_to?(:drop_local_blob) && !creator.equal?(source)
|
|
467
|
+
true
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Load a blob: document into top-level window `target`. The bytes live in
|
|
471
|
+
# whichever Browser created the blob, not the target's — `blob_bytes_for` fetches
|
|
472
|
+
# them cross-isolate. Returns false when the blob is unavailable (revoked), so
|
|
473
|
+
# the caller falls back. A click-time `snapshot` is the navigating context's own
|
|
474
|
+
# blob captured before a deferred nav could revoke it.
|
|
475
|
+
private def load_blob_into_window(target, url, source, snapshot: nil)
|
|
316
476
|
data = if snapshot.is_a?(Hash) && snapshot['b64']
|
|
317
477
|
{ bytes: Base64.decode64(snapshot['b64'].to_s), type: snapshot['type'].to_s }
|
|
318
|
-
|
|
319
|
-
|
|
478
|
+
else
|
|
479
|
+
blob_bytes_for(url, source)
|
|
320
480
|
end
|
|
321
481
|
return false unless data
|
|
322
|
-
|
|
482
|
+
target.boot_blob_document(url, data[:bytes], data[:type])
|
|
323
483
|
true
|
|
324
484
|
rescue StandardError
|
|
325
485
|
false
|
|
@@ -348,9 +508,42 @@ module Capybara
|
|
|
348
508
|
b = window_browser(handle) or return nil
|
|
349
509
|
b.read_property(prop, doc: doc)
|
|
350
510
|
end
|
|
511
|
+
# Cross-window remote-ref RPC: route a node/object proxy op to the window
|
|
512
|
+
# that owns the ref (handle), executing in that window's VM.
|
|
513
|
+
def fire_aux_window_load(handle) = ((b = window_browser(handle)) && b.fire_own_window_load)
|
|
514
|
+
def window_ref_get(handle, id, prop) = (b = window_browser(handle)) ? b.remote_ref_get(id, prop) : nil
|
|
515
|
+
def window_ref_set(handle, id, prop, value) = ((b = window_browser(handle)) && b.remote_ref_set(id, prop, value))
|
|
516
|
+
def window_ref_call(handle, id, method, args) = (b = window_browser(handle)) ? b.remote_ref_call(id, method, args) : nil
|
|
351
517
|
def window_set_location(handle, url)
|
|
352
518
|
b = window_browser(handle) or return
|
|
353
|
-
|
|
519
|
+
# Per HTML, `w.location = url` parses `url` relative to the ENTRY settings
|
|
520
|
+
# object — the document of the script doing the assignment (the active
|
|
521
|
+
# window) — NOT the target window's current document. So a cross-window
|
|
522
|
+
# `w.location.href = 'resources/x.html'` resolves against the opener's
|
|
523
|
+
# base, not the aux's (which would double a shared path segment).
|
|
524
|
+
navigate_window(b, current_browser.resolve_document_url(url), source: current_browser)
|
|
525
|
+
end
|
|
526
|
+
# A cross-window `w.history.back()/forward()/go(n)`: traverse the target
|
|
527
|
+
# window's history. The opener's VM is the one executing, so a non-active
|
|
528
|
+
# target can rebuild eagerly (like `navigate_window`); an active target
|
|
529
|
+
# (e.g. `opener.history.back()` from an aux) defers to avoid tearing down
|
|
530
|
+
# the running VM mid-call. Returns true when the traversal crossed a
|
|
531
|
+
# document boundary — the JS proxy then fires the target's deferred `load`
|
|
532
|
+
# (the same deferral as `navAux`) so the restored page's `window.onload`
|
|
533
|
+
# runs after the opener's current task. False for a same-document
|
|
534
|
+
# (pushState) traversal — popstate already fired — or a no-op.
|
|
535
|
+
def window_history_go(handle, delta)
|
|
536
|
+
b = window_browser(handle) or return false
|
|
537
|
+
if b.equal?(current_browser)
|
|
538
|
+
# `opener.history.back()` targeting the active window: defer (can't
|
|
539
|
+
# rebuild the running VM mid-call) and return false — the active
|
|
540
|
+
# window's load fires through its own navigation path when the pending
|
|
541
|
+
# traversal drains, NOT via the aux-load deferral the caller would run.
|
|
542
|
+
b.history_go(delta)
|
|
543
|
+
false
|
|
544
|
+
else
|
|
545
|
+
b.history_go(delta, force: true) == :cross_document
|
|
546
|
+
end
|
|
354
547
|
end
|
|
355
548
|
def window_closed?(handle) = window_browser(handle).nil?
|
|
356
549
|
def opener_handle_of(browser)
|
|
@@ -393,11 +586,21 @@ module Capybara
|
|
|
393
586
|
return if h == PRIMARY_HANDLE
|
|
394
587
|
@aux_windows.reject! {|w|
|
|
395
588
|
next false unless w[:handle] == h
|
|
589
|
+
drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
|
|
396
590
|
w[:browser].dispose rescue nil
|
|
397
591
|
true
|
|
398
592
|
}
|
|
399
593
|
@active_handle = nil if @active_handle == h
|
|
400
594
|
end
|
|
595
|
+
|
|
596
|
+
# Drop every blob-partition entry created by `browser` — its isolate is being
|
|
597
|
+
# disposed, so the bytes are gone and the reference must not linger (a stale
|
|
598
|
+
# entry would pin the dead Browser and route blob_bytes_for to a dead VM).
|
|
599
|
+
private def drop_blob_partitions_for(browser)
|
|
600
|
+
@blob_partitions_lock.synchronize do
|
|
601
|
+
@blob_partitions.reject! {|_url, e| e[:browser].equal?(browser) }
|
|
602
|
+
end
|
|
603
|
+
end
|
|
401
604
|
def switch_to_window(h)
|
|
402
605
|
if h == PRIMARY_HANDLE
|
|
403
606
|
@active_handle = nil
|