dommy-js-quickjs 0.1.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/.github/workflows/test.yml +28 -0
- data/CHANGELOG.md +84 -1
- data/README.md +31 -9
- data/Rakefile +70 -2
- data/lib/dommy/js/quickjs/backend.rb +138 -1
- data/lib/dommy/js/quickjs/capybara.rb +31 -17
- data/lib/dommy/js/quickjs/rack.rb +15 -0
- data/lib/dommy/js/quickjs/runtime.rb +450 -42
- data/lib/dommy/js/quickjs/script_cache.rb +37 -0
- data/lib/dommy/js/quickjs/source_guard.rb +304 -0
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +15 -15
- data/lib/dommy/js/quickjs.rb +13 -5
- data/script/build_jsx_transform.sh +27 -0
- data/script/build_stimulus_tests.sh +52 -0
- metadata +13 -14
- data/lib/dommy/js/constructor_registry.rb +0 -40
- data/lib/dommy/js/custom_elements.rb +0 -55
- data/lib/dommy/js/dom_interfaces.rb +0 -139
- data/lib/dommy/js/handle_table.rb +0 -52
- data/lib/dommy/js/host_bridge.rb +0 -400
- data/lib/dommy/js/host_runtime.js +0 -922
- data/lib/dommy/js/observable_runtime.js +0 -728
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 582867f4a71527529224d5ecf3348461ff69e4b76518f5c8f0f9dc85c5704270
|
|
4
|
+
data.tar.gz: b183f4631ea255d7844b8b2f20cc68214863d3639eaa559e3cdc3f44e1ec04a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b185321865a0e45d9b880bbc8e25ef7936c8fb570e0b9a1a60ea21bd70af68efc8dfa5dd1f82a4d8214a237f8174eb1d6a5997a67db82a5bd6d92e8e2df7a70
|
|
7
|
+
data.tar.gz: 3fe107306181b587a96a1dac23bcd73f08f2f9484c9f61a105449508d6a9438cf2dfa76f8bdd8c997f5b511784c45e09e0dca7ab3546b28985b86118a7e26f4c
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Ruby ${{ matrix.ruby }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v6
|
|
20
|
+
|
|
21
|
+
- name: Set up Ruby ${{ matrix.ruby }}
|
|
22
|
+
uses: ruby/setup-ruby@v1
|
|
23
|
+
with:
|
|
24
|
+
ruby-version: ${{ matrix.ruby }}
|
|
25
|
+
bundler-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: bundle exec rake
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — 2026-06-22
|
|
4
|
+
|
|
5
|
+
The first substantial release since `0.1.0`. The version jumps to `0.9.0` to
|
|
6
|
+
line up with the rest of the Dommy monorepo (`dommy`, `dommy-rack`,
|
|
7
|
+
`capybara-dommy`). The headline change is architectural: the engine-agnostic
|
|
8
|
+
host layer and bridge now live in `dommy` core, leaving this gem as the QuickJS
|
|
9
|
+
backend that plugs into it. On top of that foundation sits real ESM and
|
|
10
|
+
JavaScript-framework support, an event-loop-aware runtime, and a large WHATWG /
|
|
11
|
+
WPT conformance pass.
|
|
12
|
+
|
|
13
|
+
Requires `dommy >= 0.9.0` and `quickjs ~> 0.18.0`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
#### Browser & page lifecycle
|
|
18
|
+
- `Dommy::Browser`, a lightweight test browser that boots a page, runs its
|
|
19
|
+
scripts through a shared `ScriptBoot`, and exposes interaction verbs with a
|
|
20
|
+
conservative `settle` step
|
|
21
|
+
- `Browser.open` settles after boot by default (opt out / tune with the
|
|
22
|
+
`settle:` option)
|
|
23
|
+
- QuickJS is wired into Dommy page loads
|
|
24
|
+
- `SessionRuntime`, a JS host for the dommy-rack `Session`
|
|
25
|
+
|
|
26
|
+
#### ES Modules
|
|
27
|
+
- Full ESM support: `importmap`, the module loader, and `type=module` boot
|
|
28
|
+
- Inline modules' `import.meta.url` is pinned to the clean page URL
|
|
29
|
+
- External `<script src>` inserted into the DOM defers correctly rather than
|
|
30
|
+
running synchronously during append
|
|
31
|
+
|
|
32
|
+
#### JavaScript frameworks
|
|
33
|
+
- Host and conformance coverage for **Stimulus** (with a ported QUnit suite),
|
|
34
|
+
**React 18** (JSX, SSR, hydration), and **Vue 3** (global-scope script loading
|
|
35
|
+
with tolerant handles)
|
|
36
|
+
- Integration suites for **Alpine**, **htmx**, **Solid**, and **Lit**
|
|
37
|
+
|
|
38
|
+
#### Event loop, timers & promises
|
|
39
|
+
- `evaluate` / `await` are event-loop-aware and settle task-resolved results
|
|
40
|
+
- The scheduler's microtask-checkpoint hook is wired up so host-side microtasks
|
|
41
|
+
interleave with JS promises in FIFO order
|
|
42
|
+
- A throwing JS timer callback is isolated so it can no longer crash the host; a
|
|
43
|
+
runaway (force-killed) callback is recorded rather than fatal, and a throwing
|
|
44
|
+
callback is traced back to its scheduling site
|
|
45
|
+
- Ported the official **Promises/A+** suite against the host `PromiseValue`
|
|
46
|
+
|
|
47
|
+
#### Engine surface
|
|
48
|
+
- Polyfilled `Intl` and stubbed `WebAssembly` (the engine ships neither)
|
|
49
|
+
- More bare browser globals (`Image`, `Audio`, `Option`, `console`, `Object`, …)
|
|
50
|
+
are aliased onto the global scope as the native globals
|
|
51
|
+
- The per-eval timeout is configurable via `DOMMY_JS_TIMEOUT_MSEC`
|
|
52
|
+
- Bridge crossing counts are exposed; opaque unhandled rejections are enriched
|
|
53
|
+
|
|
54
|
+
#### WPT / WHATWG conformance
|
|
55
|
+
- A resource-driven WPT runner plus a large vendored corpus: DOM nodes /
|
|
56
|
+
traversal / ranges / collections, URL & URLSearchParams, Encoding, Selectors,
|
|
57
|
+
CSS (syntax, variables, color, CSSOM), WAI-ARIA roles, and accessible-name
|
|
58
|
+
(accname). The heavy thousands-of-subtests files are gated behind `WPT_HEAVY`.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **Pluggable runtime (breaking):** the engine-agnostic bridge and host layer
|
|
63
|
+
(`Browser` + the Js port) moved into `dommy` core; the JS runtime is now
|
|
64
|
+
pluggable and `Browser` is decoupled from QuickJS. This gem registers QuickJS
|
|
65
|
+
as a backend. Bridge wire-protocol tags are centralized and `JSValue` unified.
|
|
66
|
+
- **Bridge contract (breaking):** defensive `Dommy::Bridge` guards were dropped
|
|
67
|
+
in favor of requiring the backend contract; `dommy >= 0.9.0` is now required.
|
|
68
|
+
- Proxy identity and expando lifetime are preserved across crossings so reactive
|
|
69
|
+
frameworks observe stable object identity; a `NodeList` crosses as a `NodeList`
|
|
70
|
+
(not a plain array); `DOMException` subclasses cross as the single
|
|
71
|
+
`DOMException` interface (`constructor === DOMException`).
|
|
72
|
+
- Callback exceptions propagate across the bridge; `NodeFilter` objects cross as
|
|
73
|
+
live references.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- Survive a VM out-of-memory instead of crashing the browser
|
|
78
|
+
- Work around a QuickJS `for-of`-with-`yield`-in-iterable codegen bug
|
|
79
|
+
- Report a present-but-undefined IDL attribute via the `in` operator
|
|
80
|
+
- Scrub lone surrogates in dehydrated object keys
|
|
81
|
+
|
|
82
|
+
### Performance
|
|
83
|
+
|
|
84
|
+
- Skip the per-crossing Ruby `Timeout` (≈40% faster DOM crossings)
|
|
85
|
+
- Bytecode-cache the host runtime and external scripts
|
|
86
|
+
|
|
3
87
|
## 0.1.0
|
|
4
88
|
|
|
5
89
|
initial release.
|
|
6
|
-
|
data/README.md
CHANGED
|
@@ -7,15 +7,21 @@ JavaScript executes in an embedded QuickJS VM (via the
|
|
|
7
7
|
[`quickjs`](https://github.com/hmsk/quickjs.rb) gem). Dommy DOM nodes are bridged
|
|
8
8
|
to JS as objects whose property/method access routes into Dommy's
|
|
9
9
|
`__js_get__` / `__js_set__` / `__js_call__` / `__js_new__` ABI, so JS drives a
|
|
10
|
-
real Dommy document.
|
|
11
|
-
|
|
10
|
+
real Dommy document. This gem provides the QuickJS `Backend`; the
|
|
11
|
+
engine-agnostic bridge it plugs into (the `HostBridge` marshalling core and the
|
|
12
|
+
JS-side runtime) lives in the [`dommy`](https://github.com/takahashim/dommy) gem,
|
|
13
|
+
so other engines can reuse it.
|
|
12
14
|
|
|
13
15
|
The bridge presents a **spec-shaped JS DOM**, not bare proxies: `instanceof`,
|
|
14
16
|
prototype chains, `Object.prototype.toString` brands, constructable interfaces
|
|
15
17
|
(`new Event(...)`), custom elements (`class extends HTMLElement`), live
|
|
16
18
|
collections, and expandos all work — enough that the real
|
|
17
|
-
[`@hotwired/turbo`](https://github.com/hotwired/turbo)
|
|
18
|
-
|
|
19
|
+
[`@hotwired/turbo`](https://github.com/hotwired/turbo) and
|
|
20
|
+
[`@hotwired/stimulus`](https://github.com/hotwired/stimulus) bundles load and
|
|
21
|
+
drive the DOM (turbo-stream + turbo-frame; Stimulus controllers, targets,
|
|
22
|
+
values, classes, actions, and outlets — see
|
|
23
|
+
`test/dommy/js/test_turbo_integration.rb` and
|
|
24
|
+
`test/dommy/js/test_stimulus_integration.rb`).
|
|
19
25
|
|
|
20
26
|
## Installation
|
|
21
27
|
|
|
@@ -123,9 +129,9 @@ require "dommy/js/quickjs/capybara"
|
|
|
123
129
|
Selenium-style `done()` / real-time waits are not supported.
|
|
124
130
|
- **`fetch` is stub-based** via Dommy's `__fetchy_stub__` (a `{ url => entry }` map);
|
|
125
131
|
there is no real network.
|
|
126
|
-
- **Event listeners
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
- **Event listeners** — both forms work: a function (`addEventListener("...", fn)`,
|
|
133
|
+
closures intact) and the EventListener *object* form (`{ handleEvent }`, used by
|
|
134
|
+
Stimulus). `removeEventListener` detaches by the same function/object identity.
|
|
129
135
|
- **Expandos are scoped to elements**, and the JS callback table is not evicted, so
|
|
130
136
|
a very long-lived VM can grow unbounded.
|
|
131
137
|
- **DOM coverage is Dommy's.** A JS method/property works only where Dommy exposes
|
|
@@ -137,14 +143,30 @@ require "dommy/js/quickjs/capybara"
|
|
|
137
143
|
Run `bin/setup` to install dependencies, then `rake test`. `bin/console` opens an
|
|
138
144
|
interactive prompt.
|
|
139
145
|
|
|
140
|
-
The real-framework integration
|
|
141
|
-
|
|
146
|
+
The real-framework integration tests are skipped unless their bundle is
|
|
147
|
+
vendored under `test/fixtures/`:
|
|
142
148
|
|
|
143
149
|
```bash
|
|
144
150
|
curl -sL https://unpkg.com/@hotwired/turbo@8/dist/turbo.es2017-umd.js \
|
|
145
151
|
-o test/fixtures/turbo.umd.js
|
|
152
|
+
curl -sL https://unpkg.com/@hotwired/stimulus@3/dist/stimulus.umd.js \
|
|
153
|
+
-o test/fixtures/stimulus.umd.js
|
|
146
154
|
```
|
|
147
155
|
|
|
156
|
+
### Conformance suites
|
|
157
|
+
|
|
158
|
+
Two tasks run real third-party test corpora against the bridge and report a
|
|
159
|
+
pass rate — the lens that pins how faithfully the bridge hosts the platform:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
rake wpt:conformance[filter] # Web Platform Tests (vendored under test/fixtures/wpt)
|
|
163
|
+
rake stimulus:conformance[filter] # @hotwired/stimulus's own QUnit suite
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The Stimulus suite is vendored as a single bundle (`test/fixtures/
|
|
167
|
+
stimulus-tests.umd.js`) run through a small QUnit shim; each test runs in its
|
|
168
|
+
own fresh VM. Regenerate the bundle with `script/build_stimulus_tests.sh`.
|
|
169
|
+
|
|
148
170
|
## Contributing
|
|
149
171
|
|
|
150
172
|
Bug reports and pull requests are welcome at https://github.com/takahashim/dommy-js-quickjs.
|
data/Rakefile
CHANGED
|
@@ -3,9 +3,30 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "minitest/test_task"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# The OOM-resilience test forces a real QuickJS out-of-memory. Whether an OOM
|
|
7
|
+
# poisons the VM (vs. unwinding as a recoverable JS exception) depends on the
|
|
8
|
+
# allocator's state, which other JS-heavy tests in the same process perturb — so
|
|
9
|
+
# it runs in its own process to stay deterministic.
|
|
10
|
+
OOM_TEST = "test/dommy/js/test_oom_resilience.rb"
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
Minitest::TestTask.create(:test) do |t|
|
|
13
|
+
t.test_globs = FileList["test/**/test_*.rb"].exclude(OOM_TEST)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Minitest::TestTask.create(:test_oom) do |t|
|
|
17
|
+
t.test_globs = [OOM_TEST]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
namespace :test do
|
|
21
|
+
desc "Run the full test suite including the heavy (thousands-of-subtests) WPT files"
|
|
22
|
+
task :all do
|
|
23
|
+
ENV["WPT_HEAVY"] = "1"
|
|
24
|
+
Rake::Task["test"].invoke
|
|
25
|
+
Rake::Task["test_oom"].invoke
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
task default: %i[test test_oom]
|
|
9
30
|
|
|
10
31
|
namespace :wpt do
|
|
11
32
|
desc "Run the vendored WPT corpus against the bridge and report a conformance rate"
|
|
@@ -47,3 +68,50 @@ namespace :wpt do
|
|
|
47
68
|
"#{file_ok} files fully green#{blocked.empty? ? "" : ", #{blocked.size} errored"}"
|
|
48
69
|
end
|
|
49
70
|
end
|
|
71
|
+
|
|
72
|
+
namespace :stimulus do
|
|
73
|
+
desc "Run @hotwired/stimulus's QUnit suite against the bridge and report a conformance rate"
|
|
74
|
+
task :conformance, [:filter] do |_t, args|
|
|
75
|
+
$LOAD_PATH.unshift File.expand_path("test", __dir__)
|
|
76
|
+
require "test_helper"
|
|
77
|
+
require "support/stimulus_conformance"
|
|
78
|
+
|
|
79
|
+
runner = Dommy::Js::StimulusConformance
|
|
80
|
+
unless runner.available?
|
|
81
|
+
abort "Stimulus suite not vendored. Build it: script/build_stimulus_tests.sh"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
manifest = runner.manifest
|
|
85
|
+
manifest = manifest.select { |t| t["module"].match?(/#{args[:filter]}/i) } if args[:filter]
|
|
86
|
+
|
|
87
|
+
# Run each test in its own VM, grouping the printed output by module.
|
|
88
|
+
by_module = Hash.new { |h, k| h[k] = [] }
|
|
89
|
+
failures = []
|
|
90
|
+
manifest.each do |t|
|
|
91
|
+
r = runner.run_test(t["module"], t["name"])
|
|
92
|
+
by_module[t["module"]] << r
|
|
93
|
+
rescue => e
|
|
94
|
+
puts " ✗ #{t["module"]} :: #{t["name"]} (errored: #{e.class}: #{e.message[0, 70]})"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
total_pass = total_runnable = total = 0
|
|
98
|
+
by_module.each do |mod, results|
|
|
99
|
+
pass = results.count(&:pass?)
|
|
100
|
+
runnable = results.count(&:runnable?)
|
|
101
|
+
total_pass += pass
|
|
102
|
+
total_runnable += runnable
|
|
103
|
+
total += results.size
|
|
104
|
+
flag = runnable.positive? && pass == runnable ? "✓" : " "
|
|
105
|
+
printf(" %s %-42s %3d/%-3d\n", flag, mod, pass, runnable)
|
|
106
|
+
results.reject { |r| r.pass? || r.skip? || r.todo? }.each do |r|
|
|
107
|
+
failures << r
|
|
108
|
+
puts " #{r.to_s[0, 120]}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
pct = total_runnable.zero? ? 0 : (100.0 * total_pass / total_runnable).round(1)
|
|
113
|
+
puts
|
|
114
|
+
puts "Stimulus conformance: #{total_pass}/#{total_runnable} runnable tests (#{pct}%) " \
|
|
115
|
+
"across #{by_module.size} modules; #{total - total_runnable} skipped/todo, #{failures.size} failing"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -1,23 +1,136 @@
|
|
|
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
|
|
4
25
|
|
|
5
26
|
module Dommy
|
|
6
27
|
module Js
|
|
7
28
|
module Quickjs
|
|
8
29
|
# Binds HostBridge's abstract backend contract to the `quickjs` gem.
|
|
30
|
+
#
|
|
31
|
+
# Value-representation conformance: host_runtime.js now tags a top-level JS
|
|
32
|
+
# `undefined` itself (`dehydrateTop` -> `{__rb_undefined:true}`) at the
|
|
33
|
+
# JS->Ruby crossings, so the protocol no longer relies on the backend to
|
|
34
|
+
# marshal a bare `undefined` to a sentinel — keeping it engine-neutral.
|
|
35
|
+
# The `quickjs` gem happens to also deliver a bare `undefined` as the Ruby
|
|
36
|
+
# symbol `:undefined`, which HostBridge#unwrap still accepts as a defensive
|
|
37
|
+
# fallback (e.g. the `evaluate`/`tag` return path, which dehydrates without
|
|
38
|
+
# the top-level tag); either way it maps to Dommy::Bridge::UNDEFINED. No
|
|
39
|
+
# normalization is needed here.
|
|
9
40
|
class Backend
|
|
10
41
|
# The gem's default eval timeout is 100ms, which interrupts large
|
|
11
42
|
# synchronous bridge loops (every property crossing is a Ruby call).
|
|
12
43
|
DEFAULT_TIMEOUT_MSEC = 60_000
|
|
13
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
|
+
|
|
14
64
|
def initialize(**vm_opts)
|
|
15
|
-
vm_opts = {timeout_msec:
|
|
65
|
+
vm_opts = {timeout_msec: self.class.default_timeout_msec, memory_limit: DEFAULT_MEMORY_LIMIT}.merge(vm_opts)
|
|
16
66
|
@vm = ::Quickjs::VM.new(**vm_opts)
|
|
17
67
|
end
|
|
18
68
|
|
|
69
|
+
# True once the VM can no longer run JS safely: it hit out-of-memory (the
|
|
70
|
+
# gem flags it "poisoned" — further eval may segfault) or was disposed.
|
|
71
|
+
# Callers stop driving a poisoned VM (the page's JS is dead) instead of
|
|
72
|
+
# letting the error crash the whole browser — browsing survives a page
|
|
73
|
+
# whose JS ran out of memory, showing whatever rendered before it died.
|
|
74
|
+
def poisoned?
|
|
75
|
+
(@vm.respond_to?(:memory_poisoned?) && @vm.memory_poisoned?) ||
|
|
76
|
+
(@vm.respond_to?(:disposed?) && @vm.disposed?)
|
|
77
|
+
end
|
|
78
|
+
|
|
19
79
|
def eval(js)
|
|
80
|
+
return if poisoned?
|
|
81
|
+
|
|
20
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
|
|
93
|
+
|
|
94
|
+
# Compile JS source to reusable bytecode (parsed once, via a throwaway
|
|
95
|
+
# VM). Run it on any number of fresh VMs with #run_compiled — far cheaper
|
|
96
|
+
# than re-parsing the source per VM (the large host runtime / vendored
|
|
97
|
+
# bundles are identical across VMs).
|
|
98
|
+
def self.compile(source, filename: "<compiled>")
|
|
99
|
+
::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
|
+
end
|
|
109
|
+
|
|
110
|
+
# Process-global cache for the engine-internal runtime bundles run via
|
|
111
|
+
# #run_bundle (host_runtime.js, observable_runtime.js). Kept separate
|
|
112
|
+
# from ScriptCache (user-facing external scripts) so the two concerns
|
|
113
|
+
# don't share a count/namespace.
|
|
114
|
+
@bundle_cache = {}
|
|
115
|
+
@bundle_mutex = Mutex.new
|
|
116
|
+
|
|
117
|
+
def self.compiled_bundle(cache_key, source)
|
|
118
|
+
@bundle_mutex.synchronize { @bundle_cache[cache_key] ||= compile(source, filename: cache_key.to_s) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Execute precompiled bytecode (a Quickjs::Runnable) on this VM in global
|
|
122
|
+
# scope — equivalent to #eval of its source, without the parse cost.
|
|
123
|
+
def run_compiled(runnable)
|
|
124
|
+
runnable.run(on: @vm)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Run a source bundle that is identical across VMs (the bridge's host
|
|
128
|
+
# runtime, the Observable polyfill): compile it to bytecode once per
|
|
129
|
+
# process — keyed by `cache_key` — and run that on this VM. Lets the
|
|
130
|
+
# engine-agnostic bridge reuse big bundles without knowing about
|
|
131
|
+
# bytecode; the compile-once optimization stays here in the engine layer.
|
|
132
|
+
def run_bundle(cache_key, source)
|
|
133
|
+
run_compiled(self.class.compiled_bundle(cache_key, source))
|
|
21
134
|
end
|
|
22
135
|
|
|
23
136
|
# Async eval: the gem awaits the top-level result and drains the
|
|
@@ -26,15 +139,39 @@ module Dommy
|
|
|
26
139
|
@vm.eval_code(js, async: true)
|
|
27
140
|
end
|
|
28
141
|
|
|
142
|
+
# Install the ESM module resolver: a callable `(specifier, importer) ->
|
|
143
|
+
# source String | { code:, as: } | nil` the engine consults for every
|
|
144
|
+
# static/dynamic `import`. nil clears it (engine default loader).
|
|
145
|
+
def module_loader=(callable)
|
|
146
|
+
@vm.module_loader = callable
|
|
147
|
+
end
|
|
148
|
+
|
|
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
|
+
# Evaluate the module at `url` (resolved + fetched by the module loader).
|
|
156
|
+
# The importer of its relative imports is `url`, so they resolve
|
|
157
|
+
# correctly — unlike an inline module's synthetic filename.
|
|
158
|
+
def import_module_url(url)
|
|
159
|
+
@vm.import("* as __dommy_mod", filename: url, code_to_expose: "")
|
|
160
|
+
end
|
|
161
|
+
|
|
29
162
|
def define_host_function(name, &block)
|
|
30
163
|
@vm.define_function(name, &block)
|
|
31
164
|
end
|
|
32
165
|
|
|
33
166
|
def call_js(path, *args)
|
|
167
|
+
return if poisoned?
|
|
168
|
+
|
|
34
169
|
@vm.call(path, *args)
|
|
35
170
|
end
|
|
36
171
|
|
|
37
172
|
def drain_microtasks
|
|
173
|
+
return if poisoned?
|
|
174
|
+
|
|
38
175
|
@vm.drain_jobs!
|
|
39
176
|
end
|
|
40
177
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "capybara/dommy"
|
|
4
|
+
require "dommy/rack"
|
|
4
5
|
require_relative "../quickjs"
|
|
5
6
|
|
|
6
7
|
module Dommy
|
|
@@ -10,14 +11,25 @@ module Dommy
|
|
|
10
11
|
# Capybara::Dommy::Driver (via install_capybara! below), so execute_script /
|
|
11
12
|
# evaluate_script run against the current Dommy document through a QuickJS
|
|
12
13
|
# Runtime. Without this require, capybara-dommy stays JS-free (its default).
|
|
14
|
+
#
|
|
15
|
+
# The realm-per-document machinery lives in SessionRuntime (shared with
|
|
16
|
+
# Dommy::Rack::Session's `javascript: true`); the driver wires it to the
|
|
17
|
+
# session and the Capybara polling loop, and wraps results as Capybara
|
|
18
|
+
# nodes.
|
|
13
19
|
module CapybaraDriver
|
|
20
|
+
def rack_session
|
|
21
|
+
session = super
|
|
22
|
+
dommy_js_attach(session)
|
|
23
|
+
session
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
def execute_script(script, *_args)
|
|
15
|
-
|
|
27
|
+
dommy_js_host.execute(script)
|
|
16
28
|
nil
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
def evaluate_script(script, *_args)
|
|
20
|
-
decode_for_capybara(
|
|
32
|
+
decode_for_capybara(dommy_js_host.evaluate(script))
|
|
21
33
|
end
|
|
22
34
|
|
|
23
35
|
# No real async loop; evaluate synchronously. Sufficient for scripts
|
|
@@ -28,19 +40,20 @@ module Dommy
|
|
|
28
40
|
|
|
29
41
|
private
|
|
30
42
|
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
# Bind a SessionRuntime to the session (rebuilt when reset!/app_host
|
|
44
|
+
# swaps the session). The driver's frame-aware `document` is the realm
|
|
45
|
+
# target, and Capybara's retry loop pumps virtual time via time_pump.
|
|
46
|
+
def dommy_js_attach(session)
|
|
47
|
+
return if defined?(@dommy_js_session) && @dommy_js_session.equal?(session)
|
|
48
|
+
|
|
49
|
+
@dommy_js_session = session
|
|
50
|
+
@dommy_js_host = ::Dommy::Rack::SessionRuntime.new(session) { document }
|
|
51
|
+
self.time_pump = -> { @dommy_js_host.pump }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dommy_js_host
|
|
55
|
+
rack_session # ensures the host is attached for the current session
|
|
56
|
+
@dommy_js_host
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
# Map an evaluate() result to what Capybara expects:
|
|
@@ -48,10 +61,11 @@ module Dommy
|
|
|
48
61
|
# - JS undefined -> nil
|
|
49
62
|
# - Dommy::Element -> Capybara::Dommy::Node (covers HTML/SVG subclasses)
|
|
50
63
|
# - other bridge obj -> nil (Document/Text/Comment/Fragment/NodeList/
|
|
51
|
-
# Window have no Capybara representation
|
|
52
|
-
# likewise returns non-serializable values as null)
|
|
64
|
+
# Window have no Capybara representation)
|
|
53
65
|
# - primitive/Hash -> as-is
|
|
54
66
|
def decode_for_capybara(value)
|
|
67
|
+
return nil if value.equal?(::Dommy::Bridge::UNDEFINED)
|
|
68
|
+
|
|
55
69
|
case value
|
|
56
70
|
when Array
|
|
57
71
|
value.map { |element| decode_for_capybara(element) }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Wires QuickJS as the JS runtime for `Dommy::Rack::Session.new(app,
|
|
4
|
+
# javascript: true)`. Requiring this file (directly, or lazily by the session
|
|
5
|
+
# when javascript is requested) registers the :quickjs backend and points the
|
|
6
|
+
# session's runtime factory at the realm manager.
|
|
7
|
+
#
|
|
8
|
+
# The realm manager (Dommy::Rack::SessionRuntime) lives in dommy-rack and is
|
|
9
|
+
# backend-agnostic; this gem only supplies the QuickJS engine.
|
|
10
|
+
require "dommy/rack"
|
|
11
|
+
require_relative "../quickjs"
|
|
12
|
+
|
|
13
|
+
Dommy::Rack::Session.javascript_runtime_factory = lambda do |session|
|
|
14
|
+
Dommy::Rack::SessionRuntime.new(session)
|
|
15
|
+
end
|