rspec-html_messages 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ddb6067bea7df00b60a4a696657f5bb4e6f2e16d889474b3c4aeb486b4f62932
4
+ data.tar.gz: 7637184a8d916d43fd76c63e97d198b39887d415c563d6291ab6f0396f334405
5
+ SHA512:
6
+ metadata.gz: c727f783901d4cac2166281397d08c377742b772d6da96a7411b983730cc0cedcb6eb780660bd004dc97e982a86284c6e0f7147c67e891e1578bde816ac9dc22
7
+ data.tar.gz: 8e2a42829abb1483db1466c955da35d321be7be82f1b8ce3ad354b02643c4248cd44a1bdda4b5c89f85ddf12d6acd1b01e84bcaf769ceb71406c1cf3d885a24f
data/LICENSE.md ADDED
@@ -0,0 +1,134 @@
1
+ = Hippocratic License
2
+
3
+ Version: 2.1.0.
4
+
5
+ Purpose. The purpose of this License is for the Licensor named above to
6
+ permit the Licensee (as defined below) broad permission, if consistent
7
+ with Human Rights Laws and Human Rights Principles (as each is defined
8
+ below), to use and work with the Software (as defined below) within the
9
+ full scope of Licensor’s copyright and patent rights, if any, in the
10
+ Software, while ensuring attribution and protecting the Licensor from
11
+ liability.
12
+
13
+ Permission and Conditions. The Licensor grants permission by this
14
+ license ("License"), free of charge, to the extent of Licensor’s
15
+ rights under applicable copyright and patent law, to any person or
16
+ entity (the "Licensee") obtaining a copy of this software and
17
+ associated documentation files (the "Software"), to do everything with
18
+ the Software that would otherwise infringe (i) the Licensor’s copyright
19
+ in the Software or (ii) any patent claims to the Software that the
20
+ Licensor can license or becomes able to license, subject to all of the
21
+ following terms and conditions:
22
+
23
+ * Acceptance. This License is automatically offered to every person and
24
+ entity subject to its terms and conditions. Licensee accepts this
25
+ License and agrees to its terms and conditions by taking any action with
26
+ the Software that, absent this License, would infringe any intellectual
27
+ property right held by Licensor.
28
+ * Notice. Licensee must ensure that everyone who gets a copy of any part
29
+ of this Software from Licensee, with or without changes, also receives
30
+ the License and the above copyright notice (and if included by the
31
+ Licensor, patent, trademark and attribution notice). Licensee must cause
32
+ any modified versions of the Software to carry prominent notices stating
33
+ that Licensee changed the Software. For clarity, although Licensee is
34
+ free to create modifications of the Software and distribute only the
35
+ modified portion created by Licensee with additional or different terms,
36
+ the portion of the Software not modified must be distributed pursuant to
37
+ this License. If anyone notifies Licensee in writing that Licensee has
38
+ not complied with this Notice section, Licensee can keep this License by
39
+ taking all practical steps to comply within 30 days after the notice. If
40
+ Licensee does not do so, Licensee’s License (and all rights licensed
41
+ hereunder) shall end immediately.
42
+ * Compliance with Human Rights Principles and Human Rights Laws.
43
+ [arabic]
44
+ . Human Rights Principles.
45
+ [loweralpha]
46
+ .. Licensee is advised to consult the articles of the United Nations
47
+ Universal Declaration of Human Rights and the United Nations Global
48
+ Compact that define recognized principles of international human rights
49
+ (the "Human Rights Principles"). Licensee shall use the Software in a
50
+ manner consistent with Human Rights Principles.
51
+ .. Unless the Licensor and Licensee agree otherwise, any dispute,
52
+ controversy, or claim arising out of or relating to (i) Section 1(a)
53
+ regarding Human Rights Principles, including the breach of Section 1(a),
54
+ termination of this License for breach of the Human Rights Principles,
55
+ or invalidity of Section 1(a) or (ii) a determination of whether any Law
56
+ is consistent or in conflict with Human Rights Principles pursuant to
57
+ Section 2, below, shall be settled by arbitration in accordance with the
58
+ Hague Rules on Business and Human Rights Arbitration (the "Rules");
59
+ provided, however, that Licensee may elect not to participate in such
60
+ arbitration, in which event this License (and all rights licensed
61
+ hereunder) shall end immediately. The number of arbitrators shall be one
62
+ unless the Rules require otherwise.
63
+ +
64
+ Unless both the Licensor and Licensee agree to the contrary: (1) All
65
+ documents and information concerning the arbitration shall be public and
66
+ may be disclosed by any party; (2) The repository referred to under
67
+ Article 43 of the Rules shall make available to the public in a timely
68
+ manner all documents concerning the arbitration which are communicated
69
+ to it, including all submissions of the parties, all evidence admitted
70
+ into the record of the proceedings, all transcripts or other recordings
71
+ of hearings and all orders, decisions and awards of the arbitral
72
+ tribunal, subject only to the arbitral tribunal’s powers to take such
73
+ measures as may be necessary to safeguard the integrity of the arbitral
74
+ process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3)
75
+ Article 26(6) of the Rules shall not apply.
76
+ . Human Rights Laws. The Software shall not be used by any person or
77
+ entity for any systems, activities, or other uses that violate any Human
78
+ Rights Laws. "Human Rights Laws" means any applicable laws,
79
+ regulations, or rules (collectively, "Laws") that protect human,
80
+ civil, labor, privacy, political, environmental, security, economic, due
81
+ process, or similar rights; provided, however, that such Laws are
82
+ consistent and not in conflict with Human Rights Principles (a dispute
83
+ over the consistency or a conflict between Laws and Human Rights
84
+ Principles shall be determined by arbitration as stated above). Where
85
+ the Human Rights Laws of more than one jurisdiction are applicable or in
86
+ conflict with respect to the use of the Software, the Human Rights Laws
87
+ that are most protective of the individuals or groups harmed shall
88
+ apply.
89
+ . Indemnity. Licensee shall hold harmless and indemnify Licensor (and
90
+ any other contributor) against all losses, damages, liabilities,
91
+ deficiencies, claims, actions, judgments, settlements, interest, awards,
92
+ penalties, fines, costs, or expenses of whatever kind, including
93
+ Licensor’s reasonable attorneys’ fees, arising out of or relating to
94
+ Licensee’s use of the Software in violation of Human Rights Laws or
95
+ Human Rights Principles.
96
+ * Failure to Comply. Any failure of Licensee to act according to the
97
+ terms and conditions of this License is both a breach of the License and
98
+ an infringement of the intellectual property rights of the Licensor
99
+ (subject to exceptions under Laws, e.g., fair use). In the event of a
100
+ breach or infringement, the terms and conditions of this License may be
101
+ enforced by Licensor under the Laws of any jurisdiction to which
102
+ Licensee is subject. Licensee also agrees that the Licensor may enforce
103
+ the terms and conditions of this License against Licensee through
104
+ specific performance (or similar remedy under Laws) to the extent
105
+ permitted by Laws. For clarity, except in the event of a breach of this
106
+ License, infringement, or as otherwise stated in this License, Licensor
107
+ may not terminate this License with Licensee.
108
+ * Enforceability and Interpretation. If any term or provision of this
109
+ License is determined to be invalid, illegal, or unenforceable by a
110
+ court of competent jurisdiction, then such invalidity, illegality, or
111
+ unenforceability shall not affect any other term or provision of this
112
+ License or invalidate or render unenforceable such term or provision in
113
+ any other jurisdiction; provided, however, subject to a court
114
+ modification pursuant to the immediately following sentence, if any term
115
+ or provision of this License pertaining to Human Rights Laws or Human
116
+ Rights Principles is deemed invalid, illegal, or unenforceable against
117
+ Licensee by a court of competent jurisdiction, all rights in the
118
+ Software granted to Licensee shall be deemed null and void as between
119
+ Licensor and Licensee. Upon a determination that any term or provision
120
+ is invalid, illegal, or unenforceable, to the extent permitted by Laws,
121
+ the court may modify this License to affect the original purpose that
122
+ the Software be used in compliance with Human Rights Principles and
123
+ Human Rights Laws as closely as possible. The language in this License
124
+ shall be interpreted as to its fair meaning and not strictly for or
125
+ against any party.
126
+ * Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES
127
+ "AS IS," WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND
128
+ ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR
129
+ OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
130
+ OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM.
131
+
132
+ This Hippocratic License is an link:https://ethicalsource.dev[Ethical Source license] and is offered
133
+ for use by licensors and licensees at their own risk, on an "AS IS" basis, and with no warranties
134
+ express or implied, to the maximum extent permitted by Laws.
data/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # RSpec HTML Messages
2
+
3
+ [![CI](https://github.com/firstdraft/rspec-html_messages/actions/workflows/ci.yml/badge.svg)](https://github.com/firstdraft/rspec-html_messages/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/rspec-html_messages.svg)](https://badge.fury.io/rb/rspec-html_messages)
5
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/standardrb/standard)
6
+
7
+ Transform RSpec's JSON output into formatted HTML with syntax highlighting, side-by-side diffs, and Bootstrap styling.
8
+
9
+ ## Overview
10
+
11
+ `rspec-html_messages` takes the enriched JSON output from [`rspec-enriched_json`](https://github.com/firstdraft/rspec-enriched_json) and renders it as HTML. It provides:
12
+
13
+ - **Beautiful formatting** - Clean, Bootstrap-styled output.
14
+ - **Side-by-side diffs** - Visual comparison of expected vs actual values.
15
+ - **Smart data rendering** - Pretty-printing for complex objects.
16
+ - **Composable API** - Use individual components or the full renderer.
17
+ - **Flexible options** - Control diff display and message formatting.
18
+
19
+ ## Installation
20
+
21
+ Add to your Gemfile:
22
+
23
+ ```ruby
24
+ gem "rspec-html_messages"
25
+ ```
26
+
27
+ Or install directly:
28
+
29
+ ```bash
30
+ $ gem install rspec-html_messages
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Basic Usage
36
+
37
+ ```ruby
38
+ require "rspec/html_messages"
39
+
40
+ # Get enriched JSON from RSpec
41
+ # (typically from running with rspec-enriched_json formatter)
42
+ example_json = {
43
+ "id" => "spec/example_spec.rb[1:1]",
44
+ "description" => "should equal 42",
45
+ "status" => "failed",
46
+ "file_path" => "spec/example_spec.rb",
47
+ "line_number" => 10,
48
+ "details" => {
49
+ "expected" => '"42"',
50
+ "actual" => '"41"',
51
+ "matcher_name" => "RSpec::Matchers::BuiltIn::Eq",
52
+ "diffable" => true
53
+ },
54
+ "exception" => {
55
+ "message" => "expected: \"42\"\n got: \"41\"\n\n(compared using ==)"
56
+ }
57
+ }
58
+
59
+ # Render as HTML
60
+ renderer = Rspec::HtmlMessages.new(example_json)
61
+ html = renderer.render_html
62
+
63
+ # Output includes styled HTML with diff
64
+ puts html
65
+ ```
66
+
67
+ ### Using Individual Components
68
+
69
+ The gem provides a composable API where you can use individual components to build your own custom layouts:
70
+
71
+ ```ruby
72
+ renderer = Rspec::HtmlMessages.new(example_json)
73
+
74
+ # Check what content is available
75
+ if renderer.has_output?
76
+ output_html = renderer.output_html
77
+ end
78
+
79
+ if renderer.has_failure_message?
80
+ failure_html = renderer.failure_message_html
81
+ end
82
+
83
+ if renderer.has_exception_details?
84
+ exception_html = renderer.exception_details_html
85
+ end
86
+
87
+ if renderer.has_backtrace?
88
+ exception_html = renderer.backtrace_html
89
+ end
90
+
91
+ # Build your own custom layout
92
+ html = <<~HTML
93
+ <div class="my-custom-test-result">
94
+ #{output_html if renderer.has_output?}
95
+ #{failure_html if renderer.has_failure_message?}
96
+ #{exception_html if renderer.has_exception_details?}
97
+ </div>
98
+ HTML
99
+ ```
100
+
101
+ ### Options
102
+
103
+ You can customize the rendering with various options:
104
+
105
+ ```ruby
106
+ # Options can be passed to the render_html method
107
+ html = renderer.render_html(
108
+ force_diffable: ["CustomMatcher"], # Array of matchers to always show diffs for
109
+ force_not_diffable: ["RSpec::Matchers::BuiltIn::Include"], # Array of matchers to never show diffs for
110
+ rspec_diff_in_message: true, # Include RSpec's text diff in failure message (default: false)
111
+ backtrace_max_lines: 10, # Maximum backtrace lines to show (default: 10)
112
+ backtrace_silence_gems: true # Filter out gem frames from backtraces (default: true)
113
+ )
114
+
115
+ # Or to individual component methods
116
+ output_html = renderer.output_html(force_diffable: ["CustomMatcher"])
117
+ ```
118
+
119
+ #### Option Details
120
+
121
+ - **`force_diffable`**: Array of matcher class names that should always show diffs, even if they report as non-diffable.
122
+ - Default: `["RSpec::Matchers::BuiltIn::ContainExactly"]` (used by `contain_exactly` and `match_array`).
123
+ - Override by passing your own array.
124
+
125
+ - **`force_not_diffable`**: Array of matcher class names that should never show diffs, even if they report as diffable.
126
+ - Default: `["RSpec::Matchers::BuiltIn::Include", "RSpec::Matchers::BuiltIn::Compound::And", "RSpec::Matchers::BuiltIn::Compound::Or"]`.
127
+ - Override by passing your own array.
128
+
129
+ - **`rspec_diff_in_message`**: By default, RSpec's text-based diff is stripped from failure messages since we show a visual diff. Set to `true` to keep it.
130
+
131
+ - **`backtrace_max_lines`**: Maximum number of backtrace lines to display for errors.
132
+ - Default: `10`.
133
+ - Set to a higher number to see more of the stack trace.
134
+
135
+ - **`backtrace_silence_gems`**: Whether to filter out gem frames from backtraces.
136
+ - Default: `true` (hides frames from installed gems).
137
+ - Set to `false` to see the complete backtrace including gem internals.
138
+
139
+ ### Complete Example
140
+
141
+ Here's a complete example that processes RSpec output and generates an HTML report:
142
+
143
+ ```ruby
144
+ require "json"
145
+ require "rspec/html_messages"
146
+
147
+ # Run RSpec with enriched JSON formatter
148
+ json_output = `bundle exec rspec --require rspec/enriched_json \
149
+ -f RSpec::EnrichedJson::Formatters::EnrichedJsonFormatter`
150
+
151
+ # Parse the JSON
152
+ results = JSON.parse(json_output)
153
+
154
+ # Generate HTML report
155
+ html = <<~HTML
156
+ <!DOCTYPE html>
157
+ <html>
158
+ <head>
159
+ <title>RSpec Results</title>
160
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
161
+ </head>
162
+ <body>
163
+ <div class="container my-4">
164
+ <h1>Test Results</h1>
165
+ <p>#{results["summary_line"]}</p>
166
+
167
+ HTML
168
+
169
+ # Render each example
170
+ results["examples"].each do |example|
171
+ renderer = Rspec::HtmlMessages.new(example)
172
+ html << <<~EXAMPLE
173
+ <div class="mb-4">
174
+ <h3>#{example["description"]}</h3>
175
+ #{renderer.render_html}
176
+ </div>
177
+ EXAMPLE
178
+ end
179
+
180
+ html << <<~HTML
181
+ </div>
182
+ </body>
183
+ </html>
184
+ HTML
185
+
186
+ File.write("rspec_results.html", html)
187
+ ```
188
+
189
+ ## Output Examples
190
+
191
+ ### Passing Test
192
+
193
+ For a passing test, you'll see:
194
+ - Green checkmark and background.
195
+ - Test description and file location.
196
+ - No failure message or diff.
197
+
198
+ ### Failing Test with Diff
199
+
200
+ For a failing test with diffable values:
201
+ - Red X and background.
202
+ - Test description and file location.
203
+ - Side-by-side comparison showing differences.
204
+ - Failure message (with RSpec's diff stripped by default).
205
+
206
+ ### Error Display
207
+
208
+ For tests that encounter errors (exceptions) before assertions:
209
+ - Exception class name highlighted in red.
210
+ - Stack trace with configurable depth.
211
+ - Gem frames filtered by default (configurable).
212
+
213
+ ## Working with rspec-enriched_json
214
+
215
+ This gem is designed to work with [`rspec-enriched_json`](https://github.com/firstdraft/rspec-enriched_json), which provides structured data about test failures including:
216
+
217
+ - Expected and actual values as structured data (not just strings).
218
+ - Matcher information.
219
+ - Diffable status.
220
+ - Original failure messages.
221
+
222
+ To use both gems together:
223
+
224
+ 1. Add both gems to your Gemfile:
225
+ ```ruby
226
+ gem "rspec-enriched_json"
227
+ gem "rspec-html_messages"
228
+ ```
229
+
230
+ 2. Run RSpec with the enriched JSON formatter:
231
+ ```bash
232
+ bundle exec rspec --require rspec/enriched_json \
233
+ -f RSpec::EnrichedJson::Formatters::EnrichedJsonFormatter
234
+ ```
235
+
236
+ 3. Process the output with rspec-html_messages as shown in the examples above.
237
+
238
+ ## API Reference
239
+
240
+ ### Instance Methods
241
+
242
+ #### `new(example)`
243
+ Creates a new renderer instance with the example JSON data.
244
+
245
+ #### `has_output?`
246
+ Returns `true` if the example has output to display (failed tests or tests with actual values).
247
+
248
+ #### `has_failure_message?`
249
+ Returns `true` if the example has a failure message to display.
250
+
251
+ #### `has_exception_details?`
252
+ Returns `true` if the example has exception/error details to display.
253
+
254
+ #### `has_backtrace?`
255
+ Returns `true` if the example has a backtrace to display.
256
+
257
+ #### `output_html(**options)`
258
+ Renders just the output section (diff or actual value). Returns `nil` if no output to display.
259
+
260
+ #### `failure_message_html(**options)`
261
+ Renders just the failure message section. Returns `nil` if no failure message.
262
+
263
+ #### `exception_details_html(**options)`
264
+ Renders just the exception details section. Returns `nil` if no exception.
265
+
266
+ #### `backtrace_html(**options)`
267
+ Renders just the backtrace section. Returns `nil` if no backtrace.
268
+
269
+ #### `render_html(**options)`
270
+ Convenience method that renders all three sections in a standard layout.
271
+
272
+ ### Class Methods
273
+
274
+ #### `Rspec::HtmlMessages.diff_css`
275
+ Returns the CSS needed for diff display styling.
276
+
277
+ ## Development
278
+
279
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
280
+
281
+ To install this gem onto your local machine, run `bundle exec rake install`.
282
+
283
+ ## Contributing
284
+
285
+ Bug reports and pull requests are welcome on GitHub at https://github.com/firstdraft/rspec-html_messages.
286
+
287
+ ## License
288
+
289
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diffy"
4
+
5
+ module Rspec
6
+ class HtmlMessages
7
+ module DiffFormatter
8
+ def effective_diffable?(force_diffable: [], force_not_diffable: [])
9
+ return true if force_diffable&.include?(matcher_name)
10
+ return false if force_not_diffable&.include?(matcher_name)
11
+
12
+ details["diffable"]
13
+ end
14
+
15
+ # Creates side-by-side HTML diff using Diffy
16
+ # Note: Diffy generates safe HTML with properly escaped content, so we render it unescaped in templates
17
+ def create_diff(actual_value, expected_value)
18
+ split_diff = Diffy::SplitDiff.new(actual_value, expected_value, format: :html)
19
+
20
+ {
21
+ left: split_diff.left,
22
+ right: split_diff.right
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module Rspec
6
+ class HtmlMessages
7
+ module TemplateRenderer
8
+ include ActionView::Helpers::TagHelper
9
+ include ActionView::Helpers::TextHelper
10
+ include ActionView::Helpers::OutputSafetyHelper
11
+
12
+ def render_template(template_name, locals = {})
13
+ template_path = template_path_for(template_name)
14
+ erb = ERB.new(File.read(template_path))
15
+
16
+ # Create a binding with access to helper methods and locals
17
+ binding_with_locals = binding
18
+ locals.each do |key, value|
19
+ binding_with_locals.local_variable_set(key, value)
20
+ end
21
+
22
+ erb.result(binding_with_locals)
23
+ end
24
+
25
+ private
26
+
27
+ def template_path_for(template_name)
28
+ File.join(templates_dir, "#{template_name}.html.erb")
29
+ end
30
+
31
+ def templates_dir
32
+ @templates_dir ||= File.expand_path("templates", __dir__)
33
+ end
34
+
35
+ def html_escape(text)
36
+ ERB::Util.html_escape(text)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,8 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h5 class="h6 mb-0">Actual</h5>
4
+ </div>
5
+ <div class="card-body">
6
+ <pre class="mb-0 fs-6"><code><%= html_escape(prettified_actual) %></code></pre>
7
+ </div>
8
+ </div>
@@ -0,0 +1,13 @@
1
+ <% if exception_backtrace.any? %>
2
+ <% backtrace_lines = formatted_backtrace(max_lines: @backtrace_max_lines, silence_gems: @backtrace_silence_gems) %>
3
+ <div>
4
+ <h5 class="h6 text-muted mb-2">Backtrace:</h5>
5
+ <pre class="bg-dark text-white p-3 rounded backtrace"><code><% backtrace_lines.each do |line| %><%= html_escape(line) %>
6
+ <% end %></code></pre>
7
+ <% if exception_backtrace.size > backtrace_lines.size %>
8
+ <small class="text-muted mt-1 d-block">
9
+ (<%= exception_backtrace.size - backtrace_lines.size %> more frames omitted)
10
+ </small>
11
+ <% end %>
12
+ </div>
13
+ <% end %>
@@ -0,0 +1,22 @@
1
+ <div class="card-group">
2
+ <div class="card">
3
+ <div class="card-header">
4
+ <h5 class="h6 mb-0">Actual</h5>
5
+ </div>
6
+ <div class="card-body">
7
+ <div class="font-monospace">
8
+ <%= diff_html[:left] %>
9
+ </div>
10
+ </div>
11
+ </div>
12
+ <div class="card">
13
+ <div class="card-header">
14
+ <h5 class="h6 mb-0">Expected</h5>
15
+ </div>
16
+ <div class="card-body">
17
+ <div class="font-monospace">
18
+ <%= diff_html[:right] %>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ </div>
@@ -0,0 +1,12 @@
1
+ <% if exception_message.present? %>
2
+ <div class="card border-danger">
3
+ <div class="card-header">
4
+ <h5 class="h6 mb-0 text-danger">Error in code</h5>
5
+ </div>
6
+ <div class="card-body">
7
+ <pre class="text-danger fs-6 error-message mb-0"><code class="fw-bold"><%= html_escape(exception_message) %>
8
+
9
+ <% location = friendly_error_location %><% if location %><span class="text-body fw-normal"><%= html_escape(location) %></span><% end %></code></pre>
10
+ </div>
11
+ </div>
12
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <div class="card border-warning">
2
+ <div class="card-header">
3
+ <h5 class="h6 mb-0" style="color: var(--bs-orange)">Test failure message</h5>
4
+ </div>
5
+ <div class="card-body">
6
+ <pre class="failure-message mb-0 fs-6"><code><%= html_escape(@failure_message_text) %></code></pre>
7
+ </div>
8
+ </div>
@@ -0,0 +1,19 @@
1
+ <div class="rspec-html-messages">
2
+ <% if output_content = output_html(**@options) %>
3
+ <div>
4
+ <%= output_content %>
5
+ </div>
6
+ <% end %>
7
+
8
+ <% if failure_content = failure_message_html(**@options) %>
9
+ <div class="mt-3">
10
+ <%= failure_content %>
11
+ </div>
12
+ <% end %>
13
+
14
+ <% if exception_content = exception_details_html(**@options) %>
15
+ <div class="mt-3">
16
+ <%= exception_content %>
17
+ </div>
18
+ <% end %>
19
+ </div>
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj"
4
+ require "amazing_print"
5
+
6
+ module Rspec
7
+ class HtmlMessages
8
+ module ValueFormatter
9
+ # Oj options for deserializing - use object mode to restore symbols
10
+ # but keep safety options to prevent code execution
11
+ OJ_LOAD_OPTIONS = {
12
+ mode: :object, # Restore Ruby objects and symbols
13
+ auto_define: false, # DON'T auto-create classes (safety)
14
+ symbol_keys: false, # Preserve symbols as they were serialized
15
+ circular: true, # Handle circular references
16
+ create_additions: false, # Don't allow custom deserialization (safety)
17
+ create_id: nil # Disable create_id (safety)
18
+ }.freeze
19
+
20
+ AWESOME_PRINT_OPTIONS = {
21
+ plain: true, # No color codes
22
+ index: false, # No array indices
23
+ indent: -2, # 2-space indentation
24
+ sort_keys: true, # Consistent hash ordering
25
+ object_id: false, # No object IDs
26
+ raw: true # Show instance variables
27
+ }
28
+
29
+ def prettify_for_diff(value)
30
+ case value
31
+ when String then value
32
+ when nil then "nil"
33
+ else value.awesome_inspect(AWESOME_PRINT_OPTIONS)
34
+ end
35
+ end
36
+
37
+ def deserialize_value(serialized_value)
38
+ return nil unless serialized_value
39
+
40
+ Oj.load(serialized_value, OJ_LOAD_OPTIONS)
41
+ rescue Oj::ParseError, EncodingError, TypeError
42
+ serialized_value
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rspec
4
+ class HtmlMessages
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "active_support/backtrace_cleaner"
5
+
6
+ Zeitwerk::Loader.new.then do |loader|
7
+ loader.tag = "rspec-html_messages"
8
+ loader.push_dir "#{__dir__}/.."
9
+ loader.setup
10
+ end
11
+
12
+ module Rspec
13
+ # Main renderer class for converting enriched JSON examples to HTML
14
+ class HtmlMessages
15
+ include HtmlMessages::ValueFormatter
16
+ include HtmlMessages::DiffFormatter
17
+ include HtmlMessages::TemplateRenderer
18
+
19
+ # Matchers that should always show diffs regardless of their diffable flag
20
+ FORCE_DIFFABLE_MATCHERS = ["RSpec::Matchers::BuiltIn::ContainExactly"].freeze
21
+
22
+ # Matchers that should never show diffs regardless of their diffable flag
23
+ FORCE_NOT_DIFFABLE_MATCHERS = [
24
+ "RSpec::Matchers::BuiltIn::Include",
25
+ "RSpec::Matchers::BuiltIn::Compound::And",
26
+ "RSpec::Matchers::BuiltIn::Compound::Or"
27
+ ].freeze
28
+
29
+ attr_reader :example
30
+
31
+ def initialize(example)
32
+ validate_example!(example)
33
+ @example = example
34
+ end
35
+
36
+ def options
37
+ @options ||= default_options
38
+ end
39
+
40
+ # Individual rendering methods - return nil when no content to display
41
+ def output_html(**options)
42
+ return nil unless has_output?
43
+
44
+ @options = default_options.merge(options)
45
+ @force_diffable = @options[:force_diffable]
46
+ @force_not_diffable = @options[:force_not_diffable]
47
+
48
+ if should_show_diff?
49
+ render_template("_diff")
50
+ else
51
+ render_template("_actual")
52
+ end
53
+ end
54
+
55
+ def failure_message_html(**options)
56
+ @options = default_options.merge(options)
57
+
58
+ # Don't show failure message for errors - that goes in exception details
59
+ return nil unless has_failure_message?
60
+
61
+ @failure_message_text = failure_message
62
+
63
+ render_template("_failure_message")
64
+ end
65
+
66
+ def exception_details_html(**options)
67
+ return nil unless has_exception_details?
68
+
69
+ @options = default_options.merge(options)
70
+
71
+ render_template("_exception_details")
72
+ end
73
+
74
+ def backtrace_html(**options)
75
+ return nil unless has_backtrace?
76
+
77
+ @options = default_options.merge(options)
78
+ @backtrace_max_lines = @options[:backtrace_max_lines]
79
+ @backtrace_silence_gems = @options[:backtrace_silence_gems]
80
+
81
+ render_template("_backtrace")
82
+ end
83
+
84
+ # Public boolean methods so users can make their own decisions
85
+ def has_output?
86
+ # Don't show output for errors before assertions
87
+ return false if error_before_assertion?
88
+
89
+ status == "failed" || has_actual?
90
+ end
91
+
92
+ def has_failure_message?
93
+ status == "failed" && !error_before_assertion? && failure_message.present?
94
+ end
95
+
96
+ def has_exception_details?
97
+ error_before_assertion?
98
+ end
99
+
100
+ def has_backtrace?
101
+ exception_backtrace.any?
102
+ end
103
+
104
+ # Example of how to use all three together
105
+ def render_html(**options)
106
+ @options = default_options.merge(options)
107
+ render_template("example")
108
+ end
109
+
110
+ def self.diff_css
111
+ # Minimal CSS for displaying diffs generated by Diffy
112
+ <<~CSS
113
+ .diff { overflow: auto; }
114
+ .diff ul {
115
+ overflow: auto;
116
+ list-style: none;
117
+ margin: 0;
118
+ padding: 0;
119
+ display: table;
120
+ width: 100%;
121
+ }
122
+ .diff del, .diff ins {
123
+ display: block;
124
+ text-decoration: none;
125
+ }
126
+ .diff li {
127
+ padding: 0;
128
+ display: table-row;
129
+ margin: 0;
130
+ height: 1em;
131
+ }
132
+ .diff li.ins { background: #dfd; color: #080; }
133
+ .diff li.del { background: #fee; color: #b00; }
134
+ .diff li:hover { background: #ffc; }
135
+ .diff del, .diff ins, .diff span { white-space: pre-wrap; }
136
+ .diff del strong { font-weight: normal; background: #fcc; }
137
+ .diff ins strong { font-weight: normal; background: #9f9; }
138
+ .diff li.diff-comment { display: none; }
139
+ .diff li.diff-block-info { background: none repeat scroll 0 0 gray; }
140
+ CSS
141
+ end
142
+
143
+ private
144
+
145
+ def default_options
146
+ {
147
+ force_diffable: FORCE_DIFFABLE_MATCHERS,
148
+ force_not_diffable: FORCE_NOT_DIFFABLE_MATCHERS,
149
+ rspec_diff_in_message: false,
150
+ backtrace_max_lines: 10,
151
+ backtrace_silence_gems: true
152
+ }
153
+ end
154
+
155
+ def should_show_diff?
156
+ show_diff?(force_diffable: @force_diffable, force_not_diffable: @force_not_diffable)
157
+ end
158
+
159
+ # Helper methods for templates
160
+ def status
161
+ example["status"]
162
+ end
163
+
164
+ def details
165
+ @details ||= example["details"] || {}
166
+ end
167
+
168
+ def matcher_name
169
+ details["matcher_name"] || "Unknown"
170
+ end
171
+
172
+ def failure_message
173
+ @failure_message ||= calculate_failure_message
174
+ end
175
+
176
+ def calculate_failure_message
177
+ return nil unless status == "failed"
178
+
179
+ message = example.dig("exception", "message")
180
+ return nil unless message
181
+
182
+ # Strip leading newline that RSpec's built-in matchers add
183
+ message = message.sub(/\A\n/, "")
184
+
185
+ # Strip RSpec's diff section if requested
186
+ if !options[:rspec_diff_in_message]
187
+ # RSpec appends diffs with "\nDiff:" or "\nDiff for (...):"
188
+ # The diff always contains @@ markers (unified diff format) or the empty diff message
189
+ # This regex requires either @@ or "The diff is empty" to appear after Diff:
190
+ # to avoid false positives where "Diff:" might appear in user data
191
+ message = message.sub(
192
+ /\n\s*Diff(?:\s+for\s+\([^)]+\))?:.*?(?:@@|The diff is empty).*\z/m,
193
+ ""
194
+ )
195
+ end
196
+
197
+ message
198
+ end
199
+
200
+ def exception_class
201
+ example.dig("exception", "class")
202
+ end
203
+
204
+ def exception_message
205
+ example.dig("exception", "message")
206
+ end
207
+
208
+ def exception_backtrace
209
+ example.dig("exception", "backtrace") || []
210
+ end
211
+
212
+ def has_exception?
213
+ example.key?("exception")
214
+ end
215
+
216
+ def error_before_assertion?
217
+ # Check if this is an error (not a matcher failure)
218
+ has_exception? && !has_actual? && !has_expected?
219
+ end
220
+
221
+ def has_expected?
222
+ details.key?("expected")
223
+ end
224
+
225
+ def backtrace_cleaner(silence_gems: true)
226
+ ActiveSupport::BacktraceCleaner.new.tap do |bc|
227
+ # Clean up paths by removing project root
228
+ bc.add_filter { |line| line.gsub("#{project_root}/", "") }
229
+
230
+ # Optionally silence gem frames
231
+ if silence_gems
232
+ bc.add_silencer { |line| line.include?("/gems/") && !line.include?(project_root) }
233
+ bc.add_silencer { |line| line.include?("/bundle/") }
234
+ bc.add_silencer { |line| line.match?(%r{/ruby/\d+\.\d+\.\d+/}) }
235
+ end
236
+
237
+ # Always silence RSpec internals
238
+ bc.add_silencer { |line| line.match?(%r{/lib/rspec/(core|expectations|mocks)/}) }
239
+ end
240
+ end
241
+
242
+ def project_root
243
+ @project_root ||= File.expand_path("../..", File.dirname(example["file_path"]))
244
+ end
245
+
246
+ def has_actual?
247
+ details.key?("actual")
248
+ end
249
+
250
+ def actual_value
251
+ @actual_value ||= deserialize_value(details["actual"]) if has_actual?
252
+ end
253
+
254
+ def expected_value
255
+ @expected_value ||= deserialize_value(details["expected"]) if has_expected?
256
+ end
257
+
258
+ def prettified_actual
259
+ @prettified_actual ||= prettify_for_diff(actual_value)
260
+ end
261
+
262
+ def prettified_expected
263
+ @prettified_expected ||= prettify_for_diff(expected_value)
264
+ end
265
+
266
+ def negated?
267
+ details["negated"]
268
+ end
269
+
270
+ def show_diff?(force_diffable: [], force_not_diffable: [])
271
+ return false if status == "passed"
272
+ return false unless expected_value && actual_value
273
+ return false if negated?
274
+
275
+ # Check if values are identical (likely a negated matcher)
276
+ return false if prettified_actual == prettified_expected
277
+
278
+ effective_diffable?(force_diffable: force_diffable, force_not_diffable: force_not_diffable)
279
+ end
280
+
281
+ def diff_html
282
+ @diff_html ||= create_diff(prettified_actual, prettified_expected)
283
+ end
284
+
285
+ def formatted_backtrace(max_lines: 10, silence_gems: true)
286
+ return [] if exception_backtrace.empty?
287
+
288
+ cleaned = backtrace_cleaner(silence_gems: silence_gems).clean(exception_backtrace)
289
+
290
+ # If all lines were filtered out, show original with cleaned paths
291
+ if cleaned.empty?
292
+ cleaned = ActiveSupport::BacktraceCleaner.new.tap do |bc|
293
+ bc.add_filter { |line| line.gsub("#{project_root}/", "") }
294
+ end.clean(exception_backtrace)
295
+ end
296
+
297
+ cleaned.first(max_lines)
298
+ end
299
+
300
+ def friendly_error_location
301
+ return nil if exception_backtrace.empty?
302
+
303
+ first_line = exception_backtrace.first
304
+ # Remove absolute path prefix to get relative path
305
+ first_line = first_line.gsub(/^#{Regexp.escape(project_root)}\//, "")
306
+
307
+ # Parse the backtrace line format: path:line:in 'method'
308
+ if match = first_line.match(/^(.+):(\d+):in ['`](.+)['`]$/)
309
+ path, line_number, method = match.captures
310
+ "#{exception_class} on line #{line_number} of #{path} in '#{method}'"
311
+ else
312
+ # Fallback to original format if parsing fails
313
+ "#{exception_class}: #{first_line}"
314
+ end
315
+ end
316
+
317
+ private
318
+
319
+ def validate_example!(example)
320
+ raise ArgumentError, "Example cannot be nil" if example.nil?
321
+ raise ArgumentError, "Example must be a Hash" unless example.is_a?(Hash)
322
+
323
+ # Validate required fields
324
+ required_fields = %w[id description status file_path line_number]
325
+ missing_fields = required_fields - example.keys
326
+
327
+ unless missing_fields.empty?
328
+ raise ArgumentError, "Example is missing required fields: #{missing_fields.join(', ')}"
329
+ end
330
+
331
+ # Validate status value
332
+ valid_statuses = %w[passed failed pending]
333
+ unless valid_statuses.include?(example["status"])
334
+ raise ArgumentError, "Invalid status: #{example['status']}. Must be one of: #{valid_statuses.join(', ')}"
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rspec/html_messages/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rspec-html_messages"
7
+ spec.version = Rspec::HtmlMessages::VERSION
8
+ spec.authors = ["Raghu Betina"]
9
+ spec.email = ["raghu@firstdraft.com"]
10
+ spec.homepage = "https://github.com/firstdraft/rspec-html_messages"
11
+ spec.summary = "HTML formatting for RSpec enriched JSON output"
12
+ spec.license = "MIT"
13
+
14
+ spec.metadata = {
15
+ "bug_tracker_uri" => "https://github.com/firstdraft/rspec-html_messages/issues",
16
+ "changelog_uri" => "https://github.com/firstdraft/rspec-html_messages/blob/main/CHANGELOG.md",
17
+ "label" => "Rspec Html Messages",
18
+ "rubygems_mfa_required" => "true",
19
+ "source_code_uri" => "https://github.com/firstdraft/rspec-html_messages"
20
+ }
21
+
22
+ spec.required_ruby_version = "~> 3.0"
23
+ spec.add_dependency "actionview", ">= 6.0", "< 8"
24
+ spec.add_dependency "activesupport", ">= 6.0", "< 8"
25
+ spec.add_dependency "amazing_print", "~> 1.6"
26
+ spec.add_dependency "diffy", "~> 3.4"
27
+ spec.add_dependency "oj", "~> 3.16"
28
+ spec.add_dependency "zeitwerk", "~> 2.7"
29
+
30
+ spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
31
+ spec.files = Dir["*.gemspec", "lib/**/*"]
32
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-html_messages
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Raghu Betina
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionview
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '6.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '8'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '6.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '8'
52
+ - !ruby/object:Gem::Dependency
53
+ name: amazing_print
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '1.6'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '1.6'
66
+ - !ruby/object:Gem::Dependency
67
+ name: diffy
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.4'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '3.4'
80
+ - !ruby/object:Gem::Dependency
81
+ name: oj
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '3.16'
87
+ type: :runtime
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '3.16'
94
+ - !ruby/object:Gem::Dependency
95
+ name: zeitwerk
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '2.7'
101
+ type: :runtime
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '2.7'
108
+ email:
109
+ - raghu@firstdraft.com
110
+ executables: []
111
+ extensions: []
112
+ extra_rdoc_files:
113
+ - LICENSE.md
114
+ - README.md
115
+ files:
116
+ - LICENSE.md
117
+ - README.md
118
+ - lib/rspec/html_messages.rb
119
+ - lib/rspec/html_messages/diff_formatter.rb
120
+ - lib/rspec/html_messages/template_renderer.rb
121
+ - lib/rspec/html_messages/templates/_actual.html.erb
122
+ - lib/rspec/html_messages/templates/_backtrace.html.erb
123
+ - lib/rspec/html_messages/templates/_diff.html.erb
124
+ - lib/rspec/html_messages/templates/_exception_details.html.erb
125
+ - lib/rspec/html_messages/templates/_failure_message.html.erb
126
+ - lib/rspec/html_messages/templates/example.html.erb
127
+ - lib/rspec/html_messages/value_formatter.rb
128
+ - lib/rspec/html_messages/version.rb
129
+ - rspec-html_messages.gemspec
130
+ homepage: https://github.com/firstdraft/rspec-html_messages
131
+ licenses:
132
+ - MIT
133
+ metadata:
134
+ bug_tracker_uri: https://github.com/firstdraft/rspec-html_messages/issues
135
+ changelog_uri: https://github.com/firstdraft/rspec-html_messages/blob/main/CHANGELOG.md
136
+ label: Rspec Html Messages
137
+ rubygems_mfa_required: 'true'
138
+ source_code_uri: https://github.com/firstdraft/rspec-html_messages
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - "~>"
145
+ - !ruby/object:Gem::Version
146
+ version: '3.0'
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 3.6.9
154
+ specification_version: 4
155
+ summary: HTML formatting for RSpec enriched JSON output
156
+ test_files: []