rspec-tracer 1.2.3 → 2.0.0.pre.2
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 +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative 'base'
|
|
8
|
+
require_relative 'payload_builder'
|
|
9
|
+
|
|
10
|
+
module RSpecTracer
|
|
11
|
+
# Internal Reporters — see {RSpecTracer} for the user-facing surface.
|
|
12
|
+
# @api private
|
|
13
|
+
module Reporters
|
|
14
|
+
# Renders a single-page HTML report at `<report_dir>/index.html`
|
|
15
|
+
# consuming the canonical payload built by `PayloadBuilder`
|
|
16
|
+
# (shared with `JsonReporter`).
|
|
17
|
+
#
|
|
18
|
+
# Build output - the frontend bundle at
|
|
19
|
+
# `lib/rspec_tracer/reporters/html/dist/` - is committed to the
|
|
20
|
+
# repo; users never run npm. At emit time this reporter:
|
|
21
|
+
#
|
|
22
|
+
# 1. Reads the template at `html/dist/index.html`.
|
|
23
|
+
# 2. Replaces the `<!-- RSPEC_TRACER_FALLBACK -->` marker with
|
|
24
|
+
# server-rendered `<table>` HTML for each of the 5 report
|
|
25
|
+
# types (satisfies the "works without JavaScript" AC; the
|
|
26
|
+
# Preact bundle removes these on hydrate).
|
|
27
|
+
# 3. Replaces the body of `<script id="report-data">` with the
|
|
28
|
+
# full payload JSON.
|
|
29
|
+
# 4. Copies `html/dist/assets/` next to the output.
|
|
30
|
+
# 5. Writes the finished file.
|
|
31
|
+
#
|
|
32
|
+
# Failure modes are graceful: the Registry wraps `#generate` in
|
|
33
|
+
# an isolated rescue, so a template-missing or template-corrupt
|
|
34
|
+
# condition logs a warning and returns nil rather than propagating
|
|
35
|
+
# a non-zero exit into the user's test suite.
|
|
36
|
+
class HtmlReporter < Base
|
|
37
|
+
# Internal constant.
|
|
38
|
+
# @api private
|
|
39
|
+
FILENAME = 'index.html'
|
|
40
|
+
# Internal constant.
|
|
41
|
+
# @api private
|
|
42
|
+
ASSETS_DIR = 'assets'
|
|
43
|
+
# Internal constant.
|
|
44
|
+
# @api private
|
|
45
|
+
FALLBACK_MARKER = '<!-- RSPEC_TRACER_FALLBACK -->'
|
|
46
|
+
# Internal constant.
|
|
47
|
+
# @api private
|
|
48
|
+
REPORT_DATA_REGEX = %r{<script id="report-data" type="application/json">.*?</script>}m
|
|
49
|
+
|
|
50
|
+
# Absolute path to the committed frontend build under
|
|
51
|
+
# `lib/rspec_tracer/reporters/html/dist/`. Computed once at load
|
|
52
|
+
# time so tests can stub `DIST_DIR` if they need a different
|
|
53
|
+
# template root.
|
|
54
|
+
DIST_DIR = File.expand_path('html/dist', __dir__)
|
|
55
|
+
|
|
56
|
+
# Concrete implementation of {RSpecTracer::Reporters::Base#generate}.
|
|
57
|
+
# Renders the bundled HTML template with the run payload and writes
|
|
58
|
+
# `index.html` (plus the asset directory) under {#report_dir}.
|
|
59
|
+
#
|
|
60
|
+
# @return [String, nil] absolute path of the written index, or nil
|
|
61
|
+
# when there is nothing to render.
|
|
62
|
+
def generate
|
|
63
|
+
return nil if no_op?
|
|
64
|
+
|
|
65
|
+
template = read_template
|
|
66
|
+
return nil if template.nil?
|
|
67
|
+
|
|
68
|
+
payload_json = PayloadBuilder.build(
|
|
69
|
+
snapshot: snapshot,
|
|
70
|
+
run_metadata: run_metadata,
|
|
71
|
+
generated_at: generated_at_override
|
|
72
|
+
)
|
|
73
|
+
rendered = inject(template, payload_json)
|
|
74
|
+
|
|
75
|
+
FileUtils.mkdir_p(report_dir)
|
|
76
|
+
copy_assets
|
|
77
|
+
path = File.join(report_dir, FILENAME)
|
|
78
|
+
File.write(path, rendered, encoding: 'UTF-8')
|
|
79
|
+
logger&.debug("rspec-tracer: wrote HTML report to #{path}")
|
|
80
|
+
path
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Internal method on the tracer pipeline.
|
|
86
|
+
# @api private
|
|
87
|
+
def read_template
|
|
88
|
+
path = File.join(dist_dir, FILENAME)
|
|
89
|
+
return File.read(path, encoding: 'UTF-8') if File.file?(path)
|
|
90
|
+
|
|
91
|
+
logger&.warn("rspec-tracer: HTML template missing at #{path}; HTML report not emitted")
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Internal method on the tracer pipeline.
|
|
96
|
+
# @api private
|
|
97
|
+
def inject(template, payload)
|
|
98
|
+
with_payload = template.sub(
|
|
99
|
+
REPORT_DATA_REGEX,
|
|
100
|
+
%(<script id="report-data" type="application/json">#{escape_payload(payload)}</script>)
|
|
101
|
+
)
|
|
102
|
+
with_payload.sub(FALLBACK_MARKER, render_fallback(payload))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The payload sits inside a `<script>` tag. To make it inert as
|
|
106
|
+
# HTML we escape `<`, `>`, and `&` with JSON-safe `\uXXXX`
|
|
107
|
+
# escapes. The string remains valid JSON on round-trip because
|
|
108
|
+
# JSON accepts `\u003c` etc. for any code point.
|
|
109
|
+
def escape_payload(payload)
|
|
110
|
+
JSON.generate(payload).gsub('<', '\\u003c').gsub('>', '\\u003e').gsub('&', '\\u0026')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Internal method on the tracer pipeline.
|
|
114
|
+
# @api private
|
|
115
|
+
def render_fallback(payload)
|
|
116
|
+
sections = fallback_sections(payload[:reports] || {})
|
|
117
|
+
%(<div id="fallback" class="fallback-root">#{sections.join("\n")}</div>)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Internal method on the tracer pipeline.
|
|
121
|
+
# @api private
|
|
122
|
+
def fallback_sections(reports)
|
|
123
|
+
dups = reports[:duplicate_examples] || []
|
|
124
|
+
flakies = reports[:flaky_examples] || []
|
|
125
|
+
sections = [fallback_all_examples(reports[:all_examples] || [])]
|
|
126
|
+
sections << fallback_duplicate_examples(dups) if dups.any?
|
|
127
|
+
sections << fallback_flaky_examples(flakies) if flakies.any?
|
|
128
|
+
sections << fallback_examples_dependency(reports[:examples_dependency] || [])
|
|
129
|
+
sections << fallback_files_dependency(reports[:files_dependency] || [])
|
|
130
|
+
sections
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Internal method on the tracer pipeline.
|
|
134
|
+
# @api private
|
|
135
|
+
def fallback_all_examples(items)
|
|
136
|
+
headers = ['Description', 'Location', 'Status', 'Run reason', 'Result', 'Duration']
|
|
137
|
+
rows = items.map do |item|
|
|
138
|
+
result = item[:execution_result] || {}
|
|
139
|
+
cells = [
|
|
140
|
+
cell(item[:description]), cell_code(item[:location]),
|
|
141
|
+
cell(item[:status]), cell(item[:run_reason]),
|
|
142
|
+
cell(result[:status]), cell(format_duration(result[:run_time]))
|
|
143
|
+
].join
|
|
144
|
+
"<tr>#{cells}</tr>"
|
|
145
|
+
end
|
|
146
|
+
fallback_table('all-examples', 'All Examples', headers, rows)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Internal method on the tracer pipeline.
|
|
150
|
+
# @api private
|
|
151
|
+
def fallback_duplicate_examples(items)
|
|
152
|
+
headers = ['Example ID', 'Occurrences', 'Description', 'Location']
|
|
153
|
+
rows = items.flat_map do |group|
|
|
154
|
+
(group[:entries] || []).map do |entry|
|
|
155
|
+
cells = [cell_code(group[:id]), cell(group[:count]),
|
|
156
|
+
cell(entry[:description]), cell_code(entry[:location])].join
|
|
157
|
+
"<tr>#{cells}</tr>"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
fallback_table('duplicate-examples', 'Duplicate Examples', headers, rows)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Internal method on the tracer pipeline.
|
|
164
|
+
# @api private
|
|
165
|
+
def fallback_flaky_examples(items)
|
|
166
|
+
headers = ['Example ID', 'Description', 'Location']
|
|
167
|
+
rows = items.map do |item|
|
|
168
|
+
cells = [cell_code(item[:id]), cell(item[:description]), cell_code(item[:location])].join
|
|
169
|
+
"<tr>#{cells}</tr>"
|
|
170
|
+
end
|
|
171
|
+
fallback_table('flaky-examples', 'Flaky Examples', headers, rows)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Internal method on the tracer pipeline.
|
|
175
|
+
# @api private
|
|
176
|
+
def fallback_examples_dependency(items)
|
|
177
|
+
headers = ['Example ID', 'Files', 'Env keys', 'Dependencies']
|
|
178
|
+
rows = items.map do |item|
|
|
179
|
+
files = item[:files] || []
|
|
180
|
+
env_keys = item[:env_keys] || []
|
|
181
|
+
deps = (files + env_keys.map { |k| "env:#{k}" }).map { |d| CGI.escapeHTML(d.to_s) }.join('<br>')
|
|
182
|
+
cells = [cell_code(item[:example_id]), cell(files.size), cell(env_keys.size),
|
|
183
|
+
%(<td class="cell-deps">#{deps}</td>)].join
|
|
184
|
+
"<tr>#{cells}</tr>"
|
|
185
|
+
end
|
|
186
|
+
fallback_table('examples-dependency', 'Examples Dependency', headers, rows)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Internal method on the tracer pipeline.
|
|
190
|
+
# @api private
|
|
191
|
+
def fallback_files_dependency(items)
|
|
192
|
+
headers = ['File', 'Examples', 'Spec files', 'Dependent spec files']
|
|
193
|
+
rows = items.map do |item|
|
|
194
|
+
spec_files = item[:spec_files] || {}
|
|
195
|
+
deps = spec_files.map { |spec, count| "#{CGI.escapeHTML(spec.to_s)} (#{count})" }.join('<br>')
|
|
196
|
+
cells = [cell_code(item[:file_name]), cell(item[:example_count]), cell(spec_files.size),
|
|
197
|
+
%(<td class="cell-deps">#{deps}</td>)].join
|
|
198
|
+
"<tr>#{cells}</tr>"
|
|
199
|
+
end
|
|
200
|
+
fallback_table('files-dependency', 'Files Dependency', headers, rows)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Internal method on the tracer pipeline.
|
|
204
|
+
# @api private
|
|
205
|
+
def fallback_table(id, title, headers, rows)
|
|
206
|
+
header_cells = headers.map { |h| "<th scope=\"col\">#{CGI.escapeHTML(h)}</th>" }.join
|
|
207
|
+
body = rows.empty? ? %(<tr><td colspan="#{headers.size}">No rows.</td></tr>) : rows.join
|
|
208
|
+
<<~HTML
|
|
209
|
+
<section class="fallback-section" id="fallback-#{id}">
|
|
210
|
+
<h2>#{CGI.escapeHTML(title)}</h2>
|
|
211
|
+
<table class="fallback-table">
|
|
212
|
+
<thead><tr>#{header_cells}</tr></thead>
|
|
213
|
+
<tbody>#{body}</tbody>
|
|
214
|
+
</table>
|
|
215
|
+
</section>
|
|
216
|
+
HTML
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Internal method on the tracer pipeline.
|
|
220
|
+
# @api private
|
|
221
|
+
def cell(value)
|
|
222
|
+
"<td>#{CGI.escapeHTML(value.to_s)}</td>"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Internal method on the tracer pipeline.
|
|
226
|
+
# @api private
|
|
227
|
+
def cell_code(value)
|
|
228
|
+
"<td><code>#{CGI.escapeHTML(value.to_s)}</code></td>"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Internal method on the tracer pipeline.
|
|
232
|
+
# @api private
|
|
233
|
+
def format_duration(seconds)
|
|
234
|
+
return '' unless seconds.is_a?(::Numeric)
|
|
235
|
+
return format('%d us', (seconds * 1_000_000).round) if seconds < 0.001
|
|
236
|
+
return format('%.1f ms', seconds * 1000) if seconds < 1
|
|
237
|
+
|
|
238
|
+
format('%.3f s', seconds)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Internal method on the tracer pipeline.
|
|
242
|
+
# @api private
|
|
243
|
+
def copy_assets
|
|
244
|
+
src = File.join(dist_dir, ASSETS_DIR)
|
|
245
|
+
return unless File.directory?(src)
|
|
246
|
+
|
|
247
|
+
dest = File.join(report_dir, ASSETS_DIR)
|
|
248
|
+
FileUtils.rm_rf(dest)
|
|
249
|
+
FileUtils.mkdir_p(dest)
|
|
250
|
+
FileUtils.cp_r(Dir[File.join(src, '*')], dest)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Internal method on the tracer pipeline.
|
|
254
|
+
# @api private
|
|
255
|
+
def dist_dir
|
|
256
|
+
options[:dist_dir] || DIST_DIR
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Internal method on the tracer pipeline.
|
|
260
|
+
# @api private
|
|
261
|
+
def generated_at_override
|
|
262
|
+
options[:generated_at]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
require_relative 'payload_builder'
|
|
8
|
+
|
|
9
|
+
module RSpecTracer
|
|
10
|
+
# Internal Reporters — see {RSpecTracer} for the user-facing surface.
|
|
11
|
+
# @api private
|
|
12
|
+
module Reporters
|
|
13
|
+
# Machine-readable summary of a tracer run. Writes
|
|
14
|
+
# `<report_dir>/report.json` containing a stable, schema-versioned
|
|
15
|
+
# envelope around the 5 report types that 1.x's HTML reporter
|
|
16
|
+
# surfaced (All, Duplicate, Flaky, Examples Dependency, Files
|
|
17
|
+
# Dependency) plus a run summary block.
|
|
18
|
+
#
|
|
19
|
+
# Payload shape lives in `PayloadBuilder` (shared with
|
|
20
|
+
# `HtmlReporter` so the two never drift). Schema contract docs for
|
|
21
|
+
# version 1:
|
|
22
|
+
#
|
|
23
|
+
# {
|
|
24
|
+
# "schema_version": 1,
|
|
25
|
+
# "run_id": <hex md5 of sorted example ids>,
|
|
26
|
+
# "generated_at": <ISO-8601 UTC timestamp at emit time>,
|
|
27
|
+
# "summary": {
|
|
28
|
+
# "total_examples": <Integer>,
|
|
29
|
+
# "passed_examples": <Integer>,
|
|
30
|
+
# "failed_examples": <Integer>,
|
|
31
|
+
# "pending_examples": <Integer>,
|
|
32
|
+
# "skipped_examples": <Integer>,
|
|
33
|
+
# "interrupted_examples": <Integer>,
|
|
34
|
+
# "flaky_examples": <Integer>,
|
|
35
|
+
# "duplicate_examples": <Integer>,
|
|
36
|
+
# "tracked_env_keys": <Integer>,
|
|
37
|
+
# "run_time": <Float|null>,
|
|
38
|
+
# "started_at": <ISO-8601|null>,
|
|
39
|
+
# "pid": <Integer|null>,
|
|
40
|
+
# "parallel_tests": <Boolean>
|
|
41
|
+
# },
|
|
42
|
+
# "reports": {
|
|
43
|
+
# "all_examples": [ {id, description, location, status, run_reason, execution_result}, ... ],
|
|
44
|
+
# "duplicate_examples": [ {id, count, entries: [{description, location}, ...]}, ... ],
|
|
45
|
+
# "flaky_examples": [ {id, description, location}, ... ],
|
|
46
|
+
# "examples_dependency": [ {example_id, files: [...], env_keys: [...]}, ... ],
|
|
47
|
+
# "files_dependency": [ {file_name, example_count, spec_files: {path => count}}, ... ]
|
|
48
|
+
# }
|
|
49
|
+
# }
|
|
50
|
+
#
|
|
51
|
+
# Breaking schema changes bump `SCHEMA_VERSION`. Additive fields
|
|
52
|
+
# (new keys in an existing object) are NOT breaking. Removed or
|
|
53
|
+
# renamed keys ARE. Downstream consumers (HTML reporter, user CI
|
|
54
|
+
# dashboards) should branch on the top-level version.
|
|
55
|
+
class JsonReporter < Base
|
|
56
|
+
# Internal constant.
|
|
57
|
+
# @api private
|
|
58
|
+
SCHEMA_VERSION = PayloadBuilder::SCHEMA_VERSION
|
|
59
|
+
# Internal constant.
|
|
60
|
+
# @api private
|
|
61
|
+
FILENAME = 'report.json'
|
|
62
|
+
|
|
63
|
+
# Concrete implementation of {RSpecTracer::Reporters::Base#generate}.
|
|
64
|
+
# Serializes the canonical payload via {PayloadBuilder} and writes
|
|
65
|
+
# `report.json` under {#report_dir}.
|
|
66
|
+
#
|
|
67
|
+
# @return [String, nil] absolute path of the written file, or nil
|
|
68
|
+
# when the run had no examples to report.
|
|
69
|
+
def generate
|
|
70
|
+
return nil if no_op?
|
|
71
|
+
|
|
72
|
+
FileUtils.mkdir_p(report_dir)
|
|
73
|
+
path = File.join(report_dir, FILENAME)
|
|
74
|
+
File.write(path, JSON.pretty_generate(build_payload), encoding: 'UTF-8')
|
|
75
|
+
logger&.debug("rspec-tracer: wrote report JSON to #{path}")
|
|
76
|
+
path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Internal method on the tracer pipeline.
|
|
82
|
+
# @api private
|
|
83
|
+
def build_payload
|
|
84
|
+
PayloadBuilder.build(snapshot: snapshot, run_metadata: run_metadata)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Reporters — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Reporters
|
|
9
|
+
# Builds the canonical reporter payload (schema v1) from a
|
|
10
|
+
# finalized `Storage::Snapshot` plus a `run_metadata` Hash. Shared
|
|
11
|
+
# by `JsonReporter` (which `JSON.pretty_generate`s the output) and
|
|
12
|
+
# `HtmlReporter` (which embeds it in a `<script id="report-data">`
|
|
13
|
+
# tag). One source of truth for the 5-report shape so JSON and
|
|
14
|
+
# HTML never drift.
|
|
15
|
+
#
|
|
16
|
+
# `generated_at` is accepted as a kwarg so callers (especially the
|
|
17
|
+
# golden-file spec for HtmlReporter) can stub it for deterministic
|
|
18
|
+
# output; defaults to `Time.now.utc` to preserve JsonReporter's
|
|
19
|
+
# 1.x-era emit-time semantics.
|
|
20
|
+
#
|
|
21
|
+
# Schema changes are additive by default: new keys on an existing
|
|
22
|
+
# object are non-breaking and do NOT bump `SCHEMA_VERSION`.
|
|
23
|
+
# Removed or renamed keys bump `SCHEMA_VERSION` and require a
|
|
24
|
+
# downstream coordination pass.
|
|
25
|
+
class PayloadBuilder
|
|
26
|
+
# Internal constant.
|
|
27
|
+
# @api private
|
|
28
|
+
SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
# Internal helper for the tracer pipeline.
|
|
31
|
+
# @api private
|
|
32
|
+
def self.build(snapshot:, run_metadata:, generated_at: nil)
|
|
33
|
+
new(snapshot: snapshot, run_metadata: run_metadata, generated_at: generated_at).build
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Internal method on the tracer pipeline.
|
|
37
|
+
# @api private
|
|
38
|
+
def initialize(snapshot:, run_metadata:, generated_at: nil)
|
|
39
|
+
@snapshot = snapshot
|
|
40
|
+
@run_metadata = run_metadata || {}
|
|
41
|
+
@generated_at = generated_at || ::Time.now.utc
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Internal method on the tracer pipeline.
|
|
45
|
+
# @api private
|
|
46
|
+
def build
|
|
47
|
+
{
|
|
48
|
+
schema_version: SCHEMA_VERSION,
|
|
49
|
+
run_id: @snapshot.run_id,
|
|
50
|
+
generated_at: stringify_time(@generated_at),
|
|
51
|
+
summary: summary_block,
|
|
52
|
+
reports: {
|
|
53
|
+
all_examples: all_examples_report,
|
|
54
|
+
duplicate_examples: duplicate_examples_report,
|
|
55
|
+
flaky_examples: flaky_examples_report,
|
|
56
|
+
examples_dependency: examples_dependency_report,
|
|
57
|
+
files_dependency: files_dependency_report
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Internal method on the tracer pipeline.
|
|
65
|
+
# @api private
|
|
66
|
+
def summary_block
|
|
67
|
+
{
|
|
68
|
+
total_examples: @snapshot.all_examples.size,
|
|
69
|
+
passed_examples: count_status(:passed),
|
|
70
|
+
failed_examples: @snapshot.failed_examples.size,
|
|
71
|
+
pending_examples: @snapshot.pending_examples.size,
|
|
72
|
+
skipped_examples: @snapshot.skipped_examples.size,
|
|
73
|
+
interrupted_examples: @snapshot.interrupted_examples.size,
|
|
74
|
+
flaky_examples: @snapshot.flaky_examples.size,
|
|
75
|
+
duplicate_examples: @snapshot.duplicate_examples.size,
|
|
76
|
+
tracked_env_keys: (@snapshot.env_snapshot || {}).size,
|
|
77
|
+
run_time: @run_metadata[:run_time],
|
|
78
|
+
started_at: stringify_time(@run_metadata[:started_at]),
|
|
79
|
+
pid: @run_metadata[:pid],
|
|
80
|
+
parallel_tests: @run_metadata[:parallel_tests] == true
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Internal method on the tracer pipeline.
|
|
85
|
+
# @api private
|
|
86
|
+
def count_status(status)
|
|
87
|
+
status_str = status.to_s
|
|
88
|
+
@snapshot.all_examples.count do |_, meta|
|
|
89
|
+
next false unless meta.is_a?(::Hash)
|
|
90
|
+
|
|
91
|
+
result = meta[:execution_result] || meta['execution_result']
|
|
92
|
+
next false unless result.is_a?(::Hash)
|
|
93
|
+
|
|
94
|
+
(result[:status] || result['status']).to_s == status_str
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Internal method on the tracer pipeline.
|
|
99
|
+
# @api private
|
|
100
|
+
def all_examples_report
|
|
101
|
+
@snapshot.all_examples.map do |id, meta|
|
|
102
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
103
|
+
{
|
|
104
|
+
id: id,
|
|
105
|
+
description: meta[:full_description] || meta[:description],
|
|
106
|
+
location: location_for(meta),
|
|
107
|
+
status: status_for(id, meta),
|
|
108
|
+
run_reason: meta[:run_reason],
|
|
109
|
+
execution_result: normalize_execution_result(meta[:execution_result])
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Internal method on the tracer pipeline.
|
|
115
|
+
# @api private
|
|
116
|
+
def duplicate_examples_report
|
|
117
|
+
@snapshot.duplicate_examples.map do |id, entries|
|
|
118
|
+
list = entries.is_a?(::Array) ? entries : []
|
|
119
|
+
{
|
|
120
|
+
id: id,
|
|
121
|
+
count: list.size,
|
|
122
|
+
entries: list.map do |entry|
|
|
123
|
+
entry = {} unless entry.is_a?(::Hash)
|
|
124
|
+
{ description: entry[:full_description] || entry[:description], location: location_for(entry) }
|
|
125
|
+
end
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Internal method on the tracer pipeline.
|
|
131
|
+
# @api private
|
|
132
|
+
def flaky_examples_report
|
|
133
|
+
@snapshot.flaky_examples.to_a.sort.map do |id|
|
|
134
|
+
meta = @snapshot.all_examples[id]
|
|
135
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
136
|
+
{ id: id, description: meta[:full_description] || meta[:description], location: location_for(meta) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Internal method on the tracer pipeline.
|
|
141
|
+
# @api private
|
|
142
|
+
def examples_dependency_report
|
|
143
|
+
env_map = @snapshot.env_dependency || {}
|
|
144
|
+
@snapshot.dependency.keys.sort.map do |id|
|
|
145
|
+
files = @snapshot.dependency[id]
|
|
146
|
+
files_array = files.is_a?(::Set) ? files.to_a : Array(files)
|
|
147
|
+
{
|
|
148
|
+
example_id: id,
|
|
149
|
+
files: files_array.sort,
|
|
150
|
+
env_keys: Array(env_map[id]).sort
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Internal method on the tracer pipeline.
|
|
156
|
+
# @api private
|
|
157
|
+
def files_dependency_report
|
|
158
|
+
entries = @snapshot.reverse_dependency.map do |file_name, example_ids|
|
|
159
|
+
ids_array = example_ids.is_a?(::Set) ? example_ids.to_a : Array(example_ids)
|
|
160
|
+
spec_counts = aggregate_spec_counts(ids_array)
|
|
161
|
+
{
|
|
162
|
+
file_name: file_name,
|
|
163
|
+
example_count: ids_array.size,
|
|
164
|
+
spec_files: spec_counts
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
entries.sort_by { |e| [-e[:example_count], e[:file_name].to_s] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Internal method on the tracer pipeline.
|
|
171
|
+
# @api private
|
|
172
|
+
def aggregate_spec_counts(example_ids)
|
|
173
|
+
counts = ::Hash.new(0)
|
|
174
|
+
example_ids.each do |id|
|
|
175
|
+
meta = @snapshot.all_examples[id]
|
|
176
|
+
next unless meta.is_a?(::Hash)
|
|
177
|
+
|
|
178
|
+
spec = meta[:rerun_file_name] || meta[:file_name]
|
|
179
|
+
next if spec.nil? || spec.to_s.empty?
|
|
180
|
+
|
|
181
|
+
counts[spec.to_s] += 1
|
|
182
|
+
end
|
|
183
|
+
counts.sort_by { |name, count| [-count, name] }.to_h
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Internal method on the tracer pipeline.
|
|
187
|
+
# @api private
|
|
188
|
+
def location_for(meta)
|
|
189
|
+
file = meta[:rerun_file_name] || meta[:file_name]
|
|
190
|
+
line = meta[:rerun_line_number] || meta[:line_number]
|
|
191
|
+
return nil if file.nil?
|
|
192
|
+
|
|
193
|
+
trimmed = file.to_s.sub(%r{^/}, '')
|
|
194
|
+
line.nil? ? trimmed : "#{trimmed}:#{line}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Ordering: interrupted > flaky > failed > pending > skipped >
|
|
198
|
+
# execution_result.status. Matches 1.x HTML reporter's status
|
|
199
|
+
# precedence so downstream consumers see the same labels.
|
|
200
|
+
def status_for(id, meta)
|
|
201
|
+
return 'interrupted' if @snapshot.interrupted_examples.include?(id)
|
|
202
|
+
return 'flaky' if @snapshot.flaky_examples.include?(id)
|
|
203
|
+
return 'failed' if @snapshot.failed_examples.include?(id)
|
|
204
|
+
return 'pending' if @snapshot.pending_examples.include?(id)
|
|
205
|
+
return 'skipped' if @snapshot.skipped_examples.include?(id)
|
|
206
|
+
|
|
207
|
+
result = meta[:execution_result]
|
|
208
|
+
result.is_a?(::Hash) ? (result[:status] || 'unknown').to_s : 'unknown'
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Internal method on the tracer pipeline.
|
|
212
|
+
# @api private
|
|
213
|
+
def normalize_execution_result(result)
|
|
214
|
+
return nil unless result.is_a?(::Hash)
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
started_at: stringify_time(result[:started_at]),
|
|
218
|
+
finished_at: stringify_time(result[:finished_at]),
|
|
219
|
+
run_time: result[:run_time],
|
|
220
|
+
status: (result[:status] || 'unknown').to_s
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Internal method on the tracer pipeline.
|
|
225
|
+
# @api private
|
|
226
|
+
def stringify_time(value)
|
|
227
|
+
return nil if value.nil?
|
|
228
|
+
return value if value.is_a?(::String)
|
|
229
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
230
|
+
|
|
231
|
+
value.to_s
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|