capybara-simulated 0.8.0 → 0.9.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.
@@ -134,6 +134,11 @@ module Capybara
134
134
  # debounce fires" tests (Discourse refetchForSearch / doubled-filter, Avo
135
135
  # filters) still observe the intermediate state across several polls.
136
136
  FF_TRANSIENT_GUARD_POLLS = (ENV['CSIM_FF_TRANSIENT_GUARD_POLLS'] || '6').to_i
137
+ # The display the window lives on. Mirrors the JS-side `screen` /
138
+ # initial `innerWidth` / `innerHeight` (js/src/platform-globals.js)
139
+ # — the window starts filling it, `resize_to` moves the viewport
140
+ # off it, and `maximize` / `fullscreen` restore it.
141
+ SCREEN_SIZE = [1024, 768].freeze
137
142
  SETTLE_DRAIN_MS = 32
138
143
  SETTLE_MAX_ITER = 10
139
144
  # Per-`run_loop_step` task cap (its `maxIter`). Bounds a self-rescheduling
@@ -437,6 +442,8 @@ module Capybara
437
442
  # it. Both keyed by realm id, cleared when the last worker exits.
438
443
  @sw_clients = {}
439
444
  @sw_realm_controller = {}
445
+ # The realm holding the focus chain (`note_focused_realm`); nil until a first focus.
446
+ @focused_realm_id = nil
440
447
  # Cross-isolate MessagePort channels: channel id → {realm:, sw:} endpoints. A port
441
448
  # transferred between a client realm and a worker/SW isolate registers both ends here;
442
449
  # the browser relays each side's postMessage to the other. Cleared with the workers.
@@ -1006,6 +1013,15 @@ module Capybara
1006
1013
  nil
1007
1014
  end
1008
1015
 
1016
+ # Capybara's `scroll_to(:current, offset: [dx, dy])` routes here — a scroll relative to the
1017
+ # element's current offset, clamped to its scrollable range like a browser does.
1018
+ def scroll_by(handle, dx, dy)
1019
+ tick_real_time
1020
+ ensure_alive_after_tick(handle)
1021
+ dom_call('__csimScrollBy', handle, dx.to_f, dy.to_f)
1022
+ settle
1023
+ end
1024
+
1009
1025
  # Capybara::Driver::Node surface — Node calls `check_stale`
1010
1026
  # before each read, and that advances the virtual clock.
1011
1027
  def all_text(handle) = text(handle)
@@ -1841,7 +1857,11 @@ module Capybara
1841
1857
  @viewport_width = w.to_i
1842
1858
  @viewport_height = h.to_i
1843
1859
  invalidate_find_cache
1844
- @runtime.eval("globalThis.innerWidth = #{@viewport_width}; globalThis.innerHeight = #{@viewport_height};")
1860
+ # One slot for the viewport (`__csimViewport`): `innerWidth` / `innerHeight` are
1861
+ # `[Replaceable]` accessors over it, the `@media` cascade and the layout engine read it
1862
+ # directly, and the setter re-pushes every live frame's content box — a frame lays out
1863
+ # against its container, which just changed size too.
1864
+ @runtime.eval("globalThis.__csimSetViewport(#{@viewport_width}, #{@viewport_height});")
1845
1865
  # Recompute the cascade `@media` rules against the new
1846
1866
  # viewport so visibility checks (Capybara `visible?`,
1847
1867
  # `getComputedStyle().display`) re-reflect mobile-breakpoint
@@ -1861,8 +1881,13 @@ module Capybara
1861
1881
  @runtime.eval("try { (globalThis.dispatchEvent || function(){})(new Event('resize')); } catch (_) {}")
1862
1882
  nil
1863
1883
  end
1864
- def viewport_width ; @viewport_width || 1024 ; end
1865
- def viewport_height ; @viewport_height || 768 ; end
1884
+ def viewport_width ; @viewport_width || SCREEN_SIZE[0] ; end
1885
+ def viewport_height ; @viewport_height || SCREEN_SIZE[1] ; end
1886
+ # What `maximize` / `fullscreen` restore. A driver configured with a viewport is a mobile
1887
+ # session (`default_viewport`, the same channel `reset!` uses to keep it mobile across
1888
+ # resets), and maximizing must not silently promote it to desktop — its "display" is the
1889
+ # viewport it was built with.
1890
+ def screen_size ; @default_viewport || SCREEN_SIZE ; end
1866
1891
  # Capybara-initiated `page.go_back` runs from Ruby, not inside a
1867
1892
  # JS call, so it's safe to rebuild the Context synchronously. The
1868
1893
  # `force:` flag bypasses the deferral that `history_go` uses to
@@ -3842,7 +3867,7 @@ module Capybara
3842
3867
  # worker's `__csim_workerPostMessage` host fn closes over its
3843
3868
  # handle and routes outgoing messages onto a shared outbox the
3844
3869
  # main settle drains.
3845
- def worker_spawn(url, shared: false, service: false, creator_key: nil)
3870
+ def worker_spawn(url, shared: false, service: false, creator_key: nil, realm_id: 0, controller_handle: 0)
3846
3871
  handle = (@worker_seq += 1)
3847
3872
  target = resolve_against_current(url.to_s)
3848
3873
  # A worker script from a blob: URL in a DIFFERENT storage partition than this
@@ -3867,11 +3892,37 @@ module Capybara
3867
3892
  return worker_fail(handle, 'Worker script could not be loaded') if target.start_with?('blob:') && body.to_s.empty?
3868
3893
  # Pending until the worker's initial script has run (see @worker_initializing).
3869
3894
  @worker_init_lock.synchronize { @worker_initializing += 1 }
3895
+ # A SERVICE worker may call `clients.matchAll()` while its script is still EVALUATING —
3896
+ # before install, before any inbox drain — so its mirror has to be populated before the
3897
+ # script runs. Snapshot it here, on the main thread that owns the registry, and let
3898
+ # run_worker inject it pre-eval. The FOCUS chain rides along for the same reason
3899
+ # `seed_client_mirror` pairs the two: `focused` is browser state the worker isolate cannot
3900
+ # ask for, so without it an early `matchAll` reports every client unfocused and returns
3901
+ # them in creation rather than focus-first order.
3902
+ seed = service ? {clients: sw_client_records_for(handle), focused: focused_client_ids} : nil
3870
3903
  thread = Thread.new do
3871
3904
  Thread.current.report_on_exception = false
