Class: Dommy::Rack::Session

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/rack/session.rb

Overview

A single browser-like session over a Rack application. Owns the current URL, document, cookie jar, persistent header store, and history; delegates URL/redirect logic to Navigation and form data collection to FormSubmission.

Defined Under Namespace

Classes: Config

Constant Summary collapse

DEFAULT_ACCEPT =
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app, default_host: "http://example.org", follow_redirects: true, max_redirects: 5, respect_method_override: true, method_override_param: "_method", user_agent: "DommyRack", accept: DEFAULT_ACCEPT, enforce_same_origin: true, follow_meta_refresh: true) ⇒ Session

Returns a new instance of Session.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/dommy/rack/session.rb', line 24

def initialize(app,
               default_host: "http://example.org",
               follow_redirects: true,
               max_redirects: 5,
               respect_method_override: true,
               method_override_param: "_method",
               user_agent: "DommyRack",
               accept: DEFAULT_ACCEPT,
               enforce_same_origin: true,
               follow_meta_refresh: true)
  @app = app
  @config = Config.new(
    default_host: default_host,
    follow_redirects: follow_redirects,
    max_redirects: max_redirects,
    respect_method_override: respect_method_override,
    method_override_param: method_override_param,
    user_agent: user_agent,
    accept: accept,
    enforce_same_origin: enforce_same_origin,
    follow_meta_refresh: follow_meta_refresh
  ).freeze
  @cookie_jar = CookieJar.new
  @headers = HeaderStore.new
  @navigation = Navigation.new(self, @config)
  @history = History.new
  @current_url = nil
  @current_window = nil
  @last_request = nil
  @last_response = nil
  @scope_stack = []
  @request_listeners = []
  @response_listeners = []
end

Instance Attribute Details

#historyObject (readonly)

Returns the value of attribute history.



22
23
24
# File 'lib/dommy/rack/session.rb', line 22

def history
  @history
end

#last_requestObject (readonly)

Returns the value of attribute last_request.



22
23
24
# File 'lib/dommy/rack/session.rb', line 22

def last_request
  @last_request
end

#last_responseObject (readonly)

Returns the value of attribute last_response.



22
23
24
# File 'lib/dommy/rack/session.rb', line 22

def last_response
  @last_response
end

Instance Method Details

#all_css(selector) ⇒ Object



247
248
249
# File 'lib/dommy/rack/session.rb', line 247

def all_css(selector)
  scope_root&.query_selector_all(selector)
end

#all_xpath(xpath) ⇒ Object



255
256
257
# File 'lib/dommy/rack/session.rb', line 255

def all_xpath(xpath)
  document ? document.xpath(xpath) : []
end

#apply_navigation_response(response, final_url, push_history: true) ⇒ Object

Apply a final navigation response: update last_response, current_url, the document (HTML only), and the history stack.



401
402
403
404
405
406
# File 'lib/dommy/rack/session.rb', line 401

def apply_navigation_response(response, final_url, push_history: true)
  @last_response = response
  @current_url = final_url
  @current_window = response.window if response.html?
  @history.push(final_url) if push_history
end

#at_css(selector) ⇒ Object

— DOM query helpers (delegate to the document) —



243
244
245
# File 'lib/dommy/rack/session.rb', line 243

def at_css(selector)
  scope_root&.query_selector(selector)
end

#at_xpath(xpath) ⇒ Object



251
252
253
# File 'lib/dommy/rack/session.rb', line 251

def at_xpath(xpath)
  document&.at_xpath(xpath)
end

#attach_file(locator, path) ⇒ Object



332
# File 'lib/dommy/rack/session.rb', line 332

def attach_file(locator, path) = field_interactor.attach_file(locator, path)

#authorization_bearer(token) ⇒ Object

Bearer-token auth: sets a persistent Authorization header.



163
164
165
166
# File 'lib/dommy/rack/session.rb', line 163

def authorization_bearer(token)
  @headers.bearer(token)
  self
end

#backObject



86
87
88
89
# File 'lib/dommy/rack/session.rb', line 86

def back
  url = @history.back
  @navigation.revisit(url) if url
end

#basic_auth(user, password) ⇒ Object

