dommy-rack 0.8.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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ 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
+
14
+ 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
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ # The persistent request headers a Session sends on every request, plus the
6
+ # auth conveniences that set them. Encapsulates the header state and the
7
+ # case-insensitive merge the way CookieJar encapsulates cookie state — a
8
+ # Session owns one HeaderStore and mutates it via set / delete / basic_auth
9
+ # / bearer.
10
+ #
11
+ # HTTP header names are case-insensitive, so #delete and #merge match names
12
+ # case-insensitively: a per-request override replaces a stored default even
13
+ # when the two names differ only in case.
14
+ class HeaderStore
15
+ def initialize
16
+ @headers = {}
17
+ end
18
+
19
+ # A copy of the stored headers. Mutate via #set / #delete.
20
+ def to_h = @headers.dup
21
+
22
+ def set(name, value)
23
+ @headers[name.to_s] = value.to_s
24
+ self
25
+ end
26
+
27
+ def delete(name)
28
+ target = name.to_s.downcase
29
+ @headers.delete_if { |key, _| key.downcase == target }
30
+ self
31
+ end
32
+
33
+ # The headers to send for one request: the stored defaults with the
34
+ # per-request `override` applied on top. An override wins even if its name
35
+ # differs only in case from a default.
36
+ def merge(override)
37
+ return @headers.dup if override.nil? || override.empty?
38
+
39
+ merged = @headers.dup
40
+ override.each do |name, value|
41
+ merged.delete_if { |existing, _| existing.to_s.downcase == name.to_s.downcase }
42
+ merged[name] = value
43
+ end
44
+ merged
45
+ end
46
+
47
+ # HTTP Basic auth: sets a persistent Authorization header.
48
+ def basic_auth(user, password)
49
+ set("Authorization", "Basic #{["#{user}:#{password}"].pack("m0")}")
50
+ end
51
+
52
+ # Bearer-token auth: sets a persistent Authorization header.
53
+ def bearer(token)
54
+ set("Authorization", "Bearer #{token}")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ # Browser-tab-style navigation history: an ordered stack of visited URLs
6
+ # with a cursor. Visiting a new URL truncates any forward entries.
7
+ class History
8
+ def initialize
9
+ @stack = []
10
+ @index = -1
11
+ end
12
+
13
+ def push(url)
14
+ kept = @index >= 0 ? @stack[0..@index] : []
15
+ @stack = kept + [url]
16
+ @index = @stack.size - 1
17
+ url
18
+ end
19
+
20
+ # Move the cursor back one entry and return that URL, or nil at the start.
21
+ def back
22
+ return nil if @index <= 0
23
+
24
+ @index -= 1
25
+ current
26
+ end
27
+
28
+ # Move the cursor forward one entry and return that URL, or nil at the end.
29
+ def forward
30
+ return nil if @index >= @stack.size - 1
31
+
32
+ @index += 1
33
+ current
34
+ end
35
+
36
+ def current
37
+ @stack[@index] if @index >= 0
38
+ end
39
+
40
+ def entries
41
+ @stack.dup
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ # Finds DOM elements within a document by Capybara-style locators. Pure
6
+ # querying: it raises ElementNotFoundError / AmbiguousElementError but does
7
+ # not mutate the DOM or perform navigation.
8
+ class Locator
9
+ def initialize(document)
10
+ @document = document
11
+ end
12
+
13
+ # A form field by id, name, label text, placeholder, or aria-label.
14
+ def find_field(locator)
15
+ candidates = []
16
+ by_id = @document.get_element_by_id(locator)
17
+ candidates << by_id if by_id
18
+ candidates.concat(by_name(locator))
19
+ candidates.concat(label_targets(locator))
20
+ candidates.concat(by_field_attribute("placeholder", locator))
21
+ candidates.concat(by_field_attribute("aria-label", locator))
22
+ resolve_single(candidates, locator)
23
+ end
24
+
25
+ # An <a> by visible text, id, title, or exact href.
26
+ def find_link(locator)
27
+ matches = @document.query_selector_all("a").select { |a| link_matches?(a, locator) }
28
+ resolve_single(matches, locator)
29
+ end
30
+
31
+ # A submit-capable button by text, value, id, name, or alt.
32
+ def find_button(locator)
33
+ buttons = @document.query_selector_all(
34
+ "button, input[type='submit'], input[type='image'], input[type='button']"
35
+ )
36
+ resolve_single(buttons.select { |b| button_matches?(b, locator) }, locator)
37
+ end
38
+
39
+ # The <option> of a select matching by visible text, then by value.
40
+ def find_option(select_el, value)
41
+ options = select_el.options.to_a
42
+ options.find { |o| o.text_content.strip == value.to_s } ||
43
+ options.find { |o| (o.get_attribute("value") || "").to_s == value.to_s }
44
+ end
45
+
46
+ # The form owning an element: an explicit `form` attribute, else the
47
+ # nearest ancestor <form>.
48
+ def form_for(element)
49
+ form_id = element.get_attribute("form")
50
+ if form_id && !form_id.empty?
51
+ @document.get_element_by_id(form_id)
52
+ else
53
+ element.closest("form")
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def by_name(locator)
60
+ @document.query_selector_all("[name]").select { |e| e.get_attribute("name") == locator }
61
+ end
62
+
63
+ def by_field_attribute(attribute, value)
64
+ @document.query_selector_all("input, textarea, select")
65
+ .select { |e| e.get_attribute(attribute) == value }
66
+ end
67
+
68
+ def label_targets(locator)
69
+ @document.query_selector_all("label")
70
+ .select { |label| label.text_content.strip == locator }
71
+ .map { |label| label_control(label) }
72
+ .compact
73
+ end
74
+
75
+ def label_control(label)
76
+ return label.control if label.respond_to?(:control) && label.control
77
+
78
+ for_id = label.get_attribute("for")
79
+ if for_id && !for_id.empty?
80
+ @document.get_element_by_id(for_id)
81
+ else
82
+ label.query_selector("input, textarea, select")
83
+ end
84
+ end
85
+
86
+ def link_matches?(anchor, locator)
87
+ anchor.text_content.strip == locator ||
88
+ anchor.get_attribute("id") == locator ||
89
+ anchor.get_attribute("title") == locator ||
90
+ anchor.get_attribute("href") == locator
91
+ end
92
+
93
+ def button_matches?(button, locator)
94
+ if button.tag_name == "BUTTON"
95
+ return true if button.text_content.strip == locator
96
+ end
97
+ button.get_attribute("value") == locator ||
98
+ button.get_attribute("id") == locator ||
99
+ button.get_attribute("name") == locator ||
100
+ button.get_attribute("alt") == locator
101
+ end
102
+
103
+ def resolve_single(candidates, locator)
104
+ unique = candidates.compact.uniq(&:__dommy_backend_node__)
105
+ raise ElementNotFoundError, "no element matching #{locator.inspect}" if unique.empty?
106
+
107
+ if unique.size > 1
108
+ raise AmbiguousElementError, "#{unique.size} elements match #{locator.inspect}"
109
+ end
110
+
111
+ unique.first
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Dommy
6
+ module Rack
7
+ # URL resolution, redirect following, same-origin enforcement, and
8
+ # browser-tab-style history. Reads policy from the frozen Config and drives
9
+ # the Session only through its request seam (raw_request /
10
+ # apply_navigation_response / current_url).
11
+ class Navigation
12
+ KEEP_METHOD_STATUSES = [307, 308].freeze
13
+
14
+ def initialize(session, config)
15
+ @session = session
16
+ @config = config
17
+ end
18
+
19
+ # Resolve a possibly-relative URL against a base (current URL or host).
20
+ def resolve_url(url_or_path, base_url)
21
+ base = base_url || @config.default_host
22
+ URI.join(base, url_or_path.to_s).to_s
23
+ rescue URI::InvalidURIError
24
+ url_or_path.to_s
25
+ end
26
+
27
+ def check_same_origin!(url)
28
+ return unless @config.enforce_same_origin
29
+ return if same_origin?(url, @config.default_host)
30
+
31
+ raise CrossOriginError, "cross-origin request to #{url} is not allowed"
32
+ end
33
+
34
+ # Perform a navigation, following redirects per session policy, then
35
+ # apply the final response to the session (updating document + history).
36
+ def navigate(method:, url:, params: nil, body: nil, headers: {})
37
+ verb = method.to_s.upcase
38
+ target = resolve_url(url, @session.current_url)
39
+ check_same_origin!(target)
40
+
41
+ response, final_url = run(method: verb, url: target, params: params, body: body, headers: headers)
42
+ @session.apply_navigation_response(response, final_url)
43
+ maybe_follow_meta_refresh(response) || response
44
+ end
45
+
46
+ # Fetch-style request: resolves and enforces origin, runs the redirect
47
+ # loop per mode, and returns the Response without touching session state.
48
+ def fetch(url, method: "GET", params: nil, body: nil, headers: {}, redirect: :follow)
49
+ verb = method.to_s.upcase
50
+ target = resolve_url(url, @session.current_url)
51
+ check_same_origin!(target)
52
+
53
+ args = {method: verb, url: target, params: params, body: body, headers: headers}
54
+ case redirect
55
+ when :follow
56
+ run(**args, follow: true).first
57
+ when :manual
58
+ run(**args, follow: false).first
59
+ when :error
60
+ response = run(**args, follow: false).first
61
+ raise Error, "redirect encountered with redirect: :error" if response.redirect?
62
+
63
+ response
64
+ else
65
+ raise ArgumentError, "unsupported redirect mode: #{redirect.inspect}"
66
+ end
67
+ end
68
+
69
+ # Re-navigate to an already-resolved URL without pushing a new history
70
+ # entry (used by Session#back / #forward).
71
+ def revisit(url)
72
+ response, final_url = run(method: "GET", url: url)
73
+ @session.apply_navigation_response(response, final_url, push_history: false)
74
+ response
75
+ end
76
+
77
+ # Run the request/redirect loop. Returns [response, final_url].
78
+ # Public so Session#fetch can reuse it without applying navigation state.
79
+ def run(method:, url:, params: nil, body: nil, headers: {}, follow: true)
80
+ verb = method
81
+ target = url
82
+ # Carry the fragment across redirects: a redirect Location without its
83
+ # own fragment preserves the previous one (browser behavior).
84
+ fragment = uri_fragment(target)
85
+ redirect_count = 0
86
+ chain = []
87
+
88
+ loop do
89
+ response = @session.raw_request(
90
+ verb, target, params: params, body: body, headers: headers
91
+ )
92
+
93
+ unless follow && redirect_to_follow?(response)
94
+ response.redirects = chain
95
+ return [response, with_fragment(target, fragment)]
96
+ end
97
+
98
+ chain << {status: response.status, url: target, location: response.location_header}
99
+ redirect_count += 1
100
+ if redirect_count > @config.max_redirects
101
+ raise TooManyRedirectsError, "exceeded #{@config.max_redirects} redirects"
102
+ end
103
+
104
+ target = resolve_url(response.location_header, target)
105
+ check_same_origin!(target)
106
+ location_fragment = uri_fragment(target)
107
+ fragment = location_fragment unless location_fragment.nil? || location_fragment.empty?
108
+
109
+ unless KEEP_METHOD_STATUSES.include?(response.status)
110
+ params = nil
111
+ body = nil
112
+ end
113
+ verb = redirect_method(response.status, verb)
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ # If the just-applied response asks for an immediate meta refresh,
120
+ # navigate there (browser behavior). Returns the final response after any
121
+ # chain of refreshes, or nil if none applied. Detecting the refresh is
122
+ # the Response's job; this only performs the navigation.
123
+ def maybe_follow_meta_refresh(response, depth = 0)
124
+ return nil unless @config.follow_meta_refresh
125
+
126
+ url = response.meta_refresh_url
127
+ return nil unless url
128
+
129
+ if depth >= @config.max_redirects
130
+ raise TooManyRedirectsError, "exceeded #{@config.max_redirects} meta refreshes"
131
+ end
132
+
133
+ target = resolve_url(url, @session.current_url)
134
+ check_same_origin!(target)
135
+ next_response, final_url = run(method: "GET", url: target)
136
+ @session.apply_navigation_response(next_response, final_url)
137
+ maybe_follow_meta_refresh(next_response, depth + 1) || next_response
138
+ end
139
+
140
+ def uri_fragment(url)
141
+ URI.parse(url.to_s).fragment
142
+ rescue URI::InvalidURIError
143
+ nil
144
+ end
145
+
146
+ def with_fragment(url, fragment)
147
+ return url if fragment.nil? || fragment.empty?
148
+ return url unless uri_fragment(url).to_s.empty?
149
+
150
+ "#{url}##{fragment}"
151
+ end
152
+
153
+ def redirect_to_follow?(response)
154
+ response.redirect? &&
155
+ @config.follow_redirects &&
156
+ !response.location_header.to_s.empty?
157
+ end
158
+
159
+ def redirect_method(status, original)
160
+ case status
161
+ when 303 then "GET"
162
+ when 301, 302 then original == "POST" ? "GET" : original
163
+ else original # 307, 308 keep the method
164
+ end
165
+ end
166
+
167
+ def same_origin?(url_a, url_b)
168
+ a = URI.parse(url_a)
169
+ b = URI.parse(url_b)
170
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port
171
+ rescue URI::InvalidURIError
172
+ false
173
+ end
174
+ end
175
+ end
176
+ end