rails-jsdom 0.1.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 +13 -0
- data/LICENSE +21 -0
- data/README.md +351 -0
- data/lib/generators/rails_jsdom/install/install_generator.rb +108 -0
- data/lib/rails_jsdom/bridge.rb +80 -0
- data/lib/rails_jsdom/element.rb +186 -0
- data/lib/rails_jsdom/page.rb +33 -0
- data/lib/rails_jsdom/query.rb +76 -0
- data/lib/rails_jsdom/runner.js +3 -0
- data/lib/rails_jsdom/runtime.js +926 -0
- data/lib/rails_jsdom/test_case.rb +29 -0
- data/lib/rails_jsdom/test_helper.rb +767 -0
- data/lib/rails_jsdom/turbo_stream_deliveries.rb +25 -0
- data/lib/rails_jsdom/version.rb +4 -0
- data/lib/rails_jsdom.rb +83 -0
- metadata +158 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
module RailsJsdom
|
|
2
|
+
# Helpers for driving a page in jsdom from an integration test. Their names
|
|
3
|
+
# and options follow the system test methods you already use.
|
|
4
|
+
#
|
|
5
|
+
# Inherit from RailsJsdom::TestCase, which includes this module, or include
|
|
6
|
+
# it in your own `ActionDispatch::IntegrationTest`.
|
|
7
|
+
#
|
|
8
|
+
# ## Locators
|
|
9
|
+
#
|
|
10
|
+
# Actions find their element with a locator, tried in this order. The first
|
|
11
|
+
# rule that matches anything wins:
|
|
12
|
+
#
|
|
13
|
+
# 1. **Stimulus action shorthand.** `click "dropdown#toggle"` finds the
|
|
14
|
+
# element whose `data-action` calls `dropdown#toggle`.
|
|
15
|
+
# 2. **Text.**
|
|
16
|
+
# - For #click, #click_on, #click_link, #click_button, #hover,
|
|
17
|
+
# #double_click, #right_click, #find_button and #find_link: a button,
|
|
18
|
+
# link, submit, checkbox, radio, `summary`, `label` or `[role=button]`
|
|
19
|
+
# whose text, `aria-label`, `title`, `id` or button value equals the
|
|
20
|
+
# locator, or else contains it.
|
|
21
|
+
# - For #fill_in, #check, #uncheck, #choose, #attach_file, #find_field
|
|
22
|
+
# and the `from:` of #select: the field of a `label` whose text equals,
|
|
23
|
+
# or else contains, the locator; then a field whose `placeholder`,
|
|
24
|
+
# `name`, `id` or `aria-label` equals it.
|
|
25
|
+
# 3. **CSS selector.** `click "#checkout"`.
|
|
26
|
+
#
|
|
27
|
+
# #click_link only matches links, #click_button only buttons, #click_on
|
|
28
|
+
# both; #fill_in only text-like fields; #check and #uncheck only
|
|
29
|
+
# checkboxes; #choose only radio buttons; #attach_file only file fields.
|
|
30
|
+
#
|
|
31
|
+
# More than one match raises, unless you pass `match: :first`.
|
|
32
|
+
#
|
|
33
|
+
# ## Filters
|
|
34
|
+
#
|
|
35
|
+
# #find, #all, #first, #find_field, #find_button, #find_link, the
|
|
36
|
+
# `has_*?` predicates and the `assert_*` element assertions take these
|
|
37
|
+
# filters:
|
|
38
|
+
#
|
|
39
|
+
# - `text:` the visible text includes a String, or matches a Regexp.
|
|
40
|
+
# - `exact_text:` the visible text equals a String, or matches a Regexp.
|
|
41
|
+
# - `visible:` `true` (the default) only visible elements; `false` or
|
|
42
|
+
# `:all` every element; `:hidden` only hidden ones.
|
|
43
|
+
# - `with:` a field's current value.
|
|
44
|
+
# - `checked:`, `unchecked:` a checkbox or radio button's state.
|
|
45
|
+
# - `disabled:` `false` (the default for fields and buttons), `true`, or
|
|
46
|
+
# `:all`.
|
|
47
|
+
# - `href:` a link's `href`.
|
|
48
|
+
# - `type:` an input's type, or the tag name of other fields.
|
|
49
|
+
#
|
|
50
|
+
# Count assertions also take `count:`, or `minimum:` and `maximum:`.
|
|
51
|
+
#
|
|
52
|
+
# ## Scoping
|
|
53
|
+
#
|
|
54
|
+
# #within, #within_fieldset and #within_table scope every action, finder
|
|
55
|
+
# and assertion in their block, including `assert_dom`, to one element.
|
|
56
|
+
# Blocks nest.
|
|
57
|
+
#
|
|
58
|
+
# ## Dialogs
|
|
59
|
+
#
|
|
60
|
+
# An alert, confirm or prompt that appears during an action fails it, unless
|
|
61
|
+
# the action runs inside the matching #accept_alert, #accept_confirm,
|
|
62
|
+
# #dismiss_confirm, #accept_prompt or #dismiss_prompt.
|
|
63
|
+
#
|
|
64
|
+
# ## Settling
|
|
65
|
+
#
|
|
66
|
+
# Every action returns once the page has settled: due timers have run on a
|
|
67
|
+
# virtual clock, no Rails request is pending, and Turbo Stream broadcasts
|
|
68
|
+
# made so far have been applied. JavaScript errors and unhandled promise
|
|
69
|
+
# rejections raised during the action fail it. Assertions and predicates
|
|
70
|
+
# read the settled page, so they never need to wait or retry.
|
|
71
|
+
module TestHelper
|
|
72
|
+
# Requests `path` with `GET` through Rails, follows redirects, loads the
|
|
73
|
+
# response into a new page and settles it.
|
|
74
|
+
#
|
|
75
|
+
# visit project_path(projects(:billing))
|
|
76
|
+
def visit(path)
|
|
77
|
+
rails_jsdom_visit(path, "push")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Requests the current URL again and loads it as a new page, without
|
|
81
|
+
# adding a history entry.
|
|
82
|
+
def refresh
|
|
83
|
+
rails_jsdom_visit(URI(current_url).request_uri, "none")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Goes back one history entry: a Turbo Drive visit or a full page load.
|
|
87
|
+
# Does nothing on the first page.
|
|
88
|
+
def go_back
|
|
89
|
+
rails_jsdom_perform(command: "traverse", delta: -1)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Goes forward one history entry after #go_back.
|
|
93
|
+
def go_forward
|
|
94
|
+
rails_jsdom_perform(command: "traverse", delta: 1)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Runs the block as another user, with its own cookies and its own page.
|
|
98
|
+
# The other session's page stays as it was, and receives Turbo Stream
|
|
99
|
+
# broadcasts when the test returns to it.
|
|
100
|
+
#
|
|
101
|
+
# using_session(:ada) { sign_in_as users(:ada); visit chat_path }
|
|
102
|
+
# using_session(:grace) { visit chat_path; fill_in "Message", with: "Hi"; click_on "Send" }
|
|
103
|
+
# using_session(:ada) { assert_text "Hi" }
|
|
104
|
+
def using_session(name)
|
|
105
|
+
previous = rails_jsdom_session_name
|
|
106
|
+
rails_jsdom_switch_session(name.to_s)
|
|
107
|
+
yield
|
|
108
|
+
ensure
|
|
109
|
+
rails_jsdom_switch_session(previous)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Renders `partial` with `ApplicationController.render` and loads it as a
|
|
113
|
+
# page, so its Stimulus controllers connect. No route is needed.
|
|
114
|
+
#
|
|
115
|
+
# mount_partial "shared/counter", locals: { start: 2 }
|
|
116
|
+
def mount_partial(partial, locals: {}, assigns: {})
|
|
117
|
+
mount_html(ApplicationController.render(partial: partial, locals: locals, assigns: assigns))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Loads an HTML fragment as the body of a page on the application host.
|
|
121
|
+
#
|
|
122
|
+
# mount_html %(<div data-controller="counter"></div>)
|
|
123
|
+
def mount_html(html)
|
|
124
|
+
rails_jsdom_perform(command: "load", html: RailsJsdom.document(html), url: "http://#{host}/", history: "push")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Answers the page's requests to `url` with the given response instead of
|
|
128
|
+
# sending them to Rails. Call it after the page is loaded; #visit and the
|
|
129
|
+
# `mount_*` methods clear all stubs.
|
|
130
|
+
#
|
|
131
|
+
# stub_fetch "/api/agents", body: [].to_json
|
|
132
|
+
def stub_fetch(url, body: "", status: 200, headers: { "content-type" => "application/json" })
|
|
133
|
+
RailsJsdom.bridge.call(command: "stub_fetch", url: url, response: { status: status, headers: headers, body: body })
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The page: an object with every helper in this module, plus
|
|
137
|
+
# RailsJsdom::Page#body and RailsJsdom::Page#title.
|
|
138
|
+
def page
|
|
139
|
+
@rails_jsdom_page ||= Page.new(self)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Clicks any element found by `locator` (see Locators) and settles the
|
|
143
|
+
# page. Turbo links and forms are answered by Rails in-process.
|
|
144
|
+
#
|
|
145
|
+
# click "dropdown#toggle"
|
|
146
|
+
def click(locator, match: nil)
|
|
147
|
+
rails_jsdom_act("click", locator, match)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Clicks the link or button found by `locator`.
|
|
151
|
+
#
|
|
152
|
+
# click_on "Add to cart"
|
|
153
|
+
def click_on(locator, match: nil)
|
|
154
|
+
rails_jsdom_act("click", locator, match, kind: "link_or_button")
|
|
155
|
+
end
|
|
156
|
+
alias_method :click_link_or_button, :click_on
|
|
157
|
+
|
|
158
|
+
# Clicks the link found by `locator`.
|
|
159
|
+
#
|
|
160
|
+
# click_link "Second page"
|
|
161
|
+
def click_link(locator, match: nil)
|
|
162
|
+
rails_jsdom_act("click", locator, match, kind: "link")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Clicks the button found by `locator`.
|
|
166
|
+
#
|
|
167
|
+
# click_button "Save"
|
|
168
|
+
def click_button(locator, match: nil)
|
|
169
|
+
rails_jsdom_act("click", locator, match, kind: "button")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Moves the pointer over the element found by `locator`, dispatching
|
|
173
|
+
# `pointerover`, `pointerenter`, `mouseover`, `mouseenter`, `pointermove`
|
|
174
|
+
# and `mousemove`. CSS `:hover` styles are not applied.
|
|
175
|
+
#
|
|
176
|
+
# hover "Help"
|
|
177
|
+
def hover(locator, match: nil)
|
|
178
|
+
rails_jsdom_act("hover", locator, match)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Clicks the element found by `locator` twice, then dispatches `dblclick`.
|
|
182
|
+
def double_click(locator, match: nil)
|
|
183
|
+
rails_jsdom_act("double_click", locator, match)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Dispatches a right-button `mousedown`, `mouseup` and `contextmenu` on the
|
|
187
|
+
# element found by `locator`.
|
|
188
|
+
def right_click(locator, match: nil)
|
|
189
|
+
rails_jsdom_act("right_click", locator, match)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Sets the value of the text-like field found by `locator`, dispatches
|
|
193
|
+
# `input` and `change`, and settles the page.
|
|
194
|
+
#
|
|
195
|
+
# fill_in "Quantity", with: 2
|
|
196
|
+
def fill_in(locator, with:, match: nil)
|
|
197
|
+
rails_jsdom_act("fill_in", locator, match, with: with.to_s)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Checks the checkbox found by `locator`, unless it is already checked.
|
|
201
|
+
#
|
|
202
|
+
# check "Remember me"
|
|
203
|
+
def check(locator, match: nil)
|
|
204
|
+
rails_jsdom_act("check", locator, match, checked: true)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Unchecks the checkbox found by `locator`, unless it is already unchecked.
|
|
208
|
+
def uncheck(locator, match: nil)
|
|
209
|
+
rails_jsdom_act("check", locator, match, checked: false)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Selects the radio button found by `locator`.
|
|
213
|
+
#
|
|
214
|
+
# choose "Monthly"
|
|
215
|
+
def choose(locator, match: nil)
|
|
216
|
+
rails_jsdom_act("choose", locator, match)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Selects the option whose text equals, or else contains, `value`, in the
|
|
220
|
+
# select found by the `from:` locator, or in any select when `from:` is
|
|
221
|
+
# left out. Dispatches `input` and `change`.
|
|
222
|
+
#
|
|
223
|
+
# select "Canada", from: "Country"
|
|
224
|
+
def select(value, from: nil, match: nil)
|
|
225
|
+
rails_jsdom_perform(command: "select", option: value.to_s, from: from, selected: true, scope: rails_jsdom_scope, match: match)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Deselects the option `value` in a select that allows multiple options.
|
|
229
|
+
#
|
|
230
|
+
# unselect "Red", from: "Colours"
|
|
231
|
+
def unselect(value, from: nil, match: nil)
|
|
232
|
+
rails_jsdom_perform(command: "select", option: value.to_s, from: from, selected: false, scope: rails_jsdom_scope, match: match)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Puts the files at `paths` (one path or an array) in the file field found
|
|
236
|
+
# by `locator`, replacing any files it had, and dispatches `input` and
|
|
237
|
+
# `change`. Several files need a field with `multiple`. A form submits the
|
|
238
|
+
# files to Rails as a multipart request, so controllers receive
|
|
239
|
+
# `ActionDispatch::Http::UploadedFile` params.
|
|
240
|
+
#
|
|
241
|
+
# attach_file "Avatar", file_fixture("avatar.png")
|
|
242
|
+
def attach_file(locator, paths, match: nil)
|
|
243
|
+
files = Array(paths).map { |path| { name: File.basename(path), type: Rack::Mime.mime_type(File.extname(path.to_s)), base64: [ File.binread(path) ].pack("m0") } }
|
|
244
|
+
rails_jsdom_act("attach", locator, match, files: files)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Types `keys` into the focused element. A String types its characters; a
|
|
248
|
+
# Symbol presses a key: `:enter`, `:tab`, `:backspace`, `:delete`,
|
|
249
|
+
# `:escape`, `:space`, `:left`, `:right`, `:up`, `:down`, `:home`, `:end`,
|
|
250
|
+
# `:page_up` or `:page_down`. An Array presses its last key while holding
|
|
251
|
+
# the modifiers before it: `:shift`, `:control`, `:alt`, `:meta`.
|
|
252
|
+
#
|
|
253
|
+
# Each key dispatches `keydown`, `keypress` for characters and `keyup`.
|
|
254
|
+
# Unless a listener prevents it, the key then does what a browser does:
|
|
255
|
+
# characters and Backspace edit the end of a text field, Enter submits the
|
|
256
|
+
# field's form or clicks a focused button or link, Space clicks a focused
|
|
257
|
+
# button or checkbox, Tab and Shift+Tab move the focus (firing `change` on
|
|
258
|
+
# a field you typed in), and the arrow keys, Home and End step a range or
|
|
259
|
+
# number field. Typing always happens at the end of the value: there is no
|
|
260
|
+
# caret or selection.
|
|
261
|
+
#
|
|
262
|
+
# find_field("Search").send_keys("mugs", :enter)
|
|
263
|
+
# send_keys [ :shift, :tab ]
|
|
264
|
+
def send_keys(*keys)
|
|
265
|
+
rails_jsdom_perform(command: "send_keys", keys: RailsJsdom.keys(keys))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Scopes every action, finder and assertion in the block to the one
|
|
269
|
+
# element that matches the CSS `selector`, or to an element from #find.
|
|
270
|
+
#
|
|
271
|
+
# within "#cart" do
|
|
272
|
+
# click_on "Remove"
|
|
273
|
+
# assert_no_text "Mug"
|
|
274
|
+
# end
|
|
275
|
+
def within(selector, &block)
|
|
276
|
+
rails_jsdom_within(selector.is_a?(Element) ? { handle: selector.handle } : { css: selector }, &block)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Like #within, for the `fieldset` whose `id` or `legend` text is
|
|
280
|
+
# `locator`.
|
|
281
|
+
def within_fieldset(locator, &block)
|
|
282
|
+
rails_jsdom_within({ fieldset: locator }, &block)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Like #within, for the `table` whose `id` or `caption` text is `locator`.
|
|
286
|
+
def within_table(locator, &block)
|
|
287
|
+
rails_jsdom_within({ table: locator }, &block)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The one element that matches the CSS `selector` and the filters. Raises
|
|
291
|
+
# RailsJsdom::Error when none matches, or when several do unless you pass
|
|
292
|
+
# `match: :first`.
|
|
293
|
+
#
|
|
294
|
+
# find(".cart-item", text: "Mug").click
|
|
295
|
+
def find(selector, match: nil, **filters)
|
|
296
|
+
rails_jsdom_find(Query.new("css", selector, **filters), match)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The one field found by `locator` (see Locators) that matches the
|
|
300
|
+
# filters, or the only field when `locator` is left out.
|
|
301
|
+
#
|
|
302
|
+
# find_field("Quantity", with: "2")
|
|
303
|
+
def find_field(locator = nil, match: nil, **filters)
|
|
304
|
+
rails_jsdom_find(Query.new("field", locator, **filters), match)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The one button found by `locator` that matches the filters.
|
|
308
|
+
def find_button(locator = nil, match: nil, **filters)
|
|
309
|
+
rails_jsdom_find(Query.new("button", locator, **filters), match)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The one link found by `locator` that matches the filters.
|
|
313
|
+
def find_link(locator = nil, match: nil, **filters)
|
|
314
|
+
rails_jsdom_find(Query.new("link", locator, **filters), match)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Every element that matches the CSS `selector` and the filters, possibly
|
|
318
|
+
# none.
|
|
319
|
+
#
|
|
320
|
+
# all(".cart-item").map(&:text)
|
|
321
|
+
def all(selector, **filters)
|
|
322
|
+
rails_jsdom_elements(Query.new("css", selector, **filters))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The first element that matches the CSS `selector` and the filters.
|
|
326
|
+
# Raises RailsJsdom::Error when none matches.
|
|
327
|
+
def first(selector, **filters)
|
|
328
|
+
rails_jsdom_find(Query.new("css", selector, **filters), :first)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Runs `script` in the page, settles it and returns the script's value,
|
|
332
|
+
# awaited if it is a promise. The value must be JSON-serialisable.
|
|
333
|
+
#
|
|
334
|
+
# evaluate_script("document.title") # => "Projects"
|
|
335
|
+
def evaluate_script(script)
|
|
336
|
+
rails_jsdom_perform(command: "evaluate", script: script)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Runs `script` in the page and settles it, returning nothing.
|
|
340
|
+
def execute_script(script)
|
|
341
|
+
evaluate_script(script)
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Accepts every alert that appears while the block runs, and returns the
|
|
346
|
+
# first alert message that matches `text` (a String the message must
|
|
347
|
+
# include, or a Regexp). Raises RailsJsdom::Error if no matching alert
|
|
348
|
+
# appeared.
|
|
349
|
+
#
|
|
350
|
+
# accept_alert "Saved" do
|
|
351
|
+
# click_on "Save"
|
|
352
|
+
# end
|
|
353
|
+
def accept_alert(text = nil, &block)
|
|
354
|
+
rails_jsdom_modal("alert", true, text, &block)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Accepts every confirm dialog that appears while the block runs. It
|
|
358
|
+
# matches and returns the message like #accept_alert.
|
|
359
|
+
#
|
|
360
|
+
# accept_confirm "Remove the first item?" do
|
|
361
|
+
# click_on "Remove"
|
|
362
|
+
# end
|
|
363
|
+
def accept_confirm(text = nil, &block)
|
|
364
|
+
rails_jsdom_modal("confirm", true, text, &block)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Dismisses every confirm dialog that appears while the block runs. It
|
|
368
|
+
# matches and returns the message like #accept_alert.
|
|
369
|
+
#
|
|
370
|
+
# dismiss_confirm { click_on "Remove" }
|
|
371
|
+
def dismiss_confirm(text = nil, &block)
|
|
372
|
+
rails_jsdom_modal("confirm", false, text, &block)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Answers every prompt that appears while the block runs with `with:`, or
|
|
376
|
+
# with the prompt's default value when `with:` is left out. It matches and
|
|
377
|
+
# returns the message like #accept_alert.
|
|
378
|
+
#
|
|
379
|
+
# accept_prompt with: "Ada" do
|
|
380
|
+
# click_on "Rename"
|
|
381
|
+
# end
|
|
382
|
+
def accept_prompt(text = nil, with: nil, &block)
|
|
383
|
+
rails_jsdom_modal("prompt", true, text, with: with, &block)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Cancels every prompt that appears while the block runs. It matches and
|
|
387
|
+
# returns the message like #accept_alert.
|
|
388
|
+
def dismiss_prompt(text = nil, &block)
|
|
389
|
+
rails_jsdom_modal("prompt", false, text, &block)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The path of the page's current URL, after any Turbo navigation.
|
|
393
|
+
def current_path
|
|
394
|
+
URI(current_url).path
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The page's current URL, after any Turbo navigation.
|
|
398
|
+
def current_url
|
|
399
|
+
rails_jsdom_snapshot.fetch("url")
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# The visible text of the page, or of the current #within scope. Hidden
|
|
403
|
+
# elements, templates, scripts and closed dialogs are left out.
|
|
404
|
+
def page_text
|
|
405
|
+
rails_jsdom_snapshot.fetch("text")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Whether the visible text includes `text`, or matches it when it is a
|
|
409
|
+
# Regexp.
|
|
410
|
+
def has_text?(text)
|
|
411
|
+
Query.like?(page_text, text)
|
|
412
|
+
end
|
|
413
|
+
alias_method :has_content?, :has_text?
|
|
414
|
+
|
|
415
|
+
# Whether the visible text does not include `text`.
|
|
416
|
+
def has_no_text?(text)
|
|
417
|
+
!has_text?(text)
|
|
418
|
+
end
|
|
419
|
+
alias_method :has_no_content?, :has_no_text?
|
|
420
|
+
|
|
421
|
+
# Whether the current path matches `path`, as #assert_current_path
|
|
422
|
+
# compares it.
|
|
423
|
+
def has_current_path?(path, ignore_query: false, url: false)
|
|
424
|
+
Query.same?(rails_jsdom_current(ignore_query, url), path)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Whether the current path does not match `path`.
|
|
428
|
+
def has_no_current_path?(path, **options)
|
|
429
|
+
!has_current_path?(path, **options)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Whether the page title includes `title`, or matches it when it is a
|
|
433
|
+
# Regexp.
|
|
434
|
+
def has_title?(title)
|
|
435
|
+
Query.like?(rails_jsdom_snapshot.fetch("title"), title)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Whether the page title does not include `title`.
|
|
439
|
+
def has_no_title?(title)
|
|
440
|
+
!has_title?(title)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Whether elements match the CSS `selector` and the filters: at least one,
|
|
444
|
+
# exactly `count:`, or between `minimum:` and `maximum:`.
|
|
445
|
+
def has_selector?(selector, count: nil, minimum: nil, maximum: nil, **filters)
|
|
446
|
+
rails_jsdom_counted?(Query.new("css", selector, **filters), count, minimum, maximum)
|
|
447
|
+
end
|
|
448
|
+
alias_method :has_css?, :has_selector?
|
|
449
|
+
|
|
450
|
+
# Whether no element matches the CSS `selector` and the filters.
|
|
451
|
+
def has_no_selector?(selector, **filters)
|
|
452
|
+
rails_jsdom_matches(Query.new("css", selector, **filters)).empty?
|
|
453
|
+
end
|
|
454
|
+
alias_method :has_no_css?, :has_no_selector?
|
|
455
|
+
|
|
456
|
+
# Whether a field found by `locator` matches the filters.
|
|
457
|
+
def has_field?(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
458
|
+
rails_jsdom_counted?(Query.new("field", locator, **filters), count, minimum, maximum)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Whether no field found by `locator` matches the filters.
|
|
462
|
+
def has_no_field?(locator = nil, **filters)
|
|
463
|
+
rails_jsdom_matches(Query.new("field", locator, **filters)).empty?
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Whether a checked checkbox or radio button is found by `locator`.
|
|
467
|
+
def has_checked_field?(locator = nil, **filters)
|
|
468
|
+
has_field?(locator, checked: true, **filters)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Whether an unchecked checkbox or radio button is found by `locator`.
|
|
472
|
+
def has_unchecked_field?(locator = nil, **filters)
|
|
473
|
+
has_field?(locator, unchecked: true, **filters)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Whether a button found by `locator` matches the filters.
|
|
477
|
+
def has_button?(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
478
|
+
rails_jsdom_counted?(Query.new("button", locator, **filters), count, minimum, maximum)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Whether no button found by `locator` matches the filters.
|
|
482
|
+
def has_no_button?(locator = nil, **filters)
|
|
483
|
+
rails_jsdom_matches(Query.new("button", locator, **filters)).empty?
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Whether a link found by `locator` matches the filters.
|
|
487
|
+
def has_link?(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
488
|
+
rails_jsdom_counted?(Query.new("link", locator, **filters), count, minimum, maximum)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Whether no link found by `locator` matches the filters.
|
|
492
|
+
def has_no_link?(locator = nil, **filters)
|
|
493
|
+
rails_jsdom_matches(Query.new("link", locator, **filters)).empty?
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Asserts that the visible text includes `text`, or matches it when it is
|
|
497
|
+
# a Regexp.
|
|
498
|
+
def assert_text(text)
|
|
499
|
+
assert has_text?(text), "Expected the page text to include #{text.inspect}"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Asserts that the visible text does not include `text`.
|
|
503
|
+
def assert_no_text(text)
|
|
504
|
+
assert has_no_text?(text), "Expected the page text not to include #{text.inspect}"
|
|
505
|
+
end
|
|
506
|
+
alias_method :refute_text, :assert_no_text
|
|
507
|
+
|
|
508
|
+
# Asserts that the current path and query equal `path`, or match it when
|
|
509
|
+
# it is a Regexp. Pass `ignore_query: true` to compare the path only, or
|
|
510
|
+
# `url: true` to compare the whole URL.
|
|
511
|
+
#
|
|
512
|
+
# assert_current_path project_path(project)
|
|
513
|
+
def assert_current_path(path, ignore_query: false, url: false)
|
|
514
|
+
actual = rails_jsdom_current(ignore_query, url)
|
|
515
|
+
assert Query.same?(actual, path), "Expected the current path to be #{path.inspect}, but it was #{actual.inspect}"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Asserts that the current path does not match `path`.
|
|
519
|
+
def assert_no_current_path(path, ignore_query: false, url: false)
|
|
520
|
+
actual = rails_jsdom_current(ignore_query, url)
|
|
521
|
+
assert_not Query.same?(actual, path), "Expected the current path not to be #{path.inspect}"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Asserts that the page title includes `title`, or matches it when it is a
|
|
525
|
+
# Regexp.
|
|
526
|
+
def assert_title(title)
|
|
527
|
+
assert has_title?(title), "Expected the title to include #{title.inspect}, but it was #{rails_jsdom_snapshot.fetch("title").inspect}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Asserts that the page title does not include `title`.
|
|
531
|
+
def assert_no_title(title)
|
|
532
|
+
assert has_no_title?(title), "Expected the title not to include #{title.inspect}"
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Asserts that elements match the CSS `selector` and the filters: at least
|
|
536
|
+
# one by default, exactly `count:`, or between `minimum:` and `maximum:`.
|
|
537
|
+
# Only visible elements count, unless you pass `visible: false`.
|
|
538
|
+
#
|
|
539
|
+
# assert_selector ".cart-item", count: 2
|
|
540
|
+
# assert_selector "li", text: "Mug"
|
|
541
|
+
def assert_selector(selector, count: nil, minimum: nil, maximum: nil, **filters)
|
|
542
|
+
rails_jsdom_assert_count(Query.new("css", selector, **filters), count, minimum, maximum)
|
|
543
|
+
end
|
|
544
|
+
alias_method :assert_css, :assert_selector
|
|
545
|
+
|
|
546
|
+
# Asserts that no element matches the CSS `selector` and the filters.
|
|
547
|
+
def assert_no_selector(selector, **filters)
|
|
548
|
+
rails_jsdom_assert_none(Query.new("css", selector, **filters))
|
|
549
|
+
end
|
|
550
|
+
alias_method :refute_selector, :assert_no_selector
|
|
551
|
+
alias_method :assert_no_css, :assert_no_selector
|
|
552
|
+
|
|
553
|
+
# Asserts that a field found by `locator` matches the filters.
|
|
554
|
+
#
|
|
555
|
+
# assert_field "Quantity", with: "2"
|
|
556
|
+
def assert_field(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
557
|
+
rails_jsdom_assert_count(Query.new("field", locator, **filters), count, minimum, maximum)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Asserts that no field found by `locator` matches the filters.
|
|
561
|
+
def assert_no_field(locator = nil, **filters)
|
|
562
|
+
rails_jsdom_assert_none(Query.new("field", locator, **filters))
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Asserts that the checkbox or radio button found by `locator` is checked.
|
|
566
|
+
def assert_checked_field(locator = nil, **filters)
|
|
567
|
+
assert_field(locator, checked: true, **filters)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Asserts that the checkbox or radio button found by `locator` is
|
|
571
|
+
# unchecked.
|
|
572
|
+
def assert_unchecked_field(locator = nil, **filters)
|
|
573
|
+
assert_field(locator, unchecked: true, **filters)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Asserts that a button found by `locator` matches the filters. Disabled
|
|
577
|
+
# buttons only match with `disabled: true` or `disabled: :all`.
|
|
578
|
+
def assert_button(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
579
|
+
rails_jsdom_assert_count(Query.new("button", locator, **filters), count, minimum, maximum)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Asserts that no button found by `locator` matches the filters.
|
|
583
|
+
def assert_no_button(locator = nil, **filters)
|
|
584
|
+
rails_jsdom_assert_none(Query.new("button", locator, **filters))
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Asserts that a link found by `locator` matches the filters.
|
|
588
|
+
#
|
|
589
|
+
# assert_link "Second page", href: "/second"
|
|
590
|
+
def assert_link(locator = nil, count: nil, minimum: nil, maximum: nil, **filters)
|
|
591
|
+
rails_jsdom_assert_count(Query.new("link", locator, **filters), count, minimum, maximum)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Asserts that no link found by `locator` matches the filters.
|
|
595
|
+
def assert_no_link(locator = nil, **filters)
|
|
596
|
+
rails_jsdom_assert_none(Query.new("link", locator, **filters))
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# The root of the live page's DOM, or the current #within element. Rails
|
|
600
|
+
# uses it for `assert_dom`, `assert_select` and `css_select`, so those
|
|
601
|
+
# assertions read the page.
|
|
602
|
+
def document_root_element
|
|
603
|
+
html = rails_jsdom_snapshot.fetch("html")
|
|
604
|
+
rails_jsdom_scope.empty? ? Nokogiri::HTML5(html).root : Nokogiri::HTML5.fragment(html, context: "template").element_children.first
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def rails_jsdom_element(handle, action, **options) # :nodoc:
|
|
608
|
+
rails_jsdom_perform(command: "element", handle: handle, action: action, **options)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def rails_jsdom_read(handle) # :nodoc:
|
|
612
|
+
rails_jsdom_deliver_broadcasts
|
|
613
|
+
RailsJsdom.bridge.call(command: "read", handle: handle)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def rails_jsdom_page_snapshot # :nodoc:
|
|
617
|
+
rails_jsdom_deliver_broadcasts
|
|
618
|
+
RailsJsdom.bridge.call(command: "snapshot", scope: [])
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
private
|
|
622
|
+
|
|
623
|
+
def rails_jsdom_visit(path, history)
|
|
624
|
+
rails_jsdom_request("GET", path, headers: {})
|
|
625
|
+
rails_jsdom_perform(command: "load", html: response.body, url: request.url, history: history)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def rails_jsdom_session_name
|
|
629
|
+
@rails_jsdom_session_name || "default"
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def rails_jsdom_switch_session(name)
|
|
633
|
+
sessions = (@rails_jsdom_sessions ||= {})
|
|
634
|
+
sessions[rails_jsdom_session_name] = integration_session
|
|
635
|
+
@integration_session = sessions[name] || create_session(app)
|
|
636
|
+
copy_session_variables!
|
|
637
|
+
@rails_jsdom_session_name = name
|
|
638
|
+
RailsJsdom.bridge.call(command: "session", name: name)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def rails_jsdom_scope
|
|
642
|
+
@rails_jsdom_scope ||= []
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def rails_jsdom_within(entry)
|
|
646
|
+
rails_jsdom_scope.push(entry)
|
|
647
|
+
yield
|
|
648
|
+
ensure
|
|
649
|
+
rails_jsdom_scope.pop
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def rails_jsdom_act(command, locator, match, **options)
|
|
653
|
+
rails_jsdom_perform(command: command, locator: locator, scope: rails_jsdom_scope, match: match, **options)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def rails_jsdom_modal(type, accept, text, with: nil)
|
|
657
|
+
RailsJsdom.bridge.call(command: "expect_modal", type: type, accept: accept, with: with)
|
|
658
|
+
yield
|
|
659
|
+
messages = RailsJsdom.bridge.call(command: "modal_messages")
|
|
660
|
+
messages.find { |message| text.nil? || Query.like?(message, text) } || raise(Error, "Unable to find a #{type} dialog#{" matching #{text.inspect}" if text}. Saw #{messages.inspect}.")
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def rails_jsdom_matches(query)
|
|
664
|
+
rails_jsdom_deliver_broadcasts
|
|
665
|
+
query.filter(RailsJsdom.bridge.call(**query.message, scope: rails_jsdom_scope))
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def rails_jsdom_elements(query)
|
|
669
|
+
rails_jsdom_matches(query).map { |descriptor| Element.new(self, descriptor.fetch("handle")) }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def rails_jsdom_find(query, match)
|
|
673
|
+
found = rails_jsdom_elements(query)
|
|
674
|
+
if found.empty?
|
|
675
|
+
raise Error, "Unable to find #{query.description}"
|
|
676
|
+
elsif found.size > 1 && match.to_s != "first"
|
|
677
|
+
raise Error, "Ambiguous match: #{found.size} elements match #{query.description}"
|
|
678
|
+
else
|
|
679
|
+
found.first
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def rails_jsdom_range(count, minimum, maximum)
|
|
684
|
+
count ? count..count : (minimum || 1)..maximum
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def rails_jsdom_counted?(query, count, minimum, maximum)
|
|
688
|
+
rails_jsdom_range(count, minimum, maximum).cover?(rails_jsdom_matches(query).size)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def rails_jsdom_assert_count(query, count, minimum, maximum)
|
|
692
|
+
range = rails_jsdom_range(count, minimum, maximum)
|
|
693
|
+
found = rails_jsdom_matches(query).size
|
|
694
|
+
assert range.cover?(found), "Expected #{range} elements matching #{query.description}, found #{found}"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def rails_jsdom_assert_none(query)
|
|
698
|
+
found = rails_jsdom_matches(query).size
|
|
699
|
+
assert found.zero?, "Expected no elements matching #{query.description}, found #{found}"
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def rails_jsdom_current(ignore_query, url)
|
|
703
|
+
uri = URI(current_url)
|
|
704
|
+
uri.query = nil if ignore_query
|
|
705
|
+
url ? uri.to_s : uri.request_uri
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def rails_jsdom_snapshot
|
|
709
|
+
rails_jsdom_deliver_broadcasts
|
|
710
|
+
RailsJsdom.bridge.call(command: "snapshot", scope: rails_jsdom_scope)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def rails_jsdom_deliver_broadcasts
|
|
714
|
+
messages = rails_jsdom_undelivered_broadcasts
|
|
715
|
+
if messages.any?
|
|
716
|
+
rails_jsdom_call(command: "deliver", messages: messages)
|
|
717
|
+
rails_jsdom_deliver_broadcasts
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def rails_jsdom_undelivered_broadcasts
|
|
722
|
+
streams = RailsJsdom.bridge.call(command: "streams").filter_map { |name| Turbo::StreamsChannel.verified_stream_name(name) }.uniq
|
|
723
|
+
rails_jsdom_deliveries.undelivered_messages(rails_jsdom_session_name, streams)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def rails_jsdom_deliveries
|
|
727
|
+
@rails_jsdom_deliveries ||= TurboStreamDeliveries.new
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def rails_jsdom_perform(**message)
|
|
731
|
+
rails_jsdom_deliver_broadcasts
|
|
732
|
+
rails_jsdom_call(message).tap { rails_jsdom_deliver_broadcasts }
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def rails_jsdom_call(message)
|
|
736
|
+
RailsJsdom.bridge.call(message) { |fetch| rails_jsdom_fetch(fetch) }
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def rails_jsdom_fetch(fetch)
|
|
740
|
+
uri = URI(fetch.fetch("url"))
|
|
741
|
+
if uri.host == host
|
|
742
|
+
redirected = rails_jsdom_request(fetch.fetch("method"), uri.request_uri, params: fetch["body"]&.unpack1("m"), headers: fetch.fetch("headers"))
|
|
743
|
+
rails_jsdom_fetch_reply(redirected)
|
|
744
|
+
else
|
|
745
|
+
{ error: "RailsJsdom sends fetch only to #{host}, not to #{uri}." }
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def rails_jsdom_fetch_reply(redirected)
|
|
750
|
+
{ status: response.status, headers: response.headers.to_h.except("set-cookie"), body: response.body, url: request.url, redirected: redirected }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def rails_jsdom_request(method, path, headers:, params: nil)
|
|
754
|
+
process(method.downcase.to_sym, path, params: params, headers: headers)
|
|
755
|
+
rails_jsdom_follow_redirects(headers.slice("accept"), false)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def rails_jsdom_follow_redirects(headers, redirected)
|
|
759
|
+
if response.redirect?
|
|
760
|
+
follow_redirect!(headers: headers)
|
|
761
|
+
rails_jsdom_follow_redirects(headers, true)
|
|
762
|
+
else
|
|
763
|
+
redirected
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
end
|