capybara-simulated 0.9.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'capybara/driver/base'
4
+ require 'capybara/server/animation_disabler'
4
5
  require 'weakref'
5
6
  require_relative 'browser'
6
7
  require_relative 'node'
@@ -67,13 +68,22 @@ module Capybara
67
68
  # loads. The Browser tracks both as "defaults" so `reset!`
68
69
  # (per-test teardown) restores them between specs.
69
70
  def initialize(app, js_engine: nil, viewport: nil, user_agent: nil)
70
- @app = app
71
+ # `Capybara.disable_animation` is delivered to the real drivers by a SERVER
72
+ # middleware (session.rb adds AnimationDisabler to the Puma stack), which
73
+ # injects `animation-duration: 0s !important` CSS into every HTML response.
74
+ # This driver calls the Rack app in-process and never builds that server, so
75
+ # the same wrap happens here — an app suite that turns animations off
76
+ # (Discourse's rails_helper) must see the same 0s durations a real browser
77
+ # run sees, or every `await`-on-animation close path (FloatKit's menu) parks
78
+ # on a full-length animation-fallback timer no user action waits for.
79
+ @app = Capybara.disable_animation ? Capybara::Server::AnimationDisabler.new(app) : app
71
80
  @js_engine = js_engine
72
81
  # Cookies + localStorage are origin-shared across windows
73
82
  # (real browser semantics), so we own the jars at the Driver
74
83
  # level and inject them into every per-window Browser. Each
75
84
  # Browser still has its own sessionStorage + DOM + JS VM.
76
85
  @cookies = {}
86
+ @cookie_flags = {} # (host \0 name) => {secure: true} — attribute sidecar for the shared jar
77
87
  @auth_cache = {}
78
88
  @local_storage = {}
79
89
  # Cache Storage (caches/Cache) is origin-shared like localStorage — owned at the
@@ -88,6 +98,13 @@ module Capybara
88
98
  @browser = build_window_browser
89
99
  @browser.window_handle = PRIMARY_HANDLE
90
100
  @aux_windows = [] # [{handle:, browser:, name:, opener:}, …]
101
+ # Browsers whose WINDOW closed while they still host an active service-worker
102
+ # registration. A registration is profile-wide state independent of the document
103
+ # that created it (a page registers, closes, and the SW keeps controlling
104
+ # navigations elsewhere), so the Browser is parked — keeping its SW worker
105
+ # thread + scope registry alive for sw_navigation_fetch — instead of disposed.
106
+ # Reclaimed by reset_windows!.
107
+ @sw_parked = []
91
108
  @active_handle = nil
92
109
  @next_window_seq = 0
93
110
  # Driver-level blob URL partition map: url => {browser:, site:}. A blob URL's
@@ -108,6 +125,7 @@ module Capybara
108
125
  driver: self,
109
126
  js_engine: @js_engine,
110
127
  cookies: @cookies,
128
+ cookie_flags: @cookie_flags,
111
129
  auth_cache: @auth_cache,
112
130
  local_storage: @local_storage,
113
131
  cache_storage: @cache_storage,
@@ -126,6 +144,15 @@ module Capybara
126
144
  result
127
145
  end
128
146
 
147
+ # Which JS engine is behind this driver (`:v8` / `:quickjs`), for a trace's metadata.
148
+ def js_engine = browser.js_engine
149
+
150
+ # The ACTIVE window's page, painted for the trace's final state (`TracePersistence`) —
151
+ # `current_browser`, like every other user-facing read here, not the primary `browser`: a
152
+ # test that ended inside `switch_to_window` would otherwise be handed a picture of the
153
+ # window it was not looking at.
154
+ def trace_screenshot = current_browser.trace_screenshot
155
+
129
156
  def tracing? = !current_trace.nil?
130
157
  def current_trace = browser.trace || browser.pending_trace
131
158
 
@@ -256,6 +283,16 @@ module Capybara
256
283
  state
257
284
  end
258
285
 
286
+ # Worker cross-thread work in flight in ANY window — the same aggregate scope
287
+ # as run_event_loop_frame's merged `async` (which folds every window in), so a
288
+ # caller distinguishing "waiting on a worker thread" from other async channels
289
+ # (the wpt_runner's clock-hold) sees a popup-hosted worker/SW round trip too,
290
+ # not just the active window's.
291
+ def worker_drive_pending?
292
+ return true if current_browser.worker_drive_pending?
293
+ @aux_windows.any? {|w| !w[:browser].equal?(current_browser) && w[:browser].worker_drive_pending? }
294
+ end
295
+
259
296
  # Fold an aux window's frame state into the running aggregate: any window that
260
297
  # progressed / has a queued rAF / has an async channel in flight keeps the
