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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a1a9aaa4ae2c0fcc61a4054b66fe4e9e9fadb65093acfa912c1b73a5216ac7be
|
|
4
|
+
data.tar.gz: 3ead606c9fc898c53aeb6e05222f0ddbf2c4c3f08a8ccce6e881867de537019d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1f5e7fb4b35c073c069812de208765c5ba7e3aaca832a8071d320a991cc2f215ecf6d27ccec6e87988719ede65550b4d0b8901810380428495427e8fa48b6716
|
|
7
|
+
data.tar.gz: 21f1916ec0327d598142776993685685d0257e76dfa4ac998556aa116819678e96ead4b1615e31cccab60c3645de34d29a8e46f1e42638528bf1bd89071520a1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.8.0 — 2026-05-31
|
|
4
|
+
|
|
5
|
+
Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
|
|
6
|
+
No functional changes to dommy-rack itself.
|
|
7
|
+
|
|
8
|
+
## 0.7.0 — 2026-05-30
|
|
9
|
+
|
|
10
|
+
Initial release.
|
|
11
|
+
|
|
12
|
+
Versioned in lockstep with the [`dommy`](https://github.com/takahashim/dommy)
|
|
13
|
+
gem. dommy-rack lets a Rack application (including Rails) be visited and
|
|
14
|
+
manipulated as a `Dommy::Document` without launching a real browser, providing a
|
|
15
|
+
small, synchronous, browser-like session API with navigation, cookies,
|
|
16
|
+
redirects, link clicking, and form submission.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Masayoshi Takahashi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Dommy::Rack
|
|
2
|
+
|
|
3
|
+
`dommy-rack` lets a Rack application (including Rails) be visited and manipulated as a [Dommy](https://github.com/takahashim/dommy) `Document`, without launching a real browser.
|
|
4
|
+
It provides a small, synchronous, browser-like session API: navigation, cookies, redirects, link clicking, form submission, JSON requests, and simple matchers.
|
|
5
|
+
|
|
6
|
+
There is no JavaScript engine and no network: requests are dispatched straight to your Rack app object, and responses are parsed into a Dommy DOM.
|
|
7
|
+
This makes it a fast backend for integration tests and a building block for higher-level drivers such as [capybara-dommy](https://github.com/takahashim/capybara-dommy).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "dommy-rack"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "dommy/rack"
|
|
24
|
+
|
|
25
|
+
session = Dommy::Rack::Session.new(MyRackApp)
|
|
26
|
+
|
|
27
|
+
session.visit("/")
|
|
28
|
+
session.click_link("New post")
|
|
29
|
+
|
|
30
|
+
session.fill_in("post[title]", with: "Hello")
|
|
31
|
+
session.click_button("Create")
|
|
32
|
+
|
|
33
|
+
session.current_path # => "/posts/1"
|
|
34
|
+
session.at_css(".notice").text_content # => "Created"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`Session.new` accepts any Rack-callable `app` plus options:
|
|
38
|
+
|
|
39
|
+
| option | default | meaning |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `default_host` | `"http://example.org"` | base for relative URLs |
|
|
42
|
+
| `follow_redirects` | `true` | follow 3xx responses |
|
|
43
|
+
| `max_redirects` | `5` | redirect / meta-refresh limit |
|
|
44
|
+
| `respect_method_override` | `true` | honor Rails `_method` |
|
|
45
|
+
| `method_override_param` | `"_method"` | override param name |
|
|
46
|
+
| `user_agent` | `"DommyRack"` | default `User-Agent` |
|
|
47
|
+
| `accept` | HTML accept string | default `Accept` |
|
|
48
|
+
| `enforce_same_origin` | `true` | block cross-origin requests |
|
|
49
|
+
| `follow_meta_refresh` | `true` | follow `<meta http-equiv="refresh">` (delay 0) |
|
|
50
|
+
|
|
51
|
+
## Navigation
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
session.visit("/path")
|
|
55
|
+
session.get("/path", headers: {})
|
|
56
|
+
session.post("/path", params: {a: 1})
|
|
57
|
+
session.put("/path", params: {})
|
|
58
|
+
session.patch("/path", params: {})
|
|
59
|
+
session.delete("/path")
|
|
60
|
+
session.request("REPORT", "/path", params: {})
|
|
61
|
+
|
|
62
|
+
session.reload
|
|
63
|
+
session.back
|
|
64
|
+
session.forward
|
|
65
|
+
|
|
66
|
+
session.current_url # full URL
|
|
67
|
+
session.current_path # path component
|
|
68
|
+
session.current_host
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`fetch` issues a request **without** changing the current page or history, and returns the `Response` directly:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
res = session.fetch("/api/ping", redirect: :manual) # :follow | :manual | :error
|
|
75
|
+
res.status
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Inspecting the current page
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
session.status # last response status (Integer)
|
|
82
|
+
session.headers # last response headers (Hash)
|
|
83
|
+
session.body # raw response body (String)
|
|
84
|
+
session.html # serialized document HTML
|
|
85
|
+
session.text # document body text
|
|
86
|
+
|
|
87
|
+
session.success? # 2xx
|
|
88
|
+
session.not_found? # 404
|
|
89
|
+
session.client_error? # 4xx
|
|
90
|
+
session.server_error? # 5xx
|
|
91
|
+
|
|
92
|
+
session.save_page # write HTML to a temp file, returns path
|
|
93
|
+
session.save_page("out.html") # write to a specific path
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### DOM queries
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
session.at_css("h1") # first match (a Dommy element) or nil
|
|
100
|
+
session.all_css("li") # all matches
|
|
101
|
+
session.at_xpath("//h1")
|
|
102
|
+
session.all_xpath("//li")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Redirect chain
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
session.visit("/a") # /a -> /b -> /c
|
|
109
|
+
session.redirected? # => true
|
|
110
|
+
session.redirects # => [{status: 302, url: ".../a", location: "/b"}, ...]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Forms
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
session.fill_in("Email", with: "a@example.com") # by label, id, name, placeholder, aria-label
|
|
117
|
+
session.choose("Male") # radio
|
|
118
|
+
session.check("Subscribe") # checkbox
|
|
119
|
+
session.uncheck("Subscribe")
|
|
120
|
+
session.select("Tokyo", from: "City") # <select>
|
|
121
|
+
session.unselect("Tokyo", from: "City")
|
|
122
|
+
session.attach_file("Avatar", "/path/to/file.png")
|
|
123
|
+
|
|
124
|
+
session.click_button("Save") # submits the owning form
|
|
125
|
+
session.submit_form(session.at_css("form"))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Locators are Capybara-style and depend on the element type:
|
|
129
|
+
fields match by `id`, `name`, label text, placeholder, or `aria-label`;
|
|
130
|
+
links match by visible text, `id`, `title`, or exact `href`;
|
|
131
|
+
buttons match by button text, `value`, `id`, `name`, or `alt`.
|
|
132
|
+
|
|
133
|
+
## JSON
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
session.post_json("/api/posts", {title: "Hi"}) # also put_json / patch_json / delete_json
|
|
137
|
+
session.status # => 201
|
|
138
|
+
session.json # => {"id" => 7}
|
|
139
|
+
session.json(symbolize_names: true) # => {id: 7}
|
|
140
|
+
|
|
141
|
+
# On a Response:
|
|
142
|
+
res = session.fetch("/api/posts")
|
|
143
|
+
res.json? # content-type is JSON-ish (application/json, text/json, *+json)
|
|
144
|
+
res.json # parsed body (parses regardless of content-type)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A `String` passed to `post_json` is sent verbatim (already-encoded JSON).
|
|
148
|
+
`Content-Type` and `Accept` default to `application/json` and can be overridden via `headers:`.
|
|
149
|
+
|
|
150
|
+
## Persistent headers and authentication
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
session.set_header("X-Api-Key", "secret") # sent on every request
|
|
154
|
+
session.delete_header("X-Api-Key")
|
|
155
|
+
session.default_headers # current persistent headers (copy)
|
|
156
|
+
|
|
157
|
+
session.basic_auth("alice", "s3cret") # Authorization: Basic ...
|
|
158
|
+
session.authorization_bearer("token123") # Authorization: Bearer token123
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Per-request `headers:` override persistent defaults (case-insensitively).
|
|
162
|
+
|
|
163
|
+
## Cookies
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
session.cookies # all cookies
|
|
167
|
+
session.get_cookie("sid")
|
|
168
|
+
session.set_cookie("sid", "42", path: "/")
|
|
169
|
+
session.clear_cookies
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Cookies set by the app via `Set-Cookie` are stored and replayed automatically.
|
|
173
|
+
|
|
174
|
+
## Scoping and matchers
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
session.within("#sidebar") do |s|
|
|
178
|
+
s.click_link("Help")
|
|
179
|
+
s.has_text?("Contact us") # scoped to #sidebar
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
session.has_css?(".item", count: 3)
|
|
183
|
+
session.has_no_css?(".error")
|
|
184
|
+
session.has_text?("Welcome")
|
|
185
|
+
session.has_no_text?("Error")
|
|
186
|
+
session.has_link?("Home")
|
|
187
|
+
session.has_button?("Save")
|
|
188
|
+
session.has_field?("Email")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### iframes
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
session.within_frame("preview") do |s| # by id, name, CSS, or the sole frame
|
|
195
|
+
s.has_text?("inside the iframe")
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`within_frame` fetches the iframe's `src` as a sub-document and scopes finds and matchers to it for the block.
|
|
200
|
+
|
|
201
|
+
## Instrumentation
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
session.on_request { |env| Rails.logger.info("-> #{env["PATH_INFO"]}") }
|
|
205
|
+
session.on_response { |response| Rails.logger.info("<- #{response.status}") }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Errors
|
|
209
|
+
|
|
210
|
+
All errors inherit from `Dommy::Rack::Error`:
|
|
211
|
+
`ElementNotFoundError`, `AmbiguousElementError`, `ElementNotClickableError`,
|
|
212
|
+
`UnsupportedURLError`, `CrossOriginError`, `TooManyRedirectsError`,
|
|
213
|
+
`UnsupportedContentTypeError`, `InvalidFormError`, `FileNotFoundError`.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
bin/setup # install dependencies
|
|
219
|
+
rake test # run the test suite
|
|
220
|
+
bin/console # interactive prompt
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
Bug reports and pull requests are welcome at
|
|
226
|
+
https://github.com/takahashim/dommy-rack.
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Dommy
|
|
7
|
+
module Rack
|
|
8
|
+
# A simplified, same-origin cookie store. Parses Set-Cookie response
|
|
9
|
+
# headers, generates the Cookie request header, and applies domain, path,
|
|
10
|
+
# expiry, and secure matching. No public-suffix handling.
|
|
11
|
+
class CookieJar
|
|
12
|
+
CookieEntry = Struct.new(
|
|
13
|
+
:name, :value, :domain, :path, :expires, :secure, :http_only, :host_only,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@entries = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parse a single Set-Cookie header value and store the result.
|
|
22
|
+
def store_from_header(set_cookie_string, request_url)
|
|
23
|
+
uri = URI.parse(request_url)
|
|
24
|
+
entry = parse_set_cookie(set_cookie_string, uri)
|
|
25
|
+
return unless entry
|
|
26
|
+
|
|
27
|
+
if expired?(entry)
|
|
28
|
+
remove(entry.name, entry.domain, entry.path)
|
|
29
|
+
else
|
|
30
|
+
store_entry(entry)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Manually store a cookie. domain defaults to host-only on request_host.
|
|
35
|
+
def set!(name, value, domain: nil, path: "/", expires: nil, secure: false, http_only: false)
|
|
36
|
+
entry = CookieEntry.new(
|
|
37
|
+
name: name.to_s,
|
|
38
|
+
value: value.to_s,
|
|
39
|
+
domain: (domain || "").sub(/\A\./, "").downcase,
|
|
40
|
+
path: path || "/",
|
|
41
|
+
expires: expires,
|
|
42
|
+
secure: secure,
|
|
43
|
+
http_only: http_only,
|
|
44
|
+
host_only: domain.nil?
|
|
45
|
+
)
|
|
46
|
+
store_entry(entry)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# First non-expired cookie value matching the name.
|
|
50
|
+
def get(name)
|
|
51
|
+
@entries.find { |e| e.name == name.to_s && !expired?(e) }&.value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear
|
|
55
|
+
@entries = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def all
|
|
59
|
+
@entries.reject { |e| expired?(e) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Build the Cookie request header value for the given URL, or "".
|
|
63
|
+
def cookies_for(request_url)
|
|
64
|
+
uri = URI.parse(request_url)
|
|
65
|
+
secure_request = uri.scheme == "https"
|
|
66
|
+
host = uri.host.to_s.downcase
|
|
67
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
68
|
+
|
|
69
|
+
matches = @entries.reject { |e| expired?(e) }.select do |e|
|
|
70
|
+
domain_match?(e, host) &&
|
|
71
|
+
path_match?(e.path, path) &&
|
|
72
|
+
(!e.secure || secure_request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# More specific (longer) paths first, per RFC 6265.
|
|
76
|
+
matches.sort_by! { |e| -e.path.length }
|
|
77
|
+
matches.map { |e| "#{e.name}=#{e.value}" }.join("; ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def store_entry(entry)
|
|
83
|
+
remove(entry.name, entry.domain, entry.path)
|
|
84
|
+
@entries << entry
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def remove(name, domain, path)
|
|
88
|
+
@entries.reject! { |e| e.name == name && e.domain == domain && e.path == path }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_set_cookie(string, request_uri)
|
|
92
|
+
segments = string.split(";").map(&:strip)
|
|
93
|
+
name_value = segments.shift.to_s
|
|
94
|
+
return nil unless name_value.include?("=")
|
|
95
|
+
|
|
96
|
+
name, value = name_value.split("=", 2)
|
|
97
|
+
name = name.to_s.strip
|
|
98
|
+
return nil if name.empty?
|
|
99
|
+
|
|
100
|
+
attrs = parse_attributes(segments)
|
|
101
|
+
request_host = request_uri.host.to_s.downcase
|
|
102
|
+
|
|
103
|
+
domain = attrs["domain"]
|
|
104
|
+
host_only = domain.nil? || domain.empty?
|
|
105
|
+
domain = host_only ? request_host : domain.sub(/\A\./, "").downcase
|
|
106
|
+
|
|
107
|
+
CookieEntry.new(
|
|
108
|
+
name: name,
|
|
109
|
+
value: value.to_s.strip,
|
|
110
|
+
domain: domain,
|
|
111
|
+
path: attrs["path"] || default_path(request_uri),
|
|
112
|
+
expires: resolve_expiry(attrs),
|
|
113
|
+
secure: attrs.key?("secure"),
|
|
114
|
+
http_only: attrs.key?("httponly"),
|
|
115
|
+
host_only: host_only
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_attributes(segments)
|
|
120
|
+
segments.each_with_object({}) do |segment, acc|
|
|
121
|
+
key, val = segment.split("=", 2)
|
|
122
|
+
acc[key.to_s.strip.downcase] = val&.strip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Max-Age takes precedence over Expires.
|
|
127
|
+
def resolve_expiry(attrs)
|
|
128
|
+
if attrs["max-age"]
|
|
129
|
+
seconds = attrs["max-age"].to_i
|
|
130
|
+
Time.now + seconds
|
|
131
|
+
elsif attrs["expires"]
|
|
132
|
+
Time.parse(attrs["expires"]) rescue nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# RFC 6265 default-path: directory portion of the request path.
|
|
137
|
+
def default_path(uri)
|
|
138
|
+
path = uri.path.to_s
|
|
139
|
+
return "/" if path.empty? || !path.start_with?("/")
|
|
140
|
+
|
|
141
|
+
idx = path.rindex("/")
|
|
142
|
+
idx.nil? || idx.zero? ? "/" : path[0...idx]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def expired?(entry)
|
|
146
|
+
entry.expires && entry.expires <= Time.now
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def domain_match?(entry, host)
|
|
150
|
+
if entry.host_only
|
|
151
|
+
host == entry.domain
|
|
152
|
+
else
|
|
153
|
+
host == entry.domain || host.end_with?(".#{entry.domain}")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# RFC 6265 path-match.
|
|
158
|
+
def path_match?(cookie_path, request_path)
|
|
159
|
+
return true if cookie_path == request_path
|
|
160
|
+
return false unless request_path.start_with?(cookie_path)
|
|
161
|
+
|
|
162
|
+
cookie_path.end_with?("/") || request_path[cookie_path.length] == "/"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rack
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a locator matches no element.
|
|
8
|
+
class ElementNotFoundError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when an element cannot be clicked (e.g. a link with no href).
|
|
11
|
+
class ElementNotClickableError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a locator matches more than one element.
|
|
14
|
+
class AmbiguousElementError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised for hrefs that dommy-rack cannot navigate to (javascript:, mailto:, ...).
|
|
17
|
+
class UnsupportedURLError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a request would cross origins, which is not allowed.
|
|
20
|
+
class CrossOriginError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a redirect chain exceeds max_redirects.
|
|
23
|
+
class TooManyRedirectsError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a response content type cannot be handled as requested.
|
|
26
|
+
class UnsupportedContentTypeError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when a form is malformed or cannot be submitted.
|
|
29
|
+
class InvalidFormError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when a file to be uploaded does not exist.
|
|
32
|
+
class FileNotFoundError < Error; end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rack
|
|
5
|
+
# Drives form fields in the current document: fills text inputs, toggles
|
|
6
|
+
# radios / checkboxes, selects options, attaches files. Pure DOM mutation —
|
|
7
|
+
# it locates fields via a Locator and mutates the live Dommy elements, but
|
|
8
|
+
# issues no requests (Session turns a subsequent submit into navigation).
|
|
9
|
+
class FieldInteractor
|
|
10
|
+
def initialize(finder, document)
|
|
11
|
+
@finder = finder
|
|
12
|
+
@document = document
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fill_in(locator, with:)
|
|
16
|
+
field = @finder.find_field(locator)
|
|
17
|
+
field.value = with.to_s
|
|
18
|
+
field
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def choose(locator)
|
|
22
|
+
radio = @finder.find_field(locator)
|
|
23
|
+
clear_radio_group(radio)
|
|
24
|
+
radio.checked = true
|
|
25
|
+
radio
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check(locator)
|
|
29
|
+
box = @finder.find_field(locator)
|
|
30
|
+
box.checked = true
|
|
31
|
+
box
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def uncheck(locator)
|
|
35
|
+
box = @finder.find_field(locator)
|
|
36
|
+
box.checked = false
|
|
37
|
+
box
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def attach_file(locator, path)
|
|
41
|
+
input = @finder.find_field(locator)
|
|
42
|
+
raise FileNotFoundError, "no such file: #{path}" unless ::File.exist?(path)
|
|
43
|
+
|
|
44
|
+
file = Dommy::File.new(
|
|
45
|
+
[::File.binread(path)], ::File.basename(path), "type" => FileUpload.mime_type_for(path)
|
|
46
|
+
)
|
|
47
|
+
input.__driver_set_files__([file])
|
|
48
|
+
input
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def select(value, from:)
|
|
52
|
+
select_el = @finder.find_field(from)
|
|
53
|
+
option = @finder.find_option(select_el, value)
|
|
54
|
+
raise ElementNotFoundError, "no option #{value.inspect} in #{from.inspect}" unless option
|
|
55
|
+
|
|
56
|
+
select_el.options.each { |o| o.remove_attribute("selected") } unless select_el.multiple
|
|
57
|
+
option.set_attribute("selected", "")
|
|
58
|
+
select_el
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unselect(value, from:)
|
|
62
|
+
select_el = @finder.find_field(from)
|
|
63
|
+
option = @finder.find_option(select_el, value)
|
|
64
|
+
option&.remove_attribute("selected")
|
|
65
|
+
select_el
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def clear_radio_group(radio)
|
|
71
|
+
name = radio.get_attribute("name")
|
|
72
|
+
return unless name
|
|
73
|
+
|
|
74
|
+
scope = radio.closest("form") || @document
|
|
75
|
+
scope.query_selector_all("input[type='radio']").each do |r|
|
|
76
|
+
r.checked = false if r.get_attribute("name") == name
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Rack
|
|
7
|
+
# Encodes collected form params (ordered [name, value] pairs) as a
|
|
8
|
+
# multipart/form-data body. File/Blob values become file parts; other
|
|
9
|
+
# values become text parts. Owns the multipart serialization so the HTTP
|
|
10
|
+
# layer (not Dommy::FormData) is responsible for it.
|
|
11
|
+
module FileUpload
|
|
12
|
+
MIME_TYPES = {
|
|
13
|
+
".txt" => "text/plain",
|
|
14
|
+
".html" => "text/html",
|
|
15
|
+
".htm" => "text/html",
|
|
16
|
+
".json" => "application/json",
|
|
17
|
+
".csv" => "text/csv",
|
|
18
|
+
".xml" => "application/xml",
|
|
19
|
+
".png" => "image/png",
|
|
20
|
+
".jpg" => "image/jpeg",
|
|
21
|
+
".jpeg" => "image/jpeg",
|
|
22
|
+
".gif" => "image/gif",
|
|
23
|
+
".pdf" => "application/pdf"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Guess a MIME type from a file path's extension.
|
|
29
|
+
def mime_type_for(path)
|
|
30
|
+
MIME_TYPES.fetch(::File.extname(path).downcase, "application/octet-stream")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True when any pair value is a File/Blob.
|
|
34
|
+
def multipart?(pairs)
|
|
35
|
+
return false unless pairs
|
|
36
|
+
|
|
37
|
+
pairs.any? { |(_name, value)| value.respond_to?(:__dommy_bytes__) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns [body (ASCII-8BIT String), content_type with boundary].
|
|
41
|
+
def encode(pairs, boundary = nil)
|
|
42
|
+
boundary ||= "----DommyRackBoundary#{SecureRandom.hex(16)}"
|
|
43
|
+
body = +"".b
|
|
44
|
+
pairs.each { |name, value| body << part(boundary, name, value) }
|
|
45
|
+
body << "--#{boundary}--\r\n"
|
|
46
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def part(boundary, name, value)
|
|
50
|
+
head = +"--#{boundary}\r\n"
|
|
51
|
+
if value.respond_to?(:__dommy_bytes__) # File / Blob
|
|
52
|
+
filename = value.respond_to?(:name) ? value.name.to_s : ""
|
|
53
|
+
content_type = file_content_type(value)
|
|
54
|
+
head << %(Content-Disposition: form-data; name="#{escape(name)}"; filename="#{escape(filename)}"\r\n)
|
|
55
|
+
head << "Content-Type: #{content_type}\r\n\r\n"
|
|
56
|
+
head.b << value.__dommy_bytes__ << "\r\n".b
|
|
57
|
+
else
|
|
58
|
+
head << %(Content-Disposition: form-data; name="#{escape(name)}"\r\n\r\n)
|
|
59
|
+
head.b << value.to_s.dup.force_encoding(Encoding::ASCII_8BIT) << "\r\n".b
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def file_content_type(value)
|
|
64
|
+
type = value.respond_to?(:type) ? value.type.to_s : ""
|
|
65
|
+
type.empty? ? "application/octet-stream" : type
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def escape(str)
|
|
69
|
+
str.to_s.gsub('"', "%22").gsub(/[\r\n]/, "")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|