raider_reporter 0.6.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5bb1cd462fbac0bd6a44635be2b0e14f6da29667e6cb4cf0a5f358aed7581be
4
- data.tar.gz: c814a3009d7e7e6ac0857bc34636ad1fdc913d3e3489ff64c3e5b62cd7f50dbb
3
+ metadata.gz: 300255531548132ae2fedcd7f10e8e9832925a204f5790ed8accb1d272ab64c6
4
+ data.tar.gz: ae60aacce4ecd140c8d771b92e8d83b704ec945a3339917d79fb7aeb50d49da9
5
5
  SHA512:
6
- metadata.gz: a1064490905e372f3392d056343cc3b8304d1de127e785d122e5ef6e3c50622873583f7ceedece6f87bbf633d8fbe4e2b94a0111f3d325ff98ba6280ba84e75c
7
- data.tar.gz: a91849038df0a28f54023e411d4115636ce97e1aa6a4ad83ec03a298f7f87e438e0b94583a6248e568c8c14af9c9176e813ddb0b287962e5ebd2021737a8748d
6
+ metadata.gz: 0213a3b50dbeeedb77ac4e335afc632bf6a25ec815e875cabd4f54bed79fb4164b23770968f38911503055bdb6bc941460f379f08c7e34238f4c379740352f8e
7
+ data.tar.gz: 6c2c8759eee7dccfb81f93095d4a5c522b6029680ba999ab56073a91a76f5b7e7524da659c9350ea22f8185e1d9c1f294a75ee53aea4ceced898c36b699e0a8d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-10-24
4
+
5
+ - Quarantine: with `RAIDER_QUARANTINE=1` (or `config.quarantine = true`) and a token and endpoint, the formatters fetch the project's quarantine list once per run (`GET /api/v1/quarantine`, 3-second timeout) and report matching failures as `quarantined`, keeping their message, backtrace and attachments. Fail-open: any fetch problem prints `[raider_reporter] quarantine list unavailable (<reason>); no tests quarantined` and nothing is quarantined
6
+ - `raider-reporter exec -- <command…>` runs the suite (no shell) and exits 0 when the command exited 1 and every failure the framework counted was quarantined, with no errors outside tests; it prints `raider-reporter: N quarantined failure(s) did not fail the build`. Otherwise it exits with the command's code (128 + signal when killed, 127 when the command cannot start, 2 without a command)
7
+ - Runs with a quarantined result are written as schema 2; every other run stays schema 1, so older Raider servers keep accepting them
8
+ - The HTML report shows quarantined results in their own section and color; `raider-reporter comment` reads `N quarantined` from the headline
9
+ - RSpec and Cucumber exit codes are unchanged without `exec`
10
+
11
+ ## 0.7.0 — 2026-10-18
12
+
13
+ - Add a local HTML report: every run now also writes `raider-results/index.html`, one self-contained page (inline CSS and JS, no network requests) built from every `run*.json` in the folder, so shards from one machine share a report. Failures come first with their message, exception class, backtrace and screenshots; a filter box narrows by test name or file. Open it in a browser after a run, or publish it as a CI artifact
14
+ - `raider-reporter report [dir]` rebuilds the report from the run files on disk (default `raider-results`); exits 1 when there are no run files or the report cannot be written
15
+ - `config.html_report = false` turns the report off. An existing `index.html` that raider_reporter did not write is never overwritten or removed; a warning is printed instead
16
+ - Fail-open: a report error prints `[raider_reporter] HTML report failed: …` and leaves `run.json`, the upload and the exit status unchanged
17
+ - No breaking changes
18
+
3
19
  ## 0.6.0 — 2026-10-14
4
20
 
5
21
  - Schema v1, additive: optional `failure.app_frame`, the first line of the raw exception backtrace that lies inside the project root (`Dir.pwd` when the reporter starts), outside `vendor/`, `.bundle/`, `Gem.path` and Ruby's library directory, and not `<internal:...>`. The path is made project-relative and the rest of the line is unchanged, e.g. `spec/pages/login_page.rb:42:in 'LoginPage#submit'`; at most 500 characters. The key is omitted when no line qualifies or computing it fails, so payloads without it are unchanged. Raider uses it to group failures by error
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # raider_reporter
2
2
 
3
- Raider-schema test reporting for RSpec and Cucumber. `raider_reporter` is an RSpec formatter and a Cucumber reporter that records every example's result, failure details, screenshots and videos, writes them to a local `run.json` (schema v1), and can optionally upload the run to a Raider server. It replaces Allure for Ruby Raider projects.
3
+ Raider-schema test reporting for RSpec and Cucumber. `raider_reporter` is an RSpec formatter and a Cucumber reporter that records every example's result, failure details, screenshots and videos, writes them to a local `run.json` (schema v1, or v2 when a run has a quarantined result), and can optionally upload the run to a Raider server. It replaces Allure for Ruby Raider projects.
4
4
 
5
5
  It has no runtime dependencies and requires Ruby 3.2 or newer.
6
6
 
@@ -66,7 +66,7 @@ cucumber --format pretty --format RaiderReporter::Cucumber::Formatter --out raid
66
66
  - `identifier` is `"<file_path>:<line>"`: the scenario line, or the Examples row line for outline rows.
67
67
  - `tags` are the feature, scenario and Examples tags, merged, sorted, unique, without `@`.
68
68
 
69
- Statuses: passed, failed and ambiguous (reported as failed), pending and undefined (reported as pending), skipped.
69
+ Statuses: passed, failed and ambiguous (reported as failed), pending and undefined (reported as pending), skipped. Failed (and ambiguous) scenarios on the quarantine list are reported as quarantined.
70
70
 
71
71
  ### Failures
72
72
 
@@ -136,19 +136,34 @@ end
136
136
  | `token` | `ENV["RAIDER_TOKEN"]` | API token used for uploads. |
137
137
  | `endpoint` | `ENV["RAIDER_ENDPOINT"]` | Base URL of the Raider server to upload to. |
138
138
  | `output_dir` | `"raider-results"` | Directory for local output. |