261
298
  # whole loop live, and `next_timer` becomes the nearest pending timer across
@@ -288,6 +325,27 @@ module Capybara
288
325
  browser.reset!
289
326
  end
290
327
 
328
+ # Start the next fetches from a cold HTTP cache. `reset!` keeps what a persistent
329
+ # browser profile would — fresh `Cache-Control: immutable` responses, plus the
330
+ # still-fresh script / stylesheet sources and @font-face files — so a test whose
331
+ # app serves new bytes at a cacheable URL it already served (a stylesheet digested
332
+ # from a DB row that a rolled-back example reuses) asks for the cold cache a fresh
333
+ # Playwright / Cuprite context starts with. Process-wide: every session shares the
334
+ # one cache (`Capybara::Simulated.clear_http_cache` is the same call for a hook
335
+ # that runs before any session exists).
336
+ def clear_http_cache = Browser.clear_http_cache
337
+
338
+ # Join every window's background app-request threads (async <img> loads,
339
+ # keepalive fetches) without resetting anything else. The test harness calls
340
+ # this ahead of the app's own after-hooks: cleanup that bypasses
341
+ # ActiveRecord's per-connection lock (Discourse's mini_sql `DB.exec`) must
342
+ # not interleave with a still-running background request on the same raw
343
+ # socket — `reset!`'s drain alone runs after those hooks.
344
+ def drain_background_requests
345
+ @aux_windows.each {|w| w[:browser].drain_app_request_threads rescue nil }
346
+ browser.drain_app_request_threads
347
+ end
348
+
291
349
  # Dispose every auxiliary window and return focus to the primary — a fresh
292
350
  # browsing context has no sibling windows. Disposing each aux Browser tears
293
351
  # down its worker / SSE / websocket threads and its V8 isolate eagerly; left
@@ -299,6 +357,8 @@ module Capybara
299
357
  def reset_windows!
300
358
  @aux_windows.each {|w| w[:browser].dispose rescue nil }
301
359
  @aux_windows.clear
360
+ @sw_parked.each {|b| b.dispose rescue nil }
361
+ @sw_parked.clear
302
362
  @active_handle = nil
303
363
  @blob_partitions_lock.synchronize { @blob_partitions.clear }
304
364
  end
@@ -385,7 +445,12 @@ module Capybara
385
445
  # matches an existing window navigates that window instead of opening a
386
446
  # new one (HTML window-name targeting); `opener_handle` records the
387
447
  # opener so the new window's `window.opener` resolves back to it.
388
- def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil)
448
+ # `defer_load`: the caller will fire the new window's `load` itself, one task
449
+ # later, so that a handler either side registers right after `window.open()` is
450
+ # in place first (platform-globals' `fireAuxLoadSoon`). ONLY the JS `window.open`
451
+ # path does that — a `target=_blank` click or `open_new_window` hands nobody a
452
+ # reference to hook, so their window announces its own load like any other.
453
+ def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil, defer_load: false)
389
454
  name = name.to_s
390
455
  # A blob: URL opened from a different storage partition is forced noopener
391
456
  # (cross-partition-navigation), overriding an explicit rel=opener — the new
@@ -397,7 +462,7 @@ module Capybara
397
462
  opener_handle ||= handle_for(source) if opener && source
398
463
  if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
399
464
  if post
400
- existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer)
465
+ existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
401
466
  else
402
467
  navigate_window(existing[:browser], url, source: source)
403
468
  end
@@ -406,6 +471,7 @@ module Capybara
406
471
  @next_window_seq += 1
407
472
  handle = "csim-window-#{@next_window_seq}"
408
473
  aux = build_window_browser
474
+ aux.defer_window_load = defer_load
409
475
  aux.window_handle = handle
410
476
  # Register BEFORE visiting: the opened document's own boot scripts read
411
477
  # `window.opener`, which resolves through this entry — so the entry
@@ -415,7 +481,7 @@ module Capybara
415
481
  if post
416
482
  # A `<form target=_blank method=post>` loads the new window via POST,
417
483
  # carrying the opener's URL as referrer (unless rel=noreferrer → '').
418
- aux.navigate_post(url, post[:body], post[:content_type], referer: referrer)
484
+ aux.navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
419
485
  # A blob: URL isn't rack-navigable and its bytes live in the OPENER's
420
486
  # isolate — load the document directly from a click-time snapshot (a
421
487
  # deferred target=_blank nav may revoke the URL first) or, failing that,
@@ -423,7 +489,12 @@ module Capybara
423
489
  elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
424
490
  # A form submission carries a referrer (the opener's URL) unless the
425
491
  # form opted out via rel=noreferrer (referrer: '').
