capybara-simulated 0.5.0 → 0.7.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,85 @@ 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!
277
+ reset_windows!
278
+ browser.reset!
279
+ end
280
+
281
+ # Dispose every auxiliary window and return focus to the primary — a fresh
282
+ # browsing context has no sibling windows. Disposing each aux Browser tears
283
+ # down its worker / SSE / websocket threads and its V8 isolate eagerly; left
284
+ # alone they pile into V8Runtime's process-wide `@@live` set and are only
285
+ # reclaimed by the at_exit hook, which on a long-lived multi-file session
286
+ # (the WPT runner) means a slow — sometimes minutes-long — process exit. Split
287
+ # out of `reset!` so a caller can drop windows WITHOUT resetting the primary's
288
+ # page state (the WPT runner rebuilds the primary itself, per file, via visit).
289
+ def reset_windows!
209
290
  @aux_windows.each {|w| w[:browser].dispose rescue nil }
210
291
  @aux_windows.clear
211
292
  @active_handle = nil
212
- browser.reset!
293
+ @blob_partitions_lock.synchronize { @blob_partitions.clear }
213
294
  end
214
295
  def go_back = current_browser.go_back
215
296
  def go_forward = current_browser.go_forward
297
+ def reset_history! = current_browser.reset_history!
216
298
  def current_url = current_browser.current_url || ''
217
299
  def html = current_browser.html
218
300
  def title = current_browser.title
@@ -264,10 +346,22 @@ module Capybara
264
346
  # matches an existing window navigates that window instead of opening a
265
347
  # new one (HTML window-name targeting); `opener_handle` records the
266
348
  # 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)
349
+ def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil)
268
350
  name = name.to_s
351
+ # A blob: URL opened from a different storage partition is forced noopener
352
+ # (cross-partition-navigation), overriding an explicit rel=opener — the new
353
+ # top-level window is the blob's own partition (so the blob still loads), but
354
+ # the opener relationship is severed.
355
+ opener = false if opener && url.to_s.start_with?('blob:') && source && cross_partition_blob?(url, source)
356
+ # A `<form target>` keeps its opener by default (unlike a `target=_blank`
357
+ # LINK, which is noopener) — resolve the opener handle from the source.
358
+ opener_handle ||= handle_for(source) if opener && source
269
359
  if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
270
- navigate_window(existing[:browser], url, source: source)
360
+ if post
361
+ existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer)
362
+ else
363
+ navigate_window(existing[:browser], url, source: source)
364
+ end
271
365
  return existing[:handle]
272
366
  end
273
367
  @next_window_seq += 1
@@ -279,12 +373,18 @@ module Capybara
279
373
  # (with its opener) must exist before `visit` runs those scripts.
280
374
  @aux_windows << {handle: handle, browser: aux, name: name, opener: opener_handle}
281
375
  if url && !url.empty?
376
+ if post
377
+ # A `<form target=_blank method=post>` loads the new window via POST,
378
+ # carrying the opener's URL as referrer (unless rel=noreferrer → '').
379
+ aux.navigate_post(url, post[:body], post[:content_type], referer: referrer)
282
380
  # A blob: URL isn't rack-navigable and its bytes live in the OPENER's
283
381
  # isolate — load the document directly from a click-time snapshot (a
284
382
  # deferred target=_blank nav may revoke the URL first) or, failing that,
285
383
  # 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)
384
+ elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
385
+ # A form submission carries a referrer (the opener's URL) unless the
386
+ # form opted out via rel=noreferrer (referrer: '').
387
+ aux.visit(url, referer: referrer)
288
388
  end
289
389
  end
290
390
  handle
@@ -304,22 +404,94 @@ module Capybara
304
404
 
305
405
  # `window.open(url, name)` from the `opener` window's JS. Resolves the URL
306
406
  # against the opener's document and records the opener relationship.
307
- def open_window_from_js(opener_browser, url, name)
407
+ def open_window_from_js(opener_browser, url, name, opener_realm_id = 0)
308
408
  resolved = url.to_s.empty? ? nil : opener_browser.resolve_document_url(url)