3872
- run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service, creator_key: creator_key)
3905
+ run_worker(
3906
+ handle, target, body, inbox, outbox, engine_class,
3907
+ shared: shared,
3908
+ service: service,
3909
+ creator_key: creator_key,
3910
+ seed: seed
3911
+ )
3912
+ end
3913
+ # `service:` marks a SERVICE worker. The client mirror is pushed to every service worker
3914
+ # (a client belongs to the ORIGIN; `controlled` only says whether a given worker controls
3915
+ # it), and a dedicated/shared worker has no client registry to push into.
3916
+ # `realm:` is the browsing context that created this worker — a dedicated worker belongs to
3917
+ # it and is terminated when it is discarded (terminate_realm_workers).
3918
+ @workers[handle] = {thread: thread, inbox: inbox, service: service, realm: realm_id.to_i}
3919
+ # A dedicated / shared worker is a client of its ORIGIN — type 'worker' / 'sharedworker',
3920
+ # frameType 'none' — whether or not a service worker's scope covers its script; only the
3921
+ # `controlled` flag turns on that scope match, exactly as it does for a browsing context.
3922
+ # A service worker is not itself a client of anything.
3923
+ unless service
3924
+ sw_note_worker_client(handle, target, shared, worker_controller_handle(target, shared, controller_handle))
3873
3925
  end
3874
- @workers[handle] = {thread: thread, inbox: inbox}
3875
3926
  handle
3876
3927
  end
3877
3928
 
@@ -3887,7 +3938,12 @@ module Capybara
3887
3938
  def worker_post_to_worker(handle, data)
3888
3939
  w = @workers[handle.to_i]
3889
3940
  return unless w
3941
+ # Counted globally (what settle reads) AND per worker, so a worker that dies still owing
3942
+ # replies can hand back exactly what it holds. A LISTEN-ONLY worker never answers at all,
3943
+ # so without the per-worker tally its share is only released by the reset that fires when
3944
+ # the LAST worker goes — which never happens while a service worker is registered.
3890
3945
  @worker_in_flight += 1
3946
+ w[:in_flight] = w[:in_flight].to_i + 1
3891
3947
  w[:inbox] << data.to_s
3892
3948
  end
3893
3949
 
@@ -3959,11 +4015,28 @@ module Capybara
3959
4015
  # The active worker handle at an EXACT scope (0 if none) — see __csim_swActiveHandleForScope.
3960
4016
  def sw_active_handle_for_scope(scope) = @sw_registrations[scope.to_s].to_i
3961
4017
 
4018
+ # Does `handle` still control any client? HTML's "try activate" holds an installed worker in
4019
+ # the WAITING slot for exactly as long as the outgoing worker has controllees — that is what
4020
+ # makes `registration.waiting` non-null, which is how every "a new version is available"
4021
+ # banner detects an update.
4022
+ def sw_worker_controls_clients?(handle)
4023
+ h = handle.to_i
4024
+ return false if h.zero?
4025
+
4026
+ @sw_clients.any? {|_id, entry| entry[:handle] == h }
4027
+ end
4028
+
3962
4029
  # Mirror a registration's active-worker handle into Ruby, keyed by its (serialized) scope.
3963
4030
  # Emitted by the client lifecycle at activation; survives rebuild_ctx so a navigation can
3964
4031
  # find its controlling SW even after the destination realm's JS was rebuilt.
3965
4032
  def sw_register_scope(scope, handle)
4033
+ # A service worker we haven't seen before starts with an EMPTY client mirror, and every
4034
+ # context that already existed is one of its clients (a client belongs to the origin).
4035
+ # Gating on "this handle is new" rather than on the registry being empty is what makes a
4036
+ # SECOND registration see them too.
4037
+ fresh = !@sw_registrations.value?(handle.to_i)
3966
4038
  @sw_registrations[scope.to_s] = handle.to_i
4039
+ seed_client_mirror(handle.to_i) if fresh
3967
4040
  # Flush any clients.claim() that arrived before this scope was mirrored (a worker's
3968
4041
  # `activate → clients.claim()` fires decoupled from the client-side lifecycle that populates
3969
4042
  # @sw_registrations, so the claim can be drained first — see the claim handler above).
@@ -3974,6 +4047,48 @@ module Capybara
3974
4047
  nil
3975
4048
  end
3976
4049
 
4050
+ # Every known client as `handle` sees it — `controlled` is per-worker, so it is decided here
4051
+ # rather than at each call site.
4052
+ private def sw_client_records_for(handle)
4053
+ @sw_clients.each_value.map {|entry| entry[:rec].merge('controlled' => entry[:handle] == handle) }
4054
+ end
4055
+
4056
+ # Fill a newly-registered service worker's client mirror. Two sources, because a client's
4057
+ # record has two possible authors: a browsing context describes ITSELF (only the realm knows
4058
+ # its URL / frame type / controller), while a worker client has no such voice and is only in
4059
+ # the host registry. Replay the registry first, then ask the realms — a realm's own report
4060
+ # simply refreshes its record.
4061
+ private def seed_client_mirror(handle)
4062
+ if (w = @workers[handle])
4063
+ sw_client_records_for(handle).each do |rec|
4064
+ w[:inbox] << {kind: 'client_register', client: rec}
4065
+ end
4066
+ focused = focused_client_ids
4067
+ w[:inbox] << {kind: 'client_focus', ids: focused} if focused.any?
4068
+ end
4069
+ request_client_reports
4070
+ end
4071
+
4072
+ # Ask every live browsing context to announce itself as a service-worker client. Each realm
4073
+ # reports its own URL / frame type / controller (js/src/sw-client.js), so nothing here has
4074
+ # to model what a realm is — the same broadcast shape as a claim.
4075
+ private def request_client_reports = broadcast_to_realms('__csim_swReportClient')
4076
+
4077
+ # Call a host fn in EVERY live browsing context — the main realm and each frame/window realm.
4078
+ # Service-worker registration state is per-realm (each has its own registration objects), so
4079
+ # anything that changes it has to reach all of them.
4080
+ private def broadcast_to_realms(fn, *args)
4081
+ @runtime.call(fn, *args) rescue nil
4082
+ return nil unless @runtime.respond_to?(:frame_realm_ids)
4083
+
4084
+ @runtime.frame_realm_ids.each do |rid|
4085
+ @runtime.realm_call(rid, fn, *args) if @runtime.frame_realm_alive?(rid)
4086
+ rescue StandardError
4087
+ nil
4088
+ end
4089
+ nil
4090
+ end
4091
+
3977
4092
  # Deliver a clients.claim() to EVERY in-scope client: broadcast to the main realm AND every
