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 +4 -4
- data/CHANGELOG.md +23 -0
- data/TODO.md +56 -0
- data/lib/dommy/rack/cookie_jar.rb +47 -13
- data/lib/dommy/rack/errors.rb +9 -8
- data/lib/dommy/rack/field_interactor.rb +6 -75
- data/lib/dommy/rack/form_submission.rb +13 -264
- data/lib/dommy/rack/header_store.rb +9 -0
- data/lib/dommy/rack/http_exchange.rb +58 -0
- data/lib/dommy/rack/locator.rb +5 -109
- data/lib/dommy/rack/navigation.rb +79 -19
- data/lib/dommy/rack/network_bridge.rb +25 -0
- data/lib/dommy/rack/resources.rb +195 -0
- data/lib/dommy/rack/session.rb +357 -107
- data/lib/dommy/rack/session_runtime.rb +191 -0
- data/lib/dommy/rack/trace/dom_observer.rb +72 -0
- data/lib/dommy/rack/trace/formatter.rb +67 -0
- data/lib/dommy/rack/trace/ndjson.rb +93 -0
- data/lib/dommy/rack/trace/param_filter.rb +59 -0
- data/lib/dommy/rack/trace.rb +303 -0
- data/lib/dommy/rack/url.rb +48 -0
- data/lib/dommy/rack/version.rb +1 -1
- data/lib/dommy/rack/visibility.rb +14 -6
- data/lib/dommy/rack.rb +6 -0
- metadata +16 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c964baa96ffd06e89b381a195f92728d59425d855c9eed0f2f60a901b405b82
|
|
4
|
+
data.tar.gz: 3883f5b58d4bc7817443486a46f597f4aa8204b4159f46e632fc998f084585d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 = @
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
data/lib/dommy/rack/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
class
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# it
|
|
8
|
-
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|