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
data/lib/dommy/scheduler.rb
CHANGED
|
@@ -4,10 +4,18 @@ module Dommy
|
|
|
4
4
|
# Deterministic host-side scheduler for timers, rAF, and microtasks.
|
|
5
5
|
# Time advances only when the host explicitly calls `advance_time`.
|
|
6
6
|
class Scheduler
|
|
7
|
-
Timer = Struct.new(:id, :kind, :callback, :due_at, :interval_ms, :active)
|
|
7
|
+
Timer = Struct.new(:id, :kind, :callback, :due_at, :interval_ms, :active, :nesting)
|
|
8
8
|
|
|
9
9
|
FRAME_MS = 16
|
|
10
10
|
|
|
11
|
+
# HTML timer initialization steps: once a timer is nested deeper than 5, a
|
|
12
|
+
# sub-4ms timeout is clamped to 4ms. Besides matching browsers, this is what
|
|
13
|
+
# stops a self-rescheduling setTimeout(0) / setInterval(0) from spinning
|
|
14
|
+
# forever at the same virtual instant (the clamp pushes it into a future
|
|
15
|
+
# frame, so `advance_time(0)`'s due-now loop terminates).
|
|
16
|
+
MAX_NESTING_BEFORE_CLAMP = 5
|
|
17
|
+
MIN_NESTED_DELAY_MS = 4
|
|
18
|
+
|
|
11
19
|
# requestIdleCallback has no real idle period here; the callback always
|
|
12
20
|
# sees a fixed budget and didTimeout: false.
|
|
13
21
|
IDLE_DEADLINE = {"timeRemaining" => 50.0, "didTimeout" => false}.freeze
|
|
@@ -18,10 +26,40 @@ module Dommy
|
|
|
18
26
|
@timers = {}
|
|
19
27
|
@microtasks = []
|
|
20
28
|
@native_microtask_scheduler = nil
|
|
29
|
+
@external_inbox = Thread::Queue.new
|
|
30
|
+
# The nesting level of the timer task currently running (0 at top level);
|
|
31
|
+
# a timer scheduled while it runs nests one deeper. Drives the 4ms clamp.
|
|
32
|
+
@nesting_level = 0
|
|
21
33
|
end
|
|
22
34
|
|
|
23
35
|
attr_reader :now_ms
|
|
24
36
|
|
|
37
|
+
# Post a completion to be run on the page (JS) thread the next time the event
|
|
38
|
+
# loop drains. THREAD-SAFE: this is the one place another thread may hand work
|
|
39
|
+
# back — e.g. a network worker delivering a response. It only enqueues a
|
|
40
|
+
# thunk; the thunk runs single-threaded with the DOM/JS (see #deliver_external),
|
|
41
|
+
# so workers never touch Dommy/QuickJS state. The thunk takes no arguments.
|
|
42
|
+
def post_external(&thunk)
|
|
43
|
+
@external_inbox << thunk
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run all externally-posted completions. PAGE THREAD ONLY — drained as part of
|
|
48
|
+
# advancing the event loop (see #advance_time).
|
|
49
|
+
def deliver_external
|
|
50
|
+
until @external_inbox.empty?
|
|
51
|
+
thunk = @external_inbox.pop(true)
|
|
52
|
+
CallableInvoker.invoke(thunk)
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
rescue ThreadError
|
|
56
|
+
nil # raced empty between empty? and pop — nothing left to do
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True when a worker has handed back work not yet delivered. Lets the host
|
|
60
|
+
# keep the loop alive (ticking) until in-flight network responses are applied.
|
|
61
|
+
def external_pending? = !@external_inbox.empty?
|
|
62
|
+
|
|
25
63
|
# An optional hook (set by a JS runtime) that enqueues a microtask onto the
|
|
26
64
|
# engine's NATIVE promise-job queue. When present, `queue_microtask` routes
|
|
27
65
|
# through it so host-side microtasks (e.g. MutationObserver delivery)
|
|
@@ -29,6 +67,28 @@ module Dommy
|
|
|
29
67
|
# separate pass. Absent in vanilla CRuby use (falls back to `@microtasks`).
|
|
30
68
|
attr_accessor :native_microtask_scheduler
|
|
31
69
|
|
|
70
|
+
# An optional hook (set by a JS runtime) invoked when a timer / rAF / idle
|
|
71
|
+
# callback raises. The host inspects the error and returns truthy to swallow
|
|
72
|
+
# it — e.g. a runtime execution-budget interrupt: a runaway callback the
|
|
73
|
+
# engine force-killed should be recorded and let browsing continue, not crash
|
|
74
|
+
# it (WHATWG: a timer callback's exception must not escape its dispatch). When
|
|
75
|
+
# swallowed, the offending timer is dropped so a self-rescheduling interval
|
|
76
|
+
# does not re-fire and re-stall every tick. Returning falsy (or no handler at
|
|
77
|
+
# all — vanilla CRuby use) re-raises, so genuine host bugs still surface.
|
|
78
|
+
attr_accessor :timer_error_handler
|
|
79
|
+
|
|
80
|
+
# An optional hook (set by a JS runtime) that drains the ENGINE's microtask
|
|
81
|
+
# (promise-job) queue — the other half of a microtask checkpoint. The
|
|
82
|
+
# scheduler owns only its own `@microtasks`; the real microtask queue lives
|
|
83
|
+
# in the JS engine, so a spec-compliant checkpoint must drain both. WHATWG
|
|
84
|
+
# §8.1.7.3: the event loop performs a microtask checkpoint after running each
|
|
85
|
+
# task. Without this the scheduler would run every due timer task back-to-back
|
|
86
|
+
# and only drain microtasks once at the end, which reorders a microtask queued
|
|
87
|
+
# by one task after the next task — breaking code (Apollo/RxJS link chains)
|
|
88
|
+
# that relies on the per-task checkpoint. Absent in vanilla CRuby use (then a
|
|
89
|
+
# checkpoint drains only `@microtasks`).
|
|
90
|
+
attr_accessor :microtask_checkpoint
|
|
91
|
+
|
|
32
92
|
def set_timeout(callback, delay_ms)
|
|
33
93
|
register_timer(:timeout, callback, delay_ms.to_i, nil)
|
|
34
94
|
end
|
|
@@ -49,7 +109,9 @@ module Dommy
|
|
|
49
109
|
def request_animation_frame(callback)
|
|
50
110
|
frames = ((@now_ms / FRAME_MS) + 1) * FRAME_MS
|
|
51
111
|
id = next_id
|
|
52
|
-
|
|
112
|
+
# rAF is frame-aligned (never the same instant twice), so it needs no
|
|
113
|
+
# nesting clamp; nesting 0.
|
|
114
|
+
@timers[id] = Timer.new(id, :raf, callback, frames, nil, true, 0)
|
|
53
115
|
id
|
|
54
116
|
end
|
|
55
117
|
|
|
@@ -85,16 +147,30 @@ module Dommy
|
|
|
85
147
|
nil
|
|
86
148
|
end
|
|
87
149
|
|
|
150
|
+
# A WHATWG microtask checkpoint: drain the scheduler's own microtask queue
|
|
151
|
+
# AND the engine's promise-job queue (via the runtime hook). Host-side
|
|
152
|
+
# microtasks route onto the engine queue when a runtime is wired, so in that
|
|
153
|
+
# mode `@microtasks` is usually empty and the engine drain does the work; in
|
|
154
|
+
# vanilla CRuby the engine hook is absent and only `@microtasks` drains.
|
|
155
|
+
def perform_microtask_checkpoint
|
|
156
|
+
drain_microtasks
|
|
157
|
+
@microtask_checkpoint&.call
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
88
161
|
def advance_time(delta_ms)
|
|
162
|
+
deliver_external # apply any responses that arrived since the last drain
|
|
89
163
|
target = @now_ms + [delta_ms.to_i, 0].max
|
|
90
164
|
while next_due_timer_at && next_due_timer_at <= target
|
|
91
165
|
@now_ms = next_due_timer_at
|
|
92
166
|
run_due_timers
|
|
93
|
-
|
|
167
|
+
perform_microtask_checkpoint
|
|
168
|
+
deliver_external
|
|
94
169
|
end
|
|
95
170
|
|
|
96
171
|
@now_ms = target
|
|
97
|
-
|
|
172
|
+
perform_microtask_checkpoint
|
|
173
|
+
deliver_external
|
|
98
174
|
nil
|
|
99
175
|
end
|
|
100
176
|
|
|
@@ -108,6 +184,14 @@ module Dommy
|
|
|
108
184
|
@timers.values.select(&:active).map(&:due_at).min
|
|
109
185
|
end
|
|
110
186
|
|
|
187
|
+
# The earliest due time among pending requestAnimationFrame callbacks, or
|
|
188
|
+
# nil when none are scheduled. Lets a host flush RAF (advancing to the next
|
|
189
|
+
# frame boundary) during a `settle` without jumping the clock past
|
|
190
|
+
# not-yet-due `setTimeout`s.
|
|
191
|
+
def next_animation_frame_at
|
|
192
|
+
@timers.values.select { |t| t.active && t.kind == :raf }.map(&:due_at).min
|
|
193
|
+
end
|
|
194
|
+
|
|
111
195
|
private
|
|
112
196
|
|
|
113
197
|
def next_id
|
|
@@ -118,39 +202,91 @@ module Dommy
|
|
|
118
202
|
|
|
119
203
|
def register_timer(kind, callback, delay_ms, interval_ms)
|
|
120
204
|
id = next_id
|
|
121
|
-
|
|
122
|
-
|
|
205
|
+
nesting = @nesting_level + 1
|
|
206
|
+
delay = clamp_nested_delay([delay_ms, 0].max, nesting)
|
|
207
|
+
due_at = @now_ms + delay
|
|
208
|
+
@timers[id] = Timer.new(id, kind, callback, due_at, interval_ms, true, nesting)
|
|
123
209
|
id
|
|
124
210
|
end
|
|
125
211
|
|
|
212
|
+
# HTML timer initialization step: a timer nested deeper than 5 with a sub-4ms
|
|
213
|
+
# timeout is clamped to 4ms.
|
|
214
|
+
def clamp_nested_delay(delay, nesting)
|
|
215
|
+
nesting > MAX_NESTING_BEFORE_CLAMP && delay < MIN_NESTED_DELAY_MS ? MIN_NESTED_DELAY_MS : delay
|
|
216
|
+
end
|
|
217
|
+
|
|
126
218
|
def cancel_timer(id)
|
|
127
|
-
|
|
219
|
+
# WHATWG: clearTimeout/clearInterval with a missing or non-numeric handle
|
|
220
|
+
# (e.g. `clearTimeout(undefined)`, which React's scheduler emits) is a
|
|
221
|
+
# silent no-op rather than an error.
|
|
222
|
+
return unless id.respond_to?(:to_i)
|
|
223
|
+
|
|
224
|
+
key = id.to_i
|
|
225
|
+
timer = @timers[key]
|
|
128
226
|
timer.active = false if timer
|
|
129
|
-
@timers.delete(
|
|
227
|
+
@timers.delete(key)
|
|
130
228
|
nil
|
|
131
229
|
end
|
|
132
230
|
|
|
133
231
|
def run_due_timers
|
|
134
232
|
due = @timers.values.select { |timer| timer.active && timer.due_at <= @now_ms }
|
|
135
233
|
due.sort_by!(&:id)
|
|
234
|
+
# Each ordinary timer task is followed by a microtask checkpoint (WHATWG
|
|
235
|
+
# §8.1.7.3). The animation-frame callbacks of one rendering update are an
|
|
236
|
+
# exception: they run consecutively and share a single checkpoint after the
|
|
237
|
+
# batch (a microtask queued by one rAF must not run before the next rAF in
|
|
238
|
+
# the same frame), so they are checkpointed once at the end.
|
|
239
|
+
raf_ran = false
|
|
136
240
|
due.each do |timer|
|
|
137
241
|
next unless timer.active
|
|
138
242
|
|
|
139
243
|
case timer.kind
|
|
140
244
|
when :raf
|
|
141
245
|
@timers.delete(timer.id)
|
|
142
|
-
|
|
246
|
+
invoke_timer(timer, @now_ms.to_f)
|
|
247
|
+
raf_ran = true
|
|
143
248
|
when :interval
|
|
144
|
-
|
|
145
|
-
|
|
249
|
+
invoke_timer(timer)
|
|
250
|
+
if timer.active
|
|
251
|
+
# Each interval iteration nests one deeper, so a setInterval(0) is
|
|
252
|
+
# clamped to 4ms once past the nesting threshold (HTML timer steps).
|
|
253
|
+
timer.nesting += 1
|
|
254
|
+
timer.due_at = @now_ms + clamp_nested_delay(timer.interval_ms, timer.nesting)
|
|
255
|
+
end
|
|
256
|
+
perform_microtask_checkpoint
|
|
146
257
|
when :idle
|
|
147
258
|
@timers.delete(timer.id)
|
|
148
|
-
|
|
259
|
+
invoke_timer(timer, IDLE_DEADLINE.dup)
|
|
260
|
+
perform_microtask_checkpoint
|
|
149
261
|
else
|
|
150
262
|
@timers.delete(timer.id)
|
|
151
|
-
|
|
263
|
+
invoke_timer(timer)
|
|
264
|
+
perform_microtask_checkpoint
|
|
152
265
|
end
|
|
153
266
|
end
|
|
267
|
+
perform_microtask_checkpoint if raf_ran
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Run a single timer's callback. A raised error is offered to
|
|
272
|
+
# `timer_error_handler` (set by the JS runtime); if it swallows the error the
|
|
273
|
+
# timer is dropped (so a runaway interval cannot re-stall every tick) and
|
|
274
|
+
# browsing continues. With no handler — or one that declines — the error
|
|
275
|
+
# propagates, preserving the default crash-on-bug behavior.
|
|
276
|
+
def invoke_timer(timer, *args)
|
|
277
|
+
# Run the callback at this timer's nesting level, so a timer it schedules
|
|
278
|
+
# nests one deeper (driving the 4ms clamp). Restored even if it throws.
|
|
279
|
+
prev_nesting = @nesting_level
|
|
280
|
+
@nesting_level = timer.nesting || 0
|
|
281
|
+
CallableInvoker.invoke(timer.callback, *args)
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
raise unless @timer_error_handler&.call(e, timer)
|
|
284
|
+
|
|
285
|
+
timer.active = false
|
|
286
|
+
@timers.delete(timer.id)
|
|
287
|
+
nil
|
|
288
|
+
ensure
|
|
289
|
+
@nesting_level = prev_nesting
|
|
154
290
|
end
|
|
155
291
|
end
|
|
156
292
|
end
|
data/lib/dommy/screen.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `window.screen` — the Screen interface. A headless browser has no physical
|
|
5
|
+
# display, so the screen reports the (approximate) viewport size, with standard
|
|
6
|
+
# desktop colour depth and an orientation derived from the aspect ratio.
|
|
7
|
+
#
|
|
8
|
+
# `window.screen` is always present in a real browser, and `screen.width` /
|
|
9
|
+
# `screen.height` are routinely read *unguarded* (analytics, responsive logic),
|
|
10
|
+
# so a missing `screen` makes them throw "cannot read property 'width' of
|
|
11
|
+
# undefined" — which is exactly what real sites hit when Dommy lacked it.
|
|
12
|
+
class Screen
|
|
13
|
+
def initialize(window)
|
|
14
|
+
@window = window
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def __js_get__(key)
|
|
18
|
+
case key
|
|
19
|
+
when "width", "availWidth"
|
|
20
|
+
@window.inner_width
|
|
21
|
+
when "height", "availHeight"
|
|
22
|
+
@window.inner_height
|
|
23
|
+
when "availLeft", "availTop"
|
|
24
|
+
0
|
|
25
|
+
when "colorDepth", "pixelDepth"
|
|
26
|
+
24
|
|
27
|
+
when "isExtended"
|
|
28
|
+
false
|
|
29
|
+
when "orientation"
|
|
30
|
+
@orientation ||= ScreenOrientation.new(@window)
|
|
31
|
+
else
|
|
32
|
+
# Anything else is genuinely absent (JS `undefined`, `"x" in screen`
|
|
33
|
+
# false) so feature detection takes the not-supported path.
|
|
34
|
+
Bridge::ABSENT
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def __js_set__(_key, _value)
|
|
39
|
+
Bridge::UNHANDLED
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
include Bridge::Methods
|
|
43
|
+
# Screen is an EventTarget; Dommy never fires screen events, so a listener is
|
|
44
|
+
# accepted and simply never invoked.
|
|
45
|
+
js_methods %w[addEventListener removeEventListener dispatchEvent]
|
|
46
|
+
def __js_call__(method, _args)
|
|
47
|
+
case method
|
|
48
|
+
when "addEventListener", "removeEventListener" then nil
|
|
49
|
+
when "dispatchEvent" then true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `screen.orientation` — the ScreenOrientation interface. Reports a fixed
|
|
55
|
+
# orientation from the viewport aspect ratio; `lock`/`unlock` are no-ops (a
|
|
56
|
+
# headless browser cannot rotate), `lock` still returning a resolved Promise so
|
|
57
|
+
# callers that `await screen.orientation.lock(...)` proceed.
|
|
58
|
+
class ScreenOrientation
|
|
59
|
+
def initialize(window)
|
|
60
|
+
@window = window
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def __js_get__(key)
|
|
64
|
+
case key
|
|
65
|
+
when "type"
|
|
66
|
+
@window.inner_width >= @window.inner_height ? "landscape-primary" : "portrait-primary"
|
|
67
|
+
when "angle"
|
|
68
|
+
0
|
|
69
|
+
else
|
|
70
|
+
Bridge::ABSENT
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def __js_set__(_key, _value)
|
|
75
|
+
Bridge::UNHANDLED
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
include Bridge::Methods
|
|
79
|
+
js_methods %w[lock unlock addEventListener removeEventListener dispatchEvent]
|
|
80
|
+
def __js_call__(method, _args)
|
|
81
|
+
case method
|
|
82
|
+
when "lock"
|
|
83
|
+
PromiseValue.resolve(@window, nil)
|
|
84
|
+
when "unlock", "addEventListener", "removeEventListener"
|
|
85
|
+
nil
|
|
86
|
+
when "dispatchEvent"
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/dommy/shadow_root.rb
CHANGED
|
@@ -9,7 +9,9 @@ module Dommy
|
|
|
9
9
|
#
|
|
10
10
|
# Tree manipulation works the same as a normal Element/Fragment;
|
|
11
11
|
# the boundary is enforced only on outer queries and event
|
|
12
|
-
# composition. CSS scoping (`:host
|
|
12
|
+
# composition. CSS scoping (`:host` / `::slotted` / `::part`, and this
|
|
13
|
+
# tree's `<style>` rules) is handled by the cascade — see
|
|
14
|
+
# `internal/css/rule_index.rb` and css-cascade.md.
|
|
13
15
|
class ShadowRoot
|
|
14
16
|
include EventTarget
|
|
15
17
|
include Node
|
|
@@ -25,7 +27,7 @@ module Dommy
|
|
|
25
27
|
@delegates_focus = !!delegates_focus
|
|
26
28
|
@slot_assignment = slot_assignment.to_s
|
|
27
29
|
@document = host.document
|
|
28
|
-
@__node__ = @document.
|
|
30
|
+
@__node__ = @document.backend_doc.fragment("")
|
|
29
31
|
@document.__internal_register_shadow_fragment__(@__node__, self)
|
|
30
32
|
end
|
|
31
33
|
|
|
@@ -38,7 +40,7 @@ module Dommy
|
|
|
38
40
|
def inner_html=(html)
|
|
39
41
|
removed = @__node__.children.to_a
|
|
40
42
|
removed.each(&:unlink)
|
|
41
|
-
fragment = Parser.fragment(html.to_s, owner_doc: @document.
|
|
43
|
+
fragment = Parser.fragment(html.to_s, owner_doc: @document.backend_doc)
|
|
42
44
|
added = fragment.children.to_a
|
|
43
45
|
added.each { |n| @__node__.add_child(n) }
|
|
44
46
|
notify_child_list(added: added, removed: removed)
|
|
@@ -51,7 +53,7 @@ module Dommy
|
|
|
51
53
|
|
|
52
54
|
def text_content=(value)
|
|
53
55
|
@__node__.children.each(&:unlink)
|
|
54
|
-
@__node__.add_child(Backend.create_text(value.to_s, @document.
|
|
56
|
+
@__node__.add_child(Backend.create_text(value.to_s, @document.backend_doc))
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def children
|
|
@@ -83,26 +85,73 @@ module Dommy
|
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def query_selector(selector)
|
|
86
|
-
return nil if selector.nil?
|
|
88
|
+
return nil if selector.nil?
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
91
|
+
Internal::SelectorMatcher.query_first(self, ast, scope: self)
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
def query_selector_all(selector)
|
|
92
|
-
return NodeList.new if selector.nil?
|
|
95
|
+
return NodeList.new if selector.nil?
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
98
|
+
NodeList.new(Internal::SelectorMatcher.query(self, ast, scope: self))
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
def get_element_by_id(id)
|
|
98
|
-
return nil if id.nil?
|
|
102
|
+
return nil if id.nil? || id.to_s.empty?
|
|
99
103
|
|
|
100
|
-
|
|
104
|
+
# getElementById matches the `id` attribute literally, not as a CSS
|
|
105
|
+
# selector, so escape special characters (e.g. React `useId` `:rjm:`) to a
|
|
106
|
+
# valid id-selector ident — a raw "##{id}" would be an invalid selector.
|
|
107
|
+
@document.wrap_node(@__node__.at_css("##{Dommy::CSSNamespace.escape(id)}"))
|
|
101
108
|
end
|
|
102
109
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
110
|
+
# Node-level child mutations the spec exposes on a DocumentFragment (and so
|
|
111
|
+
# on a ShadowRoot). lit-html drives rendering through `insertBefore` /
|
|
112
|
+
# `removeChild` / `replaceChild` against the render root, so a shadow root
|
|
113
|
+
# must support them — not just appendChild.
|
|
114
|
+
def insert_before(node, ref)
|
|
115
|
+
nodes = detach_dom_nodes(node)
|
|
116
|
+
ref_bn = ref.respond_to?(:__dommy_backend_node__) ? ref.__dommy_backend_node__ : nil
|
|
117
|
+
if ref_bn && ref_bn.parent == @__node__
|
|
118
|
+
nodes.each { |n| ref_bn.add_previous_sibling(n) }
|
|
119
|
+
else
|
|
120
|
+
nodes.each { |n| @__node__.add_child(n) }
|
|
121
|
+
end
|
|
122
|
+
notify_child_list(added: nodes)
|
|
123
|
+
node
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def remove_child(node)
|
|
127
|
+
bn = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
|
|
128
|
+
raise DOMException::NotFoundError, "node is not a child of this shadow root" unless bn && bn.parent == @__node__
|
|
129
|
+
|
|
130
|
+
bn.unlink
|
|
131
|
+
notify_child_list(removed: [bn])
|
|
132
|
+
node
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def replace_child(new_child, old_child)
|
|
136
|
+
old_bn = old_child.respond_to?(:__dommy_backend_node__) ? old_child.__dommy_backend_node__ : nil
|
|
137
|
+
raise DOMException::NotFoundError, "node is not a child of this shadow root" unless old_bn && old_bn.parent == @__node__
|
|
138
|
+
|
|
139
|
+
added = detach_dom_nodes(new_child)
|
|
140
|
+
added.each { |n| old_bn.add_previous_sibling(n) }
|
|
141
|
+
old_bn.unlink
|
|
142
|
+
notify_child_list(added: added, removed: [old_bn])
|
|
143
|
+
old_child
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# `getRootNode()` returns the ShadowRoot itself; `getRootNode({composed:
|
|
147
|
+
# true})` crosses the shadow boundary and returns the root of the host's
|
|
148
|
+
# tree (the document, or an outer shadow root for nested shadows).
|
|
149
|
+
def get_root_node(options = nil)
|
|
150
|
+
composed = options.is_a?(Hash) &&
|
|
151
|
+
EventTarget.js_truthy?(options.key?("composed") ? options["composed"] : options[:composed])
|
|
152
|
+
return self unless composed
|
|
153
|
+
return @host.root_node({"composed" => true}) if @host.respond_to?(:root_node)
|
|
154
|
+
|
|
106
155
|
self
|
|
107
156
|
end
|
|
108
157
|
|
|
@@ -154,6 +203,13 @@ module Dommy
|
|
|
154
203
|
last_element_child
|
|
155
204
|
when "nodeType"
|
|
156
205
|
11
|
|
206
|
+
when "nodeName"
|
|
207
|
+
"#document-fragment"
|
|
208
|
+
else
|
|
209
|
+
# Any unknown key (incl. framework-private `_`/`$` expandos like
|
|
210
|
+
# lit-html's `_$litPart$`, which it probes with `=== undefined`) is
|
|
211
|
+
# genuinely absent: JS `undefined`, `in` false — matching Element.
|
|
212
|
+
Bridge::ABSENT
|
|
157
213
|
end
|
|
158
214
|
end
|
|
159
215
|
|
|
@@ -173,6 +229,7 @@ module Dommy
|
|
|
173
229
|
include Bridge::Methods
|
|
174
230
|
js_methods %w[
|
|
175
231
|
querySelector querySelectorAll getElementById append prepend replaceChildren appendChild
|
|
232
|
+
insertBefore removeChild replaceChild
|
|
176
233
|
getRootNode contains addEventListener removeEventListener dispatchEvent
|
|
177
234
|
isEqualNode isSameNode hasChildNodes normalize compareDocumentPosition
|
|
178
235
|
]
|
|
@@ -202,6 +259,12 @@ module Dommy
|
|
|
202
259
|
replace_children(*args)
|
|
203
260
|
when "appendChild"
|
|
204
261
|
append_child(args[0])
|
|
262
|
+
when "insertBefore"
|
|
263
|
+
insert_before(args[0], args[1])
|
|
264
|
+
when "removeChild"
|
|
265
|
+
remove_child(args[0])
|
|
266
|
+
when "replaceChild"
|
|
267
|
+
replace_child(args[0], args[1])
|
|
205
268
|
when "getRootNode"
|
|
206
269
|
get_root_node(args[0])
|
|
207
270
|
when "contains"
|
data/lib/dommy/storage.rb
CHANGED
data/lib/dommy/streams.rb
CHANGED
|
@@ -99,6 +99,8 @@ module Dommy
|
|
|
99
99
|
case key
|
|
100
100
|
when "locked"
|
|
101
101
|
locked
|
|
102
|
+
else
|
|
103
|
+
Bridge::ABSENT
|
|
102
104
|
end
|
|
103
105
|
end
|
|
104
106
|
|
|
@@ -244,6 +246,8 @@ module Dommy
|
|
|
244
246
|
case key
|
|
245
247
|
when "locked"
|
|
246
248
|
locked
|
|
249
|
+
else
|
|
250
|
+
Bridge::ABSENT
|
|
247
251
|
end
|
|
248
252
|
end
|
|
249
253
|
|
|
@@ -340,6 +344,8 @@ module Dommy
|
|
|
340
344
|
@readable
|
|
341
345
|
when "writable"
|
|
342
346
|
@writable
|
|
347
|
+
else
|
|
348
|
+
Bridge::ABSENT
|
|
343
349
|
end
|
|
344
350
|
end
|
|
345
351
|
end
|
data/lib/dommy/text_codec.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Dommy
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def __js_get__(key)
|
|
22
|
-
key == "encoding" ? encoding :
|
|
22
|
+
key == "encoding" ? encoding : Bridge::ABSENT
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
include Bridge::Methods
|
|
@@ -76,6 +76,8 @@ module Dommy
|
|
|
76
76
|
when "encoding" then @encoding
|
|
77
77
|
when "fatal" then @fatal
|
|
78
78
|
when "ignoreBOM" then @ignore_bom
|
|
79
|
+
else
|
|
80
|
+
Bridge::ABSENT
|
|
79
81
|
end
|
|
80
82
|
end
|
|
81
83
|
|
|
@@ -289,6 +291,8 @@ module Dommy
|
|
|
289
291
|
@writable
|
|
290
292
|
when "encoding"
|
|
291
293
|
encoding
|
|
294
|
+
else
|
|
295
|
+
Bridge::ABSENT
|
|
292
296
|
end
|
|
293
297
|
end
|
|
294
298
|
end
|
|
@@ -322,6 +326,8 @@ module Dommy
|
|
|
322
326
|
@writable
|
|
323
327
|
when "encoding"
|
|
324
328
|
@encoding
|
|
329
|
+
else
|
|
330
|
+
Bridge::ABSENT
|
|
325
331
|
end
|
|
326
332
|
end
|
|
327
333
|
end
|
data/lib/dommy/tree_walker.rb
CHANGED
|
@@ -42,9 +42,13 @@ module Dommy
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
44
|
# Returns FILTER_ACCEPT / FILTER_REJECT / FILTER_SKIP for the
|
|
45
|
-
# given wrapped node.
|
|
45
|
+
# given wrapped node. Per WHATWG "filter", the active-flag re-entrancy
|
|
46
|
+
# check comes first: a NodeFilter that re-enters its own walker/iterator
|
|
47
|
+
# (calls nextNode etc. while the filter is still running) is an
|
|
48
|
+
# InvalidStateError.
|
|
46
49
|
def accept(node)
|
|
47
50
|
return NodeFilter::FILTER_REJECT unless node
|
|
51
|
+
raise DOMException::InvalidStateError, "NodeFilter is already active" if @active
|
|
48
52
|
return NodeFilter::FILTER_SKIP if (NodeFilter.bitmask_for(node) & @what_to_show) == 0
|
|
49
53
|
|
|
50
54
|
result = invoke_filter(node)
|
|
@@ -58,11 +62,25 @@ module Dommy
|
|
|
58
62
|
# A non-null filter with no callable acceptNode is a TypeError when invoked.
|
|
59
63
|
raise Bridge::TypeError, "NodeFilter is not callable" unless cb
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
# The active flag is set around the user callback only; a re-entrant
|
|
66
|
+
# traversal during this window trips the guard in `accept`. It must be
|
|
67
|
+
# cleared even when the callback throws so the walker stays usable.
|
|
68
|
+
@active = true
|
|
69
|
+
result =
|
|
70
|
+
begin
|
|
71
|
+
# A NodeFilter's exception must propagate out of the traversal method,
|
|
72
|
+
# so use the raising invocation when the callback supports it (a JS
|
|
73
|
+
# function); a Ruby callable propagates naturally.
|
|
74
|
+
if cb.respond_to?(:__js_call_with_raise__)
|
|
75
|
+
cb.__js_call_with_raise__([node])
|
|
76
|
+
elsif cb.respond_to?(:__js_call__)
|
|
77
|
+
cb.__js_call__("call", [node])
|
|
78
|
+
else
|
|
79
|
+
cb.call(node)
|
|
80
|
+
end
|
|
81
|
+
ensure
|
|
82
|
+
@active = false
|
|
83
|
+
end
|
|
66
84
|
# WebIDL coerces the filter return (an `unsigned short`): booleans and
|
|
67
85
|
# null become 0/1, everything else ToInteger.
|
|
68
86
|
return 1 if result == true
|
|
@@ -200,6 +218,8 @@ module Dommy
|
|
|
200
218
|
@filter
|
|
201
219
|
when "currentNode"
|
|
202
220
|
@current_node
|
|
221
|
+
else
|
|
222
|
+
Bridge::ABSENT
|
|
203
223
|
end
|
|
204
224
|
end
|
|
205
225
|
|
|
@@ -345,13 +365,13 @@ module Dommy
|
|
|
345
365
|
end
|
|
346
366
|
|
|
347
367
|
# The Nokogiri node backing a wrapper — an element's `__dommy_backend_node__`
|
|
348
|
-
# or, for a Document (which isn't a wrapped node), its `
|
|
368
|
+
# or, for a Document (which isn't a wrapped node), its `backend_doc`. Lets a
|
|
349
369
|
# walker rooted at the document descend into its children.
|
|
350
370
|
def backend_node_of(node)
|
|
351
371
|
if node.respond_to?(:__dommy_backend_node__)
|
|
352
372
|
node.__dommy_backend_node__
|
|
353
|
-
elsif node.respond_to?(:
|
|
354
|
-
node.
|
|
373
|
+
elsif node.respond_to?(:backend_doc)
|
|
374
|
+
node.backend_doc
|
|
355
375
|
end
|
|
356
376
|
end
|
|
357
377
|
|
|
@@ -372,7 +392,8 @@ module Dommy
|
|
|
372
392
|
|
|
373
393
|
def last_wrapped_child(node)
|
|
374
394
|
bn = backend_node_of(node)
|
|
375
|
-
|
|
395
|
+
# `.to_a` first: Nokogiri's NodeSet has `#reverse`, Makiri's does not.
|
|
396
|
+
bn ? first_wrappable(bn.children.to_a.reverse, node) : nil
|
|
376
397
|
end
|
|
377
398
|
|
|
378
399
|
def next_sibling_wrapped(node)
|
|
@@ -490,6 +511,8 @@ module Dommy
|
|
|
490
511
|
@reference_node
|
|
491
512
|
when "pointerBeforeReferenceNode"
|
|
492
513
|
@pointer_before_reference
|
|
514
|
+
else
|
|
515
|
+
Bridge::ABSENT
|
|
493
516
|
end
|
|
494
517
|
end
|
|
495
518
|
|
data/lib/dommy/url.rb
CHANGED
|
@@ -260,6 +260,8 @@ module Dommy
|
|
|
260
260
|
when "username" then username
|
|
261
261
|
when "password" then password
|
|
262
262
|
when "searchParams" then @search_params
|
|
263
|
+
else
|
|
264
|
+
Bridge::ABSENT
|
|
263
265
|
end
|
|
264
266
|
end
|
|
265
267
|
|
|
@@ -461,6 +463,8 @@ module Dommy
|
|
|
461
463
|
case key
|
|
462
464
|
when "size", "length"
|
|
463
465
|
size
|
|
466
|
+
else
|
|
467
|
+
Bridge::ABSENT
|
|
464
468
|
end
|
|
465
469
|
end
|
|
466
470
|
|
|
@@ -517,7 +521,15 @@ module Dommy
|
|
|
517
521
|
def parse(input)
|
|
518
522
|
case input
|
|
519
523
|
when Array
|
|
520
|
-
|
|
524
|
+
# A sequence init is a sequence of [name, value] pairs: each inner element
|
|
525
|
+
# must itself be a 2-element sequence, else the constructor throws.
|
|
526
|
+
input.map do |pair|
|
|
527
|
+
unless pair.is_a?(Array) && pair.length == 2
|
|
528
|
+
raise Bridge::TypeError, "Failed to construct 'URLSearchParams': each pair must have exactly two elements"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
[pair[0].to_s, pair[1].to_s]
|
|
532
|
+
end
|
|
521
533
|
when Hash
|
|
522
534
|
input.map { |k, v| [k.to_s, v.to_s] }
|
|
523
535
|
else
|