dommy-js-quickjs 0.1.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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/Rakefile +49 -0
- data/docs/bridge-redesign.md +559 -0
- data/docs/wpt-conformance.md +752 -0
- data/lib/dommy/js/constructor_registry.rb +40 -0
- data/lib/dommy/js/custom_elements.rb +55 -0
- data/lib/dommy/js/dom_interfaces.rb +139 -0
- data/lib/dommy/js/handle_table.rb +52 -0
- data/lib/dommy/js/host_bridge.rb +400 -0
- data/lib/dommy/js/host_runtime.js +922 -0
- data/lib/dommy/js/observable_runtime.js +728 -0
- data/lib/dommy/js/quickjs/backend.rb +64 -0
- data/lib/dommy/js/quickjs/capybara.rb +80 -0
- data/lib/dommy/js/quickjs/runtime.rb +210 -0
- data/lib/dommy/js/quickjs/version.rb +9 -0
- data/lib/dommy/js/quickjs/wasm_bridge.rb +151 -0
- data/lib/dommy/js/quickjs.rb +20 -0
- data/sig/dommy/js/quickjs.rbs +8 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 17fc429b996f9fb344e581d754fa51c5a0e27d559caca8709fdd1dd6bcbd8516
|
|
4
|
+
data.tar.gz: de228d74c5b14116c3eae917154823c00f0cd3c5f3c81a89d4b33d1c89c6b44b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a4340bfcd9e6c084ee9441bafc176752bb97601814d460fd39cac699553faf52c972d08f844011a957c90b0df7b4c83b609df618f10d9545a58e4d22bb314f0e
|
|
7
|
+
data.tar.gz: 628bb3957b81036927500ee813d6b0410bc936ac378e35efc27990f8cc59c6d5ed4eb9b18bd9e1baed781bb813a7d09d864c1bf74e40d50b03cac588e0d5ec3a
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Masayoshi Takahashi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Dommy::Js::Quickjs
|
|
2
|
+
|
|
3
|
+
Run real JavaScript — including real frontend frameworks — against a
|
|
4
|
+
[Dommy](https://github.com/takahashim/dommy) DOM, without a browser.
|
|
5
|
+
|
|
6
|
+
JavaScript executes in an embedded QuickJS VM (via the
|
|
7
|
+
[`quickjs`](https://github.com/hmsk/quickjs.rb) gem). Dommy DOM nodes are bridged
|
|
8
|
+
to JS as objects whose property/method access routes into Dommy's
|
|
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.
|
|
12
|
+
|
|
13
|
+
The bridge presents a **spec-shaped JS DOM**, not bare proxies: `instanceof`,
|
|
14
|
+
prototype chains, `Object.prototype.toString` brands, constructable interfaces
|
|
15
|
+
(`new Event(...)`), custom elements (`class extends HTMLElement`), live
|
|
16
|
+
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
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
In your `Gemfile`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "dommy-js-quickjs"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "dommy"
|
|
32
|
+
require "dommy/js/quickjs"
|
|
33
|
+
|
|
34
|
+
win = Dommy.parse("<h1 class='title'>Hi</h1>")
|
|
35
|
+
|
|
36
|
+
rt = Dommy::Js::Quickjs::Runtime.new
|
|
37
|
+
rt.define_host_object("document", win.document)
|
|
38
|
+
rt.install_window(win) # exposes `window` + bare timer globals (setTimeout, ...)
|
|
39
|
+
|
|
40
|
+
rt.evaluate('document.querySelector(".title").textContent') # => "Hi"
|
|
41
|
+
rt.execute('document.querySelector(".title").textContent = "Bye"') # mutates the DOM
|
|
42
|
+
win.document.query_selector(".title").text_content # => "Bye"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `evaluate(js)` — evaluate an expression (or a `return`-using statement body) and
|
|
46
|
+
return the value; DOM nodes come back as Dommy objects, Promises are awaited.
|
|
47
|
+
- `execute(js)` — run statements for side effects; drains microtasks.
|
|
48
|
+
- Timers ride Dommy's scheduler: `win.scheduler.advance_time(ms)` fires JS
|
|
49
|
+
`setTimeout` / `setInterval` / `requestAnimationFrame` callbacks.
|
|
50
|
+
- `run_until_idle` — drive the event loop to quiescence: drains microtasks, then
|
|
51
|
+
advances the scheduler to each due timer and drains again, in WHATWG order
|
|
52
|
+
(microtasks before each timer), until nothing is pending. The one-call
|
|
53
|
+
"settle everything" entry point after an eval (`max_iterations:` bounds
|
|
54
|
+
self-rescheduling timer loops).
|
|
55
|
+
|
|
56
|
+
## What the JS sees
|
|
57
|
+
|
|
58
|
+
The JS-facing DOM behaves like a browser's, not like a plain object graph:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const el = document.querySelector("button");
|
|
62
|
+
el instanceof HTMLElement // true (full prototype chain)
|
|
63
|
+
Object.prototype.toString.call(el) // "[object HTMLButtonElement]"
|
|
64
|
+
el.constructor.name // "HTMLButtonElement"
|
|
65
|
+
|
|
66
|
+
new CustomEvent("x", { detail: 1 }) // constructable interfaces
|
|
67
|
+
el.dispatchEvent(new CustomEvent("x", {...})) // events bubble; detail round-trips
|
|
68
|
+
|
|
69
|
+
for (const c of el.children) { /* ... */ } // live, iterable collections
|
|
70
|
+
el.querySelectorAll("li").map(n => n.tagName) // NodeList crosses as a JS array
|
|
71
|
+
|
|
72
|
+
el._state = { n: 1 }; el._state === el._state // expandos keep their identity
|
|
73
|
+
|
|
74
|
+
class Card extends HTMLElement { // custom elements
|
|
75
|
+
connectedCallback() { this.textContent = "hi"; }
|
|
76
|
+
}
|
|
77
|
+
customElements.define("x-card", Card);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Method-vs-property is taken from each Dommy class's `__js_method_names__`, so no
|
|
81
|
+
method list is maintained here.
|
|
82
|
+
|
|
83
|
+
## Running real frameworks (and testing components)
|
|
84
|
+
|
|
85
|
+
To run a real bundle you need the browser globals it reaches for and a way to
|
|
86
|
+
drive deferred work. `BrowserHarness` (under `test/support/`) packages this:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
h = Dommy::Js::BrowserHarness.new(
|
|
90
|
+
"<body><div id='app'></div></body>",
|
|
91
|
+
fetch_stub: { "http://localhost/frame" => { status: 200, contentType: "text/html", body: "..." } }
|
|
92
|
+
)
|
|
93
|
+
h.load_script("vendor/turbo.umd.js") # runs the real bundle
|
|
94
|
+
h.execute("/* your app / interactions */")
|
|
95
|
+
h.pump # drive microtasks + the scheduler clock
|
|
96
|
+
assert_empty h.errors # nothing was silently swallowed
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The pieces it relies on are public Runtime API:
|
|
100
|
+
|
|
101
|
+
- `Runtime#install_browser_globals` — wire the bare globals real bundles use
|
|
102
|
+
(`self` / `location` / `history` / `navigator` / storages / `CSS` / `fetch` /
|
|
103
|
+
`addEventListener` / …), aliased onto the installed window.
|
|
104
|
+
- `Runtime#on_unhandled_rejection { |err| }` — surface promise rejections that
|
|
105
|
+
reach the microtask queue with no handler. Frameworks swallow these; `err.backtrace`
|
|
106
|
+
carries the JS stack, which is the difference between blind and one-shot debugging.
|
|
107
|
+
- `Runtime#on_log { |log| }` — observe `console.*` (`log.severity` / `log.to_s`).
|
|
108
|
+
|
|
109
|
+
### Capybara
|
|
110
|
+
|
|
111
|
+
Requiring the adapter enables `execute_script` / `evaluate_script` on
|
|
112
|
+
`Capybara::Dommy::Driver` (capybara-dommy stays JS-free without it):
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
require "dommy/js/quickjs/capybara"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Limitations
|
|
119
|
+
|
|
120
|
+
- **Deterministic scheduler, no wall clock.** Async work (timers, `requestAnimationFrame`,
|
|
121
|
+
framework "next repaint" deferral) only advances via `win.scheduler.advance_time(ms)` —
|
|
122
|
+
drive it with `Runtime#run_until_idle`, `BrowserHarness#pump`, or manually.
|
|
123
|
+
Selenium-style `done()` / real-time waits are not supported.
|
|
124
|
+
- **`fetch` is stub-based** via Dommy's `__fetchy_stub__` (a `{ url => entry }` map);
|
|
125
|
+
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.
|
|
129
|
+
- **Expandos are scoped to elements**, and the JS callback table is not evicted, so
|
|
130
|
+
a very long-lived VM can grow unbounded.
|
|
131
|
+
- **DOM coverage is Dommy's.** A JS method/property works only where Dommy exposes
|
|
132
|
+
it via the ABI (`__js_method_names__` / `__js_get__`); gaps surface as
|
|
133
|
+
`undefined` / "not a function" (see `on_unhandled_rejection`).
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
Run `bin/setup` to install dependencies, then `rake test`. `bin/console` opens an
|
|
138
|
+
interactive prompt.
|
|
139
|
+
|
|
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`:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
curl -sL https://unpkg.com/@hotwired/turbo@8/dist/turbo.es2017-umd.js \
|
|
145
|
+
-o test/fixtures/turbo.umd.js
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Contributing
|
|
149
|
+
|
|
150
|
+
Bug reports and pull requests are welcome at https://github.com/takahashim/dommy-js-quickjs.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
task default: :test
|
|
9
|
+
|
|
10
|
+
namespace :wpt do
|
|
11
|
+
desc "Run the vendored WPT corpus against the bridge and report a conformance rate"
|
|
12
|
+
task :conformance, [:filter] do |_t, args|
|
|
13
|
+
$LOAD_PATH.unshift File.expand_path("test", __dir__)
|
|
14
|
+
require "test_helper"
|
|
15
|
+
require "support/wpt_runner"
|
|
16
|
+
|
|
17
|
+
runner = Dommy::Js::WptRunner
|
|
18
|
+
files = runner.manifest
|
|
19
|
+
files = files.grep(/#{args[:filter]}/) if args[:filter]
|
|
20
|
+
|
|
21
|
+
total_pass = total = file_ok = 0
|
|
22
|
+
blocked = []
|
|
23
|
+
by_dir = Hash.new { |h, k| h[k] = [0, 0] } # dir => [pass, total]
|
|
24
|
+
files.each do |rel|
|
|
25
|
+
results = runner.run(rel)
|
|
26
|
+
pass = results.count(&:pass?)
|
|
27
|
+
n = results.size
|
|
28
|
+
total_pass += pass
|
|
29
|
+
total += n
|
|
30
|
+
dir = rel.split("/").first
|
|
31
|
+
by_dir[dir][0] += pass
|
|
32
|
+
by_dir[dir][1] += n
|
|
33
|
+
file_ok += 1 if n.positive? && pass == n
|
|
34
|
+
flag = n.zero? ? "—" : (pass == n ? "✓" : " ")
|
|
35
|
+
printf(" %s %-46s %3d/%-3d\n", flag, rel, pass, n)
|
|
36
|
+
results.reject(&:pass?).first(3).each { |r| puts " #{r.to_s[0, 110]}" }
|
|
37
|
+
rescue => e
|
|
38
|
+
blocked << rel
|
|
39
|
+
puts " ✗ #{rel} (errored: #{e.class}: #{e.message[0, 80]})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
pct = ->(p, t) { t.zero? ? 0 : (100.0 * p / t).round(1) }
|
|
43
|
+
puts
|
|
44
|
+
by_dir.sort.each { |dir, (p, t)| printf(" %-8s %4d/%-5d (%.1f%%)\n", dir, p, t, pct.call(p, t)) }
|
|
45
|
+
puts
|
|
46
|
+
puts "WPT conformance: #{total_pass}/#{total} subtests (#{pct.call(total_pass, total)}%) across #{files.size} files; " \
|
|
47
|
+
"#{file_ok} files fully green#{blocked.empty? ? "" : ", #{blocked.size} errored"}"
|
|
48
|
+
end
|
|
49
|
+
end
|