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 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 (`&amp;` ≡ `&`)
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