dommy-rails 0.9.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/README.md +129 -0
- data/lib/dommy/rails/aria_snapshot_matching.rb +100 -0
- data/lib/dommy/rails/browser_spec.rb +148 -0
- data/lib/dommy/rails/dom_source.rb +35 -0
- data/lib/dommy/rails/form_inspector.rb +52 -0
- data/lib/dommy/rails/lint.rb +109 -0
- data/lib/dommy/rails/mail_part.rb +32 -0
- data/lib/dommy/rails/match_target.rb +23 -0
- data/lib/dommy/rails/minitest/assertions.rb +298 -0
- data/lib/dommy/rails/minitest/integration.rb +13 -0
- data/lib/dommy/rails/minitest.rb +4 -0
- data/lib/dommy/rails/page_inspector.rb +66 -0
- data/lib/dommy/rails/rspec/browser.rb +40 -0
- data/lib/dommy/rails/rspec/integration.rb +16 -0
- data/lib/dommy/rails/rspec/matchers.rb +793 -0
- data/lib/dommy/rails/rspec.rb +12 -0
- data/lib/dommy/rails/stimulus.rb +50 -0
- data/lib/dommy/rails/turbo_stream.rb +40 -0
- data/lib/dommy/rails/url_matcher.rb +26 -0
- data/lib/dommy/rails/url_normalizer.rb +45 -0
- data/lib/dommy/rails/version.rb +5 -0
- data/lib/dommy/rails.rb +18 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 62f4ed9c4e72fad6e37235d7831a24fed0f941d5832be21a9469972f04cc40d8
|
|
4
|
+
data.tar.gz: dc94f1a8349a01b22819c66602d38a9795670f917e5b55aeba313eddaafea918
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2ed06d6d5570260669b7cb7626566aeeaed03f1771a4afc0f0c99377079e68a15f0283068f6b00f749a62a5cf92d16adbaa6849f424f9af22a08ee07f88084ec
|
|
7
|
+
data.tar.gz: 01e9d6f47a22a8a60dac0a88efe08835b5be3d0fb0c2fdc28fd0d30c387aaab6fdfa3f3489e20c35ce7b4875c6aed531c314a3771cf4fa731a01d33bb426e717
|
data/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# dommy-rails
|
|
2
|
+
|
|
3
|
+
Rails integration for [Dommy](https://github.com/takahashim/dommy) —
|
|
4
|
+
provides Rails-specific DOM testing helpers for request specs, view specs,
|
|
5
|
+
component specs, and mailer specs.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Form helper understanding**: Detect Rails forms, `_method` override, CSRF tokens
|
|
10
|
+
- **Turbo Stream support**: Parse and assert Turbo Stream responses
|
|
11
|
+
- **Turbo Frame support**: Assert `<turbo-frame>` presence and contents
|
|
12
|
+
- **Stimulus checking**: Verify `data-controller`, `data-action`, `data-target`, `data-*-value`
|
|
13
|
+
- **Mailer assertions**: Check HTML and plain text mail bodies
|
|
14
|
+
- **HTML quality linting**: Find duplicate IDs, invalid ARIA references, missing form labels, empty links, and nested interactive elements
|
|
15
|
+
- **URL normalization**: Compare URLs accounting for host differences, query param ordering, HTML entities
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "dommy-rails"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Minitest (ActionDispatch::IntegrationTest)
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "dommy/rails/minitest"
|
|
29
|
+
|
|
30
|
+
class ArticlesControllerTest < ActionDispatch::IntegrationTest
|
|
31
|
+
include Dommy::Rails::Minitest::Integration
|
|
32
|
+
|
|
33
|
+
def test_index
|
|
34
|
+
get articles_path
|
|
35
|
+
|
|
36
|
+
assert_dom_has_css dom, "h1", text: "Articles"
|
|
37
|
+
assert_dom_has_link dom, "New article", href: new_article_path
|
|
38
|
+
assert_dom_has_form dom, action: articles_path, method: :post
|
|
39
|
+
assert_dom_has_title dom, "Articles"
|
|
40
|
+
assert_dom_has_csrf_meta_tags dom
|
|
41
|
+
assert_dom_has_stimulus_controller dom, "articles"
|
|
42
|
+
assert_dom_has_turbo_frame dom, "articles"
|
|
43
|
+
assert_dom_no_duplicate_ids dom
|
|
44
|
+
assert_dom_no_empty_links dom
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_turbo_stream
|
|
48
|
+
post articles_path, params: { article: { title: "Hello" } }, as: :turbo_stream
|
|
49
|
+
|
|
50
|
+
assert_dom_appends_turbo_stream response, "articles" do |fragment|
|
|
51
|
+
assert_dom_has_css fragment, ".article", text: "Hello"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### RSpec
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "dommy/rails/rspec"
|
|
61
|
+
|
|
62
|
+
RSpec.configure do |config|
|
|
63
|
+
config.include Dommy::Rails::RSpec::Integration, type: :request
|
|
64
|
+
config.include Dommy::Rails::RSpec::Integration, type: :view
|
|
65
|
+
config.include Dommy::Rails::RSpec::Integration, type: :component
|
|
66
|
+
config.include Dommy::Rails::RSpec::Integration, type: :mailer
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
RSpec.describe "Articles", type: :request do
|
|
72
|
+
it "renders the index" do
|
|
73
|
+
get articles_path
|
|
74
|
+
|
|
75
|
+
expect(dom).to have_css("h1", text: "Articles")
|
|
76
|
+
expect(dom).to have_link("New article", href: new_article_path)
|
|
77
|
+
expect(dom).to have_form(action: articles_path, method: :post)
|
|
78
|
+
expect(dom).to have_title("Articles")
|
|
79
|
+
expect(dom).to have_csrf_meta_tags
|
|
80
|
+
expect(dom).to have_stimulus_controller("articles")
|
|
81
|
+
expect(dom).to have_no_duplicate_ids
|
|
82
|
+
expect(dom).to have_no_empty_links
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "renders a Turbo Stream response" do
|
|
86
|
+
post articles_path, params: { article: { title: "Hello" } }, as: :turbo_stream
|
|
87
|
+
|
|
88
|
+
expect(response).to append_turbo_stream("articles") { |fragment|
|
|
89
|
+
expect(fragment).to have_css(".article", text: "Hello")
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Turbo Frames can be checked directly:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
expect(dom).to have_turbo_frame("articles") { |frame|
|
|
99
|
+
expect(frame).to have_css(".article", text: "Hello")
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Mailer specs can check mail objects directly:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
expect(mail).to have_html_link("Confirm your account", href: confirmation_url(user))
|
|
107
|
+
expect(mail).to have_html_text("Confirm your account")
|
|
108
|
+
expect(mail).to have_plain_text("Welcome")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## URL normalization
|
|
112
|
+
|
|
113
|
+
`have_link(href:)`, `have_form(action:)`, and their Minitest counterparts
|
|
114
|
+
absorb the representational differences between Rails URL helpers and
|
|
115
|
+
rendered HTML. Before comparison, both sides are normalized:
|
|
116
|
+
|
|
117
|
+
- scheme and host are dropped (`http://www.example.com/articles` ≡ `/articles`)
|
|
118
|
+
- query parameters are sorted (`?b=2&a=1` ≡ `?a=1&b=2`)
|
|
119
|
+
- HTML entities are unescaped (`&` ≡ `&`)
|
|
120
|
+
- trailing slashes are removed (`/articles/` ≡ `/articles`)
|
|
121
|
+
|
|
122
|
+
This is deliberately lenient: because the host is ignored, an absolute
|
|
123
|
+
URL to an external site with the same path matches a relative `href:`.
|
|
124
|
+
Strict external-host matching is out of scope for now; pass a `Regexp`
|
|
125
|
+
as `href:` when you need to pin the host.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rails
|
|
5
|
+
# Compares a Playwright-compatible ARIA snapshot against an expected
|
|
6
|
+
# template using Playwright's `toMatchAriaSnapshot` semantics: a SUBSET
|
|
7
|
+
# match. Every node listed in `expected` must appear in `actual`, in order,
|
|
8
|
+
# at the same nesting — but `actual` may contain extra nodes. A node matches
|
|
9
|
+
# when role + (optional) name + (subset of) flags agree. An expected name
|
|
10
|
+
# written as `/pattern/` is matched as a regular expression; an omitted name
|
|
11
|
+
# is a wildcard.
|
|
12
|
+
module AriaSnapshotMatching
|
|
13
|
+
Node = Struct.new(:role, :name, :name_regex, :flags, :children, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def matches?(actual_text, expected_text)
|
|
18
|
+
children_match?(parse(expected_text).children, parse(actual_text).children)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- comparison ---
|
|
22
|
+
|
|
23
|
+
def node_match?(expected, actual)
|
|
24
|
+
return false unless expected.role == actual.role
|
|
25
|
+
return false unless name_match?(expected, actual)
|
|
26
|
+
return false unless (expected.flags - actual.flags).empty?
|
|
27
|
+
|
|
28
|
+
children_match?(expected.children, actual.children)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def name_match?(expected, actual)
|
|
32
|
+
return actual.name.to_s.match?(expected.name_regex) if expected.name_regex
|
|
33
|
+
return true if expected.name.nil?
|
|
34
|
+
|
|
35
|
+
expected.name == actual.name
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Greedy ordered-subsequence match: each expected child must match a later
|
|
39
|
+
# actual child, allowing extra actual children in between.
|
|
40
|
+
def children_match?(expected_children, actual_children)
|
|
41
|
+
cursor = 0
|
|
42
|
+
expected_children.each do |expected|
|
|
43
|
+
cursor += 1 until cursor >= actual_children.size || node_match?(expected, actual_children[cursor])
|
|
44
|
+
return false if cursor >= actual_children.size
|
|
45
|
+
|
|
46
|
+
cursor += 1
|
|
47
|
+
end
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# --- parsing (indentation outline -> Node tree) ---
|
|
52
|
+
|
|
53
|
+
def parse(text)
|
|
54
|
+
root = Node.new(role: nil, flags: [], children: [])
|
|
55
|
+
stack = [[-1, root]]
|
|
56
|
+
text.to_s.each_line do |line|
|
|
57
|
+
stripped = line.strip
|
|
58
|
+
next if stripped.empty? || !stripped.start_with?("- ")
|
|
59
|
+
|
|
60
|
+
indent = line[/\A */].length
|
|
61
|
+
node = parse_line(stripped[2..])
|
|
62
|
+
stack.pop while stack.size > 1 && stack.last[0] >= indent
|
|
63
|
+
stack.last[1].children << node
|
|
64
|
+
stack.push([indent, node])
|
|
65
|
+
end
|
|
66
|
+
root
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_line(body)
|
|
70
|
+
body = body.sub(/:\s*\z/, "")
|
|
71
|
+
return Node.new(role: "text", name: unquote(body.sub(/\Atext:\s*/, "")), flags: [], children: []) \
|
|
72
|
+
if body.start_with?("text:")
|
|
73
|
+
|
|
74
|
+
role, rest = body.split(/\s+/, 2)
|
|
75
|
+
name, name_regex, rest = scan_name(rest)
|
|
76
|
+
flags = rest.to_s.scan(/\[([^\]]*)\]/).flatten
|
|
77
|
+
Node.new(role: role, name: name, name_regex: name_regex, flags: flags, children: [])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scan_name(rest)
|
|
81
|
+
return [nil, nil, rest] if rest.nil?
|
|
82
|
+
|
|
83
|
+
if rest.start_with?('"') && (md = rest.match(/\A"((?:\\.|[^"\\])*)"/))
|
|
84
|
+
[unescape(md[1]), nil, rest[md.end(0)..].to_s.strip]
|
|
85
|
+
elsif rest.start_with?("/") && (md = rest.match(%r{\A/((?:\\.|[^/\\])*)/}))
|
|
86
|
+
[nil, Regexp.new(md[1]), rest[md.end(0)..].to_s.strip]
|
|
87
|
+
else
|
|
88
|
+
[nil, nil, rest]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def unquote(text)
|
|
93
|
+
md = text.to_s.strip.match(/\A"((?:\\.|[^"\\])*)"\z/)
|
|
94
|
+
md ? unescape(md[1]) : text.to_s.strip
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unescape(text) = text.gsub(/\\(.)/, '\1')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Rails
|
|
7
|
+
# Test-integration helper that runs application JavaScript against the real
|
|
8
|
+
# Rails app, bridging request-style specs to the lightweight test browser.
|
|
9
|
+
# Include it in a Minitest test or RSpec example group to get a `browser`
|
|
10
|
+
# (a `javascript: true` Dommy::Rack::Session bound to the Rails Rack app):
|
|
11
|
+
#
|
|
12
|
+
# browser.visit todos_path
|
|
13
|
+
# browser.click "li.todo"
|
|
14
|
+
# assert browser.has_css?("li.todo.is-completed")
|
|
15
|
+
#
|
|
16
|
+
# External `<script>`s and `fetch` resolve through the Rails app itself
|
|
17
|
+
# (Propshaft / Sprockets / controllers), sharing the session cookie jar.
|
|
18
|
+
# The browser is disposed at teardown, and any uncaught JS error / unhandled
|
|
19
|
+
# rejection fails the test (strict by default) unless wrapped in
|
|
20
|
+
# `allow_js_errors`.
|
|
21
|
+
module BrowserSpec
|
|
22
|
+
# Auto-wire teardown: RSpec example groups get an `after` hook (the
|
|
23
|
+
# example is passed so we can save artifacts on failure); Minitest tests
|
|
24
|
+
# use `after_teardown` (defined below).
|
|
25
|
+
def self.included(base)
|
|
26
|
+
if base.respond_to?(:after)
|
|
27
|
+
base.after do |example|
|
|
28
|
+
dommy_browser_after(failed: example.exception ? true : false,
|
|
29
|
+
label: example.full_description, exception: example.exception)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Minitest teardown hook (no-op outside Minitest).
|
|
35
|
+
def after_teardown
|
|
36
|
+
failures = respond_to?(:failures) ? self.failures : []
|
|
37
|
+
dommy_browser_after(failed: !failures.empty?, label: (name if respond_to?(:name)),
|
|
38
|
+
exception: failures.first)
|
|
39
|
+
ensure
|
|
40
|
+
super if defined?(super)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# On a failed example, write debugging artifacts (page HTML + trace +
|
|
44
|
+
# visible text) before disposing, then run the normal teardown. Shared by
|
|
45
|
+
# the RSpec and Minitest hooks.
|
|
46
|
+
def dommy_browser_after(failed:, label: nil, exception: nil)
|
|
47
|
+
dommy_save_failure_artifacts(label, exception: exception) if failed && browser_started?
|
|
48
|
+
dommy_browser_teardown
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The Rack app the browser drives. Defaults to the Rails application;
|
|
52
|
+
# override `dommy_browser_app` to point elsewhere.
|
|
53
|
+
def dommy_browser_app
|
|
54
|
+
return ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
55
|
+
|
|
56
|
+
raise "Dommy::Rails::BrowserSpec needs a Rack app: define #dommy_browser_app " \
|
|
57
|
+
"(Rails.application was not available)."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Memoized JS-enabled session bound to the app. Lazily requires the
|
|
61
|
+
# dommy-rack + QuickJS integration so the dependency is only needed when a
|
|
62
|
+
# browser spec actually runs.
|
|
63
|
+
def browser
|
|
64
|
+
@dommy_browser ||= begin
|
|
65
|
+
require "dommy/js/quickjs/rack"
|
|
66
|
+
::Dommy::Rack::Session.new(dommy_browser_app, javascript: true, trace: true, trace_dom: true,
|
|
67
|
+
trace_snapshots: true)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Directory failure artifacts are written under (override per host).
|
|
72
|
+
def dommy_failures_dir = ::File.join("tmp", "dommy", "failures")
|
|
73
|
+
|
|
74
|
+
def browser_started? = !@dommy_browser.nil?
|
|
75
|
+
|
|
76
|
+
# Suppress strict JS-error failure for errors raised inside the block (they
|
|
77
|
+
# stay in `browser.js_errors`). For specs that intentionally trigger one.
|
|
78
|
+
def allow_js_errors
|
|
79
|
+
@dommy_allow_js_errors = true
|
|
80
|
+
yield
|
|
81
|
+
ensure
|
|
82
|
+
dommy_browser_ack_js_errors
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Dispose the browser and fail if uncaught JS errors were collected. Call
|
|
86
|
+
# from a Minitest #teardown / RSpec after hook (the integration modules
|
|
87
|
+
# wire this automatically).
|
|
88
|
+
def dommy_browser_teardown
|
|
89
|
+
return unless browser_started?
|
|
90
|
+
|
|
91
|
+
pending = browser.js_errors[(@dommy_browser_acked || 0)..] || []
|
|
92
|
+
browser.dispose_js
|
|
93
|
+
@dommy_browser = nil
|
|
94
|
+
return if @dommy_allow_js_errors || pending.empty?
|
|
95
|
+
|
|
96
|
+
raise dommy_browser_js_error(pending)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Write current.html / trace.txt / visible-text.txt plus a self-contained
|
|
102
|
+
# NDJSON trace bundle for a failed example into a per-example directory, so
|
|
103
|
+
# a CI run can surface what the browser saw. Best-effort: a browser without
|
|
104
|
+
# a trace (or any IO error) is skipped silently rather than masking the
|
|
105
|
+
# real failure.
|
|
106
|
+
def dommy_save_failure_artifacts(label, exception: nil)
|
|
107
|
+
return unless browser.respond_to?(:trace) && browser.trace
|
|
108
|
+
|
|
109
|
+
# Append the failing expectation to the trace so the viewer shows where
|
|
110
|
+
# (and how) the test failed, in line with the events that led there.
|
|
111
|
+
if exception
|
|
112
|
+
browser.trace.record_error(message: exception.message.to_s.lines.first&.strip,
|
|
113
|
+
exception_class: exception.class.name)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
dir = ::File.join(dommy_failures_dir, dommy_artifact_slug(label))
|
|
117
|
+
::FileUtils.mkdir_p(dir)
|
|
118
|
+
::File.write(::File.join(dir, "current.html"), browser.html.to_s)
|
|
119
|
+
::File.write(::File.join(dir, "trace.txt"), browser.trace.to_text)
|
|
120
|
+
::File.write(::File.join(dir, "visible-text.txt"), browser.text.to_s)
|
|
121
|
+
# A self-contained NDJSON bundle (trace.ndjson + artifacts/) for the
|
|
122
|
+
# standalone `dommy-trace` viewer — the machine-readable trace format.
|
|
123
|
+
browser.trace.save(dir, status: "failed", metadata: {"example" => label.to_s})
|
|
124
|
+
if browser.respond_to?(:debug)
|
|
125
|
+
::File.write(::File.join(dir, "dom-summary.txt"), browser.debug.dom_summary)
|
|
126
|
+
::File.write(::File.join(dir, "aria-snapshot.txt"), browser.debug.aria_snapshot)
|
|
127
|
+
end
|
|
128
|
+
dir
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def dommy_artifact_slug(label)
|
|
134
|
+
slug = label.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
135
|
+
slug.empty? ? "example" : slug[0, 100]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def dommy_browser_ack_js_errors
|
|
139
|
+
@dommy_browser_acked = browser_started? ? browser.js_errors.length : 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def dommy_browser_js_error(errors)
|
|
143
|
+
lines = errors.map { |e| " #{e.class}: #{e.message}" }
|
|
144
|
+
RuntimeError.new("#{errors.length} uncaught JS error(s) during the spec:\n#{lines.join("\n")}")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mail_part"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Rails
|
|
7
|
+
# Shared implementation of the `dom` helper for the Minitest and
|
|
8
|
+
# RSpec integration modules: locates the HTML source on the test
|
|
9
|
+
# context (response / rendered / mail) and memoizes the parsed
|
|
10
|
+
# document per source string.
|
|
11
|
+
module DomSource
|
|
12
|
+
def dom
|
|
13
|
+
source = dom_html_source.to_s
|
|
14
|
+
return @dommy_rails_dom if defined?(@dommy_rails_dom_source) && @dommy_rails_dom_source == source
|
|
15
|
+
|
|
16
|
+
@dommy_rails_dom_source = source
|
|
17
|
+
@dommy_rails_dom = Dommy.parse(source).document
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def dom_html_source
|
|
23
|
+
if respond_to?(:response) && response.respond_to?(:body) && response.body
|
|
24
|
+
response.body
|
|
25
|
+
elsif respond_to?(:rendered) && rendered
|
|
26
|
+
rendered
|
|
27
|
+
elsif respond_to?(:message) && (html = MailPart.html_body(message))
|
|
28
|
+
html
|
|
29
|
+
else
|
|
30
|
+
raise "Dommy::Rails could not find HTML for `dom`. Expected response.body, rendered, or message.html_part."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dommy/internal/element_matching"
|
|
4
|
+
require_relative "url_matcher"
|
|
5
|
+
|
|
6
|
+
module Dommy
|
|
7
|
+
module Rails
|
|
8
|
+
module FormInspector
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def matches?(document, action: nil, method: nil, model: nil)
|
|
12
|
+
forms = Internal::ElementMatching.find_forms(document, action: action && UrlMatcher.new(action), method: method)
|
|
13
|
+
forms = forms.select { |form| form_has_model?(form, model_name_for(model)) } if model
|
|
14
|
+
forms.any?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def method_override(form)
|
|
18
|
+
hidden = form.query_selector("input[type='hidden'][name='_method']")
|
|
19
|
+
hidden ? hidden.get_attribute("value") : nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def authenticity_token(form)
|
|
23
|
+
input = form.query_selector("input[type='hidden'][name='authenticity_token']")
|
|
24
|
+
input ? input.get_attribute("value") : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def model_name_for(model)
|
|
28
|
+
if model.respond_to?(:model_name)
|
|
29
|
+
model.model_name.param_key
|
|
30
|
+
elsif model.respond_to?(:to_model)
|
|
31
|
+
model.to_model.model_name.param_key
|
|
32
|
+
else
|
|
33
|
+
# No ActiveSupport fallback: dommy-rails depends only on dommy,
|
|
34
|
+
# and Rails form helpers require these methods too.
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"model: expects an object responding to #model_name or #to_model, got #{model.class}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A Rails model form scopes its field names under the model's
|
|
41
|
+
# param key (e.g. name="article[title]").
|
|
42
|
+
def form_has_model?(form, model_name)
|
|
43
|
+
prefix = "#{model_name}["
|
|
44
|
+
form.query_selector_all("input, textarea, select").to_a.any? do |field|
|
|
45
|
+
field.get_attribute("name").to_s.start_with?(prefix)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private_class_method :model_name_for, :form_has_model?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dommy/internal/element_matching"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Rails
|
|
7
|
+
module Lint
|
|
8
|
+
# Inputs whose accessible name comes from elsewhere (value
|
|
9
|
+
# attribute) or that are not user-visible, so a <label> is not
|
|
10
|
+
# expected.
|
|
11
|
+
NON_LABELABLE_INPUT_TYPES = %w[hidden submit button reset image].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def duplicate_ids(document)
|
|
16
|
+
all_elements = document.query_selector_all("*[id]").to_a
|
|
17
|
+
ids = all_elements.map { |el| el.get_attribute("id") }
|
|
18
|
+
ids.select { |id| ids.count(id) > 1 }.uniq
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def invalid_aria_references(document)
|
|
22
|
+
issues = []
|
|
23
|
+
document.query_selector_all("*").each do |el|
|
|
24
|
+
%w[aria-labelledby aria-describedby].each do |attr|
|
|
25
|
+
next unless el.has_attribute?(attr)
|
|
26
|
+
|
|
27
|
+
el.get_attribute(attr).to_s.split.each do |id|
|
|
28
|
+
issues << { element: el, attribute: attr, id: id } unless document.get_element_by_id(id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
issues
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Lenient policy: aria-label / aria-labelledby / placeholder are
|
|
36
|
+
# all accepted as label substitutes, even though a placeholder is
|
|
37
|
+
# not a sufficient accessible name under WCAG.
|
|
38
|
+
def missing_form_labels(document)
|
|
39
|
+
issues = []
|
|
40
|
+
document.query_selector_all("input, textarea, select").each do |field|
|
|
41
|
+
next if field.tag_name == "INPUT" &&
|
|
42
|
+
NON_LABELABLE_INPUT_TYPES.include?(field.get_attribute("type").to_s.downcase)
|
|
43
|
+
next if field.has_attribute?("aria-label")
|
|
44
|
+
next if field.has_attribute?("aria-labelledby")
|
|
45
|
+
next if field.has_attribute?("placeholder")
|
|
46
|
+
next if Dommy::Internal::ElementMatching.field_labels(field).any?
|
|
47
|
+
|
|
48
|
+
issues << { element: field, name: field.get_attribute("name") }
|
|
49
|
+
end
|
|
50
|
+
issues
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty_links(document)
|
|
54
|
+
document.query_selector_all("a[href]").to_a.select do |link|
|
|
55
|
+
accessible_link_text(link).empty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def nested_interactive_elements(document)
|
|
60
|
+
issues = []
|
|
61
|
+
interactive_elements(document).each do |element|
|
|
62
|
+
parent = element.parent_node
|
|
63
|
+
while parent
|
|
64
|
+
if interactive_element?(parent)
|
|
65
|
+
issues << { element: element, ancestor: parent }
|
|
66
|
+
break
|
|
67
|
+
end
|
|
68
|
+
parent = parent.respond_to?(:parent_node) ? parent.parent_node : nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
issues
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def interactive_elements(document)
|
|
75
|
+
document.query_selector_all("a[href], button, input, select, textarea, summary").to_a.reject do |element|
|
|
76
|
+
element.tag_name == "INPUT" && element.get_attribute("type").to_s.downcase == "hidden"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def interactive_element?(element)
|
|
81
|
+
return false unless element.respond_to?(:tag_name)
|
|
82
|
+
|
|
83
|
+
case element.tag_name
|
|
84
|
+
when "A"
|
|
85
|
+
element.has_attribute?("href")
|
|
86
|
+
when "BUTTON", "SELECT", "TEXTAREA", "SUMMARY"
|
|
87
|
+
true
|
|
88
|
+
when "INPUT"
|
|
89
|
+
element.get_attribute("type").to_s.downcase != "hidden"
|
|
90
|
+
else
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def accessible_link_text(link)
|
|
96
|
+
[
|
|
97
|
+
link.text_content,
|
|
98
|
+
link.get_attribute("aria-label"),
|
|
99
|
+
link.get_attribute("title"),
|
|
100
|
+
image_alt_text(link)
|
|
101
|
+
].compact.join.strip
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def image_alt_text(link)
|
|
105
|
+
link.query_selector_all("img").to_a.map { |image| image.get_attribute("alt").to_s }.join
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rails
|
|
5
|
+
# Extracts HTML / plain-text bodies from Mail-like objects
|
|
6
|
+
# (multipart or single-part).
|
|
7
|
+
module MailPart
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def html_body(mail)
|
|
11
|
+
if mail.respond_to?(:html_part) && mail.html_part
|
|
12
|
+
mail.html_part.body.to_s
|
|
13
|
+
elsif mail.respond_to?(:body)
|
|
14
|
+
mail.body.to_s
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def plain_body(mail)
|
|
19
|
+
if mail.respond_to?(:text_part) && mail.text_part
|
|
20
|
+
mail.text_part.body.to_s
|
|
21
|
+
elsif mail.respond_to?(:body)
|
|
22
|
+
mail.body.to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def html_document(mail)
|
|
27
|
+
body = html_body(mail)
|
|
28
|
+
body ? Dommy.parse(body).document : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Rails
|
|
5
|
+
# Resolves the objects assertions/matchers accept (response-like,
|
|
6
|
+
# raw HTML string, or an already-parsed Dommy document/element)
|
|
7
|
+
# into a document or body string.
|
|
8
|
+
module MatchTarget
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def document(actual)
|
|
12
|
+
return actual.document if actual.respond_to?(:document)
|
|
13
|
+
return actual if actual.respond_to?(:query_selector_all)
|
|
14
|
+
|
|
15
|
+
Dommy.parse(body(actual)).document
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def body(actual)
|
|
19
|
+
actual.respond_to?(:body) ? actual.body.to_s : actual.to_s
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|