capybara-simulated 0.7.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.
@@ -122,16 +122,39 @@ module Capybara
122
122
  self
123
123
  end
124
124
 
125
- def scroll_to(*, **) ; self ; end
125
+ # Capybara's driver `scroll_to` contract (from Element#scroll_to): a target element + align
126
+ # symbol, a position symbol (`:top`/`:bottom`/`:center`), or an `[x, y]` coordinate pair.
127
+ # `self` is the element it was called on — the document root for a session-level scroll (routed
128
+ # via `find('/html')`), else an element. Drives a real scroll offset in the layout engine.
129
+ def scroll_to(target = nil, arg = nil, coords = nil)
130
+ check_stale
131
+ if target
132
+ browser.scroll_to(handle_id, target.handle_id, arg)
133
+ elsif coords
134
+ browser.scroll_to(handle_id, nil, nil, coords[0], coords[1])
135
+ elsif arg
136
+ browser.scroll_to(handle_id, nil, arg)
137
+ end
138
+ self
139
+ end
140
+
141
+ # Capybara's `Element#scroll_to(:current, offset: [dx, dy])` calls this after the position
142
+ # handling above — a relative scroll from wherever the element is now.
143
+ def scroll_by(dx, dy)
144
+ check_stale
145
+ browser.scroll_by(handle_id, dx, dy)
146
+ self
147
+ end
126
148
 
127
149
  # Capybara's standard rect API. No layout engine — but
128
- # Discourse's `wait_for_animation` helper polls `element.rect[:x]`
129
- # twice and waits for the values to stabilise, which they
130
- # immediately do here (constant zeros) since we never animate.
131
- # That unblocks every test guarded by an animation settle.
150
+ # The element's coarse border-box (viewport-relative), from the layout engine — backs the
151
+ # spatial selectors (`:above`/`:below`/`:near`) and coordinate drag. Deterministic per layout
152
+ # generation (memoised; only a DOM/style mutation changes it), so Discourse's
153
+ # `wait_for_animation` which polls `element.rect[:x]` twice and waits for it to stabilise —
154
+ # still settles immediately when nothing is animating.
132
155
  def rect
133
156
  check_stale
134
- {x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0}
157
+ browser.rect(handle_id)
135
158
  end
136
159
 
137
160
  def send_keys(*keys)
@@ -195,7 +218,10 @@ module Capybara
195
218
  def selected? = browser.option_selected?(handle_id)
196
219
  def checked? = !!self['checked']
197
220
  def readonly? = !!self['readonly']
198
- def obscured?(*) = !visible?
221
+ def obscured?(*)
222
+ check_stale
223
+ browser.obscured?(handle_id)
224
+ end
199
225
  def synchronize(*) = yield
200
226
  def style(names = [])
201
227
  check_stale
@@ -13,6 +13,23 @@
13
13
 
14
14
  require 'digest'
15
15
  require 'quickjs'
16
+ begin
17
+ # Intl moved out of the quickjs gem in 0.19. bridge.js patches Intl.DateTimeFormat, so the
18
+ # companion gem is required for the QuickJS engine — say so plainly rather than letting a bare
19
+ # LoadError name a file the user never asked for.
20
+ require 'quickjs-polyfill-intl/datetimeformat'
21
+ rescue LoadError
22
+ raise LoadError, <<~MSG
23
+ capybara-simulated's QuickJS engine needs the `quickjs-polyfill-intl` gem
24
+ (quickjs 0.19 moved Intl out of the core gem, and the DOM bridge uses
25
+ Intl.DateTimeFormat). Add it next to quickjs:
26
+
27
+ gem 'quickjs', '>= 0.19'
28
+ gem 'quickjs-polyfill-intl'
29
+
30
+ Or use the V8 engine (`gem 'rusty_racer'`), which has ICU built in.
31
+ MSG
32
+ end
16
33
 
17
34
  require_relative 'runtime_shared'
18
35
  require_relative 'worker_runtime'
@@ -150,6 +167,19 @@ module Capybara
150
167
  normalize(result)
151
168
  end
152
169
 
170
+ # QuickJS has no per-frame realms (iframes share the one VM): `within_frame` falls back to
171
+ # the same realm, and any realm id routes to the single global context.
172
+ def supports_frames? = false
173
+
174
+ def realm_call(_realm_id, name, *args) = call(name, *args)
175
+
176
+ # No per-frame realms, so no realm is ever "alive" as a distinct browsing context —
177
+ # every client id resolves to 'client-window' (the single global). Defined so a
178
+ # caller that gates realm routing on it (deliver_worker_messages) works on both engines.
179
+ def frame_realm_alive?(_realm_id) = false
180
+
181
+ def frame_realm_ids = []
182
+
153
183
  # bridge.js owns the virtual clock; we drive it from Ruby because
154
184
  # Capybara's polling cadence is wall-clock-anchored.
155
185
  def drain_timers(max_ms = nil)
@@ -216,28 +246,44 @@ module Capybara
216
246
  # already the inter-test reset point.
217
247
  def reset_page = rebuild_ctx
218
248
 
