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.
@@ -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