3978
4093
  # frame realm; each self-checks whether its own document is in the claiming registration's
3979
4094
  # scope (__csim_swClaimClient) so no realm→URL map is needed here. has_fetch = the SW's
@@ -4049,6 +4164,56 @@ module Capybara
4049
4164
  [handle, w[:has_fetch] != false, w[:script_url].to_s, scope]
4050
4165
  end
4051
4166
 
4167
+ # The handle of the service worker controlling a newly spawned worker, or nil.
4168
+ #
4169
+ # A worker with a REAL script URL is matched against registration scopes like any other
4170
+ # client, whatever its creator is doing: clients-matchall-client-types creates its dedicated
4171
+ # worker from the (out-of-scope, uncontrolled) top-level page and still expects a plain
4172
+ # `matchAll({type: 'worker'})` to return it.
4173
+ #
4174
+ # A blob: / data: script URL is OPAQUE — a UUID no scope could ever cover — so such a worker
4175
+ # takes its creator's controller instead (clients-matchall-blob-url-worker: controlled when
4176
+ # an in-scope frame creates it, uncontrolled when an out-of-scope page does). That has to be
4177
+ # the creating realm's LIVE controller, handed over at `new Worker(…)` time: control usually
4178
+ # arrives after load via `clients.claim()`, so a controller snapshotted when the realm was
4179
+ # BUILT is stale by then, and would report such a worker uncontrolled.
4180
+ private def worker_controller_handle(url, shared, creator_controller)
4181
+ return sw_client_controller_for(url)&.first if url.to_s.match?(%r{\Ahttps?://}i)
4182
+ # A SHARED worker has no single creating context to inherit from.
4183
+ return nil if shared
4184
+
4185
+ h = creator_controller.to_i
4186
+ h.zero? || !@workers[h] ? nil : h
4187
+ end
4188
+
4189
+ # Terminate every dedicated / shared worker a discarded browsing context created. A worker
4190
+ # is owned by its creating context: when that context goes away the worker is terminated,
4191
+ # so it must stop being a service-worker client too (worker_terminate unregisters it).
4192
+ # Without this a frame's worker outlives its frame — a leaked thread AND a leaked client.
4193
+ def terminate_realm_workers(realm_id)
4194
+ rid = realm_id.to_i
4195
+ return nil if rid.zero?
4196
+
4197
+ @workers.select {|_h, w| w[:realm] == rid && !w[:service] }.each do |handle, w|
4198
+ # Everything `worker_terminate` does EXCEPT waiting for the thread. That wait is two
4199
+ # blocking joins with a `Thread#kill` between them, and this runs on the frame-disposal
4200
+ # path — which a frame-heavy app takes on every navigation, so a join here is a
4201
+ # per-navigation stall on the main thread (rule 3). Asking the worker to stop is enough:
4202
+ # it breaks its own poll loop and the thread exits on its own.
4203
+ # The reap must still happen, and happen HERE: it releases the reply-pending counters the
4204
+ # worker still holds and resets them once the last worker is gone, without which
4205
+ # `polling?` stays true for the rest of the session.
4206
+ @workers.delete(handle)
4207
+ detach_worker(handle, w)
4208
+ # Racing the still-live thread is benign: a fallback reply it also answers is dropped
4209
+ # (the client's pending-fetch entry is one-shot), and the only real cost is that a blob
4210
+ # URL minted in the moment between the revoke and the thread noticing `:terminate` can
4211
+ # leak — far cheaper than stalling every navigation.
4212
+ reap_worker(handle, w)
4213
+ end
4214
+ nil
4215
+ end
4216
+
4052
4217
  # The controller an OPAQUE child browsing context (about:blank / srcdoc)
4053
4218
  # inherits from its creator. An about:blank document has no URL to scope-match,
4054
4219
  # so it's controlled by its parent's active service worker (HTML "create and
@@ -4071,29 +4236,248 @@ module Capybara
4071
4236
  nil
4072
4237
  end
4073
4238
 
4074
- # Register a controlled client (a frame/window realm) with its controlling SW
4075
- # so `clients.matchAll()` / `getClientByURL` reflect the real client set — not
4076
- # only clients that happened to postMessage the worker. The client id is
4077
- # realm-scoped (`client-<realm>`), stable for the realm's life. Pushed to the
4078
- # SW inbox (processed FIFO, so it precedes any later message that matchAll's it).
4079
- def sw_register_client(realm_id, url, type, frame_type, handle)
4080
- w = @workers[handle.to_i] or return
4081
- rec = {'id' => "client-#{realm_id.to_i}", 'url' => url.to_s, 'type' => type.to_s, 'frameType' => frame_type.to_s}
4082
- @sw_clients[realm_id.to_i] = {handle: handle.to_i, rec: rec}
4083
- w[:inbox] << {kind: 'client_register', client: rec}
4239
+ # Every live service worker's handle. A service-worker client belongs to the ORIGIN, not
4240
+ # to one registration — `matchAll({includeUncontrolled: true})` must see contexts this
4241
+ # worker doesn't control so the client mirror goes to all of them.
4242
+ private def sw_worker_handles = @workers.select {|_h, w| w[:service] }.keys
4243
+
4244
+ # Mirror a browsing context into every service worker's client set. `controller_handle` is
4245
+ # the worker that CONTROLS it, or nil for an uncontrolled context — and `controlled` is
4246
+ # per-worker, since a client controlled by worker A is genuinely uncontrolled from B's
4247
+ # point of view. Reported by the realm itself (js/src/sw-client.js) at document load and
4248
+ # whenever control is installed, because only the realm knows its own URL and frame type.
4249
+ # The client id is realm-scoped and stable for the realm's life. Pushed to the SW inbox,
4250
+ # which is FIFO, so it precedes any later message that matchAll's it.
4251
+ def sw_note_client(realm_id, url, frame_type, controller_handle = nil)
4252
+ note_client(sw_client_id(realm_id), url, 'window', frame_type, controller_handle)
4253
+ end
4254
+
4255
+ # A dedicated / shared WORKER that a service worker controls is a client too — with no
4256
+ # visibilityState or focus (those are WindowClient's), `frameType` 'none', and its script
4257
+ # URL. Keyed by worker handle, which outlives nothing else and is unique per worker.
4258
+ def sw_note_worker_client(handle, url, shared, controller_handle)
4259
+ note_client(sw_worker_client_id(handle), url, shared ? 'sharedworker' : 'worker', 'none', controller_handle)
4260
+ end
4261
+
4262
+ private def note_client(client_id, url, type, frame_type, controller_handle)
4263
+ ctrl = controller_handle.to_i
4264
+ ctrl = nil if ctrl.zero?
4265
+ rec = {'id' => client_id, 'url' => url.to_s, 'type' => type.to_s, 'frameType' => frame_type.to_s}
4266
+ @sw_clients[client_id] = {handle: ctrl, rec: rec}
4267
+ focused = focused_client_ids
4268
+ sw_worker_handles.each do |h|
4269
+ w = @workers[h] or next
4270
+ # A re-registration with the same id refreshes the record, so a client that CHANGES
4271
+ # controller needs no explicit removal: every worker is told, and the one that lost it
4272
+ # simply learns `controlled` is now false.
4273
+ w[:inbox] << {kind: 'client_register', client: rec.merge('controlled' => h == ctrl)}
4274
+ # A client arriving after focus already moved needs the current id too — `client_focus`
4275
+ # is only pushed on CHANGE, so this worker would otherwise never learn about it.
4276
+ w[:inbox] << {kind: 'client_focus', ids: focused} if focused.any?
4277
+ end
4278
+ nil
4279
+ end
4280
+
4281
+ # Navigate a client's browsing context on behalf of `WindowClient.navigate()`, then answer the
4282
+ # worker waiting on it. The reply carries where the client ENDED UP:
4283
+ # url — the final URL, when the result is same-origin (the promise resolves with the client)
4284
+ # '' — the result is CROSS-ORIGIN, which the spec resolves with null rather than handing
4285
+ # back a client this worker has no business seeing
4286
+ # error — the navigation was refused (mixed content, or a context we can't navigate), a
4287
+ # TypeError rejection
4288
+ # MIXED CONTENT is checked here rather than JS-side because "is this a secure context" is the
4289
+ # host's knowledge: an https client may not be navigated to http.
4290
+ # Queued, not performed here: `deliver_worker_messages` runs inside the `@ticking` guard, and
4291
+ # navigating rebuilds a realm (a top-level one rebuilds the whole context). Doing that
4292
+ # mid-drain would pull the rug from under the rest of the batch — the sw_msgs / claims /
4293
+ # fetch_resps still to be delivered would address realms that no longer exist — and from any
4294
+ # node handle an in-flight find is holding. `drain_pending_navigation` is where every other
4295
+ # navigation intent lands, well clear of the V8 call we are inside.
4296
+ def sw_navigate_client(handle, client_id, url, nav_id)
4297
+ (@sw_pending_client_navs ||= []) << {handle: handle.to_i, client: client_id.to_s, url: url.to_s, nav_id: nav_id.to_i}
4298
+ nil
4299
+ end
4300
+
4301
+ def consume_pending_sw_client_nav
4302
+ return if @sw_pending_client_navs.nil? || @sw_pending_client_navs.empty?
4303
+
4304
+ navs = @sw_pending_client_navs
4305
+ @sw_pending_client_navs = nil
4306
+ navs.each {|e| perform_sw_client_navigate(e[:handle], e[:client], e[:url], e[:nav_id]) }
4307
+ nil
4308
+ end
4309
+
4310
+ private def perform_sw_client_navigate(handle, client_id, url, nav_id)
4311
+ realm_id = sw_client_realm(client_id)
4312
+ return sw_navigate_reply(handle, nav_id, '', '', 'the client is not a navigable browsing context') if realm_id.nil?
4313
+
4314
+ from = client_realm_url(realm_id)
4315
+ return sw_navigate_reply(handle, nav_id, '', '', 'mixed content is not allowed') if mixed_content_navigation?(from, url)
4316
+
4317
+ # Identify the browsing CONTEXT before navigating — a frame navigation rebuilds its realm,
4318
+ # so the realm id is not stable across it, but the iframe element that owns it is.
4319
+ parent = realm_id.zero? ? nil : @runtime.frame_realm_parent(realm_id)
4320
+ container = realm_id.zero? ? nil : frame_container_handle(realm_id, parent)
4321
+ navigate_client_realm(realm_id, url)
4322
+ landed_realm = realm_id.zero? ? 0 : (realm_for_container(parent, container) || realm_id)
4323
+ landed = client_realm_url(landed_realm)
4324
+ # A cross-origin result is reported as "no client": the spec resolves navigate() with null
4325
+ # rather than handing back a client this worker has no business seeing.
4326
+ origin = url_origin(landed)
4327
+ return sw_navigate_reply(handle, nav_id, '', '', nil) if landed.empty? || origin.nil? || origin != url_origin(from)
4328
+
4329
+ # The client id is realm-derived, so the rebuild MOVED it. Reply with the id of the context
4330
+ # as it is NOW — handing back the pre-navigation id would give the worker a client whose
4331
+ # postMessage is silently dropped and whose focus() would point the focus chain at a
4332
+ # discarded realm (see the sw_clientid_model note).
4333
+ sw_navigate_reply(handle, nav_id, landed, sw_client_id(landed_realm), nil)
4334
+ rescue StandardError => e
4335
+ sw_navigate_reply(handle, nav_id, '', '', "navigation failed: #{e.message}")
4336
+ end
4337
+
4338
+ private def sw_navigate_reply(handle, nav_id, url, client_id, error)
4339
+ w = @workers[handle.to_i] or return nil
4340
+ w[:inbox] << {kind: 'client_navigate_result', nav_id: nav_id.to_i, url: url.to_s, client: client_id.to_s, error: error.to_s}
4084
4341
  nil
4085
4342
  end
4086
4343
 
4344
+ # The realm currently backing a browsing context, named by the iframe element that owns it.
4345
+ # The element outlives every realm rebuild a navigation causes, so it — not the realm id —
4346
+ # is what identifies the context across one.
4347
+ private def realm_for_container(parent, container)
4348
+ return nil if container.nil? || container.zero? || !@runtime.respond_to?(:frame_realm_ids)
4349
+
4350
+ @runtime.frame_realm_ids.find do |rid|
4351
+ @runtime.frame_realm_alive?(rid) && frame_container_handle(rid, parent) == container
4352
+ rescue StandardError
4353
+ false
4354
+ end
4355
+ end
4356
+
4357
+ private def client_realm_url(realm_id)
4358
+ (realm_id.to_i.zero? ? @current_url : frame_realm_url(realm_id)).to_s
4359
+ end
4360
+
4361
+ # An https document may not be navigated to an http one (mixed content); the reverse, and
4362
+ # any non-http(s) scheme, is not this check's business.
4363
+ private def mixed_content_navigation?(from, to)
4364
+ from.to_s.downcase.start_with?('https://') && to.to_s.downcase.start_with?('http://')
4365
+ end
4366
+
4367
+ # Re-navigate the browsing context behind a client id — the main window or a frame realm.
4368
+ private def navigate_client_realm(realm_id, url)
4369
+ return visit(url) if realm_id.zero?
4370
+
4371
+ navigate_realm_self_get(realm_id, url, record: false)
4372
+ end
4373
+
4087
4374
  # Drop a client whose realm was disposed (frame navigated away / removed) so
4088
4375
  # matchAll stops returning a dead client. No-op for an unregistered realm.
4089
4376
  def sw_unregister_client(realm_id)
4090
4377
  @sw_realm_controller.delete(realm_id.to_i)
4091
- entry = @sw_clients.delete(realm_id.to_i) or return
4092
- w = @workers[entry[:handle]] or return
4093
- w[:inbox] << {kind: 'client_unregister', id: entry[:rec]['id']}
4378
+ unregister_client(sw_client_id(realm_id))
4379
+ end
4380
+
4381
+ private def unregister_client(client_id)
4382
+ @sw_clients.delete(client_id) or return nil
4383
+
4384
+ sw_worker_handles.each do |h|
4385
+ w = @workers[h] or next
4386
+ w[:inbox] << {kind: 'client_unregister', id: client_id}
4387
+ end
4388
+ # Losing a controllee can be what finally lets a worker parked in `waiting` activate (the
4389
+ # non-skipWaiting half of "try activate"). Gated on a realm having actually parked one, so
4390
+ # the ordinary client-churn path stays free of a per-unregister broadcast (rule 3).
4391
+ broadcast_to_realms('__csim_swTryActivate') if @sw_activation_parked
4392
+ nil
4393
+ end
4394
+
4395
+ # A realm parked a worker in the waiting slot. Recorded so `unregister_client` knows whether
4396
+ # a try-activate broadcast could possibly matter.
4397
+ def sw_note_activation_parked
4398
+ @sw_activation_parked = true
4399
+ nil
4400
+ end
4401
+
4402
+ # A browsing context was discarded. HTML hands focus back to the top-level traversable
4403
+ # when the focused navigable goes away, so a realm that held the focus chain must not
4404
+ # keep it — otherwise `WindowClient.focused` stays true for a client that no longer exists.
4405
+ def note_realm_discarded(realm_id)
4406
+ note_focused_realm(0) if @focused_realm_id == realm_id.to_i
4407
+ nil
4408
+ end
4409
+
4410
+ # The focused BROWSING CONTEXT (HTML "focused area of the top-level traversable"):
4411
+ # the realm whose document owns the focus chain. Reported by the realm that commits a
4412
+ # focus — focusing an <iframe> hands focus to its NESTED context, so that realm is
4413
+ # reported rather than the container's. Feeds `WindowClient.focused`, which is why the
4414
+ # change is mirrored into every SW that holds a client (they can't query the browser).
4415
+ def note_focused_realm(realm_id)
4416
+ rid = realm_id.to_i
4417
+ return nil if @focused_realm_id == rid
4418
+
4419
+ @focused_realm_id = rid
4420
+ ids = focused_client_ids
4421
+ sw_worker_handles.each do |handle|
4422
+ w = @workers[handle] or next
4423
+ w[:inbox] << {kind: 'client_focus', ids: ids}
4424
+ end
4094
4425
  nil
4095
4426
  end
4096
4427
 
4428
+ # Every client id that counts as focused: the focused context AND its ANCESTORS.
4429
+ # `WindowClient.focused` follows `document.hasFocus()`, which is true for the whole chain
4430
+ # up from the focused frame — a page containing the focused iframe is itself focused
4431
+ # (clients-matchall-include-uncontrolled expects the top-level window and the focused
4432
+ # nested frame to BOTH report true). Not a single winner, despite the name of the field.
4433
+ def focused_client_ids
4434
+ return [] if @focused_realm_id.nil?
4435
+
4436
+ ids = []
4437
+ rid = @focused_realm_id
4438
+ 16.times do
4439
+ ids << sw_client_id(rid)
4440
+ # A top-level browsing context ends the chain: the main realm, and an auxiliary window
4441
+ # (whose OPENER is not its ancestor — `document.hasFocus()` is false in the opener while
4442
+ # the popup holds the focus, so its client must not be dragged in).
4443
+ break if top_level_realm?(rid)
4444
+
4445
+ rid = @runtime.respond_to?(:frame_realm_parent) ? @runtime.frame_realm_parent(rid).to_i : 0
4446
+ end
4447
+ ids.uniq
4448
+ end
4449
+
4450
+ # A realm with no parent NAVIGABLE. Without the runtime's window/frame maps (QuickJS has no
4451
+ # realms at all) only the main realm can be one.
4452
+ private def top_level_realm?(realm_id)
4453
+ return true if realm_id.to_i.zero?
4454
+
4455
+ @runtime.respond_to?(:top_level_realm?) ? @runtime.top_level_realm?(realm_id) : true
4456
+ end
4457
+
4458
+ # A worker's service-worker Client id. Distinct from the realm ids below so the
4459
+ # client-message router can tell a worker client from a browsing context.
4460
+ def sw_worker_client_id(handle) = "client-worker-#{handle.to_i}"
4461
+
4462
+ # A realm's service-worker Client id. The MAIN realm (id 0) is 'client-window'; every
4463
+ # other realm is `client-<realm>`. The same two spellings are produced JS-side by
4464
+ # sw-client.js's clientId() and the FetchEvent clientKey (js/src/workers.js), and read
4465
+ # back by the client-message router below — so they must be minted in exactly one place.
4466
+ def sw_client_id(realm_id) = realm_id.to_i.zero? ? 'client-window' : "client-#{realm_id.to_i}"
4467
+
4468
+ # The realm a client id names — the inverse of `sw_client_id`, nil for an unrecognized id.
4469
+ def sw_client_realm(client_id)
4470
+ id = client_id.to_s
4471
+ return 0 if id == 'client-window'
4472
+
4473
+ (m = /\Aclient-(\d+)\z/.match(id)) ? m[1].to_i : nil
4474
+ end
4475
+
4476
+ # The worker handle a client id names, or nil when the id is not a worker client's.
4477
+ def sw_client_worker(client_id)
4478
+ (m = /\Aclient-worker-(\d+)\z/.match(client_id.to_s)) ? m[1].to_i : nil
4479
+ end
4480
+
4097
4481
  # ── Cross-isolate MessagePort channel relay (client realm ↔ worker/SW isolate) ──
4098
4482
  # Each endpoint self-registers when it (de)serializes the transferred port.
4099
4483
  def port_channel_endpoint_realm(channel, realm_id)
@@ -4241,7 +4625,7 @@ module Capybara
4241
4625
  def worker_terminate(handle)
4242
4626
  w = @workers.delete(handle.to_i)
4243
4627
  return unless w
4244
- w[:inbox] << :terminate
4628
+ detach_worker(handle.to_i, w)
4245
4629
  # Most clean shutdowns are <10 ms; the kill is the fallback
4246
4630
  # for blocked workers. Join again AFTER the kill so the thread is actually
4247
4631
  # dead before we revoke its URLs — `Thread#kill` is async, and a worker
@@ -4252,6 +4636,32 @@ module Capybara
4252
4636
  w[:thread].kill
4253
4637
  w[:thread].join(WORKER_TERMINATE_GRACE)
4254
4638
  end
4639
+ reap_worker(handle.to_i, w)
4640
+ end
4641
+
4642
+ # Ask a worker to stop and stop treating it as a client. Split out of `worker_terminate` so the
4643
+ # realm-disposal path can do it WITHOUT the thread joins below (see terminate_realm_workers).
4644
+ # A dedicated / shared worker is a service-worker client (worker_spawn registers it), and a
4645
+ # terminated one must stop showing up in matchAll — the same leak sw_unregister_client
4646
+ # prevents for a disposed realm. A no-op for a SERVICE worker, which is never a client.
4647
+ private def detach_worker(handle, w)
4648
+ unregister_client(sw_worker_client_id(handle))
4649
+ w[:inbox] << :terminate
4650
+ end
4651
+
4652
+ # Everything that must happen once a worker is out of `@workers`, whether or not we waited for
4653
+ # its thread: drain what it will never answer, release the counters it still holds, and revoke
4654
+ # what it created. Load-bearing — the counter reset at the end only fires when the LAST worker
4655
+ # goes, so skipping this path leaves `polling?` stuck true for the rest of the session and
4656
+ # every later negative assertion burns the full wait.
4657
+ private def reap_worker(handle, w)
4658
+ # Hand back every plain postMessage this worker still owes a reply for — queued OR already
4659
+ # consumed by a listen-only handler that will never answer. Only a reply releases these,
4660
+ # and a dead worker sends none; leaving them counted pins `worker_pending?` (and so
4661
+ # `polling?`) true for the rest of the session, making every later negative assertion wait
4662
+ # out the full timeout.
4663
+ @worker_in_flight = [0, @worker_in_flight - w[:in_flight].to_i].max
4664
+ w[:in_flight] = 0
4255
4665
  # The dead worker never answers what was still queued in its inbox: post the matching
4256
4666
  # fallback replies so the reply-pending counters drain (a controlled fetch falls back to
4257
4667
  # the network) instead of taxing every later settle's bounded wait. Mid-dispatch deaths
@@ -4306,7 +4716,10 @@ module Capybara
4306
4716
  broadcasts, rest0 = events.partition {|e| e[:kind] == 'broadcast' }
4307
4717
  port_ends, rest0b = rest0.partition {|e| e[:kind] == 'port_endpoint' }
4308
4718
  port_msgs, rest0c = rest0b.partition {|e| e[:kind] == 'port_msg' }
4309
- sw_msgs, rest1 = rest0c.partition {|e| e[:kind] == 'sw_client_msg' }
4719
+ sw_focuses, rest0c2 = rest0c.partition {|e| e[:kind] == 'sw_client_focus' }
4720
+ sw_navs, rest0c3 = rest0c2.partition {|e| e[:kind] == 'sw_client_navigate' }
4721
+ skip_waits, rest0d = rest0c3.partition {|e| e[:kind] == 'sw_skip_waiting' }
4722
+ sw_msgs, rest1 = rest0d.partition {|e| e[:kind] == 'sw_client_msg' }
4310
4723
  swacks, rest2 = rest1.partition {|e| e[:kind] == 'swack' }
4311
4724
  claims, rest3 = rest2.partition {|e| e[:kind] == 'sw_claim' }
4312
4725
  fetch_resps, rest4 = rest3.partition {|e| e[:kind] == 'fetch_response' }
@@ -4316,6 +4729,20 @@ module Capybara
4316
4729
  # A worker/SW registering its end of a cross-isolate MessagePort channel — record it BEFORE
4317
4730
  # the message events below, so a port message carried in the same drain can already route.
4318
4731
  port_ends.each {|e| port_channel_endpoint_sw(e[:channel], e[:handle]) }
4732
+ # `WindowClient.focus()` — the worker asked to move the focus chain to a client. Applied
4733
+ # BEFORE the messages below, so a SW that focuses a client and then reports its own
4734
+ # matchAll() in the same turn sees the move it just made.
4735
+ sw_focuses.each do |e|
4736
+ rid = sw_client_realm(e[:client]) and note_focused_realm(rid)
4737
+ end
4738
+ # `WindowClient.navigate()` — only QUEUED here (see sw_navigate_client). It must not run
4739
+ # before the sw_msgs / claims / fetch_resps below: the worker emitted those FIRST, and a
4740
+ # navigation discards the realm they address. Queuing preserves the worker's own ordering
4741
+ # and keeps the realm rebuild out of the `@ticking` guard.
4742
+ sw_navs.each {|e| sw_navigate_client(e[:handle], e[:client], e[:url], e[:nav_id]) }
4743
+ # `skipWaiting()` — release a worker parked in the waiting slot. Broadcast, because the
4744
+ # registration objects holding that parked continuation are per-realm.
4745
+ skip_waits.each {|e| broadcast_to_realms('__csim_swSkipWaiting', e[:handle]) }
4319
4746
  # A worker/SW port → its remote (client-realm) peer: relay to that realm's channel endpoint.
4320
4747
  # If the client hasn't registered its endpoint yet (it decodes the transferred port in the
4321
4748
  # sw_client_msg processed just below), BUFFER until port_channel_endpoint_realm flushes.
@@ -4335,12 +4762,20 @@ module Capybara
4335
4762
  # the client's message `source` is exact. A message to a DISCARDED frame realm is dropped
4336
4763
  # (matching a real browser — a message to a gone client is discarded, not misrouted to top).
4337
4764
  sw_msgs.each do |e|
4338
- m = /\Aclient-(\d+)\z/.match(e[:client].to_s)
4339
- if m
4340
- rid = m[1].to_i
4341
- @runtime.realm_call(rid, '__csim_swDeliverClientMessage', e[:data], e[:handle]) if @runtime.frame_realm_alive?(rid)
4342
- else
4765
+ rid = sw_client_realm(e[:client])
4766
+ if (wh = sw_client_worker(e[:client]))
4767
+ # A worker CLIENT (a dedicated/shared worker the SW controls) — deliver to its own
4768
+ # isolate's `navigator.serviceWorker`, not to a browsing context, and not to the
4769
+ # worker's creator-facing `self.onmessage` (which is where a bare 'message' would land).
4770
+ (w = @workers[wh]) && w[:inbox] << {kind: 'sw_client_message', data: e[:data], handle: e[:handle]}
4771
+ elsif rid.nil?
4772
+ # An id naming nothing we know. Dropping matches a real browser (a message to a gone
4773
+ # client is discarded); delivering it to the main realm would MISROUTE it.
4774
+ nil
4775
+ elsif rid.zero?
4343
4776
  @runtime.call('__csim_swDeliverClientMessage', e[:data], e[:handle])
4777
+ elsif @runtime.frame_realm_alive?(rid)
4778
+ @runtime.realm_call(rid, '__csim_swDeliverClientMessage', e[:data], e[:handle])
4344
4779
  end
4345
4780
  end
4346
4781
  # clients.claim(): the claiming worker takes control of EVERY in-scope client — including
@@ -4388,6 +4823,9 @@ module Capybara
4388
4823
  @sw_msg_wait_deadline = nil if acks.size.positive? || swacks.size.positive?
4389
4824
  # `__error` postbacks don't correspond to a prior post, so bottom out at zero.
4390
4825
  @worker_in_flight = [0, @worker_in_flight - msgs.size].max
4826
+ # Mirror the release onto the answering worker's own tally, so what `reap_worker` hands
4827
+ # back when it dies is exactly what it still owes.
4828
+ msgs.each {|e| (w = @workers[e[:handle].to_i]) && (w[:in_flight] = [0, w[:in_flight].to_i - 1].max) }
4391
4829
  @runtime.call('__csim_deliverWorkerMessages', msgs) unless msgs.empty?
4392
4830
  events.size
4393
4831
  end
@@ -4415,9 +4853,9 @@ module Capybara
4415
4853
  # `window.open(url, name)` from JS — returns the new (or reused, by name)
4416
4854
  # window's handle, or nil. The URL is resolved against THIS document so a
4417
4855
  # relative `window.open('/x')` targets the right origin/path.
4418
- def open_child_window(url, name, opener_realm_id = 0)
4856
+ def open_child_window(url, name, opener_realm_id = 0, about_base = nil, about_origin = nil)
4419
4857
  return nil unless @driver.respond_to?(:open_window_from_js)
4420
- @driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i)
4858
+ @driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i, about_base.to_s, about_origin.to_s)
4421
4859
  end