HTTP Basic auth: sets a persistent Authorization header.



157
158
159
160
# File 'lib/dommy/rack/session.rb', line 157

def basic_auth(user, password)
  @headers.basic_auth(user, password)
  self
end

#bodyObject



188
# File 'lib/dommy/rack/session.rb', line 188

def body = @last_response&.body

#check(locator) ⇒ Object



330
# File 'lib/dommy/rack/session.rb', line 330

def check(locator) = field_interactor.check(locator)

#choose(locator) ⇒ Object



329
# File 'lib/dommy/rack/session.rb', line 329

def choose(locator) = field_interactor.choose(locator)

#clear_cookiesObject



367
# File 'lib/dommy/rack/session.rb', line 367

def clear_cookies = @cookie_jar.clear

#click_button(locator) ⇒ Object

— Form submission —



338
339
340
341
342
343
344
345
# File 'lib/dommy/rack/session.rb', line 338

def click_button(locator)
  button = finder.find_button(locator)
  # Only submit buttons submit a form. type=button / type=reset are
  # no-ops here since there is no JavaScript to handle their click.
  return button unless submit_button?(button)

  submit_form(finder.form_for(button), submitter: button)
end

— Link navigation —



306
307
308
# File 'lib/dommy/rack/session.rb', line 306

def click_link(locator)
  click_link_element(finder.find_link(locator))
end


310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/dommy/rack/session.rb', line 310

def click_link_element(element)
  href = element.get_attribute("href")
  raise ElementNotClickableError, "link has no href" if href.nil?

  scheme = href.split(":", 2).first.to_s.downcase
  raise UnsupportedURLError, "#{scheme}: URLs are not supported" if %w[javascript mailto].include?(scheme)
  return document if href.start_with?("#") # in-page fragment: no request

  target = resolve_document_url(href)
  return document if same_page_fragment?(target) # url + fragment to current page: no request

  navigate(method: "GET", url: target, headers: referer_headers)
end

#client_error?Boolean

Returns:

  • (Boolean)


199
# File 'lib/dommy/rack/session.rb', line 199

def client_error? = @last_response&.client_error? || false

#configObject



66
# File 'lib/dommy/rack/session.rb', line 66

def config = @config

#cookiesObject

— Cookie public API —



359
# File 'lib/dommy/rack/session.rb', line 359

def cookies = @cookie_jar.all

#current_hostObject



182
183
184
# File 'lib/dommy/rack/session.rb', line 182

def current_host
  @current_url && URI.parse(@current_url).host
end

#current_pathObject



178
179
180
# File 'lib/dommy/rack/session.rb', line 178

def current_path
  @current_url && URI.parse(@current_url).path
end

#current_urlObject

— Current page state —



176
# File 'lib/dommy/rack/session.rb', line 176

def current_url = @current_url

#default_headersObject

A copy of the headers currently sent on every request. Mutate via #set_header / #delete_header rather than this hash.



144
# File 'lib/dommy/rack/session.rb', line 144

def default_headers = @headers.to_h

#default_hostObject

— Config readers used by collaborators —



61
# File 'lib/dommy/rack/session.rb', line 61

def default_host = @config.default_host

#delete(path, params: nil, body: nil, headers: {}) ⇒ Object



114
115
116
# File 'lib/dommy/rack/session.rb', line 114

def delete(path, params: nil, body: nil, headers: {})
  navigate(method: "DELETE", url: path, params: params, body: body, headers: headers)
end

#delete_header(name) ⇒ Object



151
152
153
154
# File 'lib/dommy/rack/session.rb', line 151

def delete_header(name)
  @headers.delete(name)
  self
end

#delete_json(path, data, headers: {}) ⇒ Object



136
137
138
# File 'lib/dommy/rack/session.rb', line 136

def delete_json(path, data, headers: {})
  request_json("DELETE", path, data, headers: headers)
end

#documentObject



189
# File 'lib/dommy/rack/session.rb', line 189

def document = @current_window&.document

#enforce_same_origin?Boolean

Returns:

  • (Boolean)


64
# File 'lib/dommy/rack/session.rb', line 64

def enforce_same_origin? = @config.enforce_same_origin