219
- # NOTE: intentionally NO `dispose` Browser#dispose gates its
220
- # `@runtime.dispose` call on `respond_to?(:dispose)`, so QuickJS skips it.
221
- # QuickJS VMs aren't pinned by a process-wide registry the way V8 isolates
222
- # are in V8Runtime's `@@live`; an aux window's Browser becomes unreferenced
223
- # on close and Ruby GC's dfree frees its `@vm` (the same lifecycle
224
- # `rebuild_ctx` already relies on). Adding a `@vm = nil` dispose would only
225
- # introduce a NoMethodError window for any stray post-close call (eval/call
226
- # don't all nil-guard `@vm`) with no leak benefit.
249
+ # PERMANENTLY drop this runtime. Distinct from `rebuild_ctx` above, which
250
+ # deliberately does NOT `dispose!` that runs per visit, on every example,
251
+ # and `dispose!` blocks on the quickjs GC with the GVL held. Here it runs
252
+ # once, when a session is dropped for good, and the cost is the point.
253
+ #
254
+ # This used to be intentionally absent, on the reasoning that Ruby GC's
255
+ # dfree would reach an unreferenced `@vm` on its own. Measured, it does not
256
+ # in time to matter: 30 sessions created and dropped in a loop held 1979 MB
257
+ # with no dispose and 1971 MB with one, because `Browser#dispose` gates on
258
+ # `respond_to?(:dispose)` and so did nothing at all here. The suite's peak
259
+ # RSS is what pays for it.
260
+ #
261
+ # The old note's hazard — a stray post-close `eval`/`call` finding `@vm`
262
+ # nil — is real, so this leaves `@vm` in place and lets the gem's own
263
+ # freed-VM handling answer; `Driver#disposed?` keeps the live-driver walk
264
+ # from stepping a dropped runtime in the first place.
265
+ def dispose
266
+ @vm&.dispose!
267
+ rescue StandardError
268
+ nil
269
+ end
227
270
 
228
271
  # bridge.js patches `Intl.DateTimeFormat`; rusty_racer ships ICU built-in but
229
272
  # QuickJS gates it behind a polyfill flag (other surfaces bridge.js touches —
230
273
  # URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns,
231
274
  # so POLYFILL_INTL is the only one we strictly need).
232
275
  #
233
- # PERF (rule 3): quickjs is pinned to `~> 0.18.0` in the Gemfile. quickjs 0.19
234
- # both split the Intl polyfills into a separate quickjs-polyfill-intl gem (which
235
- # eval's them per VM at ~226 ms — vs this single bundled flag's ~140 ms) AND
236
- # regressed interpreter execution ~2.8× (measured: the QuickJS spec suite ran
237
- # 5.6 min on 0.18 vs 15.5 min on 0.19 with an equivalent Intl set). The 0.19
238
- # migration is recorded in the cross-window / quickjs-CI memory; re-migrate to
239
- # 0.19 + quickjs-polyfill-intl once that upstream perf regression is fixed.
240
- INTL_FEATURES = [Quickjs::POLYFILL_INTL].freeze
276
+ # Since 0.19 the Intl polyfills live in the separate `quickjs-polyfill-intl`
277
+ # gem, registered by name instead of quickjs's old bundled `POLYFILL_INTL`
278
+ # flag. `:polyfill_intl_datetimeformat_all` is the whole DateTimeFormat chain
279
+ # (getcanonicallocales locale pluralrules numberformat datetimeformat)
280
+ # as ONE registration the only chain bridge.js needs, and cheaper than
281
+ # requiring the five links separately.
282
+ #
283
+ # PERF (rule 3): the polyfill source is eval'd per VM, and the VM-pool pre-warm
284
+ # used to be GVL-serial, which put every feature on the critical path. quickjs
285
+ # now releases the GVL while loading polyfills, so the pre-warm overlaps.
286
+ INTL_FEATURES = [:polyfill_intl_datetimeformat_all].freeze
241
287
  #
242
288
  # `max_stack_size: 0` — `JS_SetMaxStackSize` measures C stack
243
289
  # delta from runtime construction; Ruby callers reach QuickJS
@@ -352,11 +398,22 @@ module Capybara
352
398
  # host fns attached *after* the replay (so snapshot_stubs.js's
353
399
  # no-ops don't overwrite real ones), `__csim_isWorker` set, +
354
400
  # the per-worker postMessage routed through `post_back`.
355
- def self.build_worker(browser, post_back)
401
+ def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {})
356
402
  vm = Quickjs::VM.new(**VM_OPTIONS)
357
403
  bridge_runnable.run(on: vm)
358
404
  attach_host_fns(vm, browser)
359
405
  vm.define_function('__csim_workerPostMessage') {|data| post_back.call(data); nil }
406
+ # A worker's BroadcastChannel fan-out routes through the thread-safe outbox, not
407
+ # `browser.broadcast_to_windows` (cross-thread inbox mutation). See v8_runtime#build_worker.
408
+ vm.define_function('__csimBroadcast') {|name, data, _rid, origin| broadcast_out&.call(name, data, origin); nil } if broadcast_out
409
+ # Service-worker → main-thread signals via the outbox (see v8_runtime#build_worker).
410
+ vm.define_function('__csim_swPostToClient') {|client_id, data| sw_hooks[:post_to_client]&.call(client_id, data); nil } if sw_hooks[:post_to_client]
411
+ vm.define_function('__csim_swFocusClient') {|client_id| sw_hooks[:focus_client]&.call(client_id); nil } if sw_hooks[:focus_client]
412
+ vm.define_function('__csim_swClaim') { sw_hooks[:claim]&.call; nil } if sw_hooks[:claim]
413
+ vm.define_function('__csim_swFetchRespond') {|fetch_id, resp, realm_id| sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil } if sw_hooks[:fetch_respond]
414
+ vm.define_function('__csim_swFetchStream') {|fetch_id, kind, payload, realm_id| sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil } if sw_hooks[:fetch_stream]
415
+ vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post]
416
+ vm.define_function('__csim_workerPortEndpoint') {|channel| sw_hooks[:port_endpoint]&.call(channel); nil } if sw_hooks[:port_endpoint]
360
417
  # Override main's __setTimersActive so worker's empty-timer-map
361
418
  # flip doesn't race main's `polling?` gate. See v8_runtime's
362
419
  # build_worker for the long-form rationale.