dommy-rack 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1a9aaa4ae2c0fcc61a4054b66fe4e9e9fadb65093acfa912c1b73a5216ac7be
4
- data.tar.gz: 3ead606c9fc898c53aeb6e05222f0ddbf2c4c3f08a8ccce6e881867de537019d
3
+ metadata.gz: 9c964baa96ffd06e89b381a195f92728d59425d855c9eed0f2f60a901b405b82
4
+ data.tar.gz: 3883f5b58d4bc7817443486a46f597f4aa8204b4159f46e632fc998f084585d6
5
5
  SHA512:
6
- metadata.gz: 1f5e7fb4b35c073c069812de208765c5ba7e3aaca832a8071d320a991cc2f215ecf6d27ccec6e87988719ede65550b4d0b8901810380428495427e8fa48b6716
7
- data.tar.gz: 21f1916ec0327d598142776993685685d0257e76dfa4ac998556aa116819678e96ead4b1615e31cccab60c3645de34d29a8e46f1e42638528bf1bd89071520a1
6
+ metadata.gz: 6a342735719087256ae5ce571ec435b69b0a4e6ce264f400db0a6bc40baaa84cf36889769701ad32619d4694e0075e64e3dfe2bd3eaecffb84045bb66d85fd99
7
+ data.tar.gz: 7ed6fa76e4123a4bb13bdce963b2d657e58f6639c19c980952d42081d191e19356c7cdb9e9ae9781443afc83a3545c13798db339ffe247624f2970692269e40e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-06-22
4
+
5
+ Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.9.0.
6
+ Unlike previous releases, 0.9.0 carries real dommy-rack changes — driven by the
7
+ new JS runtime, async subresource fetching, and the integrated trace.
8
+
9
+ ### Added
10
+ - **Trace / observability:** a structured event timeline for `Dommy::Rack::Session`, emitted as NDJSON, with extracted DOM-mutation / param-filter views and DOM snapshots.
11
+ - **Backend-agnostic `SessionRuntime`** that wires a JS runtime to a session (passing the session executor and window scheduler).
12
+ - **Cookie persistence:** `Session#export_cookies` / `#import_cookies` (backed by `CookieJar#export` / `#import!`), preserving host-only scoping across a JSON round-trip so an embedder can persist a login across restarts.
13
+ - **Subresource fetching & policy:**
14
+ - Off-thread subresource fetch via an injected executor (`network_executor:`), with observation posted back through the scheduler inbox; the synchronous default is unchanged. `external_network_pending?` supports async-load run loops.
15
+ - An opt-in cross-origin subresource allowlist on `Session`, plus an `:open` cross-origin subresource policy.
16
+ - An embedder `subresource_host_blocker` denylist hook, consulted before any fetch (even in `:open` mode); denied hosts are recorded in a distinct dropped bucket, never prompted.
17
+ - Concurrent prewarming of `<script src>` bundles before boot (gated on browser mode).
18
+ - **CSS resources:** external `<link rel=stylesheet>` CSS is fetched and applied on navigation, and `@import` URLs are resolved through the app.
19
+ - A thread-safe `CookieJar`; `HttpExchange` extracted as the per-request primitive.
20
+
21
+ ### Fixed
22
+ - `js_errors` and `console` are cleared on page load (a browser's console clears on navigation).
23
+ - GET/HEAD navigation params are folded into the URL query, so `current_url` reflects a submitted GET form.
24
+ - Non-ASCII (IRI) URLs are percent-encoded before parsing.
25
+
3
26
  ## 0.8.0 — 2026-05-31
4
27
 
5
28
  Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
data/TODO.md ADDED
@@ -0,0 +1,56 @@
1
+ # TODO: dommy-rack 側で検討したい項目
2
+
3
+ ## フレーム window の同一性を保持する(再入時に同じ realm を返す)
4
+
5
+ ### 現状
6
+
7
+ iframe を開くたびに、フレーム文書を**毎回 fetch して新しい Window/document を生成**している。
8
+ フレームロード経路は 2 つあり、どちらも再 fetch する:
9
+
10
+ - `Session#within_frame`(`lib/dommy/rack/session.rb:284`)
11
+ `frame_doc = fetch(resolve_document_url(src), headers: referer_headers).document`
12
+ - capybara-dommy `Driver#load_frame`(`capybara-dommy/lib/capybara/dommy/driver.rb:235`)
13
+ `rack_session.fetch(url, headers: ...).document`
14
+
15
+ そのため、同じ iframe に**別々の `within_frame` / `switch_to_frame` で再入する**と、
16
+ document オブジェクトが毎回新規になる。
17
+
18
+ ### なぜ問題になり得るか
19
+
20
+ dommy-js-quickjs の Capybara アダプタは「document → Runtime(= realm)」のマップで
21
+ JS VM を管理している(各 window が独自のグローバル・リスナー・タイマーを持つ realm)。
22
+ フレーム document が再入のたびに新規だと、**フレーム側に仕込んだ JS 状態が再入で失われる**。
23
+
24
+ ```ruby
25
+ within_frame(:f) { execute_script('window.__x = 1') }
26
+ within_frame(:f) { evaluate_script('window.__x') } # → undefined(実ブラウザなら 1)
27
+ ```
28
+
29
+ 実ブラウザでは iframe の window は(リロードしない限り)再入でも保持されるので、これは差分。
30
+
31
+ ### 現時点で実害がない理由(=今やらない判断の根拠)
32
+
33
+ - `within_frame` は**ブロックスコープ**。1 ブロック内では document が安定するので、
34
+ ブロックをまたがない限り状態は保持される。
35
+ - トップ window の realm 状態は dommy-js-quickjs 側で保持済み
36
+ (フレーム往復後もトップのリスナーが発火することを確認済み)。これが本来直したかった回帰。
37
+ - compliance の js グループはまだ未有効で、この差分を踏むテストは現状ゼロ。
38
+
39
+ ### 実装するときに必要なこと(簡単な一行修正ではない)
40
+
41
+ 1. **フレーム window レジストリ**を導入する(キー = iframe 要素 + src、値 = Window)。
42
+ 現状フレームは「ステートレスな再 fetch」モデルなので、window 同一性という概念を新設することになる。
43
+ 2. **無効化を正しく設計する**(ここを誤ると「古いフレーム内容が残る」= 今より悪い後退になる):
44
+ - 親ページのナビゲーション時(`on_document_loaded` はトップ用で、フレームキャッシュとは未連携)
45
+ - iframe の `src` 変更時
46
+ - iframe が DOM から切り離された時
47
+ 3. **2 つのフレームロード経路(`within_frame` と driver `load_frame`)を統一**する。
48
+ 片方だけキャッシュ化すると不整合になる。
49
+ 4. history / origin など付随状態の扱い(cookie jar 共有は既存のまま)。
50
+
51
+ ### 結論
52
+
53
+ 「ステートレス再 fetch → ステートフルな永続 window」というモデル変更であり、無効化バグの
54
+ リスクが高い。**需要が顕在化する前の投機的実装は避ける。** compliance の js グループで
55
+ フレーム + JS のテストが実際にこれを要求した段階で、上記 1〜4 を設計して入れる。
56
+ それまでは「フレーム realm の状態は `within_frame` ブロック内に閉じる」という制約を許容する。
@@ -16,6 +16,12 @@ module Dommy
16
16
 
17
17
  def initialize
18
18
  @entries = []
19
+ # The jar is shared between the page thread (document.cookie) and network
20
+ # worker threads (request Cookie headers + Set-Cookie storage), so every
21
+ # touch of @entries is guarded. The lock is held only around the array
22
+ # access (no I/O, no reentrancy into the jar), so it never serializes the
23
+ # blocking HTTP itself.
24
+ @mutex = Mutex.new
19
25
  end
20
26
 
21
27
  # Parse a single Set-Cookie header value and store the result.
@@ -24,10 +30,12 @@ module Dommy
24
30
  entry = parse_set_cookie(set_cookie_string, uri)
25
31
  return unless entry
26
32
 
27
- if expired?(entry)
28
- remove(entry.name, entry.domain, entry.path)
29
- else
30
- store_entry(entry)
33
+ @mutex.synchronize do
34
+ if expired?(entry)
35
+ remove(entry.name, entry.domain, entry.path)
36
+ else
37
+ store_entry(entry)
38
+ end
31
39
  end
32
40
  end
33
41
 
@@ -43,20 +51,43 @@ module Dommy
43
51
  http_only: http_only,
44
52
  host_only: domain.nil?
45
53
  )