#fetch(url, method: "GET", headers: {}, body: nil, params: nil, redirect: :follow) ⇒ Object

— Fetch API (returns Response; does NOT change document or history) —



170
171
172
# File 'lib/dommy/rack/session.rb', line 170

def fetch(url, method: "GET", headers: {}, body: nil, params: nil, redirect: :follow)
  @navigation.fetch(url, method: method, params: params, body: body, headers: headers, redirect: redirect)
end

#fill_in(locator, with:) ⇒ Object

Form field setting delegates to FieldInteractor (DOM mutation only; a subsequent submit is what turns into a navigation).



328
# File 'lib/dommy/rack/session.rb', line 328

def fill_in(locator, with:) = field_interactor.fill_in(locator, with: with)

#follow_meta_refresh?Boolean

Returns:

  • (Boolean)


65
# File 'lib/dommy/rack/session.rb', line 65

def follow_meta_refresh? = @config.follow_meta_refresh

#follow_redirects?Boolean

Returns:

  • (Boolean)


62
# File 'lib/dommy/rack/session.rb', line 62

def follow_redirects? = @config.follow_redirects

#forwardObject



91
92
93
94
# File 'lib/dommy/rack/session.rb', line 91

def forward
  url = @history.forward
  @navigation.revisit(url) if url
end

#get(path, headers: {}) ⇒ Object

— Basic request API (navigates, updating page state) —



98
99
100
# File 'lib/dommy/rack/session.rb', line 98

def get(path, headers: {})
  navigate(method: "GET", url: path, headers: headers)
end


366
# File 'lib/dommy/rack/session.rb', line 366

def get_cookie(name) = @cookie_jar.get(name)

#has_button?(locator) ⇒ Boolean

Returns:

  • (Boolean)


301
# File 'lib/dommy/rack/session.rb', line 301

def has_button?(locator) = element_present? { finder.find_button(locator) }

#has_css?(selector, count: nil) ⇒ Boolean

— Matchers —

Returns:

  • (Boolean)


287
288
289
290
# File 'lib/dommy/rack/session.rb', line 287

def has_css?(selector, count: nil)
  nodes = scope_root ? scope_root.query_selector_all(selector) : []
  count ? nodes.size == count : !nodes.empty?
end

#has_field?(locator) ⇒ Boolean

Returns:

  • (Boolean)


302
# File 'lib/dommy/rack/session.rb', line 302

def has_field?(locator) = element_present? { finder.find_field(locator) }

#has_link?(locator) ⇒ Boolean

Returns:

  • (Boolean)


300
# File 'lib/dommy/rack/session.rb', line 300

def has_link?(locator) = element_present? { finder.find_link(locator) }

#has_no_css?(selector, count: nil) ⇒ Boolean

Returns:

  • (Boolean)


292
# File 'lib/dommy/rack/session.rb', line 292

def has_no_css?(selector, count: nil) = !has_css?(selector, count: count)

#has_no_text?(string) ⇒ Boolean

Returns:

  • (Boolean)


298
# File 'lib/dommy/rack/session.rb', line 298

def has_no_text?(string) = !has_text?(string)

#has_text?(string) ⇒ Boolean

Returns:

  • (Boolean)


294
295
296
# File 'lib/dommy/rack/session.rb', line 294

def has_text?(string)
  scope_text.include?(string.to_s)
end

#headersObject



187
# File 'lib/dommy/rack/session.rb', line 187

def headers = @last_response&.headers

#htmlObject



222
223
224
# File 'lib/dommy/rack/session.rb', line 222

def html
  document&.to_html
end

#json(symbolize_names: false) ⇒ Object

Parsed JSON of the most recent response, or nil if no request yet.



192
193
194
# File 'lib/dommy/rack/session.rb', line 192

def json(symbolize_names: false)
  @last_response&.json(symbolize_names: symbolize_names)
end

#max_redirectsObject



63
# File 'lib/dommy/rack/session.rb', line 63

def max_redirects = @config.max_redirects


74
75
76
# File 'lib/dommy/rack/session.rb', line 74

def navigate(method: "GET", url:, params: nil, body: nil, headers: {})
  @navigation.navigate(method: method, url: url, params: params, body: body, headers: headers)
