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.
- checksums.yaml +4 -4
- data/README.md +8 -7
- data/lib/capybara/simulated/browser.rb +537 -33
- data/lib/capybara/simulated/driver.rb +42 -13
- data/lib/capybara/simulated/js/bridge.bundle.js +6606 -3888
- data/lib/capybara/simulated/node.rb +8 -0
- data/lib/capybara/simulated/quickjs_runtime.rb +22 -8
- data/lib/capybara/simulated/runtime_shared.rb +29 -28
- data/lib/capybara/simulated/v8_runtime.rb +69 -20
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
-
|
|
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 ||
|
|
1865
|
-
def viewport_height ; @viewport_height ||
|
|
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(
|
|
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
|
-
#
|
|
4075
|
-
#
|
|
4076
|
-
#
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
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
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4339
|
-
if
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
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(
|
|
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
|