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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `Dommy::Bridge` — adapter classes for JS-style bridges (wasm
5
+ # embedders that route DOM method calls and constructor `new` ops
6
+ # through the `__js_get__` / `__js_set__` / `__js_call__` /
7
+ # `__js_new__` protocol).
8
+ #
9
+ # CRuby users writing happy-dom-style tests can ignore everything
10
+ # in this namespace; it's only relevant when integrating Dommy
11
+ # with an external runtime (such as an mruby-on-wasm host) that
12
+ # constructs callbacks / events / promises via the bridge view.
13
+ #
14
+ # The protocol contract:
15
+ # - `__js_get__(name)` reads a JS-style property by string name
16
+ # - `__js_set__(name, value)` writes one
17
+ # - `__js_call__(method, args)` invokes a method with positional
18
+ # args (Array)
19
+ # - `__js_new__(args)` invokes the value as a JS constructor
20
+ module Bridge
21
+ # Wraps an external callback handle (registered in a host-side
22
+ # callback table) so the JS bridge can resolve / invoke it. The
23
+ # external host that creates these is responsible for honoring
24
+ # `invoke_callback(callback_id, args)`.
25
+ #
26
+ # The `__callback_id__` key on this object exposes the integer
27
+ # id to JS-side code that needs to round-trip it (e.g. for
28
+ # release / introspection).
29
+ class Callback
30
+ ID_KEY = "__callback_id__"
31
+
32
+ def initialize(host, callback_id)
33
+ @host = host
34
+ @callback_id = callback_id
35
+ @props = {}
36
+ end
37
+
38
+ def __js_get__(key)
39
+ if key == ID_KEY
40
+ @props.fetch(key, @callback_id)
41
+ else
42
+ @props[key]
43
+ end
44
+ end
45
+
46
+ def __js_set__(key, value)
47
+ @props[key] = value
48
+ nil
49
+ end
50
+
51
+ def __js_call__(method, args)
52
+ case method
53
+ when "call"
54
+ @host.invoke_callback(@callback_id, args)
55
+ end
56
+ end
57
+ end
58
+
59
+ # Block-as-constructor adapter — invoking `__js_new__(args)`
60
+ # calls the wrapped block with `args` and returns whatever the
61
+ # block produces. Used by Window to wire up `new Event(init)`,
62
+ # `new CustomEvent(init)`, etc. without hand-rolling a class
63
+ # for each constructor.
64
+ class Constructor
65
+ def initialize(&block)
66
+ @block = block
67
+ @class_methods = {}
68
+ end
69
+
70
+ def __js_new__(args)
71
+ @block.call(args)
72
+ end
73
+
74
+ # Register a class-level method (e.g. `URL.createObjectURL`)
75
+ # that JS bridges resolve via `__js_call__` on the constructor
76
+ # itself. Returns self for chaining.
77
+ def define_class_method(name, &block)
78
+ @class_methods[name.to_s] = block
79
+ self
80
+ end
81
+
82
+ def __js_call__(method, args)
83
+ handler = @class_methods[method.to_s]
84
+ handler&.call(args)
85
+ end
86
+ end
87
+
88
+ # `JS.global[:Promise]` view. Implements the `resolve` / `reject`
89
+ # class methods plus `new Promise(executor)` via `__js_new__`.
90
+ class PromiseConstructor
91
+ def initialize(window)
92
+ @window = window
93
+ end
94
+
95
+ def __js_call__(method, args)
96
+ case method
97
+ when "resolve"
98
+ PromiseValue.resolve(@window, args[0])
99
+ when "reject"
100
+ PromiseValue.reject(@window, args[0])
101
+ end
102
+ end
103
+
104
+ # `new Promise(executor)` — runs executor synchronously with
105
+ # (resolve, reject) callbacks.
106
+ def __js_new__(args)
107
+ executor = args[0]
108
+ promise = PromiseValue.new(@window)
109
+ resolve = PromiseSettler.new(promise, fulfilled: true)
110
+ reject = PromiseSettler.new(promise, fulfilled: false)
111
+ if executor.respond_to?(:__js_call__)
112
+ executor.__js_call__("call", [resolve, reject])
113
+ elsif executor.respond_to?(:call)
114
+ executor.call(resolve, reject)
115
+ end
116
+
117
+ promise
118
+ end
119
+ end
120
+
121
+ # Adapter so a Ruby-side executor can deliver resolve/reject
122
+ # through the same `__js_call__("call", args)` interface that
123
+ # the scheduler and JS bridge use for callbacks.
124
+ class PromiseSettler
125
+ def initialize(promise, fulfilled:)
126
+ @promise = promise
127
+ @fulfilled = fulfilled
128
+ end
129
+
130
+ def __js_call__(_method, args)
131
+ if @fulfilled
132
+ @promise.fulfill(args[0])
133
+ else
134
+ @promise.reject(args[0])
135
+ end
136
+
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
data/lib/dommy/css.rb ADDED
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `CSSStyleSheet` — stub implementation. Dommy has no CSS parser
5
+ # nor a render tree, so we don't interpret rule text; the sheet
6
+ # acts as an ordered list of opaque `CSSRule`-like wrappers.
7
+ #
8
+ # Useful for code that does:
9
+ #
10
+ # sheet.insertRule("p { color: red }", 0);
11
+ # for (const r of sheet.cssRules) console.log(r.cssText);
12
+ #
13
+ # `disabled` is honored as state. `href`, `media`, `title`, `type`
14
+ # mirror the owner node's attributes when present.
15
+ class CSSStyleSheet
16
+ attr_reader :owner_node, :css_rules
17
+
18
+ def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css")
19
+ @owner_node = owner_node
20
+ @href = href
21
+ @media = media
22
+ @title = title
23
+ @type = type
24
+ @disabled = false
25
+ @css_rules = CSSRuleList.new
26
+ end
27
+
28
+ def disabled
29
+ @disabled
30
+ end
31
+
32
+ def disabled=(v)
33
+ @disabled = !!v
34
+ end
35
+
36
+ def href
37
+ @href
38
+ end
39
+
40
+ def title
41
+ @title
42
+ end
43
+
44
+ def type
45
+ @type
46
+ end
47
+
48
+ def media
49
+ @media.to_s
50
+ end
51
+
52
+ def parent_style_sheet
53
+ nil
54
+ end
55
+
56
+ def owner_rule
57
+ nil
58
+ end
59
+
60
+ # `insertRule(rule_text, index)` — appends an opaque CSSRule at the
61
+ # given position (default: end). Returns the index used.
62
+ def insert_rule(rule_text, index = nil)
63
+ idx = index.nil? ? @css_rules.length : index.to_i
64
+ raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx > @css_rules.length
65
+
66
+ @css_rules.__insert__(idx, CSSRule.new(rule_text.to_s, self))
67
+ idx
68
+ end
69
+
70
+ def delete_rule(index)
71
+ idx = index.to_i
72
+ raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx >= @css_rules.length
73
+
74
+ @css_rules.__delete_at__(idx)
75
+ nil
76
+ end
77
+
78
+ # `replaceSync(text)` — replace all rules with a single rule blob
79
+ # (no parsing — we keep it as one opaque entry).
80
+ def replace_sync(text)
81
+ @css_rules.__clear__
82
+ return nil if text.to_s.empty?
83
+
84
+ @css_rules.__insert__(0, CSSRule.new(text.to_s, self))
85
+ nil
86
+ end
87
+
88
+ # `replace(text)` — spec returns a Promise resolved with self.
89
+ # We can't return a JS-bridge Promise from here without a Window,
90
+ # so we mirror the sync behavior and return self.
91
+ def replace(text)
92
+ replace_sync(text)
93
+ self
94
+ end
95
+
96
+ def __js_get__(key)
97
+ case key
98
+ when "cssRules", "rules"
99
+ @css_rules
100
+ when "disabled"
101
+ @disabled
102
+ when "href"
103
+ @href
104
+ when "media"
105
+ media
106
+ when "title"
107
+ @title
108
+ when "type"
109
+ @type
110
+ when "ownerNode"
111
+ @owner_node
112
+ when "parentStyleSheet"
113
+ parent_style_sheet
114
+ when "ownerRule"
115
+ owner_rule
116
+ end
117
+ end
118
+
119
+ def __js_set__(key, value)
120
+ case key
121
+ when "disabled"
122
+ self.disabled = value
123
+ end
124
+
125
+ nil
126
+ end
127
+
128
+ def __js_call__(method, args)
129
+ case method
130
+ when "insertRule"
131
+ insert_rule(args[0], args[1])
132
+ when "deleteRule"
133
+ delete_rule(args[0])
134
+ when "replaceSync"
135
+ replace_sync(args[0])
136
+ when "replace"
137
+ replace(args[0])
138
+ end
139
+ end
140
+ end
141
+
142
+ # `CSSRuleList` — indexed list of CSSRule, returned by
143
+ # `sheet.cssRules`. Live: mutations to the owning sheet are visible.
144
+ class CSSRuleList
145
+ include Enumerable
146
+
147
+ def initialize
148
+ @rules = []
149
+ end
150
+
151
+ def length
152
+ @rules.length
153
+ end
154
+
155
+ alias size length
156
+
157
+ def item(index)
158
+ i = index.to_i
159
+ return nil if i < 0 || i >= @rules.length
160
+
161
+ @rules[i]
162
+ end
163
+
164
+ def [](index)
165
+ item(index)
166
+ end
167
+
168
+ def each(&blk)
169
+ @rules.each(&blk)
170
+ end
171
+
172
+ def to_a
173
+ @rules.dup
174
+ end
175
+
176
+ def __insert__(index, rule)
177
+ @rules.insert(index, rule)
178
+ end
179
+
180
+ def __delete_at__(index)
181
+ @rules.delete_at(index)
182
+ end
183
+
184
+ def __clear__
185
+ @rules.clear
186
+ end
187
+
188
+ def __js_get__(key)
189
+ case key
190
+ when "length"
191
+ length
192
+ else
193
+ if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
194
+ item(key.to_i)
195
+ end
196
+ end
197
+ end
198
+
199
+ def __js_call__(method, args)
200
+ case method
201
+ when "item"
202
+ item(args[0])
203
+ end
204
+ end
205
+ end
206
+
207
+ # `CSSRule` — opaque wrapper over the raw rule text. Real engines
208
+ # have a subclass hierarchy (CSSStyleRule, CSSMediaRule, etc.), but
209
+ # without a CSS parser we keep one minimal type that round-trips
210
+ # the source text.
211
+ class CSSRule
212
+ STYLE_RULE = 1
213
+ CHARSET_RULE = 2
214
+ IMPORT_RULE = 3
215
+ MEDIA_RULE = 4
216
+ FONT_FACE_RULE = 5
217
+ PAGE_RULE = 6
218
+ KEYFRAMES_RULE = 7
219
+ KEYFRAME_RULE = 8
220
+
221
+ attr_reader :parent_style_sheet
222
+
223
+ def initialize(css_text, parent_style_sheet = nil)
224
+ @css_text = css_text.to_s
225
+ @parent_style_sheet = parent_style_sheet
226
+ end
227
+
228
+ def css_text
229
+ @css_text
230
+ end
231
+
232
+ def css_text=(v)
233
+ @css_text = v.to_s
234
+ end
235
+
236
+ # We don't parse, so report the generic STYLE_RULE type.
237
+ def type
238
+ STYLE_RULE
239
+ end
240
+
241
+ def parent_rule
242
+ nil
243
+ end
244
+
245
+ def __js_get__(key)
246
+ case key
247
+ when "cssText"
248
+ @css_text
249
+ when "type"
250
+ type
251
+ when "parentStyleSheet"
252
+ @parent_style_sheet
253
+ when "parentRule"
254
+ parent_rule
255
+ when "STYLE_RULE"
256
+ STYLE_RULE
257
+ when "MEDIA_RULE"
258
+ MEDIA_RULE
259
+ when "IMPORT_RULE"
260
+ IMPORT_RULE
261
+ when "FONT_FACE_RULE"
262
+ FONT_FACE_RULE
263
+ when "PAGE_RULE"
264
+ PAGE_RULE
265
+ when "KEYFRAMES_RULE"
266
+ KEYFRAMES_RULE
267
+ when "KEYFRAME_RULE"
268
+ KEYFRAME_RULE
269
+ when "CHARSET_RULE"
270
+ CHARSET_RULE
271
+ end
272
+ end
273
+
274
+ def __js_set__(key, value)
275
+ case key
276
+ when "cssText"
277
+ self.css_text = value
278
+ end
279
+
280
+ nil
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `window.customElements` — registry mapping custom element tag
5
+ # names to Ruby classes that extend `HTMLElement`. Lifecycle
6
+ # callbacks (`connected_callback` / `disconnected_callback` /
7
+ # `attribute_changed_callback` / `adopted_callback`) are invoked by
8
+ # the document's mutation pipeline when registered elements are
9
+ # added, removed, or have observed attributes mutated.
10
+ #
11
+ # Names must contain a hyphen per the HTML spec (e.g., `my-button`).
12
+ class CustomElementRegistry
13
+ NAME_RE = /\A[a-z][a-z0-9-]*-[a-z0-9-]*\z/
14
+
15
+ def initialize(window)
16
+ @window = window
17
+ # name → klass
18
+ @definitions = {}
19
+ # name → Array<{ resolve, reject }>
20
+ @pending_promises = {}
21
+ end
22
+
23
+ def define(name, klass, _options = nil)
24
+ key = name.to_s
25
+ unless key.match?(NAME_RE)
26
+ raise DOMException::SyntaxError, "name must be a hyphenated string, got #{name.inspect}"
27
+ end
28
+
29
+ raise DOMException::NotSupportedError, "#{key} already defined" if @definitions.key?(key)
30
+
31
+ @definitions[key] = klass
32
+ # Resolve any pending whenDefined() promises and re-wrap
33
+ # already-existing nodes (upgrade).
34
+ resolve_pending(key, klass)
35
+ upgrade_existing(key)
36
+ nil
37
+ end
38
+
39
+ def get(name)
40
+ @definitions[name.to_s]
41
+ end
42
+
43
+ def get_name(klass)
44
+ @definitions.each { |k, v| return k if v == klass }
45
+ nil
46
+ end
47
+
48
+ # Returns a Dommy::PromiseValue that resolves with the registered
49
+ # constructor when `name` is defined (immediately if already so).
50
+ def when_defined(name)
51
+ key = name.to_s
52
+ promise = PromiseValue.new(@window)
53
+ if (klass = @definitions[key])
54
+ promise.fulfill(klass)
55
+ else
56
+ @pending_promises[key] ||= []
57
+ @pending_promises[key] << promise
58
+ end
59
+
60
+ promise
61
+ end
62
+
63
+ # Walk `root`'s subtree and re-wrap any nodes whose tag is now
64
+ # registered; fires `connectedCallback` for each upgraded node
65
+ # that's currently attached to a document tree.
66
+ def upgrade(root)
67
+ return nil unless root.respond_to?(:__node__)
68
+
69
+ walk_descendants(root.__node__) do |nk|
70
+ next unless nk.element?
71
+ next unless @definitions.key?(nk.name)
72
+
73
+ # Force re-wrap by clearing the document's cached wrapper.
74
+ @window.document.__reset_wrapper__(nk)
75
+ wrapped = @window.document.wrap_node(nk)
76
+ @window.document.__notify_connected__(wrapped) if wrapped
77
+ end
78
+
79
+ nil
80
+ end
81
+
82
+ def __js_get__(_key)
83
+ nil
84
+ end
85
+
86
+ def __js_call__(method, args)
87
+ case method
88
+ when "define"
89
+ define(args[0], args[1], args[2])
90
+ when "get"
91
+ get(args[0])
92
+ when "whenDefined"
93
+ when_defined(args[0])
94
+ when "upgrade"
95
+ upgrade(args[0])
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def resolve_pending(name, klass)
102
+ list = @pending_promises.delete(name)
103
+ list&.each { |p| p.fulfill(klass) }
104
+ end
105
+
106
+ # When define() lands after the matching element is already in
107
+ # the document, those nodes need upgrading: re-wrap them with the
108
+ # new class and fire connectedCallback.
109
+ def upgrade_existing(name)
110
+ doc = @window.document
111
+ doc.nokogiri_doc.css(name).each do |nk|
112
+ doc.__reset_wrapper__(nk)
113
+ wrapped = doc.wrap_node(nk)
114
+ doc.__notify_connected__(wrapped) if wrapped
115
+ end
116
+ end
117
+
118
+ def walk_descendants(node, &blk)
119
+ yield node
120
+ return unless node.respond_to?(:children)
121
+
122
+ node.children.each { |c| walk_descendants(c, &blk) }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `DataTransfer` — the payload object on a DragEvent. Holds the
5
+ # files being dragged plus arbitrary string-keyed data per MIME
6
+ # format. Tests build one explicitly to simulate drag-and-drop:
7
+ #
8
+ # dt = Dommy::DataTransfer.new(files: [file])
9
+ # ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
10
+ # target.dispatch_event(ev)
11
+ #
12
+ # Spec: https://html.spec.whatwg.org/multipage/dnd.html#datatransfer
13
+ class DataTransfer
14
+ attr_reader :files
15
+
16
+ def initialize(files: [], data: {})
17
+ @files = files.is_a?(FileList) ? files : FileList.new(Array(files))
18
+ @data = data.transform_keys { |k| normalize_format(k) }
19
+ @drop_effect = "none"
20
+ @effect_allowed = "uninitialized"
21
+ end
22
+
23
+ def types
24
+ @data.keys
25
+ end
26
+
27
+ def get_data(format)
28
+ @data[normalize_format(format)].to_s
29
+ end
30
+
31
+ def set_data(format, data)
32
+ @data[normalize_format(format)] = data.to_s
33
+ nil
34
+ end
35
+
36
+ def clear_data(format = nil)
37
+ if format
38
+ @data.delete(normalize_format(format))
39
+ else
40
+ @data.clear
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ attr_accessor :drop_effect, :effect_allowed
47
+
48
+ def __js_get__(key)
49
+ case key
50
+ when "files"
51
+ @files
52
+ when "types"
53
+ types
54
+ when "dropEffect"
55
+ @drop_effect
56
+ when "effectAllowed"
57
+ @effect_allowed
58
+ end
59
+ end
60
+
61
+ def __js_set__(key, value)
62
+ case key
63
+ when "dropEffect"
64
+ @drop_effect = value.to_s
65
+ when "effectAllowed"
66
+ @effect_allowed = value.to_s
67
+ end
68
+
69
+ nil
70
+ end
71
+
72
+ def __js_call__(method, args)
73
+ case method
74
+ when "getData"
75
+ get_data(args[0])
76
+ when "setData"
77
+ set_data(args[0], args[1])
78
+ when "clearData"
79
+ clear_data(args[0])
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Per spec, "text" maps to "text/plain" and "url" maps to
86
+ # "text/uri-list"; otherwise lowercase the MIME format.
87
+ def normalize_format(format)
88
+ case format.to_s.downcase
89
+ when "text"
90
+ "text/plain"
91
+ when "url"
92
+ "text/uri-list"
93
+ else
94
+ format.to_s.downcase
95
+ end
96
+ end
97
+ end
98
+ end