end

#not_found?Boolean

Returns:

  • (Boolean)


201
# File 'lib/dommy/rack/session.rb', line 201

def not_found? = @last_response&.not_found? || false

#on_request(&block) ⇒ Object

Register a callback invoked with the Rack env just before each request.



211
212
213
214
# File 'lib/dommy/rack/session.rb', line 211

def on_request(&block)
  @request_listeners << block
  self
end

#on_response(&block) ⇒ Object

Register a callback invoked with the Response after each request.



217
218
219
220
# File 'lib/dommy/rack/session.rb', line 217

def on_response(&block)
  @response_listeners << block
  self
end

#patch(path, params: nil, body: nil, headers: {}) ⇒ Object



110
111
112
# File 'lib/dommy/rack/session.rb', line 110

def patch(path, params: nil, body: nil, headers: {})
  navigate(method: "PATCH", url: path, params: params, body: body, headers: headers)
end

#patch_json(path, data, headers: {}) ⇒ Object



132
133
134
# File 'lib/dommy/rack/session.rb', line 132

def patch_json(path, data, headers: {})
  request_json("PATCH", path, data, headers: headers)
end

#post(path, params: nil, body: nil, headers: {}) ⇒ Object



102
103
104
# File 'lib/dommy/rack/session.rb', line 102

def post(path, params: nil, body: nil, headers: {})
  navigate(method: "POST", url: path, params: params, body: body, headers: headers)
end

#post_json(path, data, headers: {}) ⇒ Object

— JSON request helpers (navigate with a JSON body) —



124
125
126
# File 'lib/dommy/rack/session.rb', line 124

def post_json(path, data, headers: {})
  request_json("POST", path, data, headers: headers)
end

#put(path, params: nil, body: nil, headers: {}) ⇒ Object



106
107
108
# File 'lib/dommy/rack/session.rb', line 106

def put(path, params: nil, body: nil, headers: {})
  navigate(method: "PUT", url: path, params: params, body: body, headers: headers)
end

#put_json(path, data, headers: {}) ⇒ Object



128
129
130
# File 'lib/dommy/rack/session.rb', line 128

def put_json(path, data, headers: {})
  request_json("PUT", path, data, headers: headers)
end

#raw_request(method, absolute_url, params: nil, body: nil, headers: {}) ⇒ Object

Execute one request against the app. Stores response cookies but does NOT update current_url / document / history.



375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/dommy/rack/session.rb', line 375

def raw_request(method, absolute_url, params: nil, body: nil, headers: {})
  # Remember the latest raw request so #reload can re-issue it. Note this
  # is the final request of any redirect chain: after a POST that
  # redirects (PRG), reload re-GETs the landing page rather than re-POSTing.
  @last_request_args = {method: method, url: absolute_url, params: params, body: body, headers: headers}
  env = RequestBuilder.new(@config).build(
    method: method,
    url: absolute_url,
    params: params,
    body: body,
    headers: @headers.merge(headers),
    cookie_string: @cookie_jar.cookies_for(absolute_url)
  )
  @last_request = env
  @request_listeners.each { |cb| cb.call(env) }
  status, response_headers, response_body = @app.call(env)
  response = Response.new(status, response_headers, response_body, url: absolute_url)
  response.set_cookie_strings.each do |sc|
    @cookie_jar.store_from_header(sc, absolute_url)
  end
  @response_listeners.each { |cb| cb.call(response) }
  response
end

#redirected?Boolean

Returns:

  • (Boolean)


206
# File 'lib/dommy/rack/session.rb', line 206

def redirected? = !redirects.empty?

#redirectsObject

— Redirect chain of the last navigation —



205
# File 'lib/dommy/rack/session.rb', line 205

def redirects = @last_response&.redirects || []

#reloadObject

Raises:



78
79
80
81
82
83
84
# File 'lib/dommy/rack/session.rb', line 78

def reload
  raise Error, "no current page to reload" unless @last_request_args

  response, final_url = @navigation.run(**@last_request_args)
  apply_navigation_response(response, final_url)
  response
end

#request(method, path, params: nil, body: nil, headers: {}) ⇒ Object



