dommy-js-quickjs 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 582867f4a71527529224d5ecf3348461ff69e4b76518f5c8f0f9dc85c5704270
4
- data.tar.gz: b183f4631ea255d7844b8b2f20cc68214863d3639eaa559e3cdc3f44e1ec04a0
3
+ metadata.gz: 56e600a474810ea778587cf249cbb7c200d9947c28270449837550971e5674de
4
+ data.tar.gz: ff9e6cf37ed37fb89f148163e38ee6fdd63e8bd5fc1e81669f8538eebc84d3ea
5
5
  SHA512:
6
- metadata.gz: 0b185321865a0e45d9b880bbc8e25ef7936c8fb570e0b9a1a60ea21bd70af68efc8dfa5dd1f82a4d8214a237f8174eb1d6a5997a67db82a5bd6d92e8e2df7a70
7
- data.tar.gz: 3fe107306181b587a96a1dac23bcd73f08f2f9484c9f61a105449508d6a9438cf2dfa76f8bdd8c997f5b511784c45e09e0dca7ab3546b28985b86118a7e26f4c
6
+ metadata.gz: ffa632c98fe6c8bedba9bc8e189cda13a1c9baef1c4bd0224fa1f7f9ffd31bdcaad8bcb84ffdbdf2b2436ec3a29bb68d0d8ceaa7879602f5cc45d8edbc1633ba
7
+ data.tar.gz: 22272e499d364204ecba6edb91cb6c01953d72d46990652b3f14907e2141686e8bbc8d94de3d5125e2f96b21e9e1a34b8d5c887c41af3dbe8cd693e33a2370bd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-10-01
4
+
5
+ Requires `dommy >= 0.14.0, < 0.15` and `quickjs ~> 0.21.0`.
6
+
7
+ ### Added
8
+
9
+ - Rejections are handed to a JS hook (`promise_rejection_hook=`) when the engine has one, so the promise and the reason cross as the values the page threw rather than an exception the engine already converted. That is what makes `event.reason`, `event.promise` and `rejectionhandled` work at all. `on_unhandled_rejection` goes quiet while the hook is installed, since both would report the same rejection.
10
+ - `Runtime#rebuild_error` builds a real `Error` inside the realm from an exception QuickJS raised for a script's throw, so `event.error` reaches the page with the name, message and stack it threw instead of an object with no properties. The engine discards the JS value when it converts the throw, so the rebuilt error matches on everything observable except identity.
11
+ - `rake capybara:compliance` runs Capybara's driver compliance suite against capybara-dommy with JavaScript on (`spec/capybara_compliance_spec.rb`). It is not part of `rake test`: it still fails in places (120 of 1413 examples today — asynchronous alerts, waiting on `current_path`, `attach_file`'s change events).
12
+
13
+ ### Changed
14
+
15
+ - Requires `dommy >= 0.14.0, < 0.15` (was `>= 0.9.0`). 0.14.0 moved the wire tags from `Dommy::Js` to `Dommy::Bridge`, which the wasm bridge now reads, and 0.13.0 is the release carrying the JS error-report API this gem's suite drives (`Window#__internal_on_unhandled_error__`, `Browser#error_log`, `Dommy::JsError`). The upper bound is new: dommy's 0.x minors change the bridge contract, as 0.14.0 did — 0.9.0, which had none, breaks against it.
16
+ - Requires `quickjs ~> 0.21.0` (was `~> 0.18.0`). Until both fixes are released upstream, the Gemfile pins [takahashim/quickjs.rb#feat/rejection-js-hook](https://github.com/takahashim/quickjs.rb/tree/feat/rejection-js-hook). It carries the JS rejection hook above, and sits on upstream's main with the checkpoint-timing fix ([hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141), merged but not yet released) that reports an unhandled rejection at the end of the microtask checkpoint as HTML requires, rather than the moment a promise rejects. Without the latter, `Promise.reject(x).catch(...)` and `try { await rejecting() } catch {}` are both misreported as unhandled — correct code that a strict host then fails a test on. CI also runs the suite on the released 0.21.0 (`gemfiles/quickjs-released.gemfile`), which is what a RubyGems install gets.
17
+ - `SourceGuard` is gone. It rewrote a `for...of` whose iterable contains a `yield` and retried, working around a QuickJS codegen bug ("stack underflow") that broke real SPA bundles. The QuickJS that 0.21 vendors compiles the construct, and the gemspec requires `~> 0.21.0`, so nothing could reach the retry — and nothing could test it either. Revert the removal if the bug ever comes back.
18
+ - The gem's per-crossing `Timeout.timeout` is skipped by prepending to `::Quickjs`'s singleton rather than redefining `_with_timeout` outright, so the original stays reachable and the patch is visible in `ancestors`.
19
+ - The last JavaScript built as a Ruby string moved into `js/error_rebuild.js`: rebuilding a thrown Error in the realm now calls a helper installed once per VM instead of evaluating freshly interpolated source on every reported error.
20
+ - Every Backend entry point that runs something in the VM is guarded against a poisoned one, not just three of the six. After an out-of-memory an inline script quietly did nothing while an external (bytecode) script and `#evaluate` raised "VM is poisoned" from a different place each time; all of them now no-op, which is the "browsing never crashes" contract the guarded three already followed.
21
+ - `DOMMY_JS_MEMORY_LIMIT_MB` sets the VM's memory ceiling, as `DOMMY_JS_TIMEOUT_MSEC` does the per-eval timeout. Lowering it is how the out-of-memory path gets exercised deliberately.
22
+ - The realm's JavaScript (the Intl polyfill, the WebAssembly stub, the aliased browser globals, the mirrored built-ins, the timer instrumentation) moved out of Ruby heredocs into `.js` files under `lib/dommy/js/quickjs/js/`, installed by a new `BrowserEnvironment` through `Backend#run_bundle` — so each payload is compiled once per process rather than reparsed for every VM.
23
+ - `Runtime` delegates to `EventLoop` (the microtask queue and Dommy's scheduler as one loop, with the three drivers sharing a pump), `ErrorTranslator` (an engine exception as what the page should see) and `Config` (every `DOMMY_JS_*` variable). Its public surface is now the Runtime port plus what hosts call; `evaluate_settled`, `bump_dom_epoch` and the other internals are private.
24
+ - `Runtime#load_module` and `Backend#import_module` are gone, unused: an inline `<script type="module">` reaches the engine through `load_module_url`.
25
+ - `install_wasm_memory_shim` installs the same WebAssembly stub as `install_browser_globals` rather than a second, near-identical one.
26
+ - The vendored WPT tree is pinned to a single upstream revision (`test/fixtures/wpt/UPSTREAM_REVISION`) and refreshed with `script/vendor_wpt.sh`, rather than growing file by file from whatever upstream was that day. Refreshing to `2f7c700` moved 24 files; `url-constructor.any.js` is green again, since upstream now expects an undecodable A-label like `https://xn--/` to parse.
27
+ - The gem ships `lib/`, `sig/`, the README, the changelog and the license, and nothing else. It used to take everything git tracks bar a few paths, which by now meant the maintainers' design notes, the WPT comparison scripts, the Capybara compliance spec and the CI workflow.
28
+
29
+ ### Fixed
30
+
31
+ - On the released quickjs (0.21.0), a promise rejection a handler catches no longer fails a strict `Dommy::Browser`. That engine reports a rejection the moment it happens, before a `.catch` or `try { await … } catch` attached later in the same microtask checkpoint can handle it, so correct code came out as an unhandled rejection. When the engine behaves that way — probed once per process, since a fixed engine still says 0.21.0 — `on_unhandled_rejection` reports nothing and says so once with a warning; a rejection the page truly leaves unhandled then goes unreported, to the host and to the page alike — `unhandledrejection` and `window.onunhandledrejection` do not fire. A quickjs with [hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141) reports them as before.
32
+ - A classic script whose completion value is a pending Promise — `window.p = new Promise(...)` as its last statement, the idiom for publishing one a later script awaits — no longer reaches the page as an uncaught error. The gem refuses to convert such a value, and the resulting host exception was reported at the window; a testharness page that saw it reported no results at all. `#load_script` and `#load_script_cached` now discard the completion value, which `#execute` already did by wrapping in an IIFE.
33
+ - `Runtime#rebuild_error` no longer copies a host exception's Ruby backtrace into the rebuilt Error's `stack`, which published this gem's file paths to any page that reads it. Engine frames are kept, everything else dropped, and a backtrace with no JS frames leaves the stack empty rather than naming a place the page did not fail.
34
+
3
35
  ## 0.9.0 — 2026-06-22
4
36
 
5
37
  The first substantial release since `0.1.0`. The version jumps to `0.9.0` to
data/README.md CHANGED
@@ -110,6 +110,13 @@ The pieces it relies on are public Runtime API:
110
110
  - `Runtime#on_unhandled_rejection { |err| }` — surface promise rejections that
111
111
  reach the microtask queue with no handler. Frameworks swallow these; `err.backtrace`
112
112
  carries the JS stack, which is the difference between blind and one-shot debugging.
113
+ The released quickjs (0.21.0) reports a rejection before a handler attached in
114
+ the same microtask checkpoint can catch it, so `.catch` and `try { await … }
115
+ catch` would look unhandled; on such an engine nothing is reported here (a
116
+ warning says so once), so a strict `Dommy::Browser` does not fail on
117
+ rejections and the page's `unhandledrejection` event (and
118
+ `window.onunhandledrejection`) never fires. A quickjs with
119
+ [hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141) reports them.
113
120
  - `Runtime#on_log { |log| }` — observe `console.*` (`log.severity` / `log.to_s`).
114
121
 
115
122
  ### Capybara
@@ -1,31 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "quickjs"
4
- require_relative "source_guard"
5
-
6
- # Performance: the quickjs gem wraps EVERY host-function call — every JS->Ruby
7
- # DOM crossing (__rb_host_get / _call / _set, …) — in `Timeout.timeout` to bound
8
- # a runaway Ruby callback. That costs ~4us per crossing (≈40% of a DOM property
9
- # read: 9.8us -> 5.1us with it removed), and a DOM-heavy SPA (React/Apollo) makes
10
- # MILLIONS of crossings while hydrating — tens of seconds of pure Timeout
11
- # overhead, the dominant cost behind a slow page.
12
- #
13
- # It is redundant here: QuickJS's own C interrupt handler still force-aborts a
14
- # runaway JS execution at the eval timeout (that mechanism is independent of this
15
- # Ruby wrapper), and Dommy's host functions are bounded DOM operations that never
16
- # hang. So skip the per-crossing Ruby Timeout. Set DOMMY_JS_CROSSING_TIMEOUT=1 to
17
- # keep the gem's original behavior (re-enabling the Ruby-callback-hang guard).
18
- if ::Quickjs.respond_to?(:_with_timeout) && ENV["DOMMY_JS_CROSSING_TIMEOUT"].to_s.empty?
19
- module Quickjs
20
- def self._with_timeout(_msec, proc, args)
21
- proc.call(*args)
22
- end
23
- end
24
- end
25
4
 
26
5
  module Dommy
27
6
  module Js
28
7
  module Quickjs
8
+ # Skips the per-crossing Ruby `Timeout.timeout` the quickjs gem wraps EVERY
9
+ # host-function call in — every JS->Ruby DOM crossing (__rb_host_get /
10
+ # _call / _set, …). That costs ~4us per crossing (≈40% of a DOM property
11
+ # read: 9.8us -> 5.1us without it), and a DOM-heavy SPA (React/Apollo)
12
+ # makes MILLIONS of them while hydrating — tens of seconds of pure Timeout
13
+ # overhead, the dominant cost behind a slow page.
14
+ #
15
+ # It is redundant here: QuickJS's own C interrupt handler still
16
+ # force-aborts a runaway JS execution at the eval timeout (independent of
17
+ # this Ruby wrapper), and Dommy's host functions are bounded DOM
18
+ # operations that never hang. DOMMY_JS_CROSSING_TIMEOUT=1 keeps the gem's
19
+ # behavior, re-enabling the Ruby-callback-hang guard.
20
+ #
21
+ # Prepended to `::Quickjs`'s singleton rather than redefining the method:
22
+ # the gem's own implementation stays in the ancestor chain (reachable with
23
+ # `super`, visible in `::Quickjs.singleton_class.ancestors`), so the patch
24
+ # can be seen and undone. Redefining it outright left no way to tell the
25
+ # method had been replaced.
26
+ module SkipCrossingTimeout
27
+ def _with_timeout(_msec, proc, args)
28
+ proc.call(*args)
29
+ end
30
+ end
31
+
32
+ if ::Quickjs.respond_to?(:_with_timeout) && !Config.crossing_timeout?
33
+ ::Quickjs.singleton_class.prepend(SkipCrossingTimeout)
34
+ end
35
+
29
36
  # Binds HostBridge's abstract backend contract to the `quickjs` gem.
30
37
  #
31
38
  # Value-representation conformance: host_runtime.js now tags a top-level JS
@@ -38,31 +45,44 @@ module Dommy
38
45
  # the top-level tag); either way it maps to Dommy::Bridge::UNDEFINED. No
39
46
  # normalization is needed here.
40
47
  class Backend
41
- # The gem's default eval timeout is 100ms, which interrupts large
42
- # synchronous bridge loops (every property crossing is a Ruby call).
43
- DEFAULT_TIMEOUT_MSEC = 60_000
44
-
45
- # The per-eval timeout, in ms. A single JS eval — a script, or one
46
- # timer/rAF callback — is force-aborted (Quickjs::InterruptedError, caught
47
- # and logged) after this long. It is ALSO the ceiling on how long QuickJS
48
- # holds the thread in C: while it runs, a Ctrl-C (delivered as a deferred
49
- # SIGINT) can't be serviced, so an interactive host (dommynx) sets a lower
50
- # value via DOMMY_JS_TIMEOUT_MSEC so a heavy/runaway burst can't freeze the
51
- # UI for the full library default.
52
- def self.default_timeout_msec
53
- env = ENV["DOMMY_JS_TIMEOUT_MSEC"].to_i
54
- env.positive? ? env : DEFAULT_TIMEOUT_MSEC
55
- end
56
-
57
- # The gem's default memory ceiling is 128 MB. A real-site SPA (note.com's
58
- # Apollo/React bundle, hydration, the whole DOM mirrored as host proxies)
59
- # blows past that and the VM hits out-of-memory, which poisons it. Give a
60
- # browser-grade VM more headroom so heavy pages actually finish rendering;
61
- # the OOM is also now survivable (see #poisoned?), not a crash.
62
- DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024
63
-
48
+ # Whether this quickjs reports a promise rejection the moment it
49
+ # happens, before a handler attached later in the same microtask
50
+ # checkpoint gets the chance — the released quickjs (0.21.0) does, so
51
+ # `Promise.reject(x).catch(f)` and `try { await p } catch {}` both
52
+ # come out as unhandled. HTML reports at the end of the checkpoint
53
+ # (hmsk/quickjs.rb#141, merged upstream but unreleased).
54
+ #
55
+ # Decided by behavior, not by version — a git checkout of the fixed
56
+ # engine still says 0.21.0 — on a throwaway VM, once per process.
57
+ def self.premature_rejection_reports?
58
+ return @premature_rejection_reports if defined?(@premature_rejection_reports)
59
+
60
+ @premature_rejection_reports = probe_premature_rejection_reports
61
+ end
62
+
63
+ def self.probe_premature_rejection_reports
64
+ vm = ::Quickjs::VM.new
65
+ reported = false
66
+ vm.on_unhandled_rejection { |_error| reported = true }
67
+ vm.eval_code(<<~JS, async: false)
68
+ Promise.reject(1).catch(function () {});
69
+ (async function () { try { await Promise.reject(2); } catch (e) {} })();
70
+ 0
71
+ JS
72
+ vm.drain_jobs!
73
+ reported
74
+ rescue ::StandardError
75
+ # A probe that cannot run says nothing either way; keep reporting.
76
+ false
77
+ ensure
78
+ vm&.dispose!
79
+ end
80
+ private_class_method :probe_premature_rejection_reports
81
+
82
+ # Timeout and memory ceiling both differ from the gem's defaults, for
83
+ # reasons Config documents.
64
84
  def initialize(**vm_opts)
65
- vm_opts = {timeout_msec: self.class.default_timeout_msec, memory_limit: DEFAULT_MEMORY_LIMIT}.merge(vm_opts)
85
+ vm_opts = {timeout_msec: Config.timeout_msec, memory_limit: Config.memory_limit}.merge(vm_opts)
66
86
  @vm = ::Quickjs::VM.new(**vm_opts)
67
87
  end
68
88
 
@@ -76,20 +96,15 @@ module Dommy
76
96
  (@vm.respond_to?(:disposed?) && @vm.disposed?)
77
97
  end
78
98
 
79
- def eval(js)
80
- return if poisoned?
81
-
82
- @vm.eval_code(js, async: false)
83
- rescue ::Quickjs::RuntimeError => e
84
- # A QuickJS codegen bug rejects `for-of` with a `yield` in the iterable
85
- # ("stack underflow") — rewrite that construct and retry once.
86
- raise unless SourceGuard.relevant_error?(e)
87
-
88
- guarded = SourceGuard.fix_for_of_yield(js)
89
- raise if guarded.equal?(js) || guarded == js
90
-
91
- @vm.eval_code(guarded, async: false)
92
- end
99
+ # Every entry point that RUNS something in the VM goes through #guarded,
100
+ # so a dead VM answers the same way whichever one a host reached for. It
101
+ # used to guard three of the six: an inline script quietly did nothing
102
+ # after an out-of-memory while an external (bytecode) one and #evaluate
103
+ # raised "VM is poisoned" from a different place each time.
104
+ #
105
+ # Registering a host function or a listener is not guarded: none of them
106
+ # runs JS, and they all happen while the VM is still alive.
107
+ def eval(js) = guarded { @vm.eval_code(js, async: false) }
93
108
 
94
109
  # Compile JS source to reusable bytecode (parsed once, via a throwaway
95
110
  # VM). Run it on any number of fresh VMs with #run_compiled — far cheaper
@@ -97,14 +112,6 @@ module Dommy
97
112
  # bundles are identical across VMs).
98
113
  def self.compile(source, filename: "<compiled>")
99
114
  ::Quickjs.compile(source, filename: filename)
100
- rescue ::Quickjs::RuntimeError => e
101
- # See #eval: work around the for-of/yield-in-iterable codegen bug.
102
- raise unless SourceGuard.relevant_error?(e)
103
-
104
- guarded = SourceGuard.fix_for_of_yield(source)
105
- raise if guarded.equal?(source) || guarded == source
106
-
107
- ::Quickjs.compile(guarded, filename: filename)
108
115
  end
109
116
 
110
117
  # Process-global cache for the engine-internal runtime bundles run via
@@ -120,9 +127,7 @@ module Dommy
120
127
 
121
128
  # Execute precompiled bytecode (a Quickjs::Runnable) on this VM in global
122
129
  # scope — equivalent to #eval of its source, without the parse cost.
123
- def run_compiled(runnable)
124
- runnable.run(on: @vm)
125
- end
130
+ def run_compiled(runnable) = guarded { runnable.run(on: @vm) }
126
131
 
127
132
  # Run a source bundle that is identical across VMs (the bridge's host
128
133
  # runtime, the Observable polyfill): compile it to bytecode once per
@@ -135,9 +140,7 @@ module Dommy
135
140
 
136
141
  # Async eval: the gem awaits the top-level result and drains the
137
142
  # microtask queue, so JS `await`/Promises resolve before returning.
138
- def eval_awaited(js)
139
- @vm.eval_code(js, async: true)
140
- end
143
+ def eval_awaited(js) = guarded { @vm.eval_code(js, async: true) }
141
144
 
142
145
  # Install the ESM module resolver: a callable `(specifier, importer) ->
143
146
  # source String | { code:, as: } | nil` the engine consults for every
@@ -146,34 +149,20 @@ module Dommy
146
149
  @vm.module_loader = callable
147
150
  end
148
151
 
149
- # Evaluate `source` as an ES module (its `import`s resolved through the
150
- # module loader). `* as` with no globalization runs it for side effects.
151
- def import_module(source)
152
- @vm.import("* as __dommy_mod", from: source, code_to_expose: "")
153
- end
154
-
155
152
  # Evaluate the module at `url` (resolved + fetched by the module loader).
156
153
  # The importer of its relative imports is `url`, so they resolve
157
154
  # correctly — unlike an inline module's synthetic filename.
158
155
  def import_module_url(url)
159
- @vm.import("* as __dommy_mod", filename: url, code_to_expose: "")
156
+ guarded { @vm.import("* as __dommy_mod", filename: url, code_to_expose: "") }
160
157
  end
161
158
 
162
159
  def define_host_function(name, &block)
163
160
  @vm.define_function(name, &block)
164
161
  end
165
162
 
166
- def call_js(path, *args)
167
- return if poisoned?
168
-
169
- @vm.call(path, *args)
170
- end
171
-
172
- def drain_microtasks
173
- return if poisoned?
163
+ def call_js(path, *args) = guarded { @vm.call(path, *args) }
174
164
 
175
- @vm.drain_jobs!
176
- end
165
+ def drain_microtasks = guarded { @vm.drain_jobs! }
177
166
 
178
167
  # Register a handler for promise rejections that reach the microtask
179
168
  # queue with no `.catch` — frameworks (Turbo, …) often swallow these,
@@ -182,19 +171,37 @@ module Dommy
182
171
  @vm.on_unhandled_rejection(&block)
183
172
  end
184
173
 
174
+ # Point the engine's promise-rejection hook at a JS function, named by an
175
+ # expression evaluated once in global scope. Only defined when the engine
176
+ # supports it, so callers can feature-detect with respond_to?.
177
+ if ::Quickjs::VM.method_defined?(:promise_rejection_hook=)
178
+ def promise_rejection_hook=(expression)
179
+ @vm.promise_rejection_hook = expression
180
+ end
181
+ end
182
+
185
183
  # Register a handler for console.(log|info|debug|warn|error). The block
186
184
  # receives a log object (#severity / #to_s / #raw).
187
185
  def on_log(&block)
188
186
  @vm.on_log(&block)
189
187
  end
190
188
 
191
- def run_gc
192
- @vm.gc!
193
- end
189
+ def run_gc = guarded { @vm.gc! }
194
190
 
195
191
  def dispose
196
192
  @vm.dispose!
197
193
  end
194
+
195
+ private
196
+
197
+ # Run `block` unless the VM is dead, in which case the answer is nil —
198
+ # the "browsing never crashes" contract: the page's JS has stopped, and
199
+ # the host has already been told once (see EventLoop#note_halted).
200
+ def guarded
201
+ return if poisoned?
202
+
203
+ yield
204
+ end
198
205
  end
199
206
  end
200
207
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Js
5
+ module Quickjs
6
+ # What this QuickJS build is missing before a page can run in it: the bare
7
+ # browser globals aliased onto the window, the timer globals routed into
8
+ # Dommy's scheduler, and stand-ins for the two things the build omits
9
+ # (Intl, WebAssembly).
10
+ #
11
+ # Separate from Runtime because it changes for its own reasons — a page
12
+ # reaching for a global nobody aliased yet — which has nothing to do with
13
+ # how the event loop is driven or how an engine exception becomes the
14
+ # page's error.
15
+ #
16
+ # Each payload is a .js file next door rather than a Ruby heredoc, the way
17
+ # the core bridge keeps host_runtime.js: it stays readable as JavaScript,
18
+ # and #run_bundle compiles it to bytecode ONCE per process instead of
19
+ # reparsing it for every VM.
20
+ class BrowserEnvironment
21
+ JS_DIR = ::File.join(__dir__, "js")
22
+ private_constant :JS_DIR
23
+
24
+ # Read at load time and frozen: the files never change at runtime, and a
25
+ # per-VM read would undo the point of caching the compile.
26
+ def self.payload(name)
27
+ ::File.read(::File.join(JS_DIR, name)).freeze
28
+ end
29
+ private_class_method :payload
30
+
31
+ TIMER_GLOBALS_JS = payload("timer_globals.js")
32
+ BROWSER_GLOBALS_JS = payload("browser_globals.js")
33
+ WINDOW_BUILTINS_JS = payload("window_builtins.js")
34
+ INTL_POLYFILL_JS = payload("intl_polyfill.js")
35
+ WASM_STUB_JS = payload("wasm_stub.js")
36
+ ERROR_REBUILD_JS = payload("error_rebuild.js")
37
+
38
+ # Nothing outside installs a payload by hand; the methods below are the
39
+ # way in.
40
+ private_constant :TIMER_GLOBALS_JS, :BROWSER_GLOBALS_JS, :WINDOW_BUILTINS_JS,
41
+ :INTL_POLYFILL_JS, :WASM_STUB_JS, :ERROR_REBUILD_JS
42
+
43
+ def initialize(backend)
44
+ @backend = backend
45
+ end
46
+
47
+ # The helper ErrorTranslator calls to rebuild a thrown Error inside the
48
+ # realm. Installed with the runtime rather than with the page globals:
49
+ # an engine exception can reach a host that never asked for a browser.
50
+ def install_error_rebuilder
51
+ run("error_rebuild.js", ERROR_REBUILD_JS)
52
+ end
53
+
54
+ # setTimeout / setInterval / requestAnimationFrame / queueMicrotask, as
55
+ # bare globals routed through the window (so they reach Dommy's
56
+ # deterministic scheduler), plus the scheduling-site capture a throwing
57
+ # callback is traced back through. Needs the window installed first.
58
+ def install_timers
59
+ run("timer_globals.js", TIMER_GLOBALS_JS)
60
+ end
61
+
62
+ # Everything a real frontend bundle expects to find on its own: the
63
+ # aliased window globals, then the two polyfills, then the built-ins
64
+ # mirrored onto the window. Order matters only in that the window must
65
+ # already be installed.
66
+ def install_globals
67
+ run("browser_globals.js", BROWSER_GLOBALS_JS)
68
+ run("intl_polyfill.js", INTL_POLYFILL_JS)
69
+ install_wasm_stub
70
+ run("window_builtins.js", WINDOW_BUILTINS_JS)
71
+ self
72
+ end
73
+
74
+ # The WebAssembly stand-in on its own. Idempotent: the payload defines
75
+ # nothing when `WebAssembly` already exists, so a host that asks for it
76
+ # explicitly (the WPT harness wants the SharedArrayBuffer that
77
+ # `Memory({shared:true})` yields) gets the same object install_globals
78
+ # would have built.
79
+ def install_wasm_stub
80
+ run("wasm_stub.js", WASM_STUB_JS)
81
+ self
82
+ end
83
+
84
+ private
85
+
86
+ def run(cache_key, source)
87
+ @backend.run_bundle(cache_key, source)
88
+ self
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -7,6 +7,9 @@ require_relative "../quickjs"
7
7
  module Dommy
8
8
  module Js
9
9
  module Quickjs
10
+ # evaluate_async_script's callback was not called within the wait budget.
11
+ class ScriptTimeoutError < Error; end
12
+
10
13
  # Opt-in Capybara integration. Requiring this file enables JS execution on
11
14
  # Capybara::Dommy::Driver (via install_capybara! below), so execute_script /
12
15
  # evaluate_script run against the current Dommy document through a QuickJS
@@ -17,29 +20,92 @@ module Dommy
17
20
  # session and the Capybara polling loop, and wraps results as Capybara
18
21
  # nodes.
19
22
  module CapybaraDriver
23
+ # The in-realm slot an async script's callback reports to. Each call
24
+ # installs a fresh state object, so a late callback from an earlier call
25
+ # writes into its own, now-orphaned one.
26
+ ASYNC_SCRIPT_STATE = "__rbAsyncScriptState"
27
+
20
28
  def rack_session
21
29
  session = super
22
30
  dommy_js_attach(session)
23
31
  session
24
32
  end
25
33
 
26
- def execute_script(script, *_args)
27
- dommy_js_host.execute(script)
34
+ def execute_script(script, *args)
35
+ host = dommy_js_host
36
+ if args.empty?
37
+ host.execute(script)
38
+ else
39
+ host.execute_with_args(script, capybara_script_args(args))
40
+ end
28
41
  nil
29
42
  end
30
43
 
31
- def evaluate_script(script, *_args)
32
- decode_for_capybara(dommy_js_host.evaluate(script))
44
+ def evaluate_script(script, *args)
45
+ host = dommy_js_host
46
+ value = args.empty? ? host.evaluate(script) : host.evaluate_with_args(script, capybara_script_args(args))
47
+ decode_for_capybara(value)
33
48
  end
34
49
 
35
- # No real async loop; evaluate synchronously. Sufficient for scripts
36
- # that resolve immediately (the common Capybara case).
50
+ # WebDriver's executeAsyncScript: the script gets a callback as its last
51
+ # argument, and its result is whatever that callback is called with.
52
+ # Virtual time is pumped until it is, for at most
53
+ # Capybara.default_max_wait_time of VIRTUAL time — so a timer the script
54
+ # waits on fires, while the budget stays deterministic.
37
55
  def evaluate_async_script(script, *args)
38
- evaluate_script(script, *args)
56
+ host = dommy_js_host
57
+ host.execute_with_args(<<~JS, capybara_script_args(args))
58
+ var state = { settled: false, value: undefined };
59
+ globalThis.#{ASYNC_SCRIPT_STATE} = state;
60
+ var args = Array.prototype.slice.call(arguments);
61
+ args.push(function (value) {
62
+ if (state.settled) return;
63
+ state.settled = true;
64
+ state.value = value;
65
+ });
66
+ (function () {
67
+ #{script}
68
+ }).apply(this, args);
69
+ JS
70
+ await_async_script(host)
71
+ decode_for_capybara(host.evaluate("globalThis.#{ASYNC_SCRIPT_STATE}.value"))
39
72
  end
40
73
 
41
74
  private
42
75
 
76
+ def await_async_script(host)
77
+ budget_ms = ::Capybara.default_max_wait_time * 1000
78
+ pumps = (budget_ms / ::Dommy::Rack::SessionRuntime::PUMP_SLICE_MS).ceil
79
+ settled = "(globalThis.#{ASYNC_SCRIPT_STATE} || {}).settled === true"
80
+ pumps.times do
81
+ return if host.evaluate(settled)
82
+
83
+ host.pump
84
+ end
85
+ return if host.evaluate(settled)
86
+
87
+ raise ScriptTimeoutError,
88
+ "evaluate_async_script did not call its callback within #{budget_ms.round}ms of virtual time"
89
+ end
90
+
91
+ # A Capybara node argument becomes the Dommy element it wraps (so it
92
+ # crosses to JS as a proxy), at any depth inside arrays and hashes.
93
+ # Capybara::Session unwraps only top-level elements, so a nested
94
+ # Capybara::Node::Element is unwrapped here too.
95
+ def capybara_script_args(args)
96
+ args.map { |arg| capybara_script_arg(arg) }
97
+ end
98
+
99
+ def capybara_script_arg(arg)
100
+ case arg
101
+ when ::Capybara::Node::Element then capybara_script_arg(arg.base)
102
+ when ::Capybara::Dommy::Node then arg.native
103
+ when Array then capybara_script_args(arg)
104
+ when Hash then arg.transform_values { |value| capybara_script_arg(value) }
105
+ else arg
106
+ end
107
+ end
108
+
43
109
  # Bind a SessionRuntime to the session (rebuilt when reset!/app_host
44
110
  # swaps the session). The driver's frame-aware `document` is the realm
45
111
  # target, and Capybara's retry loop pumps virtual time via time_pump.
@@ -51,8 +117,12 @@ module Dommy
51
117
  self.time_pump = -> { @dommy_js_host.pump }
52
118
  end
53
119
 
120
+ # The JS host bound to the CURRENT session. Reading `rack_session` is what
121
+ # (re)binds one, because the attach lives in that override — so this is a
122
+ # fetch with a deliberate side effect, not a plain reader. Capybara swaps
123
+ # the session on reset! / app_host, and the host has to follow.
54
124
  def dommy_js_host
55
- rack_session # ensures the host is attached for the current session
125
+ rack_session
56
126
  @dommy_js_host
57
127
  end
58
128
 
@@ -60,23 +130,35 @@ module Dommy
60
130
  # - Array -> recurse (so element collections wrap per item)
61
131
  # - JS undefined -> nil
62
132
  # - Dommy::Element -> Capybara::Dommy::Node (covers HTML/SVG subclasses)
63
- # - other bridge obj -> nil (Document/Text/Comment/Fragment/NodeList/
64
- # Window have no Capybara representation)
133
+ # - other host object -> nil (Document/Text/Comment/Fragment/NodeList/
134
+ # Window/Blob/FormData have no Capybara
135
+ # representation)
65
136
  # - primitive/Hash -> as-is
66
137
  def decode_for_capybara(value)
67
- return nil if value.equal?(::Dommy::Bridge::UNDEFINED)
138
+ return nil if js_undefined?(value)
68
139
 
69
140
  case value
70
- when Array
71
- value.map { |element| decode_for_capybara(element) }
72
- when ::Quickjs::Value::UNDEFINED
73
- nil
74
- when ::Dommy::Element
75
- ::Capybara::Dommy::Node.new(self, value)
76
- else
77
- value.respond_to?(:__js_get__) ? nil : value
141
+ when Array then value.map { |element| decode_for_capybara(element) }
142
+ when ::Dommy::Element then ::Capybara::Dommy::Node.new(self, value)
143
+ else host_object?(value) ? nil : value
78
144
  end
79
145
  end
146
+
147
+ # JS `undefined` arrives as either the engine-neutral sentinel the bridge
148
+ # decodes to, or — on the paths that skip that decode — the gem's own.
149
+ def js_undefined?(value)
150
+ value.equal?(::Dommy::Bridge::UNDEFINED) || value.equal?(::Quickjs::Value::UNDEFINED)
151
+ end
152
+
153
+ # Whether `value` is a Dommy object the page can read properties off
154
+ # (`__js_get__` is what the bridge calls to do that). Everything that
155
+ # answers to it is a DOM-side object, and the ones Capybara can represent
156
+ # were already taken by the branches above — so what is left has no
157
+ # representation and becomes nil rather than leaking a host object into a
158
+ # Capybara assertion.
159
+ def host_object?(value)
160
+ value.respond_to?(:__js_get__)
161
+ end
80
162
  end
81
163
 
82
164
  # Idempotently prepend JS-execution support onto Capybara::Dommy::Driver.