dommy 0.8.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/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Js
|
|
5
|
+
# The Runtime port: the contract a JS engine must satisfy to drive a Dommy
|
|
6
|
+
# DOM. `Dommy::Browser` and `Dommy::Js::ScriptBoot` depend on this interface,
|
|
7
|
+
# never on a concrete engine — so a backend (QuickJS today, others later) is
|
|
8
|
+
# a pluggable implementation registered through `Dommy::Js.register_runtime`.
|
|
9
|
+
#
|
|
10
|
+
# This module is documentation + a conformance check, not a base class:
|
|
11
|
+
# engines satisfy it by duck typing (no inheritance), and `conforms?` /
|
|
12
|
+
# `assert_conformance!` verify the surface so a partial backend fails fast
|
|
13
|
+
# with a clear message instead of an obscure NoMethodError mid-boot.
|
|
14
|
+
#
|
|
15
|
+
# The contract, as the host layer uses it:
|
|
16
|
+
#
|
|
17
|
+
# Lifecycle / wiring
|
|
18
|
+
# install_window(window) seed the realm's window globals + DOM
|
|
19
|
+
# install_browser_globals CSS / fetch / addEventListener / ...
|
|
20
|
+
# define_host_object(name, obj) expose a Ruby object under a JS global
|
|
21
|
+
# on_unhandled_rejection { |e } observe uncaught promise rejections
|
|
22
|
+
# on_log { |entry| } observe console.* output
|
|
23
|
+
# dispose tear down the realm
|
|
24
|
+
#
|
|
25
|
+
# Script boot (driven by ScriptBoot)
|
|
26
|
+
# set_document_ready_state(s) replay loading/interactive/complete
|
|
27
|
+
# module_loader = callable install the ESM resolver Proc
|
|
28
|
+
# load_script(js) run a classic inline script
|
|
29
|
+
# load_script_cached(js, cache_key:) run external script, cache bytecode
|
|
30
|
+
# load_module_url(url) run an ES module by URL
|
|
31
|
+
#
|
|
32
|
+
# Driving / settling
|
|
33
|
+
# execute(js) run for side effects
|
|
34
|
+
# evaluate(js) run and decode the result
|
|
35
|
+
# settle drain microtasks + due-now timers + rAF
|
|
36
|
+
# drain_microtasks drain the microtask queue only
|
|
37
|
+
#
|
|
38
|
+
# Optional (a backend may omit these; callers must guard with respond_to?):
|
|
39
|
+
# install_wasm_memory_shim opt-in WPT SharedArrayBuffer scaffolding
|
|
40
|
+
module Runtime
|
|
41
|
+
# The methods every conforming runtime must respond to.
|
|
42
|
+
REQUIRED_METHODS = %i[
|
|
43
|
+
install_window install_browser_globals define_host_object
|
|
44
|
+
on_unhandled_rejection on_log dispose
|
|
45
|
+
set_document_ready_state module_loader= load_script load_script_cached load_module_url
|
|
46
|
+
execute evaluate settle drain_microtasks
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Methods a backend may implement but is not required to.
|
|
50
|
+
# on_callback_error { |e } observe a timer/rAF callback the engine
|
|
51
|
+
# force-killed (runaway loop); recorded, not fatal
|
|
52
|
+
OPTIONAL_METHODS = %i[install_wasm_memory_shim on_callback_error].freeze
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# The required methods `obj` does not respond to (empty when conforming).
|
|
57
|
+
def missing_methods(obj)
|
|
58
|
+
REQUIRED_METHODS.reject { |m| obj.respond_to?(m) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def conforms?(obj) = missing_methods(obj).empty?
|
|
62
|
+
|
|
63
|
+
# Raise unless `obj` satisfies the contract, naming what is missing.
|
|
64
|
+
def assert_conformance!(obj)
|
|
65
|
+
missing = missing_methods(obj)
|
|
66
|
+
return obj if missing.empty?
|
|
67
|
+
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"#{obj.class} is not a conforming Dommy::Js::Runtime " \
|
|
70
|
+
"(missing: #{missing.join(", ")})"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Registry of JS runtime backends, keyed by name. A backend gem registers a
|
|
75
|
+
# factory on load (e.g. dommy-js-quickjs registers :quickjs); the host layer
|
|
76
|
+
# builds runtimes through `build_runtime` instead of naming a concrete class.
|
|
77
|
+
@runtime_factories = {}
|
|
78
|
+
@default_runtime = nil
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# The name of the backend `build_runtime` uses when none is given. Set by
|
|
82
|
+
# the first backend to register (and overridable by the host).
|
|
83
|
+
attr_accessor :default_runtime
|
|
84
|
+
|
|
85
|
+
# Register a runtime factory under `name`. The factory receives the keyword
|
|
86
|
+
# options passed to `build_runtime` and must return an object satisfying
|
|
87
|
+
# the Runtime contract. The first registration becomes the default.
|
|
88
|
+
def register_runtime(name, &factory)
|
|
89
|
+
raise ArgumentError, "a factory block is required" unless factory
|
|
90
|
+
|
|
91
|
+
@runtime_factories[name.to_sym] = factory
|
|
92
|
+
@default_runtime ||= name.to_sym
|
|
93
|
+
name.to_sym
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def runtime_registered?(name) = @runtime_factories.key?(name.to_sym)
|
|
97
|
+
|
|
98
|
+
def registered_runtimes = @runtime_factories.keys
|
|
99
|
+
|
|
100
|
+
# Build a runtime from the named backend (or the default), passing `opts`
|
|
101
|
+
# to its factory. Verifies the result conforms before handing it back.
|
|
102
|
+
def build_runtime(name = nil, **opts)
|
|
103
|
+
name = (name || @default_runtime)&.to_sym
|
|
104
|
+
factory = @runtime_factories[name]
|
|
105
|
+
unless factory
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"unknown JS runtime backend #{name.inspect} " \
|
|
108
|
+
"(registered: #{registered_runtimes.inspect})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Runtime.assert_conformance!(factory.call(**opts))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Js
|
|
5
|
+
# Boot a parsed document's `<script>` tags like a browser: run them in two
|
|
6
|
+
# passes that mirror the HTML spec, set `document.currentScript` around each,
|
|
7
|
+
# and replay the readyState lifecycle so ready-gated startup code (Stimulus /
|
|
8
|
+
# Turbo / jQuery ready) takes the real path.
|
|
9
|
+
#
|
|
10
|
+
# loading -> parser-blocking classic scripts (document order)
|
|
11
|
+
# -> deferred scripts: modules + classic `defer` (document order)
|
|
12
|
+
# -> interactive (DOMContentLoaded)
|
|
13
|
+
# -> complete (load)
|
|
14
|
+
#
|
|
15
|
+
# The two passes matter: a `<script type="module">` is *deferred* — it must
|
|
16
|
+
# run after the parser-inserted classic scripts even when it appears earlier
|
|
17
|
+
# in the document (e.g. a Nuxt entry module placed above the inline
|
|
18
|
+
# `window.__NUXT__ = {...}` bootstrap it depends on). Running everything in
|
|
19
|
+
# one document-order pass would execute the module against half-initialized
|
|
20
|
+
# globals. A failed fetch or a throwing script is isolated; `on_error` is
|
|
21
|
+
# notified (the Browser collects it for strict mode, the Capybara adapter
|
|
22
|
+
# ignores it) so the rest of the page still loads. Shared by `Dommy::Browser`
|
|
23
|
+
# and the Capybara driver so script boot lives in one place.
|
|
24
|
+
#
|
|
25
|
+
# The module is the stable entry point; the work lives on ScriptBooter, a
|
|
26
|
+
# short-lived instance that holds the runtime / document / resources /
|
|
27
|
+
# on_error collaborators so they aren't threaded through every step.
|
|
28
|
+
module ScriptBoot
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def run_document_scripts(runtime, document, resources: nil, on_error: nil, on_script: nil)
|
|
32
|
+
ScriptBooter.new(runtime, document, resources: resources, on_error: on_error, on_script: on_script).run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fetch + execute a single `<script src>` that was dynamically inserted into
|
|
36
|
+
# an already-booted document (webpack/Vite on-demand chunk loading), then
|
|
37
|
+
# fire its load / error event so the loader's promise settles.
|
|
38
|
+
def run_external_script(runtime, document, element, src, resources: nil, on_error: nil)
|
|
39
|
+
ScriptBooter.new(runtime, document, resources: resources, on_error: on_error).run_inserted_external(element, src)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One document's script-boot run. Instantiated per boot by ScriptBoot; the
|
|
44
|
+
# collaborators are ivars so the per-script steps take only what varies.
|
|
45
|
+
class ScriptBooter
|
|
46
|
+
def initialize(runtime, document, resources: nil, on_error: nil, on_script: nil)
|
|
47
|
+
@runtime = runtime
|
|
48
|
+
@document = document
|
|
49
|
+
@resources = resources
|
|
50
|
+
@on_error = on_error
|
|
51
|
+
@on_script = on_script
|
|
52
|
+
@loader = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run
|
|
56
|
+
@runtime.set_document_ready_state("loading")
|
|
57
|
+
@loader = install_module_loader
|
|
58
|
+
scripts = @document.scripts.to_a
|
|
59
|
+
# Pass 1: parser-blocking classic scripts, in document order.
|
|
60
|
+
scripts.each { |element| run_one(element) unless deferred?(element) }
|
|
61
|
+
# Pass 2: deferred scripts (modules + classic `defer`), in document order.
|
|
62
|
+
scripts.each { |element| run_one(element) if deferred?(element) }
|
|
63
|
+
@runtime.set_document_ready_state("interactive")
|
|
64
|
+
@runtime.set_document_ready_state("complete")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Fetch + run a dynamically-inserted external script, then fire `load` (or
|
|
68
|
+
# `error` if the fetch failed / it threw) so a loader awaiting the script
|
|
69
|
+
# element's onload resolves. The src was already taken from the element by
|
|
70
|
+
# the mutation coordinator, so this does not re-consume pending state.
|
|
71
|
+
def run_inserted_external(element, src)
|
|
72
|
+
ran = false
|
|
73
|
+
if @resources && (url = resolve_url(src)) && (response = @resources.get(url)) && response.success?
|
|
74
|
+
with_current_script(element) { @runtime.load_script_cached(response.body, cache_key: url) }
|
|
75
|
+
ran = true
|
|
76
|
+
end
|
|
77
|
+
dispatch_script_event(element, ran ? "load" : "error")
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
@on_error&.call(e)
|
|
80
|
+
dispatch_script_event(element, "error")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Fire the script's load/error event ASYNCHRONOUSLY (a microtask), like a
|
|
86
|
+
# real browser. Code commonly does `head.appendChild(s); s.onload = …`
|
|
87
|
+
# (handlers set AFTER insertion), so a synchronous dispatch — during the
|
|
88
|
+
# appendChild — would fire before any handler is attached and be missed,
|
|
89
|
+
# hanging a loader that awaits onload (e.g. note.com's gtag plugin, which
|
|
90
|
+
# blocked Nuxt hydration). Deferring to a microtask lets the handler attach
|
|
91
|
+
# first.
|
|
92
|
+
def dispatch_script_event(element, type)
|
|
93
|
+
return unless element.respond_to?(:dispatch_event)
|
|
94
|
+
|
|
95
|
+
fire = proc { element.dispatch_event(Dommy::Event.new(type)) rescue nil }
|
|
96
|
+
scheduler = (@document.default_view&.scheduler if @document.respond_to?(:default_view))
|
|
97
|
+
scheduler ? scheduler.queue_microtask(fire) : fire.call
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether the element runs in the deferred pass rather than at its parse
|
|
101
|
+
# position. Module scripts are always deferred; a classic script is
|
|
102
|
+
# deferred only with a `src` and the `defer` attribute (inline classic
|
|
103
|
+
# scripts ignore `defer`). `async` opts out of deferral — it runs as soon
|
|
104
|
+
# as it is available, which in this synchronous model is the first pass.
|
|
105
|
+
def deferred?(element)
|
|
106
|
+
return false if element.async
|
|
107
|
+
|
|
108
|
+
module_script?(element) || (external?(element) && element.defer)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def module_script?(element) = element.type.to_s.strip.downcase == "module"
|
|
112
|
+
def external?(element) = !element.src.to_s.empty?
|
|
113
|
+
|
|
114
|
+
# Wire the ESM resolver before any module runs: parse the page's first
|
|
115
|
+
# <script type="importmap">, then resolve bare specifiers through it and
|
|
116
|
+
# fetch module sources through `resources`. Returns the loader so inline
|
|
117
|
+
# modules can be seeded under a document URL.
|
|
118
|
+
def install_module_loader
|
|
119
|
+
loader = ModuleLoader.new(@resources, parse_import_map, base_url: document_base)
|
|
120
|
+
# The engine requires a Proc specifically.
|
|
121
|
+
@runtime.module_loader = ->(specifier, importer) { loader.call(specifier, importer) }
|
|
122
|
+
loader
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_import_map
|
|
126
|
+
el = @document.scripts.find { |s| s.type.to_s.strip.downcase == "importmap" }
|
|
127
|
+
ImportMap.parse(el ? el.text : "")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Run one <script> element and, only when it actually had pending work,
|
|
131
|
+
# notify `on_script` (element, error) — success with nil, failure with the
|
|
132
|
+
# raised error (alongside the existing `on_error`). Skipped/non-classic
|
|
133
|
+
# elements (no pending body/src/module) are not reported.
|
|
134
|
+
def run_one(element)
|
|
135
|
+
@on_script.call(element, nil) if run_pending(element) && @on_script
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
@on_error&.call(e)
|
|
138
|
+
@on_script&.call(element, e)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Execute the element's pending inline body / external src / module, and
|
|
142
|
+
# return whether any of them matched (so run_one knows a script ran).
|
|
143
|
+
def run_pending(element)
|
|
144
|
+
if (body = element.__internal_take_pending_script__)
|
|
145
|
+
with_current_script(element) { @runtime.load_script(body) }
|
|
146
|
+
elsif (src = element.__internal_take_pending_src__)
|
|
147
|
+
run_external(element, src)
|
|
148
|
+
elsif (mod = element.__internal_take_pending_module__)
|
|
149
|
+
run_module(mod)
|
|
150
|
+
else
|
|
151
|
+
return false
|
|
152
|
+
end
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# An ES module script. `currentScript` is null for modules (spec), so it
|
|
157
|
+
# is not set. An inline body is seeded under the document URL (so its
|
|
158
|
+
# relative imports resolve against the page) and pinned to the page's
|
|
159
|
+
# `import.meta.url`: the engine derives import.meta.url from the module's
|
|
160
|
+
# unique cache key, which carries a `#dommy-inline-N` fragment for a
|
|
161
|
+
# second inline module, so we set `import.meta.url` (writable) to the
|
|
162
|
+
# clean page URL up front. An external module loads by its own URL.
|
|
163
|
+
def run_module(mod)
|
|
164
|
+
kind, value = mod
|
|
165
|
+
if kind == :inline
|
|
166
|
+
base = inline_base
|
|
167
|
+
# No newline, so the original body's line numbers are preserved.
|
|
168
|
+
body = "import.meta.url = #{base.to_json}; #{value}"
|
|
169
|
+
@runtime.load_module_url(@loader.seed_inline(base, body))
|
|
170
|
+
elsif (url = resolve_url(value))
|
|
171
|
+
@runtime.load_module_url(url)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The page URL an inline module is identified by (its import.meta.url and
|
|
176
|
+
# the base for its relative imports).
|
|
177
|
+
def inline_base
|
|
178
|
+
base = document_base
|
|
179
|
+
base.empty? ? "about:blank" : base
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def run_external(element, src)
|
|
183
|
+
return unless @resources
|
|
184
|
+
|
|
185
|
+
url = resolve_url(src)
|
|
186
|
+
return unless url
|
|
187
|
+
|
|
188
|
+
response = @resources.get(url)
|
|
189
|
+
return unless response&.success?
|
|
190
|
+
|
|
191
|
+
# Cache the compiled bytecode by URL: vendored bundles re-parse on
|
|
192
|
+
# every fresh VM otherwise.
|
|
193
|
+
with_current_script(element) { @runtime.load_script_cached(response.body, cache_key: url) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Resolve a script's `src` against the document's base URL, which is the
|
|
197
|
+
# realm's own location (correct for frames too).
|
|
198
|
+
def resolve_url(src)
|
|
199
|
+
::URI.join(document_base, src).to_s
|
|
200
|
+
rescue ::URI::InvalidURIError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The document's effective base URL string: its `<base>`-derived base
|
|
205
|
+
# URI, falling back to the realm's own location. Empty string when
|
|
206
|
+
# neither is set (callers decide their own fallback).
|
|
207
|
+
def document_base
|
|
208
|
+
base = @document.base_uri
|
|
209
|
+
base = @document.url if base.to_s.empty?
|
|
210
|
+
base.to_s
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def with_current_script(element)
|
|
214
|
+
@document.__internal_set_current_script__(element)
|
|
215
|
+
yield
|
|
216
|
+
ensure
|
|
217
|
+
@document.__internal_set_current_script__(nil)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Js
|
|
5
|
+
# The wire protocol shared by every Ruby<->JS marshaller in this gem: the
|
|
6
|
+
# tagged-Hash shapes that cross the boundary (a handle, a callback, an opaque
|
|
7
|
+
# JS ref, a byte buffer, a tagged exception, …). Both Ruby marshallers —
|
|
8
|
+
# HostBridge#wrap/#unwrap (the Proxy bridge) and WasmBridge#pack/#unpack (the
|
|
9
|
+
# wasm guest bridge) — build and match these keys, so keeping them as one set
|
|
10
|
+
# of constants prevents the two sides from drifting apart.
|
|
11
|
+
#
|
|
12
|
+
# The JS half (host_runtime.js: dehydrate/rehydrate/wasmTag/wasmDeref) mirrors
|
|
13
|
+
# the SAME string literals. When changing a tag here, update host_runtime.js
|
|
14
|
+
# in lockstep — these constants are the canonical names; the JS literals are
|
|
15
|
+
# the mirror.
|
|
16
|
+
module WireTags
|
|
17
|
+
# A bridged Ruby object, referenced by its HandleTable id (becomes an ES
|
|
18
|
+
# Proxy on the JS side).
|
|
19
|
+
HANDLE = "__rb_handle"
|
|
20
|
+
# The host object's WebIDL interface name, carried alongside its handle so
|
|
21
|
+
# makeProxy can reuse a cached per-interface descriptor (prototype chain +
|
|
22
|
+
# method set) instead of a `__rb_host_describe` round trip per new proxy —
|
|
23
|
+
# the dominant overhead when JS traverses/builds many nodes.
|
|
24
|
+
INTERFACE = "__rb_if"
|
|
25
|
+
# The custom-element tag of a handle whose node is a registered custom
|
|
26
|
+
# element (so makeProxy upgrades it), carried per-instance since it is the
|
|
27
|
+
# one part of a describe that is NOT per-interface.
|
|
28
|
+
CUSTOM_ELEMENT = "__rb_ce"
|
|
29
|
+
# A live JS function that crossed into Ruby, referenced by callback id.
|
|
30
|
+
CALLBACK = "__rb_callback"
|
|
31
|
+
# An opaque JS value referenced by its id in the JS-side `jsRefs` table
|
|
32
|
+
# (shared by the Proxy and wasm bridges). See Dommy::Bridge::JSValue.
|
|
33
|
+
JS_REF = "__rb_js_ref"
|
|
34
|
+
# A human-readable label captured alongside a JS ref (for #to_s/#inspect).
|
|
35
|
+
JS_LABEL = "__rb_js_label"
|
|
36
|
+
# Marks a JS ref that implements the EventListener interface (handleEvent).
|
|
37
|
+
HANDLE_EVENT = "__rb_handle_event"
|
|
38
|
+
# Marks a JS ref that implements the NodeFilter interface (acceptNode).
|
|
39
|
+
ACCEPT_NODE = "__rb_accept_node"
|
|
40
|
+
# The JS `undefined` value (distinct from null / Ruby nil).
|
|
41
|
+
UNDEFINED = "__rb_undefined"
|
|
42
|
+
# A genuinely-absent property: marshals to JS `undefined` as a value, but the
|
|
43
|
+
# proxy reports it MISSING for the `in` operator (distinct from UNDEFINED,
|
|
44
|
+
# which is present-but-undefined). See Dommy::Bridge::ABSENT.
|
|
45
|
+
ABSENT = "__rb_absent"
|
|
46
|
+
# A byte buffer crossing as a JS Uint8Array.
|
|
47
|
+
BYTES = "__rb_bytes"
|
|
48
|
+
# A byte buffer crossing as a bare JS ArrayBuffer.
|
|
49
|
+
ARRAY_BUFFER = "__rb_arraybuffer"
|
|
50
|
+
# A host-raised DOMException/TypeError/RangeError, re-thrown JS-side.
|
|
51
|
+
EXCEPTION = "__rb_exception__"
|
|
52
|
+
# A host-created native JS error (TypeError/RangeError) crossing as a VALUE
|
|
53
|
+
# — not thrown — so e.g. a promise rejected with it has a reason that is a
|
|
54
|
+
# real `instanceof TypeError`. Rehydrates to the error object itself.
|
|
55
|
+
ERROR_VALUE = "__rb_error_value"
|
|
56
|
+
# An arbitrary host-thrown value, re-thrown JS-side verbatim.
|
|
57
|
+
THROW = "__rb_throw__"
|
|
58
|
+
# A callback whose JS invocation threw (the thrown value is carried here).
|
|
59
|
+
CALLBACK_THREW = "__rb_cb_threw__"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/dommy/location.rb
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "internal/css/media_query"
|
|
4
|
+
|
|
3
5
|
module Dommy
|
|
4
6
|
# `MediaQueryList` — what `window.matchMedia(query)` returns.
|
|
5
7
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
8
|
+
# `matches` evaluates the query against the window's media environment
|
|
9
|
+
# (viewport 1280x720 by default; see Window#resize_to). When the
|
|
10
|
+
# environment changes, the window notifies every list it handed out and a
|
|
11
|
+
# `change` event fires for those whose result flipped.
|
|
12
|
+
#
|
|
13
|
+
# `__test_set_matches__(bool)` remains as a test seam: it forces the
|
|
14
|
+
# match state (overriding evaluation) and fires `change` — the surface
|
|
9
15
|
# libraries like Material-UI / Bootstrap / @testing-library consult.
|
|
10
16
|
#
|
|
11
17
|
# Spec: https://drafts.csswg.org/cssom-view/#mediaquerylist
|
|
@@ -17,12 +23,16 @@ module Dommy
|
|
|
17
23
|
def initialize(window, query)
|
|
18
24
|
@window = window
|
|
19
25
|
@media = query.to_s
|
|
20
|
-
@
|
|
26
|
+
@forced = nil
|
|
21
27
|
@onchange = nil
|
|
28
|
+
@last_matches = evaluate
|
|
29
|
+
window.__internal_register_media_query_list__(self) if window.respond_to?(:__internal_register_media_query_list__)
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def matches
|
|
25
|
-
@
|
|
33
|
+
return @forced unless @forced.nil?
|
|
34
|
+
|
|
35
|
+
@last_matches = evaluate
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
alias matches? matches
|
|
@@ -40,13 +50,26 @@ module Dommy
|
|
|
40
50
|
|
|
41
51
|
alias removeListener remove_listener
|
|
42
52
|
|
|
43
|
-
# Test seam:
|
|
44
|
-
# subscribers re-render.
|
|
53
|
+
# Test seam: force the match state (evaluation is bypassed from then
|
|
54
|
+
# on) and dispatch a `change` event so subscribers re-render.
|
|
45
55
|
def __test_set_matches__(value)
|
|
46
|
-
return if
|
|
56
|
+
return if matches == !!value
|
|
47
57
|
|
|
48
|
-
@
|
|
49
|
-
|
|
58
|
+
@forced = !!value
|
|
59
|
+
dispatch_change(@forced)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Called by the window when the media environment changed (resize etc.).
|
|
64
|
+
# Fires `change` when the evaluated result flipped; a forced value wins.
|
|
65
|
+
def __internal_environment_changed__
|
|
66
|
+
return unless @forced.nil?
|
|
67
|
+
|
|
68
|
+
current = evaluate
|
|
69
|
+
return if current == @last_matches
|
|
70
|
+
|
|
71
|
+
@last_matches = current
|
|
72
|
+
dispatch_change(current)
|
|
50
73
|
nil
|
|
51
74
|
end
|
|
52
75
|
|
|
@@ -55,9 +78,11 @@ module Dommy
|
|
|
55
78
|
when "media"
|
|
56
79
|
@media
|
|
57
80
|
when "matches"
|
|
58
|
-
|
|
81
|
+
matches
|
|
59
82
|
when "onchange"
|
|
60
83
|
@onchange
|
|
84
|
+
else
|
|
85
|
+
Bridge::ABSENT
|
|
61
86
|
end
|
|
62
87
|
end
|
|
63
88
|
|
|
@@ -75,13 +100,14 @@ module Dommy
|
|
|
75
100
|
end
|
|
76
101
|
|
|
77
102
|
include Bridge::Methods
|
|
103
|
+
# `matches` is a read-only attribute (boolean), exposed through __js_get__ —
|
|
104
|
+
# not a method. Listing it here would shadow the getter with a callable, so
|
|
105
|
+
# `mql.matches` would evaluate to a function instead of the boolean.
|
|
78
106
|
js_methods %w[
|
|
79
|
-
|
|
107
|
+
addListener removeListener addEventListener removeEventListener dispatchEvent
|
|
80
108
|
]
|
|
81
109
|
def __js_call__(method, args)
|
|
82
110
|
case method
|
|
83
|
-
when "matches"
|
|
84
|
-
@matches
|
|
85
111
|
when "addListener"
|
|
86
112
|
add_listener(args[0])
|
|
87
113
|
when "removeListener"
|
|
@@ -98,6 +124,16 @@ module Dommy
|
|
|
98
124
|
def __internal_event_parent__
|
|
99
125
|
nil
|
|
100
126
|
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def evaluate
|
|
131
|
+
Internal::CSS::MediaQuery.match?(@media, @window.media_environment)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def dispatch_change(matches)
|
|
135
|
+
dispatch_event(MediaQueryListEvent.new("change", "matches" => matches, "media" => @media))
|
|
136
|
+
end
|
|
101
137
|
end
|
|
102
138
|
|
|
103
139
|
# `MediaQueryListEvent` — the `change` event payload.
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Dommy
|
|
4
4
|
# `MessageChannel` — creates a pair of `MessagePort`s connected to
|
|
5
|
-
# each other. `port1.postMessage(x)` queues a
|
|
6
|
-
# a `message` event on `port2`, and vice
|
|
5
|
+
# each other. `port1.postMessage(x)` queues a TASK (the "post message" task
|
|
6
|
+
# source, not a microtask) that fires a `message` event on `port2`, and vice
|
|
7
|
+
# versa — so the message is delivered in a later event-loop turn, after the
|
|
8
|
+
# current task's microtask checkpoint. React's scheduler relies on this to
|
|
9
|
+
# yield as a macrotask.
|
|
7
10
|
#
|
|
8
11
|
# Spec: https://html.spec.whatwg.org/multipage/web-messaging.html
|
|
9
12
|
class MessageChannel
|
|
@@ -22,6 +25,8 @@ module Dommy
|
|
|
22
25
|
@port1
|
|
23
26
|
when "port2"
|
|
24
27
|
@port2
|
|
28
|
+
else
|
|
29
|
+
Bridge::ABSENT
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
end
|
|
@@ -47,7 +52,10 @@ module Dommy
|
|
|
47
52
|
port = @entangled
|
|
48
53
|
return unless port
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
# The "post message" task source — a task, NOT a microtask, so delivery
|
|
56
|
+
# happens in a later event-loop turn (after the current task's microtask
|
|
57
|
+
# checkpoint), matching browsers.
|
|
58
|
+
@window.scheduler.set_timeout(
|
|
51
59
|
proc do
|
|
52
60
|
evt = MessageEvent.new("message", "data" => Dommy.structured_clone(data))
|
|
53
61
|
if port.__internal_started?
|
|
@@ -55,7 +63,8 @@ module Dommy
|
|
|
55
63
|
else
|
|
56
64
|
port.__internal_enqueue__(evt)
|
|
57
65
|
end
|
|
58
|
-
end
|
|
66
|
+
end,
|
|
67
|
+
0
|
|
59
68
|
)
|
|
60
69
|
|
|
61
70
|
nil
|
|
@@ -88,6 +97,8 @@ module Dommy
|
|
|
88
97
|
case key
|
|
89
98
|
when "onmessage"
|
|
90
99
|
@onmessage
|
|
100
|
+
else
|
|
101
|
+
Bridge::ABSENT
|
|
91
102
|
end
|
|
92
103
|
end
|
|
93
104
|
|
|
@@ -209,10 +220,13 @@ module Dommy
|
|
|
209
220
|
peers = @@registries[@window][@name].reject { |p| p.equal?(self) || p.closed? }
|
|
210
221
|
cloned = Dommy.structured_clone(data)
|
|
211
222
|
peers.each do |peer|
|
|
212
|
-
|
|
223
|
+
# A task (post message task source), not a microtask — delivered in a
|
|
224
|
+
# later turn like a real BroadcastChannel.
|
|
225
|
+
@window.scheduler.set_timeout(
|
|
213
226
|
proc do
|
|
214
227
|
peer.dispatch_event(MessageEvent.new("message", "data" => cloned))
|
|
215
|
-
end
|
|
228
|
+
end,
|
|
229
|
+
0
|
|
216
230
|
)
|
|
217
231
|
end
|
|
218
232
|
|
|
@@ -239,6 +253,8 @@ module Dommy
|
|
|
239
253
|
@name
|
|
240
254
|
when "onmessage"
|
|
241
255
|
@onmessage
|
|
256
|
+
else
|
|
257
|
+
Bridge::ABSENT
|
|
242
258
|
end
|
|
243
259
|
end
|
|
244
260
|
|
|
@@ -79,6 +79,21 @@ module Dommy
|
|
|
79
79
|
refute_includes(actual_classes, class_name.to_s, msg)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Assert the scope contains an element with computed ARIA role `role`
|
|
83
|
+
# (+ optional accessible name / level). Walks the accessibility tree, so
|
|
84
|
+
# aria-hidden / invisible elements are excluded.
|
|
85
|
+
def assert_dom_has_role(scope, role, name: nil, level: nil, count: nil, exact: false, msg: nil)
|
|
86
|
+
matched = dom_roles_for(scope, role, name: name, level: level, exact: exact)
|
|
87
|
+
msg ||= "expected to find role #{role.to_s.inspect}#{role_clause(name, level, count)}, found #{matched.size}"
|
|
88
|
+
assert(Internal::DomMatching.count_matches?(matched.size, count), msg)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def refute_dom_has_role(scope, role, name: nil, level: nil, count: nil, exact: false, msg: nil)
|
|
92
|
+
matched = dom_roles_for(scope, role, name: name, level: level, exact: exact)
|
|
93
|
+
msg ||= "expected NOT to find role #{role.to_s.inspect}#{role_clause(name, level, nil)}, found #{matched.size}"
|
|
94
|
+
refute(Internal::DomMatching.count_matches?(matched.size, count), msg)
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
def assert_dom_html_equal(scope, expected_html, msg: nil)
|
|
83
98
|
scope = Internal::ScopeResolution.resolve(scope)
|
|
84
99
|
actual_n = Internal::DomMatching.normalize_html(Internal::DomMatching.html_of(scope))
|
|
@@ -100,6 +115,18 @@ module Dommy
|
|
|
100
115
|
def dom_text_of(scope)
|
|
101
116
|
Internal::DomMatching.text_of(Internal::ScopeResolution.resolve(scope))
|
|
102
117
|
end
|
|
118
|
+
|
|
119
|
+
def dom_roles_for(scope, role, name:, level:, exact:)
|
|
120
|
+
Interaction::RoleQuery.match(Internal::ScopeResolution.resolve(scope), role: role, name: name, level: level, exact: exact)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def role_clause(name, level, count)
|
|
124
|
+
clause = +""
|
|
125
|
+
clause << " named #{name.inspect}" if name
|
|
126
|
+
clause << " at level #{level}" if level
|
|
127
|
+
clause << " (count: #{count.inspect})" if count
|
|
128
|
+
clause
|
|
129
|
+
end
|
|
103
130
|
end
|
|
104
131
|
end
|
|
105
132
|
end
|