118
119
120
# File 'lib/dommy/rack/session.rb', line 118

def request(method, path, params: nil, body: nil, headers: {})
  navigate(method: method, url: path, params: params, body: body, headers: headers)
end

#save_page(path = nil) ⇒ Object

Write the current page HTML to ‘path` (default: a timestamped file in the system temp dir) and return the path. For debugging.

Raises:



232
233
234
235
236
237
238
239
# File 'lib/dommy/rack/session.rb', line 232

def save_page(path = nil)
  content = html
  raise Error, "no current page to save" if content.nil?

  path ||= ::File.join(Dir.tmpdir, "dommy-rack-#{Time.now.strftime("%Y%m%d%H%M%S")}.html")
  ::File.write(path, content)
  path
end

#select(value, from:) ⇒ Object



333
# File 'lib/dommy/rack/session.rb', line 333

def select(value, from:) = field_interactor.select(value, from: from)

#server_error?Boolean

Returns:

  • (Boolean)


200
# File 'lib/dommy/rack/session.rb', line 200

def server_error? = @last_response&.server_error? || false


361
362
363
364
# File 'lib/dommy/rack/session.rb', line 361

def set_cookie(name, value, path: "/", domain: nil, **opts)
  resolved_domain = domain || (@current_url && URI.parse(@current_url).host) || URI.parse(@config.default_host).host
  @cookie_jar.set!(name, value, domain: resolved_domain, path: path, **opts)
end

#set_header(name, value) ⇒ Object



146
147
148
149
# File 'lib/dommy/rack/session.rb', line 146

def set_header(name, value)
  @headers.set(name, value)
  self
end

#statusObject



186
# File 'lib/dommy/rack/session.rb', line 186

def status = @last_response&.status

#submit_form(form, submitter: nil) ⇒ Object Also known as: submit

Raises:



347
348
349
350
351
352
353
# File 'lib/dommy/rack/session.rb', line 347

def submit_form(form, submitter: nil)
  raise InvalidFormError, "element is not inside a form" if form.nil?

  result = FormSubmission.new(form, submitter, @config).submit!
  navigate(method: result[:method], url: resolve_document_url(result[:url]),
           params: result[:params], headers: referer_headers)
end

#success?Boolean

— Status predicates (delegate to the last response) —

Returns:

  • (Boolean)


198
# File 'lib/dommy/rack/session.rb', line 198

def success? = @last_response&.success? || false

#textObject



226
227
228
# File 'lib/dommy/rack/session.rb', line 226

def text
  document&.body&.text_content
end

#uncheck(locator) ⇒ Object



331
# File 'lib/dommy/rack/session.rb', line 331

def uncheck(locator) = field_interactor.uncheck(locator)

#unselect(value, from:) ⇒ Object



334
# File 'lib/dommy/rack/session.rb', line 334

def unselect(value, from:) = field_interactor.unselect(value, from: from)

#visit(path) ⇒ Object

— Navigation API —



70
71
72
# File 'lib/dommy/rack/session.rb', line 70

def visit(path)
  @navigation.navigate(method: "GET", url: path)
end

#within(selector, &block) ⇒ Object

Restrict element finds and matchers to within the first element matching ‘selector` for the duration of the block.



263
264
265
266
267
268
# File 'lib/dommy/rack/session.rb', line 263

def within(selector, &block)
  node = scope_root&.query_selector(selector)
  raise ElementNotFoundError, "no element matching #{selector.inspect}" unless node

  with_scope(node, &block)
end

#within_frame(locator = nil, &block) ⇒ Object

Load the iframe matched by ‘locator` (id, name, or CSS; the sole frame if omitted) and scope finds/matchers to its document for the block.



272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/dommy/rack/session.rb', line 272

def within_frame(locator = nil, &block)
  frame = find_frame(locator)
  raise ElementNotFoundError, "no iframe matching #{locator.inspect}" unless frame

  src = frame.get_attribute("src")
  raise Error, "iframe has no src" if src.nil? || src.empty?

  frame_doc = fetch(resolve_document_url(src), headers: referer_headers).document
  raise UnsupportedContentTypeError, "iframe did not return an HTML document" unless frame_doc

  with_scope(frame_doc, &block)
end