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.
@@ -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: self,
89
- js_engine: @js_engine,
90
- cookies: @cookies,
91
- local_storage: @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
- navigate_window(existing[:browser], url, source: source)
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
- unless url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot)
287
- aux.visit(url)
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
- # Load a blob: document into aux window `aux` from `source`'s (the opener's)
313
- # local blob store the bytes live in the opener's isolate, not the aux's.
314
- # Returns false if `source`/bytes are unavailable (caller falls back).
315
- private def load_blob_into_window(aux, url, source, snapshot: nil)
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
- elsif source.respond_to?(:read_blob_for_window)
319
- source.read_blob_for_window(url)
478
+ else
479
+ blob_bytes_for(url, source)
320
480
  end
321
481
  return false unless data
322
- aux.boot_blob_document(url, data[:bytes], data[:type])
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
- navigate_window(b, b.resolve_document_url(url), source: current_browser)
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