4422
4860
 
4423
4861
  # A `target=_blank`/named link/area activation from a frame or window realm in
@@ -4437,10 +4875,13 @@ module Capybara
4437
4875
  # works (dom/nodes/remove-and-adopt-thcrash). Returns nil to fall back to the
4438
4876
  # separate-VM aux-window path. First stage: about:blank only (a non-blank
4439
4877
  # same-origin URL still takes the aux path until realm URL-loading lands).
4440
- def open_window_realm(url, name: nil, opener_realm_id: 0)
4878
+ def open_window_realm(url, name: nil, opener_realm_id: 0, about_base: nil, about_origin: nil)
4441
4879
  return nil unless @runtime.respond_to?(:create_window_realm)
4442
4880
  return nil unless url.nil?
4443
- @runtime.create_window_realm('', '', 'text/html', window_name: name, opener_id: opener_realm_id)
4881
+ @runtime.create_window_realm(
4882
+ '', '', 'text/html',
4883
+ window_name: name, opener_id: opener_realm_id, about_base: about_base, about_origin: about_origin
4884
+ )
4444
4885
  end
4445
4886
 
4446
4887
  # `targetWindow.postMessage(data, origin)` — route to the target window's
@@ -5289,6 +5730,7 @@ module Capybara
5289
5730
  @sw_pending_claims = []
