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.
- checksums.yaml +7 -0
- data/README.md +213 -0
- data/lib/dommy/attr.rb +200 -0
- data/lib/dommy/blob.rb +182 -0
- data/lib/dommy/bridge.rb +141 -0
- data/lib/dommy/css.rb +283 -0
- data/lib/dommy/custom_elements.rb +125 -0
- data/lib/dommy/data_transfer.rb +98 -0
- data/lib/dommy/document.rb +674 -0
- data/lib/dommy/dom_exception.rb +258 -0
- data/lib/dommy/dom_parser.rb +88 -0
- data/lib/dommy/element.rb +1975 -0
- data/lib/dommy/event.rb +589 -0
- data/lib/dommy/fetch.rb +241 -0
- data/lib/dommy/form_data.rb +208 -0
- data/lib/dommy/html_collection.rb +207 -0
- data/lib/dommy/html_elements.rb +4455 -0
- data/lib/dommy/internal/cookie_jar.rb +27 -0
- data/lib/dommy/internal/dom_matching.rb +141 -0
- data/lib/dommy/internal/mutation_coordinator.rb +172 -0
- data/lib/dommy/internal/node_traversal.rb +36 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
- data/lib/dommy/internal/observer_manager.rb +31 -0
- data/lib/dommy/internal/observer_matcher.rb +31 -0
- data/lib/dommy/internal/scope_resolution.rb +27 -0
- data/lib/dommy/internal/shadow_root_registry.rb +35 -0
- data/lib/dommy/internal/template_content_registry.rb +97 -0
- data/lib/dommy/minitest/assertions.rb +105 -0
- data/lib/dommy/minitest.rb +17 -0
- data/lib/dommy/navigator.rb +271 -0
- data/lib/dommy/node.rb +218 -0
- data/lib/dommy/observer.rb +199 -0
- data/lib/dommy/parser.rb +29 -0
- data/lib/dommy/promise.rb +199 -0
- data/lib/dommy/router.rb +275 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
- data/lib/dommy/rspec/matchers.rb +230 -0
- data/lib/dommy/rspec.rb +18 -0
- data/lib/dommy/scheduler.rb +135 -0
- data/lib/dommy/shadow_root.rb +255 -0
- data/lib/dommy/storage.rb +112 -0
- data/lib/dommy/test_helpers.rb +78 -0
- data/lib/dommy/tree_walker.rb +425 -0
- data/lib/dommy/url.rb +479 -0
- data/lib/dommy/version.rb +5 -0
- data/lib/dommy/world.rb +209 -0
- data/lib/dommy.rb +119 -0
- metadata +110 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Manages <template> element content fragments.
|
|
6
|
+
# When HTML contains <template>X</template>, the inner content X is
|
|
7
|
+
# detached and stored in a separate DocumentFragment, accessed via
|
|
8
|
+
# the element's `content` property (per HTML spec).
|
|
9
|
+
#
|
|
10
|
+
# Keeping these fragments off-document is what makes template content
|
|
11
|
+
# invisible to querySelector, getElementById, etc., on the main tree.
|
|
12
|
+
class TemplateContentRegistry
|
|
13
|
+
def initialize(document)
|
|
14
|
+
@document = document
|
|
15
|
+
# template_node.object_id → Nokogiri fragment
|
|
16
|
+
@fragments = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse HTML into a fragment and attach it as the template's content.
|
|
20
|
+
# Drops any pre-existing direct children of the template element.
|
|
21
|
+
def attach(template_element, html)
|
|
22
|
+
template_element.__node__.children.each(&:unlink)
|
|
23
|
+
fragment = @document.nokogiri_doc.fragment(html.to_s)
|
|
24
|
+
@fragments[template_element.__node__.object_id] = fragment
|
|
25
|
+
fragment
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the wrapped Fragment for a template element, seeding from
|
|
29
|
+
# the template's current children if not previously migrated.
|
|
30
|
+
def fragment_for(template_element)
|
|
31
|
+
fragment = @fragments[template_element.__node__.object_id]
|
|
32
|
+
fragment ||= seed(template_element)
|
|
33
|
+
@document.wrap_node(fragment)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raw (Nokogiri) fragment lookup by Nokogiri node — used by
|
|
37
|
+
# internal traversal to skip template-content sub-trees.
|
|
38
|
+
def raw_fragment_for(nokogiri_node)
|
|
39
|
+
@fragments[nokogiri_node.object_id]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inner_html_of(template_element)
|
|
43
|
+
fragment = @fragments[template_element.__node__.object_id]
|
|
44
|
+
return "" unless fragment
|
|
45
|
+
|
|
46
|
+
fragment.children.map(&:to_html).join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def has_content?(nokogiri_node)
|
|
50
|
+
@fragments.key?(nokogiri_node.object_id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Direct register — called after manual fragment construction
|
|
54
|
+
# (e.g., when seeding from existing template children).
|
|
55
|
+
def store(template_node, fragment)
|
|
56
|
+
@fragments[template_node.object_id] = fragment
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Walk a Nokogiri subtree, finding <template> elements whose
|
|
60
|
+
# children are still direct (not yet migrated to a fragment), and
|
|
61
|
+
# migrate each one. Called after innerHTML / fragment-parsing to
|
|
62
|
+
# keep template content out of the main tree.
|
|
63
|
+
def migrate_descendants(root)
|
|
64
|
+
targets = []
|
|
65
|
+
targets << root if template_needing_migration?(root)
|
|
66
|
+
root.traverse do |node|
|
|
67
|
+
targets << node if template_needing_migration?(node)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
targets.uniq.each { |t| migrate_one(t) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def template_needing_migration?(node)
|
|
76
|
+
return false unless node.respond_to?(:name) && node.name == "template"
|
|
77
|
+
|
|
78
|
+
!has_content?(node)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def seed(template_element)
|
|
82
|
+
migrate_one(template_element.__node__)
|
|
83
|
+
@fragments[template_element.__node__.object_id]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def migrate_one(template_node)
|
|
87
|
+
fragment = @document.nokogiri_doc.fragment("")
|
|
88
|
+
template_node.children.to_a.each do |child|
|
|
89
|
+
child.unlink
|
|
90
|
+
fragment.add_child(child)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@fragments[template_node.object_id] = fragment
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../internal/dom_matching"
|
|
4
|
+
require_relative "../internal/scope_resolution"
|
|
5
|
+
|
|
6
|
+
module Dommy
|
|
7
|
+
module Minitest
|
|
8
|
+
# Custom Minitest assertions for testing Dommy DOM objects.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# require "dommy/minitest"
|
|
12
|
+
#
|
|
13
|
+
# class MyTest < Minitest::Test
|
|
14
|
+
# include Dommy::Minitest::Assertions
|
|
15
|
+
#
|
|
16
|
+
# def test_renders_button
|
|
17
|
+
# dom = parse_html("<button class='primary'>Submit</button>")
|
|
18
|
+
# assert_dom_contains(dom, "button.primary")
|
|
19
|
+
# assert_dom_contains_text(dom, "Submit")
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
module Assertions
|
|
23
|
+
def assert_dom_contains(scope, selector, text: nil, count: nil, msg: nil)
|
|
24
|
+
matched = dom_matched_for(scope, selector, text: text)
|
|
25
|
+
msg ||= "expected to contain DOM matching #{selector.inspect}" \
|
|
26
|
+
"#{text ? " with text #{text.inspect}" : ""}" \
|
|
27
|
+
"#{count ? " (count: #{count.inspect})" : ""}, found #{matched.size}"
|
|
28
|
+
assert(Internal::DomMatching.count_matches?(matched.size, count), msg)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def refute_dom_contains(scope, selector, text: nil, count: nil, msg: nil)
|
|
32
|
+
matched = dom_matched_for(scope, selector, text: text)
|
|
33
|
+
msg ||= "expected NOT to contain DOM matching #{selector.inspect}" \
|
|
34
|
+
"#{text ? " with text #{text.inspect}" : ""}, found #{matched.size}"
|
|
35
|
+
refute(Internal::DomMatching.count_matches?(matched.size, count), msg)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def assert_dom_contains_text(scope, text, msg: nil)
|
|
39
|
+
actual = dom_text_of(scope)
|
|
40
|
+
msg ||= "expected text to include #{text.inspect}, got #{actual.inspect}"
|
|
41
|
+
assert(Internal::DomMatching.text_matches?(actual, text), msg)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refute_dom_contains_text(scope, text, msg: nil)
|
|
45
|
+
actual = dom_text_of(scope)
|
|
46
|
+
msg ||= "expected text NOT to include #{text.inspect}, got #{actual.inspect}"
|
|
47
|
+
refute(Internal::DomMatching.text_matches?(actual, text), msg)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Without a value argument, checks attribute existence only.
|
|
51
|
+
# With a value, checks string equality.
|
|
52
|
+
def assert_dom_has_attribute(element, name, value = UNSET, msg: nil)
|
|
53
|
+
present = element.has_attribute?(name.to_s)
|
|
54
|
+
if value.equal?(UNSET)
|
|
55
|
+
msg ||= "expected element to have attribute #{name.inspect}"
|
|
56
|
+
assert(present, msg)
|
|
57
|
+
else
|
|
58
|
+
actual = element.get_attribute(name.to_s)
|
|
59
|
+
msg ||= "expected attribute #{name.inspect} to equal #{value.inspect}, got #{actual.inspect}"
|
|
60
|
+
assert_equal(value.to_s, actual.to_s, msg)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def refute_dom_has_attribute(element, name, msg: nil)
|
|
65
|
+
present = element.has_attribute?(name.to_s)
|
|
66
|
+
msg ||= "expected element NOT to have attribute #{name.inspect}"
|
|
67
|
+
refute(present, msg)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def assert_dom_has_class(element, class_name, msg: nil)
|
|
71
|
+
actual_classes = element.class_list.value.to_s.split(/\s+/)
|
|
72
|
+
msg ||= "expected element to have class #{class_name.inspect}, got #{actual_classes.inspect}"
|
|
73
|
+
assert_includes(actual_classes, class_name.to_s, msg)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def refute_dom_has_class(element, class_name, msg: nil)
|
|
77
|
+
actual_classes = element.class_list.value.to_s.split(/\s+/)
|
|
78
|
+
msg ||= "expected element NOT to have class #{class_name.inspect}, got #{actual_classes.inspect}"
|
|
79
|
+
refute_includes(actual_classes, class_name.to_s, msg)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def assert_dom_html_equal(scope, expected_html, msg: nil)
|
|
83
|
+
scope = Internal::ScopeResolution.resolve(scope)
|
|
84
|
+
actual_n = Internal::DomMatching.normalize_html(Internal::DomMatching.html_of(scope))
|
|
85
|
+
expected_n = Internal::DomMatching.normalize_html(expected_html)
|
|
86
|
+
msg ||= "expected DOM HTML to match.\nExpected: #{expected_n}\nActual: #{actual_n}"
|
|
87
|
+
assert_equal(expected_n, actual_n, msg)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sentinel for "value was not passed"
|
|
91
|
+
UNSET = Object.new.freeze
|
|
92
|
+
private_constant :UNSET
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def dom_matched_for(scope, selector, text:)
|
|
97
|
+
Internal::DomMatching.filter(Internal::ScopeResolution.resolve(scope), selector, text: text)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def dom_text_of(scope)
|
|
101
|
+
Internal::DomMatching.text_of(Internal::ScopeResolution.resolve(scope))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point for using Dommy from Minitest test suites.
|
|
4
|
+
# Loads the test helpers and DOM assertion modules so users can
|
|
5
|
+
# `include` them into their test classes.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# require "dommy/minitest"
|
|
9
|
+
#
|
|
10
|
+
# class MyTest < Minitest::Test
|
|
11
|
+
# include Dommy::TestHelpers
|
|
12
|
+
# include Dommy::Minitest::Assertions
|
|
13
|
+
# end
|
|
14
|
+
|
|
15
|
+
require "dommy"
|
|
16
|
+
require "dommy/test_helpers"
|
|
17
|
+
require "dommy/minitest/assertions"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `window.navigator` — exposes browser-agent metadata plus
|
|
5
|
+
# `clipboard` / `permissions` sub-objects. Dommy returns sensible
|
|
6
|
+
# defaults (Dommy as user agent, "en" language, online=true) that
|
|
7
|
+
# tests can override.
|
|
8
|
+
class Navigator
|
|
9
|
+
DEFAULT_USER_AGENT = "Mozilla/5.0 (Dommy) Ruby"
|
|
10
|
+
|
|
11
|
+
attr_accessor :user_agent, :language, :languages, :platform, :vendor, :on_line, :cookie_enabled
|
|
12
|
+
|
|
13
|
+
def initialize(window)
|
|
14
|
+
@window = window
|
|
15
|
+
@user_agent = DEFAULT_USER_AGENT
|
|
16
|
+
@language = "en"
|
|
17
|
+
@languages = ["en"].freeze
|
|
18
|
+
@platform = "Dommy"
|
|
19
|
+
@vendor = "Dommy"
|
|
20
|
+
@on_line = true
|
|
21
|
+
@cookie_enabled = true
|
|
22
|
+
@clipboard = Clipboard.new(window)
|
|
23
|
+
@permissions = Permissions.new(window)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :clipboard, :permissions
|
|
27
|
+
|
|
28
|
+
def [](key)
|
|
29
|
+
__js_get__(key.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def []=(k, v)
|
|
33
|
+
__js_set__(k.to_s, v)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def __js_get__(key)
|
|
37
|
+
case key
|
|
38
|
+
when "userAgent"
|
|
39
|
+
@user_agent
|
|
40
|
+
when "language"
|
|
41
|
+
@language
|
|
42
|
+
when "languages"
|
|
43
|
+
@languages
|
|
44
|
+
when "platform"
|
|
45
|
+
@platform
|
|
46
|
+
when "vendor"
|
|
47
|
+
@vendor
|
|
48
|
+
when "onLine"
|
|
49
|
+
@on_line
|
|
50
|
+
when "cookieEnabled"
|
|
51
|
+
@cookie_enabled
|
|
52
|
+
when "clipboard"
|
|
53
|
+
@clipboard
|
|
54
|
+
when "permissions"
|
|
55
|
+
@permissions
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def __js_set__(_key, _value)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `navigator.clipboard` — an in-memory clipboard for tests. Real
|
|
65
|
+
# OS clipboard access is intentionally not implemented; reads and
|
|
66
|
+
# writes round-trip through Ruby memory only.
|
|
67
|
+
#
|
|
68
|
+
# Async APIs (`readText`/`writeText`/`read`/`write`) return
|
|
69
|
+
# PromiseValue so callers' `.await` chains keep working.
|
|
70
|
+
class Clipboard
|
|
71
|
+
include EventTarget
|
|
72
|
+
|
|
73
|
+
def initialize(window)
|
|
74
|
+
@window = window
|
|
75
|
+
@text = ""
|
|
76
|
+
@items = []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Sync read for tests that don't want to await.
|
|
80
|
+
def text
|
|
81
|
+
@text
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def text=(value)
|
|
85
|
+
@text = value.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_text
|
|
89
|
+
PromiseValue.resolve(@window, @text)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_text(text)
|
|
93
|
+
@text = text.to_s
|
|
94
|
+
PromiseValue.resolve(@window, nil)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read
|
|
98
|
+
PromiseValue.resolve(@window, @items.dup)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write(items)
|
|
102
|
+
@items = items.is_a?(Array) ? items : [items]
|
|
103
|
+
PromiseValue.resolve(@window, nil)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def __js_get__(_key)
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def __js_set__(_key, _value)
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def __js_call__(method, args)
|
|
115
|
+
case method
|
|
116
|
+
when "readText"
|
|
117
|
+
read_text
|
|
118
|
+
when "writeText"
|
|
119
|
+
write_text(args[0])
|
|
120
|
+
when "read"
|
|
121
|
+
read
|
|
122
|
+
when "write"
|
|
123
|
+
write(args[0])
|
|
124
|
+
when "addEventListener"
|
|
125
|
+
add_event_listener(args[0], args[1], args[2])
|
|
126
|
+
when "removeEventListener"
|
|
127
|
+
remove_event_listener(args[0], args[1])
|
|
128
|
+
when "dispatchEvent"
|
|
129
|
+
dispatch_event(args[0])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def __event_parent__
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# `navigator.permissions` — query returns a PermissionStatus whose
|
|
139
|
+
# `state` defaults to "granted" for every recognized name. Tests
|
|
140
|
+
# can override via `permissions.set("name", "denied")` before
|
|
141
|
+
# exercising user code.
|
|
142
|
+
class Permissions
|
|
143
|
+
KNOWN_NAMES = %w[
|
|
144
|
+
geolocation
|
|
145
|
+
notifications
|
|
146
|
+
push
|
|
147
|
+
midi
|
|
148
|
+
camera
|
|
149
|
+
microphone
|
|
150
|
+
clipboard-read
|
|
151
|
+
clipboard-write
|
|
152
|
+
background-fetch
|
|
153
|
+
background-sync
|
|
154
|
+
persistent-storage
|
|
155
|
+
accelerometer
|
|
156
|
+
gyroscope
|
|
157
|
+
magnetometer
|
|
158
|
+
screen-wake-lock
|
|
159
|
+
storage-access
|
|
160
|
+
window-management
|
|
161
|
+
]
|
|
162
|
+
.freeze
|
|
163
|
+
|
|
164
|
+
def initialize(window)
|
|
165
|
+
@window = window
|
|
166
|
+
@overrides = {}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Test helper: override the resolved state for a permission name.
|
|
170
|
+
# Subsequent `query()` calls will see the new value, and existing
|
|
171
|
+
# PermissionStatus objects fire `change` events.
|
|
172
|
+
def set(name, state)
|
|
173
|
+
key = name.to_s
|
|
174
|
+
@overrides[key] = state.to_s
|
|
175
|
+
@statuses ||= {}
|
|
176
|
+
status = @statuses[key]
|
|
177
|
+
status&.__set_state__(state.to_s)
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def query(descriptor)
|
|
182
|
+
name = if descriptor.is_a?(Hash)
|
|
183
|
+
(descriptor["name"] || descriptor[:name]).to_s
|
|
184
|
+
else
|
|
185
|
+
descriptor.to_s
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
state = @overrides[name] || "granted"
|
|
189
|
+
@statuses ||= {}
|
|
190
|
+
status = @statuses[name] ||= PermissionStatus.new(@window, name, state)
|
|
191
|
+
PromiseValue.resolve(@window, status)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def __js_get__(_key)
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def __js_set__(_key, _value)
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def __js_call__(method, args)
|
|
203
|
+
case method
|
|
204
|
+
when "query"
|
|
205
|
+
query(args[0])
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# `PermissionStatus` — `state` + `onchange` event handler. Fires a
|
|
211
|
+
# `change` event when `Permissions#set` mutates the underlying
|
|
212
|
+
# value (mirrors browser behavior where the user toggles a
|
|
213
|
+
# permission).
|
|
214
|
+
class PermissionStatus
|
|
215
|
+
include EventTarget
|
|
216
|
+
|
|
217
|
+
attr_reader :name, :state
|
|
218
|
+
|
|
219
|
+
def initialize(window, name, state)
|
|
220
|
+
@window = window
|
|
221
|
+
@name = name
|
|
222
|
+
@state = state
|
|
223
|
+
@onchange = nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def __set_state__(new_state)
|
|
227
|
+
return if @state == new_state
|
|
228
|
+
|
|
229
|
+
@state = new_state
|
|
230
|
+
dispatch_event(Event.new("change"))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def __js_get__(key)
|
|
234
|
+
case key
|
|
235
|
+
when "name"
|
|
236
|
+
@name
|
|
237
|
+
when "state"
|
|
238
|
+
@state
|
|
239
|
+
when "onchange"
|
|
240
|
+
@onchange
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def __js_set__(key, value)
|
|
245
|
+
case key
|
|
246
|
+
when "onchange"
|
|
247
|
+
# Assigning to onchange overwrites the previous handler.
|
|
248
|
+
remove_event_listener("change", @onchange) if @onchange
|
|
249
|
+
@onchange = value
|
|
250
|
+
add_event_listener("change", value) if value
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def __js_call__(method, args)
|
|
257
|
+
case method
|
|
258
|
+
when "addEventListener"
|
|
259
|
+
add_event_listener(args[0], args[1], args[2])
|
|
260
|
+
when "removeEventListener"
|
|
261
|
+
remove_event_listener(args[0], args[1])
|
|
262
|
+
when "dispatchEvent"
|
|
263
|
+
dispatch_event(args[0])
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def __event_parent__
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/dommy/node.rb
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `NodeList` — Array sub-class that adds the DOM NodeList surface
|
|
5
|
+
# (`item(i)` / `forEach(cb)` / `entries` / `keys` / `values`) on
|
|
6
|
+
# top of regular Array operations. Returned from
|
|
7
|
+
# `querySelectorAll`, `getElementsBy*`, `childNodes`, etc.
|
|
8
|
+
#
|
|
9
|
+
# Live vs. static collections aren't distinguished here — Dommy
|
|
10
|
+
# snapshots tree state at the time of the query, matching what
|
|
11
|
+
# most happy-dom test patterns expect.
|
|
12
|
+
class NodeList < Array
|
|
13
|
+
# Spec-compliant: out-of-range returns nil, not raise (Array#[] is
|
|
14
|
+
# close but we make negative indices fail too — DOM `item(-1)` is
|
|
15
|
+
# nil, not Array#[-1]'s last element).
|
|
16
|
+
def item(index)
|
|
17
|
+
i = index.to_i
|
|
18
|
+
return nil if i < 0 || i >= length
|
|
19
|
+
|
|
20
|
+
self[i]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Spec signature: `forEach(callback(value, key, listObj))`. The
|
|
24
|
+
# Ruby `each_with_index` block-arg order is (value, index), which
|
|
25
|
+
# we re-yield as (value, index, self) for spec parity.
|
|
26
|
+
def for_each(&block)
|
|
27
|
+
each_with_index do |value, index|
|
|
28
|
+
block.call(value, index, self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias forEach for_each
|
|
35
|
+
|
|
36
|
+
# NodeList `entries` returns an enumerator of [index, value].
|
|
37
|
+
def entries
|
|
38
|
+
each_with_index.map { |value, index| [index, value] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def keys
|
|
42
|
+
(0...length).to_a
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `values` is the iterator of the NodeList itself; we return
|
|
46
|
+
# `self.to_a` (a plain Array copy) so callers can't mutate
|
|
47
|
+
# the original list.
|
|
48
|
+
def values
|
|
49
|
+
to_a
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def __js_get__(key)
|
|
53
|
+
case key
|
|
54
|
+
when "length"
|
|
55
|
+
length
|
|
56
|
+
else
|
|
57
|
+
if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
|
|
58
|
+
item(key.to_i)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def __js_call__(method, args)
|
|
64
|
+
case method
|
|
65
|
+
when "item"
|
|
66
|
+
item(args[0])
|
|
67
|
+
when "forEach"
|
|
68
|
+
for_each(&args[0])
|
|
69
|
+
when "entries"
|
|
70
|
+
entries
|
|
71
|
+
when "keys"
|
|
72
|
+
keys
|
|
73
|
+
when "values"
|
|
74
|
+
values
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `LiveNodeList` — like NodeList, but re-evaluates its source on
|
|
80
|
+
# every access. Returned by APIs whose spec says "live" — e.g.
|
|
81
|
+
# `Node.childNodes`. The constructor takes a block that yields the
|
|
82
|
+
# current array of nodes; `length`, `item`, iteration all call it.
|
|
83
|
+
#
|
|
84
|
+
# Inherits Array so `list[i]` / `list.each` still work for callers
|
|
85
|
+
# that don't know about the live semantics, but those work off a
|
|
86
|
+
# snapshot taken at the moment of the call. The DOM-shape methods
|
|
87
|
+
# (`length`, `item`, `for_each`) re-query on every call.
|
|
88
|
+
class LiveNodeList
|
|
89
|
+
include Enumerable
|
|
90
|
+
|
|
91
|
+
def initialize(&block)
|
|
92
|
+
@compute = block
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def length
|
|
96
|
+
@compute.call.length
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
alias size length
|
|
100
|
+
|
|
101
|
+
def item(index)
|
|
102
|
+
i = index.to_i
|
|
103
|
+
arr = @compute.call
|
|
104
|
+
return nil if i < 0 || i >= arr.length
|
|
105
|
+
|
|
106
|
+
arr[i]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def [](index)
|
|
110
|
+
case index
|
|
111
|
+
when Integer
|
|
112
|
+
item(index)
|
|
113
|
+
else
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def first
|
|
119
|
+
@compute.call.first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def last
|
|
123
|
+
@compute.call.last
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def each(&block)
|
|
127
|
+
@compute.call.each(&block)
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_a
|
|
132
|
+
@compute.call.dup
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def for_each(&block)
|
|
136
|
+
@compute.call.each_with_index do |value, index|
|
|
137
|
+
block.call(value, index, self)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
alias forEach for_each
|
|
144
|
+
|
|
145
|
+
def entries
|
|
146
|
+
@compute.call.each_with_index.map { |v, i| [i, v] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def keys
|
|
150
|
+
(0...length).to_a
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def values
|
|
154
|
+
to_a
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def empty?
|
|
158
|
+
@compute.call.empty?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def __js_get__(key)
|
|
162
|
+
case key
|
|
163
|
+
when "length"
|
|
164
|
+
length
|
|
165
|
+
else
|
|
166
|
+
if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
|
|
167
|
+
item(key.to_i)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def __js_call__(method, args)
|
|
173
|
+
case method
|
|
174
|
+
when "item"
|
|
175
|
+
item(args[0])
|
|
176
|
+
when "forEach"
|
|
177
|
+
for_each(&args[0])
|
|
178
|
+
when "entries"
|
|
179
|
+
entries
|
|
180
|
+
when "keys"
|
|
181
|
+
keys
|
|
182
|
+
when "values"
|
|
183
|
+
values
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `Node` — common base mixin. All node-like classes (Element,
|
|
189
|
+
# TextNode, CommentNode, CharacterDataNode, Document, Fragment,
|
|
190
|
+
# DocumentType, ShadowRoot) include this so `el.is_a?(Dommy::Node)`
|
|
191
|
+
# works.
|
|
192
|
+
#
|
|
193
|
+
# Real classes already define `nodeType` / `nodeName` / `nodeValue`
|
|
194
|
+
# / `parentNode` / `isConnected` / `cloneNode` independently; this
|
|
195
|
+
# module is primarily an identity marker. Adding new shared methods
|
|
196
|
+
# later is straightforward.
|
|
197
|
+
module Node
|
|
198
|
+
# Standardized nodeType constants — duplicated from Element so
|
|
199
|
+
# callers can refer to `Dommy::Node::ELEMENT_NODE` without
|
|
200
|
+
# depending on a specific subclass.
|
|
201
|
+
ELEMENT_NODE = 1
|
|
202
|
+
ATTRIBUTE_NODE = 2
|
|
203
|
+
TEXT_NODE = 3
|
|
204
|
+
CDATA_SECTION_NODE = 4
|
|
205
|
+
PROCESSING_INSTRUCTION_NODE = 7
|
|
206
|
+
COMMENT_NODE = 8
|
|
207
|
+
DOCUMENT_NODE = 9
|
|
208
|
+
DOCUMENT_TYPE_NODE = 10
|
|
209
|
+
DOCUMENT_FRAGMENT_NODE = 11
|
|
210
|
+
|
|
211
|
+
DOCUMENT_POSITION_DISCONNECTED = 0x01
|
|
212
|
+
DOCUMENT_POSITION_PRECEDING = 0x02
|
|
213
|
+
DOCUMENT_POSITION_FOLLOWING = 0x04
|
|
214
|
+
DOCUMENT_POSITION_CONTAINS = 0x08
|
|
215
|
+
DOCUMENT_POSITION_CONTAINED_BY = 0x10
|
|
216
|
+
DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20
|
|
217
|
+
end
|
|
218
|
+
end
|