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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ VERSION = "0.8.0"
6
+ end
7
+ 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