426
- aux.visit(url, referer: referrer)
492
+ # The OPENER's document is the navigation initiator — it seeds the popup
493
+ # load's Sec-Fetch-Site (the SameSite cookie gate reads it).
494
+ # The OPENER's document is the navigation initiator — it seeds the popup
495
+ # load's Sec-Fetch-Site (the SameSite cookie gate reads it). raw_current_url:
496
+ # the ticking `current_url` must not run re-entrantly inside window.open.
497
+ aux.visit(url, referer: referrer, initiator: source&.raw_current_url)
427
498
  end
428
499
  end
429
500
  handle
@@ -462,7 +533,7 @@ module Capybara
462
533
  if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id, about_base: about_base, about_origin: about_origin))
463
534
  return rid
464
535
  end
465
- open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser)
536
+ open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser, defer_load: true)
466
537
  end
467
538
 
468
539
  # The storage-partition site a blob: URL was created in (its creator's top-level
@@ -536,11 +607,13 @@ module Capybara
536
607
  false
537
608
  end
538
609
 
539
- # `targetWindow.postMessage(data, origin)` — queue on the target window's
540
- # Browser, tagged with the source window's handle.
541
- def window_post_message(source_browser, target_handle, data, _origin)
610
+ # `targetWindow.postMessage(data, targetOrigin)` — queue on the target window's
611
+ # Browser, tagged with the source window's handle. The targetOrigin travels with
612
+ # the message and gates delivery in the target VM (where its current origin is
613
+ # known); the sender's origin becomes the delivered event.origin.
614
+ def window_post_message(source_browser, target_handle, data, target_origin, sender_origin)
542
615
  target = window_browser(target_handle) or return
543
- target.enqueue_window_message(data, _origin, handle_for(source_browser))
616
+ target.enqueue_window_message(data, target_origin, sender_origin, handle_for(source_browser))
544
617
  end
545
618
 
546
619
  # `BroadcastChannel.postMessage` — deliver to every OTHER window's channels
@@ -572,7 +645,9 @@ module Capybara
572
645
  end
573
646
  end
574
647
 
575
- def window_location(handle) = (window_browser(handle)&.current_url).to_s
648
+ # raw_: an identity read — this runs inside host-fn callbacks (a popup's boot
649
+ # script reading opener.location), where the ticking current_url must not re-enter.
650
+ def window_location(handle) = (window_browser(handle)&.raw_current_url).to_s
576
651
  # A cross-window property read (`win.foo` / `win.document.foo`) — read the
577
652
  # primitive off the target window's VM.
578
653
  def window_read(handle, prop, doc: false)
@@ -641,7 +716,9 @@ module Capybara
641
716
  if browser.equal?(current_browser)
642
717
  browser.location_assign(url)
643
718
  else
644
- browser.visit(url)
719
+ # A cross-window navigation's initiator is the SETTING window's document
720
+ # (seeds Sec-Fetch-Site → the SameSite cookie gate); raw_ — no re-entrant tick.
721
+ browser.visit(url, initiator: source&.raw_current_url)
645
722
  end
646
723
  end
647
724
 
@@ -664,13 +741,75 @@ module Capybara
664
741
  return if h == PRIMARY_HANDLE
665
742
  @aux_windows.reject! {|w|
666
743
  next false unless w[:handle] == h
667
- drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
668
- w[:browser].dispose rescue nil
744
+ # HTML "close a browsing context": the teardown events (pagehide+unload,
745
+ # this window and every nested frame, parent-first) fire while the VM
746
+ # still works — a nested iframe's unload keepalive beacon depends on it.
747
+ w[:browser].fire_document_teardown if w[:browser].respond_to?(:fire_document_teardown)
748
+ if w[:browser].sw_registrations_active?
749
+ # Still hosting a live service-worker registration — park (see @sw_parked):
750
+ # tear down the document-scoped machinery, retire older parked browsers
751
+ # whose scopes this one re-registered (the newest registration for a scope
752
+ # is THE registration, as in a real profile), and keep the browser alive.
753
+ w[:browser].park_for_service_workers!
754
+ retire_shadowed_parked(w[:browser])
755
+ @sw_parked << w[:browser]
756
+ else
757
+ drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
758
+ w[:browser].dispose rescue nil
759
+ end
669
760
  true
670
761
  }
671
762
  @active_handle = nil if @active_handle == h
672
763
  end
673
764
 