46
- store_entry(entry)
54
+ @mutex.synchronize { store_entry(entry) }
47
55
  end
48
56
 
49
57
  # First non-expired cookie value matching the name.
50
58
  def get(name)
51
- @entries.find { |e| e.name == name.to_s && !expired?(e) }&.value
59
+ @mutex.synchronize { @entries.find { |e| e.name == name.to_s && !expired?(e) }&.value }
52
60
  end
53
61
 
54
62
  def clear
55
- @entries = []
63
+ @mutex.synchronize { @entries = [] }
56
64
  end
57
65
 
58
66
  def all
59
- @entries.reject { |e| expired?(e) }
67
+ @mutex.synchronize { @entries.reject { |e| expired?(e) } }
68
+ end
69
+
70
+ # Every non-expired cookie as a plain Hash (name/value/domain/path/expires/
71
+ # secure/http_only/host_only) — enough to round-trip the jar to disk and
72
+ # back via #import! without losing host-only scoping. `expires` is a Time or
73
+ # nil; the caller serializes it.
74
+ def export
75
+ all.map(&:to_h)
76
+ end
77
+
78
+ # Restore a cookie from an #export Hash, preserving host_only exactly (unlike
79
+ # #set!, which infers it). Skips an already-expired entry. Symbol- or
80
+ # string-keyed Hashes both work, so a JSON round-trip is fine.
81
+ def import!(attrs)
82
+ h = attrs.transform_keys(&:to_sym)
83
+ entry = CookieEntry.new(
84
+ name: h[:name].to_s, value: h[:value].to_s,
85
+ domain: h[:domain].to_s, path: (h[:path] || "/"),
86
+ expires: h[:expires], secure: !!h[:secure],
87
+ http_only: !!h[:http_only], host_only: !!h[:host_only]
88
+ )
89
+ @mutex.synchronize { store_entry(entry) unless expired?(entry) }
90
+ nil
60
91
  end