139
- | `clean_output_dir` | `true` | Remove the previous `run.json` and `attachments/` directory (`run-<n>.json` and `attachments-<n>/` when sharded) inside `output_dir` before writing. Nothing else in `output_dir` is touched. |
139
+ | `clean_output_dir` | `true` | Remove the previous `run.json` and `attachments/` directory (`run-<n>.json` and `attachments-<n>/` when sharded) inside `output_dir` before writing. Nothing else in `output_dir` is touched, except a raider_reporter-written `index.html`. |
140
+ | `html_report` | `true` | Write `index.html`, the local HTML report, into `output_dir` after each run. See [Local report](#local-report). |
141
+ | `quarantine` | `true` when `ENV["RAIDER_QUARANTINE"]` is `1` or `true` | Fetch the quarantine list at run start and report matching failures as `quarantined`. Needs `token` and `endpoint`. See [Quarantine](#quarantine). |
140
142
  | `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
141
143
 
142
144
  ## Local output
143
145
 
144
146
  ```
145
147
  raider-results/
146
- run.json # the run in schema v1
148
+ run.json # the run in schema v1 (v2 with quarantined results)
147
149
  attachments/ # copies of attached screenshots and videos
150
+ index.html # the local report, see below
148
151
  ```
149
152
 
150
153
  Add `raider-results/` to your `.gitignore`. When a run is sharded, the files are named per shard; see [Parallel runs](#parallel-runs).
151
154
 
155
+ ## Local report
156
+
157
+ Every run also writes `raider-results/index.html`: one self-contained page (inline CSS and JS, no network requests, so it works offline) built from every `run*.json` in the folder. It shows totals and pass rate, then failures first with message, exception class, backtrace and screenshots, followed by the other statuses; a filter box narrows by test name or file. Open it in a browser or publish it as a CI artifact.
158
+
159
+ Rebuild it from the run files on disk, for example after `parallel_rspec` has written one file per shard:
160
+
161
+ ```
162
+ raider-reporter report [dir] # default: raider-results; prints the path, exits 1 when there are no run files or the report cannot be written
163
+ ```
164
+
165
+ Set `config.html_report = false` to turn it off. An `index.html` that raider_reporter did not write is never overwritten or removed; a warning is printed instead.
166
+
152
167
  ## Uploading to Raider
153
168
 
154
169
  If both a token and an endpoint are set, the run (plus attachments) is uploaded when the suite finishes:
@@ -165,6 +180,24 @@ Reporting is fail-open: no error inside the gem, network failure, timeout or non
165
180
 
166
181
  The hosted Raider service is not public yet; uploads go to a Raider server whose address you provide in `RAIDER_ENDPOINT`.
167
182
 
183
+ ## Quarantine
184
+
185
+ A test quarantined in Raider (with a reason and an expiry) still runs and is still reported, but its failure is reported as `quarantined`, and a CI build stays green when quarantined tests are its only failures.
186
+
187
+ 1. Turn it on in CI with `RAIDER_QUARANTINE=1` (or `config.quarantine = true`), next to `RAIDER_TOKEN` and `RAIDER_ENDPOINT`.
188
+ 2. Run the suite through `raider-reporter exec`:
189
+
190
+ ```
191
+ bundle exec raider-reporter exec -- bundle exec rspec
192
+ bundle exec raider-reporter exec -- bundle exec cucumber
193
+ ```
194
+
195
+ When the run starts, the formatter fetches the project's quarantine list once (`GET /api/v1/quarantine`, 3-second timeout). On any error it prints `[raider_reporter] quarantine list unavailable (<reason>); no tests quarantined` and nothing is quarantined. Matching failures keep their message, backtrace and screenshots; the run is written as schema 2, and `run.json`, the upload and the HTML report show `quarantined`.
196
+
197
+ `exec` runs the command without a shell and exits with its status, with one exception: when the command exits 1, every failure the framework counted is quarantined, and nothing went wrong outside tests, it prints `raider-reporter: N quarantined failure(s) did not fail the build` to stderr and exits 0. "Outside tests" means RSpec errors outside examples, a run stopped early (`--fail-fast`, an interrupt, a load error), or a Cucumber run that did not finish every scenario. RSpec `--fail-fast` counts even when the last example triggers it, and interrupts count too, so the build stays red. Under Cucumber `--strict`, a flaky scenario (failed, then passed on retry) counts as a real failure and keeps the build red. A command killed by a signal exits 128 + the signal number; a command that cannot be started exits 127. Several processes under one `exec` (for example `parallel_rspec`) never turn red into green: their summaries conflict and the command's exit code is used.
198
+
199
+ Without `exec`, RSpec and Cucumber exit codes are unchanged; quarantine only changes how failures are reported.
200
+
168
201
  ## Pull request comments
169
202
 
170
203
  On pull requests, `raider-reporter comment` posts one comment with the build's results (and sets a `raider/tests` commit status on the PR head commit). Run it in a job that runs after your test jobs, so every shard has uploaded. Use the workflow's `GITHUB_TOKEN` (or a GitHub App token): a personal access token posts as a user, so the step adds a new comment on every run instead of updating the existing one.
@@ -305,7 +338,7 @@ Tip: run `rm -rf raider-results` before a parallel run. Each process only cleans
305
338
 
306
339
  ## Schema
307
340
 
308
- `run.json` follows schema v1, documented in [docs/schema-v1.md](docs/schema-v1.md). Statuses are exactly `passed`, `failed`, `pending` and `skipped`. In both RSpec and Cucumber, a failure may carry `app_frame` (when a project frame is found): the first backtrace line inside your project, for example `spec/pages/login_page.rb:42:in 'LoginPage#submit'`, which Raider uses to group failures with the same error.
341
+ `run.json` follows schema v1, documented in [docs/schema-v1.md](docs/schema-v1.md). Statuses are `passed`, `failed`, `pending` and `skipped`, plus `quarantined` in schema 2 (written only when a run has a quarantined result). In both RSpec and Cucumber, a failure may carry `app_frame` (when a project frame is found): the first backtrace line inside your project, for example `spec/pages/login_page.rb:42:in 'LoginPage#submit'`, which Raider uses to group failures with the same error.
309
342
 
310
343
  ## Contributing
311
344
 
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "raider_reporter"
4
+ require "raider_reporter/exec"
4
5
 
5
6
  module RaiderReporter
6
- # `raider-reporter comment`: posts the build summary as a PR comment and sets a commit status.
7
- # Fail-open: #run always returns 0, notices go to stderr, nothing goes to stdout.
7
+ # `raider-reporter exec -- <command…>` runs the test suite and decides the build's exit code (see Exec).
8
+ # `raider-reporter comment` posts the build summary as a PR comment and sets a commit status
9
+ # (fail-open: always exits 0, notices go to stderr). `raider-reporter report [dir]` rebuilds the
10
+ # local HTML report (exits 1 when there are no run files or the report cannot be written).
8
11
  class CLI
9
12
  MARKER = "<!-- raider-reporter:build-comment -->"
10
13
  CONTEXT = "raider/tests"
@@ -23,6 +26,12 @@ module RaiderReporter
23
26
  }.freeze
24
27
  DEFAULT_API_URL = "https://api.github.com"
25
28
  USAGE = <<~TEXT
29
+ #{Exec::USAGE.chomp}
30
+
31
+ Usage: raider-reporter report [dir]
32
+
33
+ Rebuilds <dir>/index.html (default raider-results) from every run*.json in it. Prints its path.
34
+
26
35
  Usage: raider-reporter comment
27
36
 
28
37
  Posts (or updates) the Raider build summary on the current pull request and sets a status.
@@ -43,6 +52,22 @@ module RaiderReporter
43
52
  end
44
53
 
45
54
  def run
55
+ return run_exec if @argv.first == "exec"
56
+
57
+ run_reporting
58
+ end
59
+
60
+ private
61
+
62
+ # exec decides the build's exit code; Exec owns its own failure handling.
63
+ def run_exec
64
+ command = @argv[1] == "--" ? @argv.drop(2) : []
65
+ Exec.new(command, err: @err).run
66
+ end
67
+
68
+ def run_reporting
69
+ return report if @argv.first == "report" && !@argv.include?("--help") && !@argv.include?("-h")
70
+
46
71
  command = @argv.first
47
72
  if command == "comment" && !@argv.include?("--help") && !@argv.include?("-h")
48
73
  comment
@@ -55,7 +80,19 @@ module RaiderReporter
55
80
  0
56
81
  end
57
82
 
58
- private
83
+ def report
84
+ dir = File.expand_path(@argv[1] || "raider-results")
85
+ path = Writers::Html.new.write(dir)
86
+ unless path
87
+ @err.puts("[raider_reporter] no run*.json in #{dir}")
88
+ return 1
89
+ end
90
+ @out.puts(path)
91
+ 0
92
+ rescue StandardError => e
93
+ @err.puts("[raider_reporter] report failed: #{e.class}: #{e.message}")
94
+ 1
95
+ end
59
96
 
60
97
  def comment
61
98
  if @env["GITHUB_ACTIONS"] == "true"
@@ -160,7 +197,7 @@ module RaiderReporter
160
197
  url.empty? ? DEFAULT_API_URL : url
161
198
  end
162
199
 
163
- # "**Raider: failed** - 2 failed, 148 passed, 3 skipped (1m 12s)" -> "2 failed, 148 passed, 3 skipped".
200
+ # "**Raider: failed** - 2 failed, 1 quarantined, 148 passed (1m 12s)" -> "2 failed, 1 quarantined, 148 passed".
164
201
  # Falls back to a fixed text per state when the Markdown carries no headline counts.
165
202
  def description(summary)
166
203
  counts = headline_counts(summary.markdown)
@@ -172,7 +209,7 @@ module RaiderReporter
172
209
  line = markdown.to_s.lines.find { |l| l.include?("Raider:") }
173
210
  return nil unless line
174
211
 
175
- counts = line.scan(/\b\d+ (?:failed|passed|skipped|pending)\b/)
212
+ counts = line.scan(/\b\d+ (?:failed|quarantined|passed|skipped|pending)\b/)
176
213
  counts.empty? ? nil : counts.join(", ")
177
214
  end
178
215
 
@@ -3,15 +3,19 @@
3
3
  require "securerandom"
4
4
  require "tmpdir"
5
5
  require "fileutils"
6
+ require "set"
6
7
 
7
8
  module RaiderReporter
8
9
  # Accumulates one suite run. Framework formatters call it; writers read the Run it returns.
9
10
  class Collector
10
- def initialize(max_attachment_bytes:, warn:, clock: -> { Time.now.utc }, uuid: -> { SecureRandom.uuid })
11
+ def initialize(max_attachment_bytes:, warn:, clock: -> { Time.now.utc }, uuid: -> { SecureRandom.uuid },
12
+ quarantine: -> { Quarantine::EMPTY })
11
13
  @max_attachment_bytes = max_attachment_bytes
12
14
  @warn = warn
13
15
  @clock = clock
14
16
  @uuid = uuid
17
+ @quarantine = quarantine
18
+ @quarantined = nil
15
19
  @results = []
16
20
  @pending_attachments = nil
17
21
  @staged = 0
@@ -20,6 +24,26 @@ module RaiderReporter
20
24
  def start_run
21
25
  @started_at = @clock.call
22
26
  @results = []
27
+ @quarantined = load_quarantine
28
+ end
29
+
30
+ # True when the test's key is in the list loaded at run start.
31
+ def quarantined?(file_path, full_name)
32
+ return false if @quarantined.nil? || @quarantined.empty?
33
+
34
+ @quarantined.include?(RaiderReporter.test_key(file_path, full_name))
35
+ end
36
+
37
+ attr_reader :framework_counts
38
+
39
+ # What the framework itself counted (spec §4.4); nil until it reports, and
40
+ # then no exec summary is written.
41
+ def record_framework_counts(failures:, errors_outside_tests:)
42
+ @framework_counts = { failures: Integer(failures), errors_outside_tests: Integer(errors_outside_tests) }
43
+ end
44
+
45
+ def quarantined_count
46
+ @results.count { |result| result.status == "quarantined" }
23
47
  end
24
48
 
25
49
  def start_test
@@ -81,6 +105,13 @@ module RaiderReporter
81
105
 
82
106
  private
83
107
 
108
+ def load_quarantine
109
+ @quarantine.call
110
+ rescue StandardError => e
111
+ @warn.call(Quarantine.warning("#{e.class}: #{e.message}"))
112
+ Quarantine::EMPTY
113
+ end
114
+
84
115
  def keep?(attachment)
85
116
  path = attachment.source_path
86
117
  unless File.file?(path)
@@ -4,21 +4,32 @@ require "raider_reporter/azure_env"
4
4
 
5
5
  module RaiderReporter
6
6
  class Configuration
7
- attr_accessor :token, :endpoint, :output_dir, :clean_output_dir, :environment, :max_attachment_bytes
7
+ TRUTHY = %w[1 true].freeze
8
+
9
+ attr_accessor :token, :endpoint, :output_dir, :clean_output_dir, :html_report, :environment, :max_attachment_bytes,
10
+ :quarantine, :exec_summary_path
8
11
 
9
12
  def initialize(env = ENV)
10
13
  @token = AzureEnv.value(env, "RAIDER_TOKEN")
11
14
  @endpoint = AzureEnv.value(env, "RAIDER_ENDPOINT")
12
15
  @output_dir = "raider-results"
13
16
  @clean_output_dir = true
17
+ @html_report = true
14
18
  @environment = {}
15
19
  @max_attachment_bytes = 5 * 1024 * 1024
20
+ @quarantine = TRUTHY.include?(AzureEnv.value(env, "RAIDER_QUARANTINE").to_s.downcase)
21
+ @exec_summary_path = AzureEnv.value(env, "RAIDER_EXEC_SUMMARY")
16
22
  end
17
23
 
18
24
  def upload?
19
25
  present?(token) && present?(endpoint)
20
26
  end
21
27
 
28
+ # Quarantine needs the server: on, with both token and endpoint (spec §4.1).
29
+ def quarantine?
30
+ quarantine == true && upload?
31
+ end
32
+
22
33
  private
23
34
 
24
35
  def present?(value)
@@ -84,6 +84,10 @@ module RaiderReporter
84
84
  @buffer = {}
85
85
  @attempts = Hash.new(0)
86
86
  @attempt = nil
87
+ @outcomes = {}
88
+ @failed_before = {}
89
+ @outcomes_broken = false
90
+ @expected_cases = nil
87
91
  guard { setup(config) if self.class.claim(config) }
88
92
  end
89
93
 
@@ -95,6 +99,8 @@ module RaiderReporter
95
99
  @collector = RaiderReporter.start_collector!
96
100
  RaiderReporter.dry_run = config.dry_run?
97
101
  config.on_event(:test_run_started) { |_event| guard { @collector.start_run } }
102
+ config.on_event(:test_run_started) { |event| guard { expect_cases(event.test_cases) } }
103
+ config.on_event(:test_case_finished) { |event| count_outcome(event.test_case, event.result) }
98
104
  config.on_event(:test_case_started) { |event| guard { case_started(event.test_case) } }
99
105
  config.on_event(:test_step_finished) { |event| guard { step_finished(event.test_step, event.result) } }
100
106
  config.on_event(:test_case_finished) { |event| guard { case_finished(event.test_case, event.result) } }
@@ -124,6 +130,7 @@ module RaiderReporter
124
130
  # Always buffer: a lookup problem may lose detail, never the scenario.
125
131
  failure = status == "failed" ? safely(nil) { failure_for(problem, result, test_case) } : nil
126
132
  fields = identity_for(test_case)
133
+ status = "quarantined" if status == "failed" && quarantined?(fields)
127
134
  key = "#{test_case.location}|#{test_case.name}"
128
135
  @attempts[key] += 1
129
136
  # The newest attempt replaces the older one, taking its attachments with it.
@@ -135,11 +142,42 @@ module RaiderReporter
135
142
  def run_finished
136
143
  @buffer.each_value { |result| @collector.add_result(result) }
137
144
  @buffer.clear
145
+ safely(nil) { record_framework_counts }
138
146
  RaiderReporter.finish!($stderr)
139
147
  ensure
140
148
  self.class.release(@config)
141
149
  end
142
150
 
151
+ def expect_cases(test_cases)
152
+ @expected_cases = test_cases.map { |test_case| "#{test_case.location}|#{test_case.name}" }.uniq.size
153
+ end
154
+
155
+ # Its own handler, registered before case_finished: if counting ever fails, no
156
+ # summary is written and exec keeps cucumber's exit code. The newest attempt wins,
157
+ # except that under strict flaky (--strict, --strict-flaky) a scenario that failed
158
+ # on an earlier attempt and then passed still fails cucumber's run.
159
+ def count_outcome(test_case, result)
160
+ return if @outcomes_broken
161
+
162
+ key = "#{test_case.location}|#{test_case.name}"
163
+ failed = !result.ok?(strict: @config.strict)
164
+ flaky = !failed && @failed_before[key] && @config.strict.strict?(:flaky)
165
+ @failed_before[key] ||= failed
166
+ @outcomes[key] = failed || flaky ? true : false
167
+ rescue StandardError => e
168
+ @outcomes_broken = true
169
+ RaiderReporter.warn("#{e.class}: #{e.message}")
170
+ end
171
+
172
+ # Scenarios that make cucumber's run fail; one error outside tests when the run
173
+ # stopped before every scenario finished (--fail-fast) (spec §4.4).
174
+ def record_framework_counts
175
+ return if @outcomes_broken || @expected_cases.nil?
176
+
177
+ aborted = @outcomes.size < @expected_cases ? 1 : 0
178
+ @collector.record_framework_counts(failures: @outcomes.values.count(true), errors_outside_tests: aborted)
179
+ end
180
+
143
181
  def identity_for(test_case)
144
182
  file_path = relative_path(test_case.location.file)
145
183
  line_number = test_case.location.lines.max
@@ -152,6 +190,11 @@ module RaiderReporter
152
190
  }
153
191
  end
154
192
 
193
+ # A lookup problem must never turn a real failure into a quarantined one.
194
+ def quarantined?(fields)
195
+ safely(false) { @collector.quarantined?(fields[:file_path], fields[:full_name]) }
196
+ end
197
+
155
198
  def full_name(test_case)
156
199
  safely(test_case.name) do
157
200
  feature = @ast.gherkin_document(test_case.location.file)&.feature
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "raider_reporter/exec_summary"
5
+
6
+ module RaiderReporter
7
+ # `raider-reporter exec -- <command…>` (spec §4.5). Runs the command without a
8
+ # shell, streams its output, and keeps its exit code, except that exit 1 becomes 0
9
+ # when the summary says every counted failure was quarantined and nothing else
10
+ # went wrong. Anything uncertain keeps the command's code.
11
+ class Exec
12
+ USAGE = <<~TEXT
13
+ Usage: raider-reporter exec -- <command> [args...]
14
+
15
+ Runs the command (no shell) and exits with its status. When the command exits 1, every failure it
16
+ counted was quarantined in Raider and nothing else went wrong, exits 0 instead and says so on stderr.
17
+ Quarantine needs RAIDER_QUARANTINE=1 with RAIDER_TOKEN and RAIDER_ENDPOINT.
18
+ TEXT
19
+ USAGE_ERROR = 2
20
+ NOT_STARTED = 127
21
+ # A terminal Ctrl-C already reaches the command through the process group.
22
+ FORWARDED = %w[TERM HUP].freeze
23
+ IGNORED = %w[INT].freeze
24
+
25
+ def self.quarantined_only?(summary)
26
+ return false unless summary
27
+
28
+ failures = summary["framework_failures"]
29
+ failures.positive? && failures == summary["quarantined"] && summary["errors_outside_tests"].zero?
30
+ end
31
+
32
+ def initialize(command, err: $stderr)
33
+ @command = command
34
+ @err = err
35
+ @pid = nil
36
+ @code = nil
37
+ @pending = nil
38
+ end
39
+
40
+ def run
41
+ if @command.empty?
42
+ @err.puts(USAGE)
43
+ return USAGE_ERROR
44
+ end
45
+
46
+ Dir.mktmpdir("raider-exec") { |dir| run_with(File.join(dir, "summary.json")) }
47
+ rescue StandardError => e
48
+ @err.puts("[raider_reporter] exec failed: #{e.class}: #{e.message}")
49
+ @code || 1
50
+ end
51
+
52
+ private
53
+
54
+ def run_with(summary_path)
55
+ previous = install_traps
56
+ @pid = start(summary_path)
57
+ return NOT_STARTED unless @pid
58
+
59
+ forward(@pending) if @pending
60
+
61
+ @code = wait
62
+ summary = ExecSummary.read(summary_path)
63
+ return @code unless @code == 1 && self.class.quarantined_only?(summary)
64
+
65
+ @err.puts("raider-reporter: #{summary['quarantined']} quarantined failure(s) did not fail the build")
66
+ 0
67
+ ensure
68
+ previous&.each { |signal, handler| trap(signal, handler || "DEFAULT") }
69
+ end
70
+
71
+ def start(summary_path)
72
+ program, *args = @command
73
+ Process.spawn({ ExecSummary::ENV_NAME => summary_path }, [program, program], *args)
74
+ rescue SystemCallError => e
75
+ @err.puts("[raider_reporter] exec: cannot run #{program}: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ def wait
80
+ _, status = Process.wait2(@pid)
81
+ status.signaled? ? 128 + status.termsig : status.exitstatus
82
+ end
83
+
84
+ def forward(signal)
85
+ Process.kill(signal, @pid)
86
+ rescue SystemCallError
87
+ nil
88
+ end
89
+
90
+ # Installed before spawning; a signal that arrives before the child exists is kept in
91
+ # @pending and forwarded right after spawn. A Ruby handler (not "IGNORE") is reset to
92
+ # the default in the child by exec(2).
93
+ def install_traps
94
+ forwarded = FORWARDED.to_h do |signal|
95
+ handler = trap(signal) { @pid ? forward(signal) : @pending = signal }
96
+ [signal, handler]
97
+ end
98
+ forwarded.merge(IGNORED.to_h { |signal| [signal, trap(signal) {}] })
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RaiderReporter
6
+ # The file `raider-reporter exec` reads to decide its exit code (spec §4.4). One
7
+ # process writes it. A second writer (several processes under one exec) turns it
8
+ # into a conflict, which exec treats as no summary, so the command's code wins.
9
+ module ExecSummary
10
+ ENV_NAME = "RAIDER_EXEC_SUMMARY"
11
+ KEYS = %w[framework_failures quarantined errors_outside_tests].freeze
12
+ CONFLICT = { "conflict" => true }.freeze
13
+
14
+ module_function
15
+
16
+ def write(path, framework_failures:, quarantined:, errors_outside_tests:, pid: Process.pid)
17
+ summary = { "framework_failures" => framework_failures, "quarantined" => quarantined,
18
+ "errors_outside_tests" => errors_outside_tests, "pid" => pid }
19
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
20
+ file.flock(File::LOCK_EX)
21
+ existing = file.read
22
+ same_writer = existing.empty? || parse(existing)&.fetch("pid", nil) == pid
23
+ file.rewind
24
+ file.truncate(0)
25
+ file.write(JSON.generate(same_writer ? summary : CONFLICT))
26
+ end
27
+ end
28
+
29
+ def read(path)
30
+ data = parse(File.read(path))
31
+ return nil if data.nil? || data.key?("conflict")
32
+ return nil unless KEYS.all? { |key| data[key].is_a?(Integer) && !data[key].negative? }
33
+
34
+ data.slice(*KEYS)
35
+ rescue SystemCallError, IOError
36
+ nil
37
+ end
38
+
39
+ def parse(text)
40
+ data = JSON.parse(text)
41
+ data.is_a?(Hash) ? data : nil
42
+ rescue JSON::ParserError
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- SCHEMA_VERSION = 1
5
- STATUSES = %w[passed failed pending skipped].freeze
4
+ SCHEMA_VERSION = 2
5
+ STATUSES = %w[passed failed pending skipped quarantined].freeze
6
6
 
7
7
  Failure = Data.define(:message, :exception_class, :backtrace, :app_frame) do
8
8
  def initialize(message:, exception_class:, backtrace:, app_frame: nil)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- # Serializes a Run into schema v1. Local mode points attachments at copied
4
+ # Serializes a Run: schema 1, or schema 2 when a result is quarantined. Local mode points attachments at copied
5
5
  # files; upload mode points them at multipart part names.
6
6
  class Payload
7
7
  MODES = %i[local upload].freeze
@@ -17,7 +17,7 @@ module RaiderReporter
17
17
 
18
18
  entries = attachment_entries.each
19
19
  {
20
- "schema_version" => SCHEMA_VERSION,
20
+ "schema_version" => schema_version,
21
21
  "client_run_id" => @run.client_run_id,
22
22
  "run" => run_hash,
23
23
  "results" => @run.results.map do |result|
@@ -34,6 +34,10 @@ module RaiderReporter
34
34
 
35
35
  def run_file = shard_index ? "run-#{shard_index}.json" : "run.json"
36
36
 
37
+ # Version 2 only adds the quarantined status; older servers keep accepting
38
+ # every run that has none (spec §4.3).
39
+ def schema_version = @run.results.any? { |result| result.status == "quarantined" } ? SCHEMA_VERSION : 1
40
+
37
41
  def attachment_entries
38
42
  @attachment_entries ||= @run.results.flat_map(&:attachments).each_with_index.map do |attachment, index|
39
43
  {
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "net/http"
6
+ require "set"
7
+ require "timeout"
8
+ require "uri"
9
+
10
+ module RaiderReporter
11
+ NUL = "\u0000"
12
+ NUL_REPLACEMENT = "\u{FFFD}"
13
+
14
+ # The server's test identity: raider_saas TestCase.key_for over the payload's
15
+ # file_path and full_name, after ingest replaced NUL in each of them.
16
+ def self.test_key(file_path, full_name)
17
+ parts = [file_path, full_name].map { |part| part.to_s.tr(NUL, NUL_REPLACEMENT) }
18
+ Digest::SHA256.hexdigest(parts.join(NUL))
19
+ end
20
+
21
+ # Fetches the project's quarantine list (spec §4.2). Fail-open: any problem
22
+ # means one warning and an empty set, never an exception.
23
+ module Quarantine
24
+ PATH = "/api/v1/quarantine"
25
+ TIMEOUT = 3
26
+ EMPTY = Set.new.freeze
27
+
28
+ class Unavailable < StandardError; end
29
+
30
+ def self.load(config, warn:)
31
+ return EMPTY unless config.quarantine?
32
+
33
+ Set.new(fetch(config)).freeze
34
+ rescue StandardError => e
35
+ reason = e.is_a?(Unavailable) ? e.message : "#{e.class}: #{e.message}"
36
+ warn.call(warning(reason, token: config.token))
37
+ EMPTY
38
+ end
39
+
40
+ # The one place the fail-open warning text is built (used by load and the Collector).
41
+ def self.warning(reason, token: nil)
42
+ "quarantine list unavailable (#{redact(reason, token)}); no tests quarantined"
43
+ end
44
+
45
+ def self.fetch(config)
46
+ uri = URI.parse("#{config.endpoint.to_s.strip.chomp('/')}#{PATH}")
47
+ raise Unavailable, "RAIDER_ENDPOINT must start with http:// or https://" unless uri.is_a?(URI::HTTP)
48
+
49
+ request = Net::HTTP::Get.new(uri)
50
+ request["Authorization"] = "Bearer #{config.token}"
51
+ request["Accept"] = "application/json"
52
+ request["User-Agent"] = "raider_reporter/#{VERSION}"
53
+ # The per-step timeouts do not bound a server that trickles bytes; the deadline does.
54
+ response = Timeout.timeout(TIMEOUT) do
55
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
56
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT, max_retries: 0) { |http| http.request(request) }
57
+ end
58
+ raise Unavailable, "HTTP #{response.code}" unless response.code == "200"
59
+
60
+ keys(response.body)
61
+ end
62
+
63
+ def self.keys(body)
64
+ data = JSON.parse(body.to_s)
65
+ list = data.is_a?(Hash) ? data["quarantined"] : nil
66
+ raise Unavailable, "unexpected response" unless list.is_a?(Array)
67
+
68
+ list.filter_map { |entry| entry["test_key"] if entry.is_a?(Hash) && entry["test_key"].is_a?(String) }
69
+ rescue JSON::ParserError
70
+ raise Unavailable, "invalid JSON"
71
+ end
72
+
73
+ def self.redact(text, token)
74
+ token.to_s.empty? ? text : text.gsub(token.to_s, "[REDACTED]")
75
+ end
76
+
77
+ private_class_method :fetch, :keys, :redact
78
+ end
79
+ end
@@ -11,7 +11,7 @@ module RaiderReporter
11
11
  class Formatter
12
12
  ::RSpec::Core::Formatters.register self,
13
13
  :start, :example_started, :example_passed,
14
- :example_failed, :example_pending, :close
14
+ :example_failed, :example_pending, :dump_summary, :close
15
15
 
16
16
  NON_TAG_KEYS = %i[pending skip].freeze
17
17
  MAX_MESSAGE = Text::MAX_MESSAGE
@@ -45,6 +45,13 @@ module RaiderReporter
45
45
  end
46
46
  end
47
47
 
48
+ def dump_summary(notification)
49
+ guard do
50
+ @collector.record_framework_counts(failures: notification.failure_count,
51
+ errors_outside_tests: errors_outside_tests(notification))
52
+ end
53
+ end
54
+
48
55
  def close(_notification)
49
56
  guard { RaiderReporter.finish! }
50
57
  end
@@ -54,19 +61,32 @@ module RaiderReporter
54
61
  def record(example, status)
55
62
  metadata = example.metadata
56
63
  execution = example.execution_result
64
+ file_path = metadata[:file_path].to_s.delete_prefix("./")
65
+ full_name = utf8(example.full_description)
66
+ failure = status == "failed" ? failure_for(execution.exception) : nil
67
+ # The key is computed from the payload's own file_path and full_name (spec §4.2).
68
+ status = "quarantined" if status == "failed" && @collector.quarantined?(file_path, full_name)
57
69
  @collector.finish_test(
58
70
  identifier: example.id,
59
- full_name: utf8(example.full_description),
60
- file_path: metadata[:file_path].to_s.delete_prefix("./"),
71
+ full_name: full_name,
72
+ file_path: file_path,
61
73
  line_number: metadata[:line_number],
62
74
  status: status,
63
75
  duration_ms: ((execution.run_time || 0) * 1000).round,
64
76
  attempts: metadata.fetch(:retry_attempts, 0).to_i + 1,
65
77
  tags: metadata.select { |key, value| value == true && !NON_TAG_KEYS.include?(key) }.keys.map(&:to_s).sort,
66
- failure: status == "failed" ? failure_for(execution.exception) : nil
78
+ failure: failure
67
79
  )
68
80
  end
69
81
 
82
+ # Errors RSpec reported outside examples, plus one when it stopped early
83
+ # (--fail-fast, an interrupt, a load error): then not every example ran.
84
+ def errors_outside_tests(notification)
85
+ world = ::RSpec.world
86
+ quitting = world.wants_to_quit || (world.respond_to?(:rspec_is_quitting) && world.rspec_is_quitting)
87
+ notification.errors_outside_of_examples_count + (quitting ? 1 : 0)
88
+ end
89
+
70
90
  def failure_for(exception)
71
91
  return nil unless exception
72
92
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+
8
+ module RaiderReporter
9
+ module Writers
10
+ # Builds index.html from every run*.json in a results folder: one page with inline
11
+ # CSS and JS and no network requests, so a run can be read without Raider or Java.
12
+ class Html
13
+ class ForeignFile < StandardError; end
14
+
15
+ FILE = "index.html"
16
+ MARKER = '<meta name="generator" content="raider_reporter">'
17
+ ROW_LIMIT = 2_000
18
+ RUN_FILE = /\Arun(-[^\/]+)?\.json\z/
19
+ SECTIONS = [%w[failed Failed], %w[quarantined Quarantined], %w[passed Passed], %w[pending Pending], %w[skipped Skipped]].freeze
20
+ PROVIDERS = { "github_actions" => "GitHub Actions", "azure_pipelines" => "Azure Pipelines" }.freeze
21
+
22
+ def self.ours?(path)
23
+ File.file?(path) && File.read(path, 4096).to_s.include?(MARKER)
24
+ rescue SystemCallError
25
+ false
26
+ end
27
+
28
+ def write(dir)
29
+ dir = File.expand_path(dir)
30
+ files = run_files(dir)
31
+ return nil if files.empty?
32
+
33
+ target = File.join(dir, FILE)
34
+ if File.exist?(target) && !self.class.ours?(target)
35
+ raise ForeignFile, "#{target} exists and was not written by raider_reporter; not overwritten"
36
+ end
37
+
38
+ runs, skipped = load(files)
39
+ runs, ignored = newest_run(runs)
40
+ tmp = "#{target}.tmp-#{Process.pid}"
41
+ File.write(tmp, render(runs.map { |r| r[:data] }, skipped, ignored, project_name))
42
+ File.rename(tmp, target)
43
+ target
44
+ ensure
45
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
46
+ end
47
+
48
+ private
49
+
50
+ def run_files(dir)
51
+ return [] unless File.directory?(dir)
52
+
53
+ Dir.children(dir).grep(RUN_FILE).sort.map { |name| File.join(dir, name) }
54
+ end
55
+
56
+ def load(files)
57
+ runs = []
58
+ skipped = []
59
+ files.each do |file|
60
+ data = JSON.parse(File.read(file))
61
+ raise TypeError unless data.is_a?(Hash) && data["results"].is_a?(Array)
62
+
63
+ runs << { name: File.basename(file), data: data, mtime: File.mtime(file) }
64
+ rescue StandardError
65
+ skipped << File.basename(file)
66
+ end
67
+ [runs, skipped]
68
+ end
69
+
70
+ SHARD_WINDOW = 300
71
+
72
+ # Old files from earlier runs stay in the folder, so report only the newest run:
73
+ # its own file, or all the shard files that belong to it. Everything else is named in the footer.
74
+ def newest_run(runs)
75
+ return [runs, []] if runs.empty?
76
+
77
+ newest = runs.max_by { |r| time(meta_of(r)["finished_at"]) || r[:mtime] }
78
+ keep = shard_of(newest) ? runs.select { |r| same_run?(r, newest) } : [newest]
79
+ [keep, (runs - keep).map { |r| r[:name] }.sort]
80
+ end
81
+
82
+ def meta_of(run) = run[:data]["run"].is_a?(Hash) ? run[:data]["run"] : {}
83
+
84
+ def shard_of(run) = meta_of(run)["shard"].is_a?(Hash) ? meta_of(run)["shard"] : nil
85
+
86
+ def same_run?(run, newest)
87
+ return true if run.equal?(newest)
88
+
89
+ shard = shard_of(run)
90
+ return false unless shard
91
+
92
+ total = shard_of(newest)["total"]
93
+ return false if total && shard["total"] && shard["total"] != total
94
+
95
+ (started_at(run) - started_at(newest)).abs <= SHARD_WINDOW
96
+ end
97
+
98
+ def started_at(run) = time(meta_of(run)["started_at"]) || run[:mtime]
99
+
100
+ def project_name
101
+ name = File.basename(Dir.pwd)
102
+ name.empty? || name == "/" ? "Raider run" : name
103
+ end
104
+
105
+ def render(runs, skipped, ignored, project)
106
+ results = runs.flat_map { |run| run["results"] }.select { |r| r.is_a?(Hash) }
107
+ meta = runs.map { |run| run["run"].is_a?(Hash) ? run["run"] : {} }
108
+ counts = SECTIONS.to_h { |status, _| [status, results.count { |r| r["status"] == status }] }
109
+ counts["flaky"] = results.count { |r| r["status"] == "passed" && r["attempts"].to_i > 1 }
110
+
111
+ <<~HTML
112
+ <!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8">
116
+ <meta name="viewport" content="width=device-width, initial-scale=1">
117
+ #{MARKER}
118
+ <title>#{h(project)} · Raider report</title>
119
+ <style>#{CSS}</style>
120
+ </head>
121
+ <body>
122
+ <header>
123
+ <h1>#{h(project)}</h1>
124
+ <p class="totals">#{totals(counts)}</p>
125
+ <p class="meta">#{meta_line(meta)}</p>
126
+ </header>
127
+ <main>
128
+ #{results.empty? ? '<p class="empty">No results</p>' : filter_box}
129
+ #{SECTIONS.map { |status, label| section(status, label, results) }.join}
130
+ </main>
131
+ <footer>#{footer(skipped, ignored)}</footer>
132
+ <script>#{JS}</script>
133
+ </body>
134
+ </html>
135
+ HTML
136
+ end
137
+
138
+ def totals(counts)
139
+ passed = counts["passed"]
140
+ flaky = counts["flaky"]
141
+ decided = passed + counts["failed"]
142
+ rate = decided.zero? ? "—" : "#{(passed * 100.0 / decided).round}%"
143
+ parts = SECTIONS.filter_map do |status, _|
144
+ next if status == "quarantined" && counts[status].zero?
145
+
146
+ %(<span class="b #{status}">#{counts[status]} #{status}</span>)
147
+ end
148
+ parts << %(<span class="b flaky">#{flaky} flaky</span>) if flaky.positive?
149
+ "#{parts.join(' ')} <span class=\"rate\">Pass rate #{rate}</span>"
150
+ end
151
+
152
+ def meta_line(meta)
153
+ parts = []
154
+ duration = run_duration(meta)
155
+ parts << "Duration #{h(duration)}" if duration
156
+ git = meta.map { |m| m["git"] }.find { |g| g.is_a?(Hash) } || {}
157
+ parts << "Branch #{h(git['branch'])}" if git["branch"]
158
+ parts << "Commit #{h(git['commit_sha'].to_s[0, 7])}" if git["commit_sha"]
159
+ ci = meta.map { |m| m["ci"] }.find { |c| c.is_a?(Hash) } || {}
160
+ parts << ci_link(ci) if ci["provider"]
161
+ parts << "Generated #{h(Time.now.utc.strftime('%Y-%m-%d %H:%M UTC'))}"
162
+ parts.join(" · ")
163
+ end
164
+
165
+ def ci_link(ci)
166
+ label = h(PROVIDERS.fetch(ci["provider"], ci["provider"]))
167
+ url = ci["build_url"].to_s
168
+ url.match?(%r{\Ahttps?://}) ? %(<a href="#{h(url)}">#{label}</a>) : label
169
+ end
170
+
171
+ def run_duration(meta)
172
+ starts = meta.filter_map { |m| time(m["started_at"]) }
173
+ ends = meta.filter_map { |m| time(m["finished_at"]) }
174
+ return nil if starts.empty? || ends.empty? || ends.max < starts.min
175
+
176
+ format_ms(((ends.max - starts.min) * 1000).round)
177
+ end
178
+
179
+ def time(value)
180
+ Time.iso8601(value.to_s)
181
+ rescue ArgumentError
182
+ nil
183
+ end
184
+
185
+ def filter_box
186
+ '<input id="q" type="search" placeholder="Filter by test name or file" aria-label="Filter tests">'
187
+ end
188
+
189
+ def section(status, label, results)
190
+ rows = results.select { |r| r["status"] == status }
191
+ return "" if rows.empty?
192
+
193
+ shown = rows.first(ROW_LIMIT).map { |r| row(r) }.join
194
+ more = rows.size > ROW_LIMIT ? %(<p class="more">#{rows.size - ROW_LIMIT} more not shown</p>) : ""
195
+ open = status == "failed" ? " open" : ""
196
+ %(<details class="s"#{open}><summary>#{label} (#{rows.size})</summary>#{shown}#{more}</details>\n)
197
+ end
198
+
199
+ def row(result)
200
+ key = "#{result['full_name']} #{result['file_path']}".downcase
201
+ location = [result["file_path"], result["line_number"]].compact.join(":")
202
+ attempts = result["attempts"].to_i > 1 ? %( <span class="att">#{result['attempts'].to_i} attempts</span>) : ""
203
+ <<~ROW
204
+ <div class="r #{h(result['status'])}" data-k="#{h(key)}">
205
+ <p class="n"><span class="b #{h(result['status'])}">#{h(result['status'])}</span> #{h(result['full_name'])}</p>
206
+ <p class="l">#{h(location)} · #{h(format_ms(result['duration_ms'].to_i))}#{attempts}</p>
207
+ #{failure(result['failure'])}#{attachments(result['attachments'])}
208
+ </div>
209
+ ROW
210
+ end
211
+
212
+ def failure(failure)
213
+ return "" unless failure.is_a?(Hash)
214
+
215
+ trace = Array(failure["backtrace"]).join("\n")
216
+ backtrace = trace.empty? ? "" : "<details><summary>Backtrace</summary><pre>#{h(trace)}</pre></details>"
217
+ %(<p class="x">#{h(failure['exception_class'])}</p><pre>#{h(failure['message'])}</pre>#{backtrace})
218
+ end
219
+
220
+ def attachments(list)
221
+ items = Array(list).filter_map do |attachment|
222
+ next unless attachment.is_a?(Hash)
223
+
224
+ href = safe_href(attachment["path"])
225
+ next unless href
226
+
227
+ if attachment["content_type"].to_s.start_with?("image/")
228
+ %(<a href="#{href}"><img loading="lazy" src="#{href}" alt="#{h(attachment['name'])}"></a>)
229
+ else
230
+ %(<a href="#{href}">#{h(attachment['name'])}</a>)
231
+ end
232
+ end
233
+ items.empty? ? "" : %(<div class="a">#{items.join(' ')}</div>)
234
+ end
235
+
236
+ # Relative paths inside the results folder only, each segment URL-encoded.
237
+ def safe_href(path)
238
+ path = path.to_s
239
+ segments = path.split("/")
240
+ return nil if path.empty? || path.start_with?("/") || segments.include?("..") || path.include?("\\")
241
+
242
+ segments.map { |segment| ERB::Util.url_encode(segment) }.join("/")
243
+ end
244
+
245
+ def footer(skipped, ignored)
246
+ note = skipped.empty? ? "" : "Skipped unreadable run files: #{h(skipped.join(', '))} · "
247
+ note += "Ignored older run files: #{h(ignored.join(', '))} · " unless ignored.empty?
248
+ "#{note}Generated by raider_reporter #{h(RaiderReporter::VERSION)}"
249
+ end
250
+
251
+ def format_ms(ms)
252
+ return "0ms" if ms.negative?
253
+ return "#{ms}ms" if ms < 1000
254
+
255
+ seconds = (ms / 1000.0).round
256
+ return format("%.1fs", ms / 1000.0) if ms < 59_950
257
+
258
+ "#{seconds / 60}m #{seconds % 60}s"
259
+ end
260
+
261
+ def h(value) = ERB::Util.html_escape(value.to_s)
262
+
263
+ CSS = <<~CSS.gsub(/\s*\n\s*/, "")
264
+ :root{--bg:#fff;--fg:#1f2328;--mute:#656d76;--bdr:#d0d7de;--card:#f6f8fa;--ruby:#C14420;--ok:#1a7f37;--warn:#9a6700;--q:#6B4FA0}
265
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--mute:#8d96a0;--bdr:#30363d;--card:#161b22;--ok:#3fb950;--warn:#d29922;--q:#a78bfa}}
266
+ *{box-sizing:border-box}
267
+ body{margin:0;padding:16px;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
268
+ header,main,footer{max-width:1100px;margin:0 auto}
269
+ h1{margin:0 0 4px;font-size:20px;color:var(--ruby)}
270
+ .meta,.l,footer,.more{color:var(--mute);font-size:12px}
271
+ .b{display:inline-block;padding:0 6px;border-radius:9px;font-size:11px;font-weight:600;border:1px solid var(--bdr)}
272
+ .b.failed{background:var(--ruby);color:#fff;border-color:var(--ruby)}
273
+ .b.quarantined{color:var(--q);border-color:var(--q)}
274
+ .b.passed{color:var(--ok)}.b.pending,.b.skipped,.b.flaky{color:var(--warn)}
275
+ .rate{font-weight:600;margin-left:6px}
276
+ #q{width:100%;margin:12px 0;padding:8px;border:1px solid var(--bdr);border-radius:6px;background:var(--card);color:var(--fg)}
277
+ details.s{margin:8px 0;border:1px solid var(--bdr);border-radius:8px}
278
+ details.s>summary{padding:8px 12px;font-weight:600;cursor:pointer}
279
+ .r{padding:8px 12px;border-top:1px solid var(--bdr)}
280
+ .r p{margin:2px 0;overflow-wrap:anywhere}.n{font-weight:500}.x{color:var(--ruby);font-weight:600}
281
+ pre{margin:4px 0;padding:8px;background:var(--card);border-radius:6px;overflow-x:auto;white-space:pre-wrap;font-size:12px}
282
+ .a img{max-width:100%;max-height:240px;border:1px solid var(--bdr);border-radius:4px;margin-top:4px}
283
+ a{color:var(--ruby)}.empty{padding:24px 0;color:var(--mute)}
284
+ CSS
285
+
286
+ JS = <<~JS.gsub(/\s*\n\s*/, "")
287
+ (function(){var q=document.getElementById("q");if(!q)return;
288
+ q.addEventListener("input",function(){var v=q.value.toLowerCase();
289
+ document.querySelectorAll("details.s").forEach(function(s){var n=0;
290
+ s.querySelectorAll(".r").forEach(function(r){var hide=v!==""&&r.dataset.k.indexOf(v)<0;r.hidden=hide;if(!hide)n++;});
291
+ if(v===""){s.hidden=false;if(s.dataset.o!==undefined){s.open=s.dataset.o==="1";delete s.dataset.o;}}
292
+ else{if(s.dataset.o===undefined)s.dataset.o=s.open?"1":"0";s.hidden=n===0;s.open=n>0;}});});})();
293
+ JS
294
+ end
295
+ end
296
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require_relative "html"
5
6
 
6
7
  module RaiderReporter
7
8
  module Writers
@@ -28,6 +29,8 @@ module RaiderReporter
28
29
  def clean(dir, payload)
29
30
  FileUtils.rm_f(File.join(dir, payload.run_file))
30
31
  FileUtils.rm_rf(File.join(dir, payload.attachments_dir))
32
+ index = File.join(dir, Html::FILE)
33
+ FileUtils.rm_f(index) if Html.ours?(index)
31
34
  end
32
35
  end
33
36
  end
@@ -8,9 +8,12 @@ require_relative "raider_reporter/models"
8
8
  require_relative "raider_reporter/app_frame"
9
9
  require_relative "raider_reporter/payload"
10
10
  require_relative "raider_reporter/configuration"
11
+ require_relative "raider_reporter/quarantine"
12
+ require_relative "raider_reporter/exec_summary"
11
13
  require_relative "raider_reporter/content_types"
12
14
  require_relative "raider_reporter/collector"
13
15
  require_relative "raider_reporter/writers/local"
16
+ require_relative "raider_reporter/writers/html"
14
17
  require_relative "raider_reporter/ci_detector"
15
18
  require_relative "raider_reporter/shard_detector"
16
19
  require_relative "raider_reporter/writers/uploader"
@@ -39,6 +42,7 @@ module RaiderReporter
39
42
  @configuration = nil
40
43
  @collector = nil
41
44
  @project_root = nil
45
+ @quarantined_keys = nil
42
46
  end
43
47
 
44
48
  def start_collector!
@@ -46,10 +50,16 @@ module RaiderReporter
46
50
  @project_root = Dir.pwd
47
51
  @collector = Collector.new(
48
52
  max_attachment_bytes: configuration.max_attachment_bytes,
49
- warn: ->(message) { warn(message) }
53
+ warn: ->(message) { warn(message) },
54
+ quarantine: -> { quarantined_keys }
50
55
  )
51
56
  end
52
57
 
58
+ # Fetched once per process, at the first run start (spec §4.2).
59
+ def quarantined_keys
60
+ @quarantined_keys ||= Quarantine.load(configuration, warn: ->(message) { warn(message) })
61
+ end
62
+
53
63
  def attach(name, path, type: nil)
54
64
  unless collector&.in_test?
55
65
  warn("attach(#{name.inspect}) called outside a running example; ignored")
@@ -81,6 +91,8 @@ module RaiderReporter
81
91
  def finish!(output = $stderr)
82
92
  return nil if dry_run?
83
93
 
94
+ write_exec_summary
95
+
84
96
  detector = CiDetector.new
85
97
  ci = detector.ci
86
98
  shard = ShardDetector.new(job: ci&.fetch("job", nil)).shard
@@ -91,6 +103,7 @@ module RaiderReporter
91
103
  rescue StandardError => e
92
104
  warn("could not write #{configuration.output_dir}: #{e.class}: #{e.message}")
93
105
  end
106
+ write_html_report
94
107
  return nil unless configuration.upload?
95
108
 
96
109
  result = Writers::Uploader.new(configuration).upload(payload)
@@ -106,6 +119,27 @@ module RaiderReporter
106
119
 
107
120
  private
108
121
 
122
+ # First, so a later reporting problem cannot lose it. A failure here only means
123
+ # no summary, and exec then keeps the command's exit code.
124
+ def write_exec_summary
125
+ path = configuration.exec_summary_path
126
+ counts = collector&.framework_counts
127
+ return if path.nil? || counts.nil?
128
+
129
+ ExecSummary.write(path, framework_failures: counts[:failures], quarantined: collector.quarantined_count,
130
+ errors_outside_tests: counts[:errors_outside_tests])
131
+ rescue StandardError => e
132
+ warn("could not write the exec summary: #{e.class}: #{e.message}")
133
+ end
134
+
135
+ def write_html_report
136
+ return unless configuration.html_report
137
+
138
+ Writers::Html.new.write(configuration.output_dir)
139
+ rescue StandardError => e
140
+ warn("HTML report failed: #{e.class}: #{e.message}")
141
+ end
142
+
109
143
  def success_line(result)
110
144
  link = result.build_url || result.url
111
145
  return "Raider report: #{link}" if link
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raider_reporter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Augustin Gottlieb
@@ -36,14 +36,18 @@ files:
36
36
  - lib/raider_reporter/content_types.rb
37
37
  - lib/raider_reporter/cucumber.rb
38
38
  - lib/raider_reporter/cucumber/formatter.rb
39
+ - lib/raider_reporter/exec.rb
40
+ - lib/raider_reporter/exec_summary.rb
39
41
  - lib/raider_reporter/github_event.rb
40
42
  - lib/raider_reporter/models.rb
41
43
  - lib/raider_reporter/payload.rb
44
+ - lib/raider_reporter/quarantine.rb
42
45
  - lib/raider_reporter/rspec.rb
43
46
  - lib/raider_reporter/rspec/formatter.rb
44
47
  - lib/raider_reporter/shard_detector.rb
45
48
  - lib/raider_reporter/text.rb
46
49
  - lib/raider_reporter/version.rb
50
+ - lib/raider_reporter/writers/html.rb
47
51
  - lib/raider_reporter/writers/local.rb
48
52
  - lib/raider_reporter/writers/uploader.rb
49
53
  homepage: https://github.com/RaiderHQ/raider_reporter
@@ -67,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
71
  - !ruby/object:Gem::Version
68
72
  version: '0'
69
73
  requirements: []
70
- rubygems_version: 4.0.6
74
+ rubygems_version: 4.0.21
71
75
  specification_version: 4
72
76
  summary: RSpec and Cucumber formatters that write Raider reports locally and upload
73
77
  them to Raider