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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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
@@ -35,6 +35,8 @@ module Dommy
35
35
  URI(@origin).scheme ? "#{URI(@origin).scheme}:" : ""
36
36
  when "port"
37
37
  (URI(@origin).port || 80).to_s
38
+ else
39
+ Bridge::ABSENT
38
40
  end
39
41
  end
40
42
 
@@ -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
- # Dommy has no layout / viewport, so `matches` is `false` by default.
7
- # Tests drive media query changes via `__test_set_matches__(bool)`, which
8
- # flips the boolean and fires a `change` event exactly the surface
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
- @matches = false
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
- @matches
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: flip the match state and dispatch a `change` event so
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 @matches == !!value
56
+ return if matches == !!value
47
57
 
48
- @matches = !!value
49
- dispatch_event(MediaQueryListEvent.new("change", "matches" => @matches, "media" => @media))
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
- @matches
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
- matches addListener removeListener addEventListener removeEventListener dispatchEvent
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 microtask that fires
6
- # a `message` event on `port2`, and vice versa.
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
- @window.scheduler.queue_microtask(
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
- @window.scheduler.queue_microtask(
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