61
92
 
62
93
  # Build the Cookie request header value for the given URL, or "".
@@ -66,13 +97,16 @@ module Dommy
66
97
  host = uri.host.to_s.downcase
67
98
  path = uri.path.to_s.empty? ? "/" : uri.path
68
99
 
69
- matches = @entries.reject { |e| expired?(e) }.select do |e|
70
- domain_match?(e, host) &&
71
- path_match?(e.path, path) &&
72
- (!e.secure || secure_request)
100
+ matches = @mutex.synchronize do
101
+ @entries.reject { |e| expired?(e) }.select do |e|
102
+ domain_match?(e, host) &&
103
+ path_match?(e.path, path) &&
104
+ (!e.secure || secure_request)
105
+ end
73
106
  end
74
107
 
75
- # More specific (longer) paths first, per RFC 6265.
108
+ # More specific (longer) paths first, per RFC 6265 (on the snapshot copy,
109
+ # outside the lock).
76
110
  matches.sort_by! { |e| -e.path.length }
77
111
  matches.map { |e| "#{e.name}=#{e.value}" }.join("; ")
78
112
  end
@@ -1,18 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dommy"
4
+
3
5
  module Dommy
4
6
  module Rack
5
7
  class Error < StandardError; end
6
8
 
7
- # Raised when a locator matches no element.
8
- class ElementNotFoundError < Error; end
9
+ # Locating / ambiguity / file errors ARE the shared interaction errors, so a
10
+ # locator that finds nothing raises the same class whether driven by the
11
+ # Rack session or the standalone Browser. Aliased so existing
12
+ # `Dommy::Rack::X` call sites keep resolving.
13
+ ElementNotFoundError = Dommy::Interaction::ElementNotFoundError
14
+ AmbiguousElementError = Dommy::Interaction::AmbiguousElementError
15
+ FileNotFoundError = Dommy::Interaction::FileNotFoundError
9
16
 
10
17
  # Raised when an element cannot be clicked (e.g. a link with no href).
11
18
  class ElementNotClickableError < Error; end
12
19
 
13
- # Raised when a locator matches more than one element.
14
- class AmbiguousElementError < Error; end
15
-
16
20
  # Raised for hrefs that dommy-rack cannot navigate to (javascript:, mailto:, ...).
17
21
  class UnsupportedURLError < Error; end
18
22
 
@@ -27,8 +31,5 @@ module Dommy
27
31
 
28
32
  # Raised when a form is malformed or cannot be submitted.
29
33
  class InvalidFormError < Error; end
30
-
31
- # Raised when a file to be uploaded does not exist.
32
- class FileNotFoundError < Error; end
33
34
  end
34
35
  end
@@ -1,81 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dommy"
4
+
3
5
  module Dommy
