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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. 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