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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/Rakefile +8 -0
- data/lib/dommy/rack/cookie_jar.rb +166 -0
- data/lib/dommy/rack/errors.rb +34 -0
- data/lib/dommy/rack/field_interactor.rb +81 -0
- data/lib/dommy/rack/file_upload.rb +73 -0
- data/lib/dommy/rack/form_submission.rb +273 -0
- data/lib/dommy/rack/header_store.rb +58 -0
- data/lib/dommy/rack/history.rb +45 -0
- data/lib/dommy/rack/locator.rb +115 -0
- data/lib/dommy/rack/navigation.rb +176 -0
- data/lib/dommy/rack/request_builder.rb +134 -0
- data/lib/dommy/rack/response.rb +153 -0
- data/lib/dommy/rack/session.rb +525 -0
- data/lib/dommy/rack/version.rb +7 -0
- data/lib/dommy/rack/visibility.rb +47 -0
- data/lib/dommy/rack.rb +24 -0
- data/sig/dommy/rack.rbs +232 -0
- metadata +94 -0
|
@@ -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
|