409
+ # Opening a blob: URL whose storage partition differs from the opener's
410
+ # top-level site is forced NOOPENER (cross-partition-navigation): the new
411
+ # auxiliary window is its own top-level context in the blob's partition, so the
412
+ # blob still loads — but there is no opener relationship and `window.open`
413
+ # returns null. Same-partition keeps the normal opener.
414
+ if resolved.to_s.start_with?('blob:') && cross_partition_blob?(resolved, opener_browser)
415
+ open_aux_window(resolved, name: name, source: opener_browser) # no opener_handle ⇒ window.opener null
416
+ return nil # window.open(...) === null
417
+ end
418
+ # Same-origin window → a realm in the opener's isolate (shared heap); the
419
+ # returned realm-id context becomes a native WindowProxy on the JS side, so
420
+ # cross-window scripting/adoption need no cross-isolate RPC. The opener's realm
421
+ # id wires the popup's window.opener. Falls through to the separate-VM aux path
422
+ # (cross-origin, or a URL we don't yet realm-load).
423
+ if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id))
424
+ return rid
425
+ end
309
426
  open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser)
310
427
  end
311
428
 
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)
429
+ # The storage-partition site a blob: URL was created in (its creator's top-level
430
+ # site), or nil for an unknown / revoked / unpartitioned URL.
431
+ def blob_partition_site_of(url)
432
+ e = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
433
+ e && e[:site]
434
+ end
435
+
436
+ # Is this blob: URL in a different storage partition than `accessor`'s top-level
437
+ # site? Unknown blobs (no entry) are treated as same-partition (no extra gating
438
+ # beyond the existing same-origin behaviour).
439
+ def cross_partition_blob?(url, accessor)
440
+ site = blob_partition_site_of(url)
441
+ !site.nil? && site != accessor.blob_partition_site
442
+ end
443
+
444
+ # Resolve a blob: URL's bytes from whichever Browser created it (the bytes live
445
+ # in the creator's isolate, not necessarily the navigator's). Used to load a blob
446
+ # document into a TOP-LEVEL window (window.open / a window navigation): that new
447
+ # context is the blob's own partition, so no partition gate applies here — the
448
+ # cross-partition rule for windows is the noopener severing above, and for nested
449
+ # frames it is enforced at the frame-navigation site. Falls back to the
450
+ # accessor's own store for an unpartitioned URL (worker / legacy path).
451
+ def blob_bytes_for(url, accessor)
452
+ entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
453
+ creator = entry ? entry[:browser] : accessor
454
+ creator.respond_to?(:read_blob_for_window) ? creator.read_blob_for_window(url) : nil
455
+ end
456
+
457
+ # Record / drop a blob URL's storage partition (called by Browser#blob_register /
458
+ # #blob_unregister). `site` is the creating context's top-level site.
459
+ def register_blob_partition(url, browser, site)
460
+ @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] = {browser: browser, site: site.to_s} }
461
+ end
462
+
463
+ def unregister_blob_partition(url)
464
+ @blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
465
+ end
466
+
467
+ # A user `URL.revokeObjectURL(url)` from `source`. Storage-partitioned: a revoke
468
+ # from a different top-level site than the blob's is a NO-OP (cross-partition.https
469
+ # "shouldn't be revocable from a cross-partition iframe/worker"). A same-partition
470
+ # revoke drops the Driver entry AND invalidates the blob in the CREATOR's isolate
471
+ # (the blob may have been created in another window), so every window stops
472
+ # resolving it. Returns false when vetoed (caller leaves its local copy intact).
473
+ def revoke_blob_partitioned(url, source)
474
+ entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
475
+ return false if entry && entry[:site] != source.blob_partition_site
476
+ @blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
477
+ creator = entry && entry[:browser]
478
+ creator.drop_local_blob(url.to_s) if creator && creator.respond_to?(:drop_local_blob) && !creator.equal?(source)
479
+ true
480
+ end
481
+
482
+ # Load a blob: document into top-level window `target`. The bytes live in
483
+ # whichever Browser created the blob, not the target's — `blob_bytes_for` fetches
484
+ # them cross-isolate. Returns false when the blob is unavailable (revoked), so
485
+ # the caller falls back. A click-time `snapshot` is the navigating context's own
486
+ # blob captured before a deferred nav could revoke it.
487
+ private def load_blob_into_window(target, url, source, snapshot: nil)
316
488
  data = if snapshot.is_a?(Hash) && snapshot['b64']
