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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +7 -0
- data/lib/dommy/js/quickjs/backend.rb +100 -93
- data/lib/dommy/js/quickjs/browser_environment.rb +93 -0
- data/lib/dommy/js/quickjs/capybara.rb +101 -19
- data/lib/dommy/js/quickjs/config.rb +62 -0
- data/lib/dommy/js/quickjs/error_translator.rb +137 -0
- data/lib/dommy/js/quickjs/event_loop.rb +149 -0
- data/lib/dommy/js/quickjs/js/browser_globals.js +36 -0
- data/lib/dommy/js/quickjs/js/error_rebuild.js +22 -0
- data/lib/dommy/js/quickjs/js/intl_polyfill.js +56 -0
- data/lib/dommy/js/quickjs/js/timer_globals.js +46 -0
- data/lib/dommy/js/quickjs/js/wasm_stub.js +26 -0
- data/lib/dommy/js/quickjs/js/window_builtins.js +14 -0
- data/lib/dommy/js/quickjs/runtime.rb +243 -442
- data/lib/dommy/js/quickjs/script_cache.rb +4 -1
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +14 -8
- data/lib/dommy/js/quickjs.rb +5 -1
- metadata +22 -13
- data/.github/workflows/test.yml +0 -28
- data/Rakefile +0 -117
- data/docs/bridge-redesign.md +0 -559
- data/docs/wpt-conformance.md +0 -752
- data/lib/dommy/js/quickjs/source_guard.rb +0 -304
- data/script/build_jsx_transform.sh +0 -27
- data/script/build_stimulus_tests.sh +0 -52
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56e600a474810ea778587cf249cbb7c200d9947c28270449837550971e5674de
|
|
4
|
+
data.tar.gz: ff9e6cf37ed37fb89f148163e38ee6fdd63e8bd5fc1e81669f8538eebc84d3ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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, *
|
|
27
|
-
dommy_js_host
|
|
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, *
|
|
32
|
-
|
|
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
|
-
#
|
|
36
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
64
|
-
# Window have no Capybara
|
|
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
|
|
138
|
+
return nil if js_undefined?(value)
|
|
68
139
|
|
|
69
140
|
case value
|
|
70
|
-
when Array
|
|
71
|
-
|
|
72
|
-
|
|
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.
|