765
+ # Dispose parked browsers whose live scopes are ALL re-registered by `newcomer` —
766
+ # their registrations are replaced, so keeping the old worker would let a stale
767
+ # SW shadow the new one in sw_navigation_fetch. A partially-overlapping parked
768
+ # browser stays (its non-shadowed scopes are still the live registrations; the
769
+ # newest-first search below picks the newcomer for the shared ones).
770
+ private def retire_shadowed_parked(newcomer)
771
+ fresh = newcomer.sw_live_scopes
772
+ @sw_parked.reject! {|b|
773
+ scopes = b.sw_live_scopes
774
+ next false if scopes.empty? || !(scopes - fresh).empty?
775
+ drop_blob_partitions_for(b)
776
+ b.dispose rescue nil
777
+ true
778
+ }
779
+ end
780
+
781
+ # Dispose parked browsers whose service workers have all died since parking
782
+ # (the SW unregistered itself / closed) — nothing routes to them anymore, and
783
+ # each pins a whole V8 isolate until reset otherwise.
784
+ private def sweep_dead_parked
785
+ @sw_parked.reject! {|b|
786
+ next false if b.sw_registrations_active?
787
+ drop_blob_partitions_for(b)
788
+ b.dispose rescue nil
789
+ true
790
+ }
791
+ end
792
+
793
+ # The fetch-event round-trip for a controlled NAVIGATION, resolved across the whole
794
+ # window set: a service-worker registration is profile-wide, so the Browser hosting
795
+ # the controlling SW may be any window's — or a parked one whose window already
796
+ # closed (searched newest-parked first: a re-registered scope's newest worker is
797
+ # the active registration). Returns the owner's respondWith wire hash, or nil
798
+ # (uncontrolled → load from the network).
799
+ def sw_navigation_fetch(url, **kw)
800
+ sweep_dead_parked
801
+ owner = ([@browser] + @aux_windows.map {|w| w[:browser] } + @sw_parked.reverse).find {|b|
802
+ b.sw_controls_navigation?(url)
803
+ }
804
+ return nil unless owner
805
+
806
+ resp = owner.service_worker_navigation_fetch(url, **kw)
807
+ # Nobody pumps a parked browser — drop what the handler queued for its dead
808
+ # clients so the outbox can't grow across a long test.
809
+ owner.drop_dead_letter_worker_messages if @sw_parked.include?(owner)
810
+ resp
811
+ end
812
+
674
813
  # Drop every blob-partition entry created by `browser` — its isolate is being
675
814
  # disposed, so the bytes are gone and the reference must not linger (a stale
676
815
  # entry would pin the dead Browser and route blob_bytes_for to a dead VM).
@@ -728,11 +867,18 @@ module Capybara
728
867
  end
729
868
  end
730
869
 
731
- def invalid_element_errors = [Capybara::Simulated::StaleElement]
870
+ def invalid_element_errors = [Capybara::Simulated::StaleElement, Capybara::Simulated::ClickIntercepted]
732
871
  def no_such_window_error = Capybara::WindowError
733
872
 
734
- def save_screenshot(path, **_opts)
735
- File.write(path, current_browser.html.to_s)
873
+ # A real raster of the laid-out page (see js/src/paint.js), not a serialization of it: the
874
+ # painter reads the same boxes every geometry query reads, so a screenshot can only show
875
+ # what the driver already believes. `full: true` paints the whole document rather than the
876
+ # viewport.
877
+ def save_screenshot(path, full: false, **_opts)
878
+ data = current_browser.screenshot_png(full: full)
879
+ raise Capybara::Simulated::ScreenshotFailed, 'screenshot: the page painted nothing' if data.nil?
880
+
881
+ File.binwrite(path, data)
736
882
  path
737
883
  end
738
884
 
@@ -10,11 +10,23 @@ module Capybara
10
10
  # cached element.
11
11
  class StaleElement < Capybara::ElementNotFound; end
12
12
 
13
+ # Raised when the click point's hit-test lands on an unrelated element
14
+ # painted over the target (WebDriver "element click intercepted") — a
15
+ # modal backdrop mid-exit, a full-page overlay. Listed as an
16
+ # `invalid_element_error`, so Capybara's `synchronize` retries the
17
+ # find+click until the obstruction is gone, exactly as it does for a
18
+ # real driver's ElementClickInterceptedError.
19
+ class ClickIntercepted < Capybara::ElementNotFound; end
20
+
13
21
  # Raised by `switch_to_frame` when the active JS engine can't give the
14
22
  # target `<iframe>` its own browsing context (a real per-frame realm).
15
23
  # Only the V8 engine (rusty_racer) builds per-frame realms; under
16
24
  # QuickJS the frame stays a same-realm fallback we can't route DOM ops
17
25
  # into, so `within_frame` is unsupported there.
18
26
  class FrameNotSupported < Capybara::NotSupportedByDriverError; end
27
+
28
+ # Raised by `save_screenshot` when the page could not be rastered — most often because
29
+ # `ruby-vips` (the rasteriser behind the whole canvas stack) isn't in the bundle.
30
+ class ScreenshotFailed < Capybara::CapybaraError; end
19
31
  end
20
32
  end