317
489
  { 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)
490
+ else
491
+ blob_bytes_for(url, source)
320
492
  end
321
493
  return false unless data
322
- aux.boot_blob_document(url, data[:bytes], data[:type])
494
+ target.boot_blob_document(url, data[:bytes], data[:type])
323
495
  true
324
496
  rescue StandardError
325
497
  false
@@ -348,9 +520,42 @@ module Capybara
348
520
  b = window_browser(handle) or return nil
349
521
  b.read_property(prop, doc: doc)
350
522
  end
523
+ # Cross-window remote-ref RPC: route a node/object proxy op to the window
524
+ # that owns the ref (handle), executing in that window's VM.
525
+ def fire_aux_window_load(handle) = ((b = window_browser(handle)) && b.fire_own_window_load)
526
+ def window_ref_get(handle, id, prop) = (b = window_browser(handle)) ? b.remote_ref_get(id, prop) : nil
527
+ def window_ref_set(handle, id, prop, value) = ((b = window_browser(handle)) && b.remote_ref_set(id, prop, value))
528
+ def window_ref_call(handle, id, method, args) = (b = window_browser(handle)) ? b.remote_ref_call(id, method, args) : nil
351
529
  def window_set_location(handle, url)
352
530
  b = window_browser(handle) or return
353
- navigate_window(b, b.resolve_document_url(url), source: current_browser)
531
+ # Per HTML, `w.location = url` parses `url` relative to the ENTRY settings
532
+ # object — the document of the script doing the assignment (the active
533
+ # window) — NOT the target window's current document. So a cross-window
534
+ # `w.location.href = 'resources/x.html'` resolves against the opener's
535
+ # base, not the aux's (which would double a shared path segment).
536
+ navigate_window(b, current_browser.resolve_document_url(url), source: current_browser)
537
+ end
538
+ # A cross-window `w.history.back()/forward()/go(n)`: traverse the target
539
+ # window's history. The opener's VM is the one executing, so a non-active
540
+ # target can rebuild eagerly (like `navigate_window`); an active target
541
+ # (e.g. `opener.history.back()` from an aux) defers to avoid tearing down
542
+ # the running VM mid-call. Returns true when the traversal crossed a
543
+ # document boundary — the JS proxy then fires the target's deferred `load`
544
+ # (the same deferral as `navAux`) so the restored page's `window.onload`
545
+ # runs after the opener's current task. False for a same-document
546
+ # (pushState) traversal — popstate already fired — or a no-op.
547
+ def window_history_go(handle, delta)
548
+ b = window_browser(handle) or return false
549
+ if b.equal?(current_browser)
550
+ # `opener.history.back()` targeting the active window: defer (can't
551
+ # rebuild the running VM mid-call) and return false — the active
552
+ # window's load fires through its own navigation path when the pending
553
+ # traversal drains, NOT via the aux-load deferral the caller would run.
554
+ b.history_go(delta)
555
+ false
556
+ else
557
+ b.history_go(delta, force: true) == :cross_document
558
+ end
354
559
  end
355
560
  def window_closed?(handle) = window_browser(handle).nil?
356
561
  def opener_handle_of(browser)
@@ -393,11 +598,21 @@ module Capybara
393
598
  return if h == PRIMARY_HANDLE
394
599
  @aux_windows.reject! {|w|
395
600
  next false unless w[:handle] == h
601
+ drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
396
602
  w[:browser].dispose rescue nil
397
603
  true
398
604
  }
399
605
  @active_handle = nil if @active_handle == h
400
606
  end
607
+
608
+ # Drop every blob-partition entry created by `browser` — its isolate is being
609
+ # disposed, so the bytes are gone and the reference must not linger (a stale
610
+ # entry would pin the dead Browser and route blob_bytes_for to a dead VM).
611
+ private def drop_blob_partitions_for(browser)
612
+ @blob_partitions_lock.synchronize do
613
+ @blob_partitions.reject! {|_url, e| e[:browser].equal?(browser) }
614
+ end
615
+ end
401
616
  def switch_to_window(h)
402
617
  if h == PRIMARY_HANDLE
403
618
  @active_handle = nil