dommy 0.5.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dommy
6
+ # `fetch` polyfill. No real network — instead consults
7
+ # `JS.global[:__fetchy_stub__]` (a Hash{url => entry}) installed by
8
+ # the test. Mirrors the same fixture protocol that `test_fetchy.rb`'s
9
+ # JavaScript installer uses, so tests don't need a JS engine to drive
10
+ # the stub.
11
+ #
12
+ # Each entry in the stub hash supports:
13
+ # "status" / "statusText" / "body" / "contentType" /
14
+ # "headers" (Hash) / "delay" (ms)
15
+ # plus AbortController signal propagation when `init[:signal]` is
16
+ # passed.
17
+ class FetchFn
18
+ def initialize(window)
19
+ @window = window
20
+ end
21
+
22
+ # JS calls `fetch(url, init)` end up here via either Window-level
23
+ # `__js_call__("fetch", ...)` or as a callable handle. Both routes
24
+ # delegate to `call(args)` so behavior is identical.
25
+ def __js_call__(_method, args)
26
+ url = args[0].to_s
27
+ init = normalize_init(args[1] || {})
28
+
29
+ # Each spec file installs its stub under its own global name.
30
+ # `test_fetchy.rb` uses `__fetchy_stub__`; `test_resource*.rb`
31
+ # use `__resource_fetch_stub__` and `__inject_fetch_stub__`.
32
+ # Check them in order — only one should be set at a time.
33
+ stub_map = @window.globals["__fetchy_stub__"] ||
34
+ @window.globals["__resource_fetch_stub__"] ||
35
+ @window.globals["__inject_fetch_stub__"] ||
36
+ {}
37
+ # `js_eval`'s JS installer increments these globals; mirror so
38
+ # specs that probe `__fetch_count__` / `__last_url__` / etc.
39
+ # observe the same state shape they'd see from a real injector.
40
+ @window.globals["__fetch_count__"] = (@window.globals["__fetch_count__"] || 0).to_i + 1
41
+ @window.globals["__last_url__"] = url
42
+ @window.globals["__last_init__"] = init
43
+ @window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
44
+
45
+ entry = stub_map[url] if stub_map.is_a?(Hash)
46
+ promise = PromiseValue.new(@window)
47
+
48
+ if entry.nil?
49
+ response = Response.new(@window, body: "not found", status: 404, status_text: "Not Found")
50
+ promise.fulfill(response)
51
+ return promise
52
+ end
53
+
54
+ body = entry["body"]
55
+ status = (entry["status"] || 200).to_i
56
+ status_text = entry["statusText"] || ""
57
+ content_type = entry["contentType"] || "text/plain"
58
+ headers = entry["headers"] || {"Content-Type" => content_type}
59
+
60
+ delay = entry["delay"]
61
+ if delay
62
+ install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
63
+ else
64
+ promise.fulfill(
65
+ Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, url: url)
66
+ )
67
+ end
68
+
69
+ promise
70
+ end
71
+
72
+ private
73
+
74
+ # Coerce `init` into a Hash with string keys so the rest of the
75
+ # pipeline (and the `__last_init__` globals) sees a uniform shape.
76
+ # When the body is a Blob/File, fill in `Content-Type` from the
77
+ # blob's type unless the caller already provided a header for it.
78
+ def normalize_init(init)
79
+ return init unless init.is_a?(Hash)
80
+
81
+ h = init.transform_keys(&:to_s)
82
+ body = h["body"]
83
+ return h unless body.is_a?(Blob)
84
+
85
+ headers = (h["headers"] || {}).dup
86
+ content_type_set = headers.any? { |k, _| k.to_s.downcase == "content-type" }
87
+ headers["Content-Type"] = body.type if !content_type_set && !body.type.empty?
88
+ h["headers"] = headers
89
+ h
90
+ end
91
+
92
+ def install_delayed_resolve(promise, body, status, status_text, headers, init, delay_ms)
93
+ # AbortController cancellation: when init.signal is present and
94
+ # `.abort()` fires before the timer, reject with an AbortError.
95
+ # The timer is cleared in that path so it doesn't leak through
96
+ # the test scheduler's drain loop.
97
+ cancelled = [false]
98
+ timer_id = @window.scheduler.set_timeout(
99
+ lambda do |*_args|
100
+ next if cancelled[0]
101
+
102
+ promise.fulfill(Response.new(@window, body: body, status: status, status_text: status_text, headers: headers))
103
+ end,
104
+ delay_ms.to_i
105
+ )
106
+ signal = init.is_a?(Hash) ? init["signal"] : nil
107
+ return unless signal.respond_to?(:__js_call__)
108
+
109
+ window_ref = @window
110
+ abort_cb = lambda do |*_args|
111
+ cancelled[0] = true
112
+ window_ref.scheduler.clear_timeout(timer_id)
113
+ err = ErrorValue.new("aborted", name: "AbortError")
114
+ promise.reject(err)
115
+ end
116
+
117
+ signal.__js_call__("addEventListener", ["abort", abort_cb])
118
+ end
119
+ end
120
+
121
+ # `Response` polyfill — just enough surface for Fetchy:
122
+ # `[:status]` / `[:ok]` / `[:url]` / `[:headers]` (with
123
+ # `.entries()` / `.get(name)`) and `.text()` / `.json()` / `.body`
124
+ # / `.arrayBuffer()` which all return Promise-like values.
125
+ class Response
126
+ def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "")
127
+ @window = window
128
+ @body = body.to_s
129
+ @status = status
130
+ @status_text = status_text.to_s
131
+ @headers = Headers.new(headers || {})
132
+ @url = url.to_s
133
+ end
134
+
135
+ def __js_get__(key)
136
+ case key
137
+ when "status"
138
+ @status
139
+ when "ok"
140
+ @status >= 200 && @status < 300
141
+ when "statusText"
142
+ @status_text
143
+ when "url"
144
+ @url
145
+ when "headers"
146
+ @headers
147
+ when "body"
148
+ @body
149
+ end
150
+ end
151
+
152
+ def __js_set__(_key, _value)
153
+ nil
154
+ end
155
+
156
+ def __js_call__(method, _args)
157
+ case method
158
+ when "text"
159
+ immediate(@body)
160
+ when "json"
161
+ begin
162
+ immediate(JSON.parse(@body))
163
+ rescue JSON::ParserError => e
164
+ err = ErrorValue.new("JSON parse: #{e.message}")
165
+ rejected(err)
166
+ end
167
+
168
+ when "arrayBuffer", "blob"
169
+ immediate(@body)
170
+ when "clone"
171
+ Response.new(
172
+ @window,
173
+ body: @body,
174
+ status: @status,
175
+ status_text: @status_text,
176
+ headers: @headers.to_h,
177
+ url: @url
178
+ )
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def immediate(value)
185
+ PromiseValue.resolve(@window, value)
186
+ end
187
+
188
+ def rejected(value)
189
+ PromiseValue.reject(@window, value)
190
+ end
191
+ end
192
+
193
+ # Minimal `Headers` proxy. Consumer code typically calls
194
+ # `headers.call(:entries)` and iterates via `Array.from(...)`, so
195
+ # we just need `entries` and `get`.
196
+ class Headers
197
+ def initialize(hash)
198
+ @hash = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
199
+ end
200
+
201
+ def to_h
202
+ @hash.dup
203
+ end
204
+
205
+ def __js_get__(_key)
206
+ nil
207
+ end
208
+
209
+ def __js_set__(_key, _value)
210
+ nil
211
+ end
212
+
213
+ def __js_call__(method, args)
214
+ case method
215
+ when "get"
216
+ name = args[0].to_s
217
+ @hash[name] || @hash[Headers.canonical(name)]
218
+ when "entries"
219
+ @hash.to_a
220
+ when "has"
221
+ @hash.key?(args[0].to_s)
222
+ when "forEach"
223
+ # Browser API: forEach(callback) — callback(value, key)
224
+ cb = args[0]
225
+ @hash.each do |k, v|
226
+ if cb.respond_to?(:__js_call__)
227
+ cb.__js_call__("call", [v, k])
228
+ elsif cb.respond_to?(:call)
229
+ cb.call(v, k)
230
+ end
231
+ end
232
+
233
+ nil
234
+ end
235
+ end
236
+
237
+ def self.canonical(name)
238
+ name.split("-").map(&:capitalize).join("-")
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `FormData` — collects name/value entries from an `<form>` (or
5
+ # programmatically), preserving insertion order. Values are
6
+ # stringified per spec; `File` values are passed through as-is
7
+ # (Dommy has no File class, so this only matters for embedders
8
+ # that supply their own).
9
+ #
10
+ # Usage:
11
+ # fd = Dommy::FormData.new(form)
12
+ # fd.get("email") # "alice@x.test"
13
+ # fd.append("tag", "ruby")
14
+ # fd.entries # [["email", "..."], ["tag", "ruby"]]
15
+ class FormData
16
+ include Enumerable
17
+
18
+ def initialize(form = nil)
19
+ @pairs = []
20
+ collect_from(form) if form
21
+ end
22
+
23
+ def append(name, value, _filename = nil)
24
+ @pairs << [name.to_s, stringify(value)]
25
+ nil
26
+ end
27
+
28
+ def set(name, value, _filename = nil)
29
+ key = name.to_s
30
+ v = stringify(value)
31
+ replaced = false
32
+ @pairs = @pairs.flat_map do |k, existing|
33
+ if k == key
34
+ if replaced
35
+ []
36
+ else
37
+ replaced = true
38
+ [[key, v]]
39
+ end
40
+ else
41
+ [[k, existing]]
42
+ end
43
+ end
44
+
45
+ @pairs << [key, v] unless replaced
46
+ nil
47
+ end
48
+
49
+ def get(name)
50
+ pair = @pairs.find { |k, _| k == name.to_s }
51
+ pair && pair[1]
52
+ end
53
+
54
+ def get_all(name)
55
+ @pairs.select { |k, _| k == name.to_s }.map { |_, v| v }
56
+ end
57
+
58
+ alias getAll get_all
59
+
60
+ def has(name)
61
+ @pairs.any? { |k, _| k == name.to_s }
62
+ end
63
+
64
+ alias has? has
65
+
66
+ def delete(name)
67
+ @pairs.reject! { |k, _| k == name.to_s }
68
+ nil
69
+ end
70
+
71
+ def keys
72
+ @pairs.map { |k, _| k }
73
+ end
74
+
75
+ def values
76
+ @pairs.map { |_, v| v }
77
+ end
78
+
79
+ def entries
80
+ @pairs.dup
81
+ end
82
+
83
+ def for_each(&block)
84
+ @pairs.each { |k, v| block.call(v, k, self) }
85
+ nil
86
+ end
87
+
88
+ alias forEach for_each
89
+
90
+ def each(&block)
91
+ @pairs.each(&block)
92
+ end
93
+
94
+ def size
95
+ @pairs.length
96
+ end
97
+
98
+ alias length size
99
+
100
+ def to_s
101
+ @pairs.map { |k, v| "#{k}=#{v}" }.join("&")
102
+ end
103
+
104
+ def __js_get__(key)
105
+ case key
106
+ when "size", "length"
107
+ size
108
+ end
109
+ end
110
+
111
+ def __js_call__(method, args)
112
+ case method
113
+ when "append"
114
+ append(args[0], args[1], args[2])
115
+ when "set"
116
+ set(args[0], args[1], args[2])
117
+ when "get"
118
+ get(args[0])
119
+ when "getAll"
120
+ get_all(args[0])
121
+ when "has"
122
+ has(args[0])
123
+ when "delete"
124
+ delete(args[0])
125
+ when "keys"
126
+ keys
127
+ when "values"
128
+ values
129
+ when "entries"
130
+ entries
131
+ when "forEach"
132
+ for_each(&args[0])
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ # Collect submittable name/value pairs from a form element.
139
+ # Per spec, the submitter (clicked button) is included only when
140
+ # the user passes it explicitly; we don't model that here.
141
+ def collect_from(form)
142
+ form.elements.each do |el|
143
+ next unless el.respond_to?(:name)
144
+
145
+ name = el.name.to_s
146
+ next if name.empty?
147
+ next if disabled?(el)
148
+
149
+ case el.__node__.name
150
+ when "input"
151
+ collect_input(el, name)
152
+ when "select"
153
+ collect_select(el, name)
154
+ when "textarea", "button", "output"
155
+ @pairs << [name, el.value.to_s] if el.respond_to?(:value)
156
+ end
157
+ end
158
+ end
159
+
160
+ def collect_input(el, name)
161
+ type = el.type.to_s.downcase
162
+ case type
163
+ when "submit", "reset", "button", "image"
164
+ # submit/button: only the activated submitter is included (skip).
165
+ nil
166
+ when "file"
167
+ # Each File in the input's FileList becomes its own entry, per
168
+ # the HTML "constructing the entry list" spec. An empty list
169
+ # contributes a single empty File-like entry so name= survives.
170
+ files = el.respond_to?(:files) ? el.files : nil
171
+ if files && !files.empty?
172
+ files.each { |f| @pairs << [name, f] }
173
+ else
174
+ @pairs << [name, File.new([], "", "type" => "application/octet-stream")]
175
+ end
176
+
177
+ when "checkbox", "radio"
178
+ @pairs << [name, (el.value.to_s.empty? ? "on" : el.value.to_s)] if el.checked
179
+ else
180
+ @pairs << [name, el.value.to_s]
181
+ end
182
+ end
183
+
184
+ def collect_select(el, name)
185
+ if el.multiple
186
+ el.selected_options.each { |opt| @pairs << [name, opt.value.to_s] }
187
+ else
188
+ opt = el.selected_options[0]
189
+ @pairs << [name, opt ? opt.value.to_s : ""]
190
+ end
191
+ end
192
+
193
+ def disabled?(el)
194
+ el.respond_to?(:disabled) && el.disabled
195
+ end
196
+
197
+ def stringify(value)
198
+ # File / Blob values pass through unchanged (multipart form
199
+ # encoding handles them); other values are stringified per spec.
200
+ return value if value.is_a?(Blob)
201
+ # Backward-compat: embedders' own file-marker objects.
202
+ return value if value.respond_to?(:__file_marker__)
203
+ return "" if value.nil?
204
+
205
+ value.to_s
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `HTMLCollection` — live, ordered set of Element nodes. Distinct
5
+ # from `NodeList` in two ways:
6
+ #
7
+ # - Always element-only (Node types other than Element are skipped)
8
+ # - Supports `namedItem(name)` lookup by `id` or `name` attribute
9
+ #
10
+ # Live behavior: pass an evaluator block (called `&compute`) that
11
+ # returns the current element list on every access. Each query
12
+ # re-evaluates, so mutations to the parent tree are reflected
13
+ # immediately.
14
+ #
15
+ # Intentionally NOT a subclass of Array; spec semantics demand
16
+ # `Array.isArray(html_collection) === false` in real browsers, and
17
+ # mirroring that here helps tests written against MDN behavior.
18
+ class HTMLCollection
19
+ include Enumerable
20
+
21
+ def initialize(&compute)
22
+ @compute = compute
23
+ end
24
+
25
+ def length
26
+ to_a.length
27
+ end
28
+
29
+ alias size length
30
+
31
+ def empty?
32
+ to_a.empty?
33
+ end
34
+
35
+ def item(index)
36
+ i = index.to_i
37
+ return nil if i < 0
38
+
39
+ to_a[i]
40
+ end
41
+
42
+ # `namedItem(name)` returns the first element whose `id` or
43
+ # `name` attribute equals `name`. Returns nil if no match.
44
+ def named_item(name)
45
+ key = name.to_s
46
+ return nil if key.empty?
47
+
48
+ to_a.find do |el|
49
+ next false unless el.respond_to?(:__node__)
50
+
51
+ el.__node__["id"].to_s == key || el.__node__["name"].to_s == key
52
+ end
53
+ end
54
+
55
+ # `[]` supports both integer index (`coll[0]`, `coll[-1]`) and
56
+ # string name (`coll["myId"]`). Negative indices are interpreted
57
+ # Ruby-style (offset from the end), even though the spec's
58
+ # `item(i)` is positive-only.
59
+ def [](key)
60
+ case key
61
+ when Integer
62
+ to_a[key]
63
+ when /\A-?\d+\z/
64
+ to_a[key.to_i]
65
+ else
66
+ named_item(key)
67
+ end
68
+ end
69
+
70
+ def first(n = nil)
71
+ n.nil? ? to_a.first : to_a.first(n)
72
+ end
73
+
74
+ def last(n = nil)
75
+ n.nil? ? to_a.last : to_a.last(n)
76
+ end
77
+
78
+ def each(&blk)
79
+ to_a.each(&blk)
80
+ end
81
+
82
+ def to_a
83
+ @compute.call
84
+ end
85
+
86
+ def __js_get__(key)
87
+ case key
88
+ when "length"
89
+ length
90
+ when Integer
91
+ item(key)
92
+ else
93
+ s = key.to_s
94
+ if s.match?(/\A\d+\z/)
95
+ item(s.to_i)
96
+ else
97
+ named_item(s) || (s == "length" ? length : nil)
98
+ end
99
+ end
100
+ end
101
+
102
+ def __js_call__(method, args)
103
+ case method
104
+ when "item"
105
+ item(args[0])
106
+ when "namedItem"
107
+ named_item(args[0])
108
+ end
109
+ end
110
+ end
111
+
112
+ # `HTMLOptionsCollection` — specialized `<select>.options` collection.
113
+ # Adds `add(option, before?)`, `remove(index)`, the `selectedIndex`
114
+ # getter/setter, and a `length=` setter that truncates or extends.
115
+ #
116
+ # Live, like the parent class. Constructed by `HTMLSelectElement`
117
+ # and passed its owner; mutations route through the owner's tree.
118
+ class HTMLOptionsCollection < HTMLCollection
119
+ def initialize(owner, &compute)
120
+ super(&compute)
121
+ @owner = owner
122
+ end
123
+
124
+ # Append (or insert before `before`) an option element. `before`
125
+ # accepts either another option (insert before that node) or an
126
+ # integer index. Strings/`null` append.
127
+ def add(option, before = nil)
128
+ return nil unless option.respond_to?(:__node__)
129
+
130
+ case before
131
+ when nil
132
+ @owner.append_child(option)
133
+ when Integer
134
+ anchor = item(before)
135
+ anchor ? @owner.insert_before(option, anchor) : @owner.append_child(option)
136
+ else
137
+ if before.respond_to?(:__node__)
138
+ @owner.insert_before(option, before)
139
+ else
140
+ @owner.append_child(option)
141
+ end
142
+ end
143
+
144
+ nil
145
+ end
146
+
147
+ def remove(index)
148
+ target = item(index)
149
+ target&.remove
150
+ nil
151
+ end
152
+
153
+ def selected_index
154
+ @owner.selected_index
155
+ end
156
+
157
+ def selected_index=(value)
158
+ @owner.selected_index = value
159
+ end
160
+
161
+ # Setter mirrors `<select>.options.length = n` — destructive resize.
162
+ # Shrinks by removing trailing options, grows by appending blank
163
+ # `<option>`s. Real browsers do the same.
164
+ def length=(new_length)
165
+ n = new_length.to_i
166
+ current = to_a
167
+ if n < current.length
168
+ current[n..].each(&:remove)
169
+ elsif n > current.length
170
+ (n - current.length).times { @owner.append_child(@owner.document.create_element("option")) }
171
+ end
172
+
173
+ n
174
+ end
175
+
176
+ def __js_get__(key)
177
+ case key
178
+ when "selectedIndex"
179
+ selected_index
180
+ else
181
+ super
182
+ end
183
+ end
184
+
185
+ def __js_set__(key, value)
186
+ case key
187
+ when "selectedIndex"
188
+ self.selected_index = value
189
+ when "length"
190
+ self.length = value
191
+ end
192
+
193
+ nil
194
+ end
195
+
196
+ def __js_call__(method, args)
197
+ case method
198
+ when "add"
199
+ add(args[0], args[1])
200
+ when "remove"
201
+ remove(args[0])
202
+ else
203
+ super
204
+ end
205
+ end
206
+ end
207
+ end