4
6
  module Rack
5
- # Drives form fields in the current document: fills text inputs, toggles
6
- # radios / checkboxes, selects options, attaches files. Pure DOM mutation —
7
- # it locates fields via a Locator and mutates the live Dommy elements, but
8
- # issues no requests (Session turns a subsequent submit into navigation).
9
- class FieldInteractor
10
- def initialize(finder, document)
11
- @finder = finder
12
- @document = document
13
- end
14
-
15
- def fill_in(locator, with:)
16
- field = @finder.find_field(locator)
17
- field.value = with.to_s
18
- field
19
- end
20
-
21
- def choose(locator)
22
- radio = @finder.find_field(locator)
23
- clear_radio_group(radio)
24
- radio.checked = true
25
- radio
26
- end
27
-
28
- def check(locator)
29
- box = @finder.find_field(locator)
30
- box.checked = true
31
- box
32
- end
33
-
34
- def uncheck(locator)
35
- box = @finder.find_field(locator)
36
- box.checked = false
37
- box
38
- end
39
-
40
- def attach_file(locator, path)
41
- input = @finder.find_field(locator)
42
- raise FileNotFoundError, "no such file: #{path}" unless ::File.exist?(path)
43
-
44
- file = Dommy::File.new(
45
- [::File.binread(path)], ::File.basename(path), "type" => FileUpload.mime_type_for(path)
46
- )
47
- input.__driver_set_files__([file])
48
- input
49
- end
50
-
51
- def select(value, from:)
52
- select_el = @finder.find_field(from)
53
- option = @finder.find_option(select_el, value)
54
- raise ElementNotFoundError, "no option #{value.inspect} in #{from.inspect}" unless option
55
-
56
- select_el.options.each { |o| o.remove_attribute("selected") } unless select_el.multiple
57
- option.set_attribute("selected", "")
58
- select_el
59
- end
60
-
61
- def unselect(value, from:)
62
- select_el = @finder.find_field(from)
63
- option = @finder.find_option(select_el, value)
64
- option&.remove_attribute("selected")
65
- select_el
66
- end
67
-
68
- private
69
-
70
- def clear_radio_group(radio)
71
- name = radio.get_attribute("name")
72
- return unless name
73
-
74
- scope = radio.closest("form") || @document
75
- scope.query_selector_all("input[type='radio']").each do |r|
76
- r.checked = false if r.get_attribute("name") == name
77
- end
78
- end
79
- end
7
+ # The field interactor now lives in dommy core
8
+ # (Dommy::Interaction::FieldInteractor), shared with the standalone Browser
9
+ # (it also fires input/change events now). Aliased here for existing refs.
10
+ FieldInteractor = Dommy::Interaction::FieldInteractor
80
11
  end
81
12
  end
@@ -1,272 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dommy"
4
+
3
5
  module Dommy
4
6
  module Rack
5
- # Collects successful form controls and resolves the effective method,
6
- # action, and enctype for a form submission. Stateless given the form,
7
- # submitter, and session config it returns a plain data hash and does
8
- # not make any requests itself.
9
- class FormSubmission
10
- FORM_URLENCODED = "application/x-www-form-urlencoded"
11
- MULTIPART = "multipart/form-data"
12
- OVERRIDE_METHODS = %w[PATCH PUT DELETE].freeze
13
-
7
+ # The form-serialization logic now lives in dommy core
8
+ # (Dommy::Interaction::FormSubmission). This thin subclass adapts the Rack
9
+ # session's `config` object to core's explicit method-override keywords, so
10
+ # existing `FormSubmission.new(form, submitter, config)` call sites and tests
11
+ # keep working.
12
+ class FormSubmission < Dommy::Interaction::FormSubmission
14
13
  def initialize(form, submitter, config)
