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,525 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Dommy
|
|
8
|
+
module Rack
|
|
9
|
+
# A single browser-like session over a Rack application. Owns the current
|
|
10
|
+
# URL, document, cookie jar, persistent header store, and history; delegates
|
|
11
|
+
# URL/redirect logic to Navigation and form data collection to FormSubmission.
|
|
12
|
+
class Session
|
|
13
|
+
DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
14
|
+
|
|
15
|
+
Config = Struct.new(
|
|
16
|
+
:default_host, :follow_redirects, :max_redirects,
|
|
17
|
+
:respect_method_override, :method_override_param,
|
|
18
|
+
:user_agent, :accept, :enforce_same_origin, :follow_meta_refresh,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
attr_reader :last_request, :last_response, :history
|
|
23
|
+
|
|
24
|
+
def initialize(app,
|
|
25
|
+
default_host: "http://example.org",
|
|
26
|
+
follow_redirects: true,
|
|
27
|
+
max_redirects: 5,
|
|
28
|
+
respect_method_override: true,
|
|
29
|
+
method_override_param: "_method",
|
|
30
|
+
user_agent: "DommyRack",
|
|
31
|
+
accept: DEFAULT_ACCEPT,
|
|
32
|
+
enforce_same_origin: true,
|
|
33
|
+
follow_meta_refresh: true)
|
|
34
|
+
@app = app
|
|
35
|
+
@config = Config.new(
|
|
36
|
+
default_host: default_host,
|
|
37
|
+
follow_redirects: follow_redirects,
|
|
38
|
+
max_redirects: max_redirects,
|
|
39
|
+
respect_method_override: respect_method_override,
|
|
40
|
+
method_override_param: method_override_param,
|
|
41
|
+
user_agent: user_agent,
|
|
42
|
+
accept: accept,
|
|
43
|
+
enforce_same_origin: enforce_same_origin,
|
|
44
|
+
follow_meta_refresh: follow_meta_refresh
|
|
45
|
+
).freeze
|
|
46
|
+
@cookie_jar = CookieJar.new
|
|
47
|
+
@headers = HeaderStore.new
|
|
48
|
+
@navigation = Navigation.new(self, @config)
|
|
49
|
+
@history = History.new
|
|
50
|
+
@current_url = nil
|
|
51
|
+
@current_window = nil
|
|
52
|
+
@last_request = nil
|
|
53
|
+
@last_response = nil
|
|
54
|
+
@scope_stack = []
|
|
55
|
+
@request_listeners = []
|
|
56
|
+
@response_listeners = []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- Config readers used by collaborators ---
|
|
60
|
+
|
|
61
|
+
def default_host = @config.default_host
|
|
62
|
+
def follow_redirects? = @config.follow_redirects
|
|
63
|
+
def max_redirects = @config.max_redirects
|
|
64
|
+
def enforce_same_origin? = @config.enforce_same_origin
|
|
65
|
+
def follow_meta_refresh? = @config.follow_meta_refresh
|
|
66
|
+
def config = @config
|
|
67
|
+
|
|
68
|
+
# --- Navigation API ---
|
|
69
|
+
|
|
70
|
+
def visit(path)
|
|
71
|
+
@navigation.navigate(method: "GET", url: path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def navigate(method: "GET", url:, params: nil, body: nil, headers: {})
|
|
75
|
+
@navigation.navigate(method: method, url: url, params: params, body: body, headers: headers)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reload
|
|
79
|
+
raise Error, "no current page to reload" unless @last_request_args
|
|
80
|
+
|
|
81
|
+
response, final_url = @navigation.run(**@last_request_args)
|
|
82
|
+
apply_navigation_response(response, final_url)
|
|
83
|
+
response
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def back
|
|
87
|
+
url = @history.back
|
|
88
|
+
@navigation.revisit(url) if url
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def forward
|
|
92
|
+
url = @history.forward
|
|
93
|
+
@navigation.revisit(url) if url
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Basic request API (navigates, updating page state) ---
|
|
97
|
+
|
|
98
|
+
def get(path, headers: {})
|
|
99
|
+
navigate(method: "GET", url: path, headers: headers)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def post(path, params: nil, body: nil, headers: {})
|
|
103
|
+
navigate(method: "POST", url: path, params: params, body: body, headers: headers)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def put(path, params: nil, body: nil, headers: {})
|
|
107
|
+
navigate(method: "PUT", url: path, params: params, body: body, headers: headers)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def patch(path, params: nil, body: nil, headers: {})
|
|
111
|
+
navigate(method: "PATCH", url: path, params: params, body: body, headers: headers)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def delete(path, params: nil, body: nil, headers: {})
|
|
115
|
+
navigate(method: "DELETE", url: path, params: params, body: body, headers: headers)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def request(method, path, params: nil, body: nil, headers: {})
|
|
119
|
+
navigate(method: method, url: path, params: params, body: body, headers: headers)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- JSON request helpers (navigate with a JSON body) ---
|
|
123
|
+
|
|
124
|
+
def post_json(path, data, headers: {})
|
|
125
|
+
request_json("POST", path, data, headers: headers)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def put_json(path, data, headers: {})
|
|
129
|
+
request_json("PUT", path, data, headers: headers)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def patch_json(path, data, headers: {})
|
|
133
|
+
request_json("PATCH", path, data, headers: headers)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def delete_json(path, data, headers: {})
|
|
137
|
+
request_json("DELETE", path, data, headers: headers)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# --- Persistent request headers (sent on every request) ---
|
|
141
|
+
|
|
142
|
+
# A copy of the headers currently sent on every request. Mutate via
|
|
143
|
+
# #set_header / #delete_header rather than this hash.
|
|
144
|
+
def default_headers = @headers.to_h
|
|
145
|
+
|
|
146
|
+
def set_header(name, value)
|
|
147
|
+
@headers.set(name, value)
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def delete_header(name)
|
|
152
|
+
@headers.delete(name)
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# HTTP Basic auth: sets a persistent Authorization header.
|
|
157
|
+
def basic_auth(user, password)
|
|
158
|
+
@headers.basic_auth(user, password)
|
|
159
|
+
self
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Bearer-token auth: sets a persistent Authorization header.
|
|
163
|
+
def authorization_bearer(token)
|
|
164
|
+
@headers.bearer(token)
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Fetch API (returns Response; does NOT change document or history) ---
|
|
169
|
+
|
|
170
|
+
def fetch(url, method: "GET", headers: {}, body: nil, params: nil, redirect: :follow)
|
|
171
|
+
@navigation.fetch(url, method: method, params: params, body: body, headers: headers, redirect: redirect)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# --- Current page state ---
|
|
175
|
+
|
|
176
|
+
def current_url = @current_url
|
|
177
|
+
|
|
178
|
+
def current_path
|
|
179
|
+
@current_url && URI.parse(@current_url).path
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def current_host
|
|
183
|
+
@current_url && URI.parse(@current_url).host
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def status = @last_response&.status
|
|
187
|
+
def headers = @last_response&.headers
|
|
188
|
+
def body = @last_response&.body
|
|
189
|
+
def document = @current_window&.document
|
|
190
|
+
|
|
191
|
+
# Parsed JSON of the most recent response, or nil if no request yet.
|
|
192
|
+
def json(symbolize_names: false)
|
|
193
|
+
@last_response&.json(symbolize_names: symbolize_names)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- Status predicates (delegate to the last response) ---
|
|
197
|
+
|
|
198
|
+
def success? = @last_response&.success? || false
|
|
199
|
+
def client_error? = @last_response&.client_error? || false
|
|
200
|
+
def server_error? = @last_response&.server_error? || false
|
|
201
|
+
def not_found? = @last_response&.not_found? || false
|
|
202
|
+
|
|
203
|
+
# --- Redirect chain of the last navigation ---
|
|
204
|
+
|
|
205
|
+
def redirects = @last_response&.redirects || []
|
|
206
|
+
def redirected? = !redirects.empty?
|
|
207
|
+
|
|
208
|
+
# --- Instrumentation hooks ---
|
|
209
|
+
|
|
210
|
+
# Register a callback invoked with the Rack env just before each request.
|
|
211
|
+
def on_request(&block)
|
|
212
|
+
@request_listeners << block
|
|
213
|
+
self
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Register a callback invoked with the Response after each request.
|
|
217
|
+
def on_response(&block)
|
|
218
|
+
@response_listeners << block
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def html
|
|
223
|
+
document&.to_html
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def text
|
|
227
|
+
document&.body&.text_content
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Write the current page HTML to `path` (default: a timestamped file in
|
|
231
|
+
# the system temp dir) and return the path. For debugging.
|
|
232
|
+
def save_page(path = nil)
|
|
233
|
+
content = html
|
|
234
|
+
raise Error, "no current page to save" if content.nil?
|
|
235
|
+
|
|
236
|
+
path ||= ::File.join(Dir.tmpdir, "dommy-rack-#{Time.now.strftime("%Y%m%d%H%M%S")}.html")
|
|
237
|
+
::File.write(path, content)
|
|
238
|
+
path
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- DOM query helpers (delegate to the document) ---
|
|
242
|
+
|
|
243
|
+
def at_css(selector)
|
|
244
|
+
scope_root&.query_selector(selector)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def all_css(selector)
|
|
248
|
+
scope_root&.query_selector_all(selector)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def at_xpath(xpath)
|
|
252
|
+
document&.at_xpath(xpath)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def all_xpath(xpath)
|
|
256
|
+
document ? document.xpath(xpath) : []
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# --- Scoping ---
|
|
260
|
+
|
|
261
|
+
# Restrict element finds and matchers to within the first element
|
|
262
|
+
# matching `selector` for the duration of the block.
|
|
263
|
+
def within(selector, &block)
|
|
264
|
+
node = scope_root&.query_selector(selector)
|
|
265
|
+
raise ElementNotFoundError, "no element matching #{selector.inspect}" unless node
|
|
266
|
+
|
|
267
|
+
with_scope(node, &block)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Load the iframe matched by `locator` (id, name, or CSS; the sole frame
|
|
271
|
+
# if omitted) and scope finds/matchers to its document for the block.
|
|
272
|
+
def within_frame(locator = nil, &block)
|
|
273
|
+
frame = find_frame(locator)
|
|
274
|
+
raise ElementNotFoundError, "no iframe matching #{locator.inspect}" unless frame
|
|
275
|
+
|
|
276
|
+
src = frame.get_attribute("src")
|
|
277
|
+
raise Error, "iframe has no src" if src.nil? || src.empty?
|
|
278
|
+
|
|
279
|
+
frame_doc = fetch(resolve_document_url(src), headers: referer_headers).document
|
|
280
|
+
raise UnsupportedContentTypeError, "iframe did not return an HTML document" unless frame_doc
|
|
281
|
+
|
|
282
|
+
with_scope(frame_doc, &block)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# --- Matchers ---
|
|
286
|
+
|
|
287
|
+
def has_css?(selector, count: nil)
|
|
288
|
+
nodes = scope_root ? scope_root.query_selector_all(selector) : []
|
|
289
|
+
count ? nodes.size == count : !nodes.empty?
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def has_no_css?(selector, count: nil) = !has_css?(selector, count: count)
|
|
293
|
+
|
|
294
|
+
def has_text?(string)
|
|
295
|
+
scope_text.include?(string.to_s)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def has_no_text?(string) = !has_text?(string)
|
|
299
|
+
|
|
300
|
+
def has_link?(locator) = element_present? { finder.find_link(locator) }
|
|
301
|
+
def has_button?(locator) = element_present? { finder.find_button(locator) }
|
|
302
|
+
def has_field?(locator) = element_present? { finder.find_field(locator) }
|
|
303
|
+
|
|
304
|
+
# --- Link navigation ---
|
|
305
|
+
|
|
306
|
+
def click_link(locator)
|
|
307
|
+
click_link_element(finder.find_link(locator))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def click_link_element(element)
|
|
311
|
+
href = element.get_attribute("href")
|
|
312
|
+
raise ElementNotClickableError, "link has no href" if href.nil?
|
|
313
|
+
|
|
314
|
+
scheme = href.split(":", 2).first.to_s.downcase
|
|
315
|
+
raise UnsupportedURLError, "#{scheme}: URLs are not supported" if %w[javascript mailto].include?(scheme)
|
|
316
|
+
return document if href.start_with?("#") # in-page fragment: no request
|
|
317
|
+
|
|
318
|
+
target = resolve_document_url(href)
|
|
319
|
+
return document if same_page_fragment?(target) # url + fragment to current page: no request
|
|
320
|
+
|
|
321
|
+
navigate(method: "GET", url: target, headers: referer_headers)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Form field setting ---
|
|
325
|
+
|
|
326
|
+
# Form field setting delegates to FieldInteractor (DOM mutation only;
|
|
327
|
+
# a subsequent submit is what turns into a navigation).
|
|
328
|
+
def fill_in(locator, with:) = field_interactor.fill_in(locator, with: with)
|
|
329
|
+
def choose(locator) = field_interactor.choose(locator)
|
|
330
|
+
def check(locator) = field_interactor.check(locator)
|
|
331
|
+
def uncheck(locator) = field_interactor.uncheck(locator)
|
|
332
|
+
def attach_file(locator, path) = field_interactor.attach_file(locator, path)
|
|
333
|
+
def select(value, from:) = field_interactor.select(value, from: from)
|
|
334
|
+
def unselect(value, from:) = field_interactor.unselect(value, from: from)
|
|
335
|
+
|
|
336
|
+
# --- Form submission ---
|
|
337
|
+
|
|
338
|
+
def click_button(locator)
|
|
339
|
+
button = finder.find_button(locator)
|
|
340
|
+
# Only submit buttons submit a form. type=button / type=reset are
|
|
341
|
+
# no-ops here since there is no JavaScript to handle their click.
|
|
342
|
+
return button unless submit_button?(button)
|
|
343
|
+
|
|
344
|
+
submit_form(finder.form_for(button), submitter: button)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def submit_form(form, submitter: nil)
|
|
348
|
+
raise InvalidFormError, "element is not inside a form" if form.nil?
|
|
349
|
+
|
|
350
|
+
result = FormSubmission.new(form, submitter, @config).submit!
|
|
351
|
+
navigate(method: result[:method], url: resolve_document_url(result[:url]),
|
|
352
|
+
params: result[:params], headers: referer_headers)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
alias submit submit_form
|
|
356
|
+
|
|
357
|
+
# --- Cookie public API ---
|
|
358
|
+
|
|
359
|
+
def cookies = @cookie_jar.all
|
|
360
|
+
|
|
361
|
+
def set_cookie(name, value, path: "/", domain: nil, **opts)
|
|
362
|
+
resolved_domain = domain || (@current_url && URI.parse(@current_url).host) || URI.parse(@config.default_host).host
|
|
363
|
+
@cookie_jar.set!(name, value, domain: resolved_domain, path: path, **opts)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def get_cookie(name) = @cookie_jar.get(name)
|
|
367
|
+
def clear_cookies = @cookie_jar.clear
|
|
368
|
+
|
|
369
|
+
# --- Collaboration API used by Navigation ---
|
|
370
|
+
# Public so Navigation can drive the session, but not part of the everyday
|
|
371
|
+
# browsing API; prefer #visit / #get / #fetch etc.
|
|
372
|
+
|
|
373
|
+
# Execute one request against the app. Stores response cookies but does
|
|
374
|
+
# NOT update current_url / document / history.
|
|
375
|
+
def raw_request(method, absolute_url, params: nil, body: nil, headers: {})
|
|
376
|
+
# Remember the latest raw request so #reload can re-issue it. Note this
|
|
377
|
+
# is the final request of any redirect chain: after a POST that
|
|
378
|
+
# redirects (PRG), reload re-GETs the landing page rather than re-POSTing.
|
|
379
|
+
@last_request_args = {method: method, url: absolute_url, params: params, body: body, headers: headers}
|
|
380
|
+
env = RequestBuilder.new(@config).build(
|
|
381
|
+
method: method,
|
|
382
|
+
url: absolute_url,
|
|
383
|
+
params: params,
|
|
384
|
+
body: body,
|
|
385
|
+
headers: @headers.merge(headers),
|
|
386
|
+
cookie_string: @cookie_jar.cookies_for(absolute_url)
|
|
387
|
+
)
|
|
388
|
+
@last_request = env
|
|
389
|
+
@request_listeners.each { |cb| cb.call(env) }
|
|
390
|
+
status, response_headers, response_body = @app.call(env)
|
|
391
|
+
response = Response.new(status, response_headers, response_body, url: absolute_url)
|
|
392
|
+
response.set_cookie_strings.each do |sc|
|
|
393
|
+
@cookie_jar.store_from_header(sc, absolute_url)
|
|
394
|
+
end
|
|
395
|
+
@response_listeners.each { |cb| cb.call(response) }
|
|
396
|
+
response
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Apply a final navigation response: update last_response, current_url,
|
|
400
|
+
# the document (HTML only), and the history stack.
|
|
401
|
+
def apply_navigation_response(response, final_url, push_history: true)
|
|
402
|
+
@last_response = response
|
|
403
|
+
@current_url = final_url
|
|
404
|
+
@current_window = response.window if response.html?
|
|
405
|
+
@history.push(final_url) if push_history
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
private
|
|
409
|
+
|
|
410
|
+
# Serialize data as a JSON body and navigate. A String is sent verbatim
|
|
411
|
+
# (already-encoded JSON); anything else is run through JSON.generate.
|
|
412
|
+
# Callers can override the default Content-Type/Accept via headers.
|
|
413
|
+
def request_json(method, path, data, headers:)
|
|
414
|
+
json_headers = {"Content-Type" => "application/json", "Accept" => "application/json"}.merge(headers)
|
|
415
|
+
navigate(method: method, url: path,
|
|
416
|
+
body: data.is_a?(String) ? data : JSON.generate(data),
|
|
417
|
+
headers: json_headers)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Link clicks and form submits send the current page as the Referer;
|
|
421
|
+
# a bare visit does not.
|
|
422
|
+
def referer_headers
|
|
423
|
+
@current_url ? {"Referer" => @current_url} : {}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# A link to the current page that differs only by fragment does not
|
|
427
|
+
# issue a request (browser behavior).
|
|
428
|
+
def same_page_fragment?(target)
|
|
429
|
+
return false unless @current_url
|
|
430
|
+
|
|
431
|
+
t = URI.parse(target)
|
|
432
|
+
c = URI.parse(@current_url)
|
|
433
|
+
!t.fragment.nil? &&
|
|
434
|
+
t.scheme == c.scheme && t.host == c.host && t.port == c.port &&
|
|
435
|
+
t.path == c.path && t.query == c.query
|
|
436
|
+
rescue URI::InvalidURIError
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Resolve a document-relative href/action against <base href> (if any),
|
|
441
|
+
# then the current URL, then the default host. Redirect Location
|
|
442
|
+
# resolution is handled separately by Navigation against the request URL.
|
|
443
|
+
def resolve_document_url(href)
|
|
444
|
+
base = document&.base_uri
|
|
445
|
+
base = current_url if base.nil? || base.empty?
|
|
446
|
+
base ||= default_host
|
|
447
|
+
URI.join(base, href.to_s).to_s
|
|
448
|
+
rescue URI::InvalidURIError
|
|
449
|
+
href.to_s
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# A Locator bound to the current scope (document, or the innermost
|
|
453
|
+
# #within / #within_frame node), for finding elements.
|
|
454
|
+
def finder
|
|
455
|
+
Locator.new(scope_root)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# A FieldInteractor bound to the current scope's finder and the document
|
|
459
|
+
# (the document is the fallback scope when clearing a radio group).
|
|
460
|
+
def field_interactor
|
|
461
|
+
FieldInteractor.new(finder, document)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# The node element finds and matchers query against: the innermost active
|
|
465
|
+
# scope, or the document when none is open.
|
|
466
|
+
def scope_root
|
|
467
|
+
@scope_stack.last || document
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Push `node` as the active scope for the block, always restoring the
|
|
471
|
+
# previous scope afterward. Returns the block value, or the node itself
|
|
472
|
+
# when no block is given.
|
|
473
|
+
def with_scope(node)
|
|
474
|
+
@scope_stack.push(node)
|
|
475
|
+
begin
|
|
476
|
+
block_given? ? yield(self) : node
|
|
477
|
+
ensure
|
|
478
|
+
@scope_stack.pop
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Visible text of the current scope. Documents expose text via <body>;
|
|
483
|
+
# elements expose it directly.
|
|
484
|
+
def scope_text
|
|
485
|
+
root = scope_root
|
|
486
|
+
return "" unless root
|
|
487
|
+
return root.body&.text_content.to_s if root.respond_to?(:body) # document-like
|
|
488
|
+
|
|
489
|
+
root.respond_to?(:text_content) ? root.text_content.to_s : ""
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# True if the block finds an element. A unique match or an ambiguous
|
|
493
|
+
# match both count as present; only "not found" counts as absent.
|
|
494
|
+
def element_present?
|
|
495
|
+
yield
|
|
496
|
+
true
|
|
497
|
+
rescue ElementNotFoundError
|
|
498
|
+
false
|
|
499
|
+
rescue AmbiguousElementError
|
|
500
|
+
true
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Locate an iframe by id, name attribute, or CSS selector; the sole frame
|
|
504
|
+
# in scope when no locator is given.
|
|
505
|
+
def find_frame(locator)
|
|
506
|
+
return scope_root&.query_selector("iframe, frame") if locator.nil?
|
|
507
|
+
|
|
508
|
+
scope_root&.get_element_by_id(locator) ||
|
|
509
|
+
scope_root&.query_selector("iframe[name='#{locator}'], frame[name='#{locator}']") ||
|
|
510
|
+
scope_root&.query_selector(locator)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# A <button> defaults to type=submit; an <input> submits only for
|
|
514
|
+
# type=submit or type=image.
|
|
515
|
+
def submit_button?(button)
|
|
516
|
+
if button.tag_name == "BUTTON"
|
|
517
|
+
button.type == "submit"
|
|
518
|
+
else
|
|
519
|
+
%w[submit image].include?(button.type)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rack
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Simplified visibility check for capybara-style interaction. An element
|
|
8
|
+
# is hidden if it (or an ancestor) is hidden via the `hidden` attribute,
|
|
9
|
+
# an inline `display: none` / `visibility: hidden` style, or is an
|
|
10
|
+
# `<input type="hidden">`. No CSS cascade / computed style / layout.
|
|
11
|
+
def visible?(element)
|
|
12
|
+
return false if element.nil?
|
|
13
|
+
|
|
14
|
+
node = element
|
|
15
|
+
while node.respond_to?(:get_attribute)
|
|
16
|
+
return false if hidden_node?(node)
|
|
17
|
+
|
|
18
|
+
node = node.respond_to?(:parent_element) ? node.parent_element : nil
|
|
19
|
+
break if node.nil?
|
|
20
|
+
end
|
|
21
|
+
return false if hidden_by_closed_details?(element)
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Inside a closed <details>, everything except the <summary> is hidden.
|
|
27
|
+
def hidden_by_closed_details?(element)
|
|
28
|
+
return false unless element.respond_to?(:closest)
|
|
29
|
+
return false if element.tag_name == "DETAILS"
|
|
30
|
+
|
|
31
|
+
details = element.closest("details")
|
|
32
|
+
return false if details.nil? || details.has_attribute?("open")
|
|
33
|
+
|
|
34
|
+
summary = element.closest("summary")
|
|
35
|
+
!(summary && summary.closest("details")&.equal?(details))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hidden_node?(element)
|
|
39
|
+
return true if element.has_attribute?("hidden")
|
|
40
|
+
return true if element.tag_name == "TEMPLATE"
|
|
41
|
+
return true if element.tag_name == "INPUT" && element.respond_to?(:type) && element.type == "hidden"
|
|
42
|
+
|
|
43
|
+
style = element.get_attribute("style").to_s.downcase
|
|
44
|
+
style.match?(/display\s*:\s*none/) || style.match?(/visibility\s*:\s*hidden/)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/dommy/rack.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "dommy"
|
|
5
|
+
|
|
6
|
+
require_relative "rack/version"
|
|
7
|
+
require_relative "rack/errors"
|
|
8
|
+
require_relative "rack/response"
|
|
9
|
+
require_relative "rack/cookie_jar"
|
|
10
|
+
require_relative "rack/header_store"
|
|
11
|
+
require_relative "rack/request_builder"
|
|
12
|
+
require_relative "rack/file_upload"
|
|
13
|
+
require_relative "rack/visibility"
|
|
14
|
+
require_relative "rack/history"
|
|
15
|
+
require_relative "rack/locator"
|
|
16
|
+
require_relative "rack/field_interactor"
|
|
17
|
+
require_relative "rack/form_submission"
|
|
18
|
+
require_relative "rack/navigation"
|
|
19
|
+
require_relative "rack/session"
|
|
20
|
+
|
|
21
|
+
module Dommy
|
|
22
|
+
module Rack
|
|
23
|
+
end
|
|
24
|
+
end
|