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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17fc429b996f9fb344e581d754fa51c5a0e27d559caca8709fdd1dd6bcbd8516
4
- data.tar.gz: de228d74c5b14116c3eae917154823c00f0cd3c5f3c81a89d4b33d1c89c6b44b
3
+ metadata.gz: 582867f4a71527529224d5ecf3348461ff69e4b76518f5c8f0f9dc85c5704270
4
+ data.tar.gz: b183f4631ea255d7844b8b2f20cc68214863d3639eaa559e3cdc3f44e1ec04a0
5
5
  SHA512:
6
- metadata.gz: a4340bfcd9e6c084ee9441bafc176752bb97601814d460fd39cac699553faf52c972d08f844011a957c90b0df7b4c83b609df618f10d9545a58e4d22bb314f0e
7
- data.tar.gz: 628bb3957b81036927500ee813d6b0410bc936ac378e35efc27990f8cc59c6d5ed4eb9b18bd9e1baed781bb813a7d09d864c1bf74e40d50b03cac588e0d5ec3a
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. The QuickJS-specific code is isolated in a small `Backend`;
11
- the rest of the bridge is engine-agnostic.
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) bundle loads and drives
18
- the DOM (turbo-stream + turbo-frame; see `test/dommy/js/test_turbo_integration.rb`).
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 must be functions** `addEventListener("...", fn)` works
127
- (closures intact; `removeEventListener` with the same function detaches it), but
128
- the `{ handleEvent }` object form is not.
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 test (`test_turbo_integration.rb`) is skipped
141
- unless the Turbo bundle is vendored at `test/fixtures/turbo.umd.js`:
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
- Minitest::TestTask.create
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
- task default: :test
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: DEFAULT_TIMEOUT_MSEC}.merge(vm_opts)
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
- dommy_js_runtime.execute(script)
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(dommy_js_runtime.evaluate(script))
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
- # One Runtime per document. Rebuilt when navigation swaps the document
32
- # so JS always sees the current page (and the old VM is released).
33
- def dommy_js_runtime
34
- doc = document
35
- unless defined?(@dommy_js_doc) && @dommy_js_doc.equal?(doc)
36
- @dommy_js_runtime&.dispose
37
- @dommy_js_runtime = Runtime.new
38
- @dommy_js_runtime.define_host_object("document", doc)
39
- view = doc.default_view
40
- @dommy_js_runtime.install_window(view) if view
41
- @dommy_js_doc = doc
42
- end
43
- @dommy_js_runtime
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; a browser
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