5290
5731
  @sw_clients = {}
5291
5732
  @sw_realm_controller = {}
5733
+ @focused_realm_id = nil
5292
5734
  @port_channels = {}
5293
5735
  @sw_nav_outbox.clear
5294
5736
  @transfer_buffer_lock.synchronize {
@@ -5638,7 +6080,7 @@ module Capybara
5638
6080
  # `build_worker` factory, evaluates the worker script, then
5639
6081
  # loops draining microtasks + timers + inbox until `:terminate`
5640
6082
  # lands or an exception propagates.
5641
- private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false, creator_key: nil)
6083
+ private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false, creator_key: nil, seed: nil)
5642
6084
  # Release the spawn-time `@worker_initializing` count exactly once, however
5643
6085
  # this method exits (normal start, `self.close()`, or an exception), so
5644
6086
  # worker_pending? doesn't stay stuck true forever.
@@ -5673,7 +6115,17 @@ module Capybara
5673
6115
  sw_has_fetch = false
5674
6116
  sw_hooks = {
5675
6117
  post_to_client: ->(client_id, data) { outbox << {handle: handle, kind: 'sw_client_msg', client: client_id, data: data.to_s} },
6118
+ # WindowClient.focus() — moving the focus chain is cross-realm browser state, so the
6119
+ # worker asks rather than does. Delivered by deliver_worker_messages, which echoes the
6120
+ # move back to every SW as a `client_focus`.
6121
+ focus_client: ->(client_id) { outbox << {handle: handle, kind: 'sw_client_focus', client: client_id} },
6122
+ # WindowClient.navigate() — like focus_client, the browser owns the act; unlike it, the
6123
+ # worker is waiting on the OUTCOME (final URL / cross-origin / refusal), so the reply
6124
+ # comes back on this worker's inbox keyed by nav_id.
6125
+ navigate_client: ->(client_id, url, nav_id) { outbox << {handle: handle, kind: 'sw_client_navigate', client: client_id, url: url.to_s, nav_id: nav_id.to_i} },
5676
6126
  claim: -> { outbox << {handle: handle, kind: 'sw_claim', has_fetch: sw_has_fetch} },
6127
+ # skipWaiting() — the waiting slot is client-side, so the request rides the outbox.
6128
+ skip_waiting: -> { outbox << {handle: handle, kind: 'sw_skip_waiting'} },
5677
6129
  fetch_respond: ->(fetch_id, resp, realm_id) { sw_deliver_fetch_response(handle, fetch_id.to_i, resp.to_s, outbox, realm_id.to_i) },
5678
6130
  # A streaming respondWith frame (start / chunk / close / error) for a controlled client's
5679
6131
  # fetch — rides the outbox in emission order so the client realm reassembles the body
@@ -5715,6 +6167,12 @@ module Capybara
5715
6167
  # A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
5716
6168
  # (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
5717
6169
  rt.eval('__csim_installServiceWorkerScope();') if service
6170
+ # Seed the client mirror BEFORE the script evaluates: `clients.matchAll()` at top level is a
6171
+ # real pattern (clients-matchall-on-evaluation), and an empty mirror there returns nothing.
6172
+ if seed
6173
+ seed[:clients].each {|rec| rt.call('__csim_swRegisterClient', rec) }
6174
+ rt.call('__csim_swNoteFocusedClient', seed[:focused]) if seed[:focused].any?
6175
+ end
5718
6176
  rt.eval(body)
5719
6177
  rt.drain_microtasks
5720
6178
  # Drive the service worker's lifecycle: fire `install`, then `activate`, draining each
@@ -5796,6 +6254,21 @@ module Capybara
5796
6254
  # pending counter): the inbox is FIFO, so it's processed before any later message
5797
6255
  # whose handler matchAll's the client.
5798
6256
  rt.call('__csim_swRegisterClient', msg[:client])
6257
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_focus'
6258
+ # The focus chain moved: `WindowClient.focused` is per-browsing-context state the
6259
+ # worker isolate can't read, so the browser pushes the focused client's id on every
6260
+ # change (and once at registration, for a worker that started after the move).
6261
+ rt.call('__csim_swNoteFocusedClient', msg[:ids])
6262
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_navigate_result'
6263
+ # The outcome of a WindowClient.navigate() this worker is awaiting — settles the
6264
+ # promise it is holding (js/src/workers.js __csim_swClientNavigateResult).
6265
+ rt.call('__csim_swClientNavigateResult', msg[:nav_id], msg[:url], msg[:client], msg[:error])
6266
+ elsif msg.is_a?(Hash) && msg[:kind] == 'sw_client_message'
6267
+ # A service worker → THIS worker, which is one of its clients: `client.postMessage`
6268
+ # targets the client's `navigator.serviceWorker` 'message' event, which a worker
6269
+ # isolate has just like a document (WorkerNavigator.serviceWorker). Fire-and-forget,
6270
+ # like the register/focus mirrors — the SW's send is not awaiting a reply.
6271
+ rt.call('__csim_swDeliverClientMessage', msg[:data], msg[:handle])
5799
6272
  elsif msg.is_a?(Hash) && msg[:kind] == 'client_unregister'
5800
6273
  # The client's realm was disposed — drop it so matchAll stops returning a dead client.
5801
6274
  rt.call('__csim_swUnregisterClient', msg[:id])
@@ -7316,6 +7789,7 @@ module Capybara
7316
7789
  end
7317
7790
  end
7318
7791
  def drain_pending_navigation
7792
+ consume_pending_sw_client_nav
7319
7793
  consume_pending_location
7320
7794
  consume_pending_frame_nav
7321
7795
  consume_pending_frame_submit
@@ -7353,7 +7827,9 @@ module Capybara
7353
7827
  # in place rather than appending. Both the state and (when given)
7354
7828
  # the URL are mirrored on Ruby's slot so a subsequent back to
7355
7829
  # this entry restores the same state.
7356
- def history_state(url, state = nil)
7830
+ def history_state(url, state = nil, realm_id = 0)
7831
+ return note_frame_same_document_url(realm_id.to_i, url) unless realm_id.to_i.zero?
7832
+
7357
7833
  if url
7358
7834
  resolved = resolve_against_current(url.to_s)
7359
7835
  record_url_transition(resolved)
@@ -7371,13 +7847,41 @@ module Capybara
7371
7847
  # Mirror that on the Ruby side so `Capybara#go_back` traverses
7372
7848
  # within the pushState chain (fires `popstate`) and only crosses
7373
7849
  # to a real reload when the back hits a `:visit` boundary.
7374
- def history_push(url, state = nil)
7850
+ def history_push(url, state = nil, realm_id = 0)
7851
+ return note_frame_same_document_url(realm_id.to_i, url) unless realm_id.to_i.zero?
7852
+
7375
7853
  resolved = resolve_against_current(url.to_s)
7376
7854
  record_url_transition(resolved)
7377
7855
  @current_url = resolved
7378
7856
  record_history({method: :get, url: resolved, state: state, kind: :push_state})
7379
7857
  end
7380
7858
 
7859
+ # A SAME-DOCUMENT URL change (pushState / replaceState / a fragment navigation) made by a
7860
+ # NESTED browsing context. It belongs to that frame's own session history — mirroring it onto
7861
+ # the top document's would make `current_url` report a URL no window is at, which is what an
7862
+ # iframe'd SPA does on every navigation. Recorded as the current entry's URL (rather than a
7863
+ # new entry) so a later `location.reload()` refetches the pushState'd URL, not the stale
7864
+ # `src`; in-frame same-document TRAVERSAL over such entries is still unmodelled.
7865
+ # A same-isolate window realm (a popup) has no iframe container and so no entry to update —
7866
+ # its handle is 0, and not touching the top history is already the fix there.
7867
+ private def note_frame_same_document_url(realm_id, url)
7868
+ return nil if url.nil? || realm_id.zero?
7869
+
7870
+ parent = @runtime.frame_realm_parent(realm_id)
7871
+ handle = frame_container_handle(realm_id, parent)
7872
+ return nil if handle.zero?
7873
+
7874
+ h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
7875
+ if h[:idx].negative?
7876
+ # Seed entry 0 from the document as it is NOW — this runs before the location update,
7877
+ # so it still reads the URL the frame was loaded at.
7878
+ h[:entries] << frame_history_entry(realm_id)
7879
+ h[:idx] = 0
7880
+ end
7881
+ h[:entries][h[:idx]] = (h[:entries][h[:idx]] || {}).merge(url: url.to_s)
7882
+ nil
7883
+ end
7884
+
7381
7885
  # Total history entries (after forward-tail truncation), surfaced
7382
7886
  # to JS `history.length` via the `__historyLength` host fn.
7383
7887
  def history_length