15
- @form = form
16
- @submitter = submitter
17
- @config = config
18
- end
19
-
20
- # Returns { method:, url:, params:, enctype: }.
21
- def submit!
22
- method = form_method
23
- params = collect_params
24
- method = apply_method_override(method, params)
25
- params = apply_charset(params)
26
-
27
- {
28
- method: method,
29
- url: resolve_action(form_method),
30
- params: params,
31
- enctype: form_enctype
32
- }
33
- end
34
-
35
- private
36
-
37
- def form_method
38
- raw = (attr(@submitter, "formmethod") || attr(@form, "method")).to_s.upcase
39
- %w[GET POST].include?(raw) ? raw : "GET"
40
- end
41
-
42
- def form_enctype
43
- attr(@submitter, "formenctype") || attr(@form, "enctype") || FORM_URLENCODED
44
- end
45
-
46
- # For GET forms the action's existing query string is discarded and
47
- # replaced by the form data; POST keeps it.
48
- def resolve_action(method)
49
- raw = (attr(@submitter, "formaction") || attr(@form, "action") || "").to_s
50
- method == "GET" ? raw.split("?", 2).first.to_s : raw
51
- end
52
-
53
- # Returns ordered [name, value] pairs in document order. The clicked
54
- # submitter is emitted at its document position; only if it isn't among
55
- # the form's controls do we append it at the end.
56
- def collect_params
57
- pairs = []
58
- submitter_emitted = false
59
- controls.each do |el|
60
- next if disabled?(el)
61
-
62
- case el.tag_name
63
- when "INPUT" then submitter_emitted = true if collect_input(el, pairs)
64
- when "TEXTAREA" then collect_named(el, normalize_newlines(el.value.to_s), pairs)
65
- when "SELECT" then collect_select(el, pairs)
66
- when "BUTTON" then submitter_emitted = true if collect_button(el, pairs)
67
- end
68
- end
69
- append_submitter(pairs) unless submitter_emitted
70
- pairs
71
- end
72
-
73
- # Returns true when this input is the clicked submitter (and was emitted).
74
- def collect_input(el, pairs)
75
- type = el.type
76
- if %w[submit image].include?(type)
77
- return false unless submitter?(el)
78
-
79
- emit_submitter(el, pairs)
80
- return true
81
- end
82
- return false if %w[reset button].include?(type) # never submitted
83
-
84
- case type
85
- when "checkbox", "radio"
86
- if el.checked
87
- value = el.has_attribute?("value") ? el.get_attribute("value") : "on"
88
- collect_named(el, value, pairs)
89
- end
90
- when "file"
91
- collect_file(el, pairs)
92
- else
93
- collect_named(el, el.value.to_s, pairs)
94
- end
95
- false
96
- end
97
-
98
- # Only the clicked submitter button contributes its name/value.
99
- def collect_button(el, pairs)
100
- return false unless submitter?(el)
101
-
102
- emit_submitter(el, pairs)
103
- true
104
- end
105
-
106
- def submitter?(el)
107
- @submitter && el.__dommy_backend_node__.equal?(@submitter.__dommy_backend_node__)
108
- end
109
-
110
- # Each File becomes its own entry. An empty file input still
111
- # contributes an empty File so the field name survives (HTML spec).
112
- def collect_file(el, pairs)
113
- name = attr(el, "name")
114
- return if blank?(name)
115
-
116
- files = el.respond_to?(:files) ? el.files : nil
117
-
118
- unless multipart?
119
- # Non-multipart forms submit only the file's basename, per browsers.
120
- filename = files && !files.empty? ? files.first.name.to_s : ""
121
- pairs << [name, ::File.basename(filename)]
122
- return
123
- end
124
-
125
- if files && !files.empty?
126
- files.each { |file| pairs << [name, file] }
127
- else
128
- pairs << [name, Dommy::File.new([], "", "type" => "application/octet-stream")]
129
- end
130
- end
131
-
132
- def multipart?
133
- form_enctype == MULTIPART
134
- end
135
-
136
- def collect_select(el, pairs)
137
- name = attr(el, "name")
138
- return if blank?(name)
139
-
140
- each_node(el.selected_options) do |option|
141
- pairs << [name, option.value.to_s]
142
- end
143
- end
144
-
145
- # Browsers submit textarea values with CRLF line endings.
146
- def normalize_newlines(value)
147
- value.gsub(/\r\n|\r|\n/, "\r\n")
148
- end
149
-
150
- def collect_named(el, value, pairs)
151
- name = attr(el, "name")
152
- pairs << [name, value] unless blank?(name)
153
- end
154
-
155
- # Fallback when the submitter is not among the form's controls.
156
- def append_submitter(pairs)
157
- return unless @submitter
158
-
159
- emit_submitter(@submitter, pairs)
160
- end
161
-
162
- # The submitter's name/value (or image coordinates) join the form data.
163
- # `formaction`/`formmethod`/`formenctype` are honored elsewhere;
164
- # `formtarget` is ignored (single session, like `target`) and
165
- # `formnovalidate` is moot (dommy-rack runs no client-side validation).
166
- def emit_submitter(el, pairs)
167
- if image_submitter?(el)
168
- # Image buttons submit click coordinates. With no layout we use 0,0.
169
- prefix = blank?(attr(el, "name")) ? "" : "#{attr(el, "name")}."
170
- pairs << ["#{prefix}x", "0"]
171
- pairs << ["#{prefix}y", "0"]
172
- return
173
- end
174
-
175
- name = attr(el, "name")
176
- return if blank?(name)
177
-
178
- pairs << [name, attr(el, "value") || ""]
179
- end
180
-
181
- def image_submitter?(el)
182
- el.tag_name == "INPUT" && el.type == "image"
183
- end
184
-
185
- # Honor the form's accept-charset by encoding string values into the
186
- # requested charset's bytes; urlencoding/multipart then carries them
187
- # verbatim. Names are assumed ASCII. UTF-8 (the default) is a no-op.
188
- def apply_charset(pairs)
189
- charset = form_charset
190
- return pairs if charset.nil? || charset == Encoding::UTF_8
191
-
192
- pairs.map { |name, value| [name, encode_in(value, charset)] }
193
- end
194
-
195
- def form_charset
196
- raw = attr(@form, "accept-charset").to_s
197
- token = raw.split(/[\s,]+/).find { |t| !t.empty? }
198
- return nil unless token
199
-
200
- begin
201
- Encoding.find(token)
202
- rescue ArgumentError
203
- nil
204
- end
205
- end
206
-
207
- def encode_in(value, charset)
208
- case value
209
- when Array then value.map { |v| encode_in(v, charset) }
210
- when String then encode_string(value, charset)
211
- else value # File/Blob pass through unchanged
212
- end
213
- end
214
-
215
- def encode_string(value, charset)
216
- value.encode(charset).b
217
- rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
218
- value
219
- end
220
-
221
- def apply_method_override(method, pairs)
222
- return method unless method == "POST" && @config.respect_method_override
223
-
224
- index = pairs.index { |name, _| name == @config.method_override_param }
225
- return method unless index
226
-
227
- override = pairs.delete_at(index)[1]
228
- candidate = override.to_s.upcase
229
- OVERRIDE_METHODS.include?(candidate) ? candidate : method
230
- end
231
-
232
- # All controls belonging to this form, in document order: descendants
233
- # without a `form` attribute, plus any element anywhere associated to
234
- # this form by `form="<this form's id>"`. Scanning the whole document
235
- # once keeps them in document order (matters for param ordering).
236
- def controls
237
- form_id = attr(@form, "id")
238
- @form.document.query_selector_all("input, textarea, select, button").select do |el|
239
- if el.has_attribute?("form")
240
- !blank?(form_id) && el.get_attribute("form") == form_id
241
- else
242
- el.closest("form")&.equal?(@form)
243
- end
244
- end
245
- end
246
-
247
- # A control is unsuccessful if it or an ancestor <fieldset> is disabled.
248
- def disabled?(el)
249
- return true if el.has_attribute?("disabled")
250
-
251
- fieldset = el.closest("fieldset")
252
- fieldset ? fieldset.has_attribute?("disabled") : false
253
- end
254
-
255
-
256
- def attr(el, name)
257
- el&.get_attribute(name)
258
- end
259
-
260
- def blank?(value)
261
- value.nil? || value.empty?
262
- end
263
-
264
- def each_node(collection)
265
- if collection.respond_to?(:each)
266
- collection.each { |node| yield node }
267
- else
268
- collection.length.times { |i| yield collection.item(i) }
269
- end
14
+ super(
15
+ form, submitter,
16
+ respect_method_override: config.respect_method_override,
17
+ method_override_param: config.method_override_param
18
+ )
270
19
  end
271
20
  end
272
21
  end
@@ -19,6 +19,15 @@ module Dommy
19
19
  # A copy of the stored headers. Mutate via #set / #delete.
20
20
  def to_h = @headers.dup
21
21
 
22
+ # A detached HeaderStore with the same headers, safe to hand to a network
23
+ # worker thread: it owns its own state (no shared mutation with the page's
24
+ # store) and keeps the case-insensitive #merge a plain Hash would lose.
25
+ def snapshot
26
+ copy = HeaderStore.new
27
+ @headers.each { |name, value| copy.set(name, value) }
28
+ copy
29
+ end
30
+
22
31
  def set(name, value)
23
32
  @headers[name.to_s] = value.to_s
24
33
  self