raider_reporter 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6eb732095ea2284078a27ae18b268a3fe33a128e2ec131f95999bd278cd15cd2
4
- data.tar.gz: 556f21195f523faabf7785eada01089a5537a7a4f052617152de87a0673c362b
3
+ metadata.gz: dda0a98d0886eefbfd851be91d57c881e74abee367956b0272fc9b7a464045cf
4
+ data.tar.gz: 8012de5d41e890b7dae144ab5f208c20ed2ffa94736645a4308901f6abe24174
5
5
  SHA512:
6
- metadata.gz: ecf148c6450bdd03be6ed3634a89149ecd117d4b7e02cc65196d91c66cab050bedab950d3db7a2ebf9b66186cab381ff32d91e6783796f72bcb8a7d6a54877d9
7
- data.tar.gz: 6dfe2cd5cb2ed735e124f580501c90ed9ef6ace13a21d7c37c7e8f8ed0bb25a6044f0ba248891b4c943898757f58bd077d7b57f9b05c0230cfce697d393f736b
6
+ metadata.gz: fa44978dfb6d93f9bbb316fe36d837b3ecb1ae668c7292b7f7e6ac345646b4c9099dd30375d8881e466f8f21eeb4ae6ae01d53e76652f975ffeaaa83b263939c
7
+ data.tar.gz: 6a43fa45d7a90997e64e95b9f5e40e978c398343695ce74950809f9cadbafd7420edce162c4560d01dd67892861b61180c5c7938afc2cf28a6191f1d1e18a54b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-10-24
4
+
5
+ - `raider-reporter gate` waits for the Raider quality gate verdict of the CI build (`GET /api/v1/builds/gate`, every 10 seconds, up to `RAIDER_GATE_TIMEOUT` seconds, default 600, 1–3600) and exits 0 when the gate passed or the project has no gate rules, 1 when it failed (naming the failed rules), and 2 when it gets no verdict (timeout, Raider unreachable, token rejected, build not found, any other error). 429, 5xx and network errors are retried within the time limit
6
+ - It posts a `raider/gate` status: a GitHub commit status on the PR head commit (`pull_request` events) or `GITHUB_SHA`, or an Azure Repos PR status; pending while it waits, then success, failure or error. Best effort: a post that fails prints a warning and never changes the exit code
7
+ - `exec`, uploads and `comment` are unchanged and stay fail-open
8
+ - Needs a Raider server with the gate endpoint
9
+
10
+ ## 0.8.0 — 2026-10-24
11
+
12
+ - 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
13
+ - `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)
14
+ - Runs with a quarantined result are written as schema 2; every other run stays schema 1, so older Raider servers keep accepting them
15
+ - The HTML report shows quarantined results in their own section and color; `raider-reporter comment` reads `N quarantined` from the headline
16
+ - RSpec and Cucumber exit codes are unchanged without `exec`
17
+
3
18
  ## 0.7.0 — 2026-10-18
4
19
 
5
20
  - 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
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
 
@@ -138,13 +138,14 @@ end
138
138
  | `output_dir` | `"raider-results"` | Directory for local output. |
139
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
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). |
141
142
  | `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
142
143
 
143
144
  ## Local output
144
145
 
145
146
  ```
146
147
  raider-results/
147
- run.json # the run in schema v1
148
+ run.json # the run in schema v1 (v2 with quarantined results)
148
149
  attachments/ # copies of attached screenshots and videos
149
150
  index.html # the local report, see below
150
151
  ```
@@ -179,6 +180,24 @@ Reporting is fail-open: no error inside the gem, network failure, timeout or non
179
180
 
180
181
  The hosted Raider service is not public yet; uploads go to a Raider server whose address you provide in `RAIDER_ENDPOINT`.
181
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
+
182
201
  ## Pull request comments
183
202
 
184
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.
@@ -209,6 +228,42 @@ On pull requests, `raider-reporter comment` posts one comment with the build's r
209
228
  - The command never fails your CI: it always exits 0 and prints notices to stderr, prefixed `[raider_reporter]`. It needs `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH`, `RAIDER_TOKEN` and `RAIDER_ENDPOINT`; if one is missing, or the event is not a pull request, it does nothing.
210
229
  - It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
211
230
 
231
+ ## Quality gate
232
+
233
+ A Raider project can turn on gate rules (minimum pass rate, no new failures against the default branch, no new flaky tests) under its Gate page. Raider computes one verdict per build once every shard is in, and never changes it. `raider-reporter gate` waits for that verdict in CI and fails the job when the gate fails. Run it once, after every test job:
234
+
235
+ ```yaml
236
+ raider-gate:
237
+ needs: [test] # every test job id
238
+ if: always() && vars.RAIDER_GATE == '1'
239
+ runs-on: ubuntu-latest
240
+ permissions:
241
+ contents: read
242
+ statuses: write
243
+ steps:
244
+ - uses: actions/checkout@v4
245
+ - uses: ruby/setup-ruby@v1
246
+ with: { ruby-version: "3.4", bundler-cache: true }
247
+ - name: Wait for the Raider quality gate
248
+ run: bundle exec raider-reporter gate
249
+ env:
250
+ RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
251
+ RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
252
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
253
+ ```
254
+
255
+ | Outcome | Exit | Output |
256
+ |---|---|---|
257
+ | passed | 0 | stdout: `raider-reporter gate: passed` and one line per rule |
258
+ | no gate rules | 0 | stdout: `raider-reporter gate: no gate rules for this project` |
259
+ | failed | 1 | stderr: `raider-reporter gate: failed`, one line per failed rule (` ✗ pass rate: 96% < 98%`) and the build URL |
260
+ | no verdict | 2 | stderr: timeout, Raider unreachable, token rejected, build not found, or another error |
261
+
262
+ - It polls every 10 seconds (10-second timeouts) for up to `RAIDER_GATE_TIMEOUT` seconds (default 600, 1–3600) and retries 429, 5xx and network errors. While waiting it prints, at most once a minute, `waiting for the verdict (build incomplete: 2 of 4 shards)` or `(analysing results)`.
263
+ - It needs `RAIDER_TOKEN`, `RAIDER_ENDPOINT` and the CI build key that uploads and `comment` use (GitHub Actions or Azure Pipelines); without them it exits 2.
264
+ - It posts a `raider/gate` status: pending while it waits, then success, failure (the failed rules) or error. On GitHub it needs `GITHUB_TOKEN` and `statuses: write`, and uses the PR head commit on `pull_request` events, else `GITHUB_SHA`. On Azure Repos pull requests it needs `SYSTEM_ACCESSTOKEN: $(System.AccessToken)`; other Azure builds get no status. A status that cannot be posted prints a warning and never changes the exit code. Require the `raider/gate` check in branch protection to block merges.
265
+ - Unlike `comment`, `gate` fails closed. Turn it on per repository (the `RAIDER_GATE` variable above) so pull requests from forks and projects without a token are not blocked. The token is never printed.
266
+
212
267
  ## Azure Pipelines
213
268
 
214
269
  The gem detects Azure Pipelines when `TF_BUILD` is `true`. It reports the build (`provider: azure_pipelines`, build URL, attempt, job), the branch and commit, the pull request and a build key, so shards of one build group together. On a pull request from Azure Repos, `raider-reporter comment` posts one comment thread with the results and sets a `raider/tests` status on the PR.
@@ -319,7 +374,7 @@ Tip: run `rm -rf raider-results` before a parallel run. Each process only cleans
319
374
 
320
375
  ## Schema
321
376
 
322
- `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.
377
+ `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.
323
378
 
324
379
  ## Contributing
325
380
 
@@ -26,6 +26,16 @@ module RaiderReporter
26
26
  value(env, "BUILD_REASON") == "PullRequest" && value(env, "BUILD_REPOSITORY_PROVIDER") == "TfsGit"
27
27
  end
28
28
 
29
+ # The REST base of a pull request (threads, statuses), or nil when a part is unknown.
30
+ def pull_request_api_url(env, number)
31
+ base = collection_base(env)
32
+ project = value(env, "SYSTEM_TEAMPROJECTID")
33
+ repo = value(env, "BUILD_REPOSITORY_ID")
34
+ return nil unless base && project && repo && number
35
+
36
+ "#{base}#{project}/_apis/git/repositories/#{repo}/pullRequests/#{number}"
37
+ end
38
+
29
39
  def strip_heads(ref)
30
40
  return nil if ref.nil?
31
41
  return ref.delete_prefix("refs/heads/") if ref.start_with?("refs/heads/")
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "raider_reporter"
4
+ require "raider_reporter/exec"
5
+ require "raider_reporter/gate"
4
6
 
5
7
  module RaiderReporter
8
+ # `raider-reporter exec -- <command…>` runs the test suite and decides the build's exit code (see Exec).
9
+ # `raider-reporter gate` waits for the Raider quality gate verdict and exits 0, 1 or 2 (see Gate).
6
10
  # `raider-reporter comment` posts the build summary as a PR comment and sets a commit status
7
11
  # (fail-open: always exits 0, notices go to stderr). `raider-reporter report [dir]` rebuilds the
8
12
  # local HTML report (exits 1 when there are no run files or the report cannot be written).
@@ -22,8 +26,11 @@ module RaiderReporter
22
26
  "failed" => "Raider: tests failed",
23
27
  "incomplete" => "Raider: build incomplete"
24
28
  }.freeze
25
- DEFAULT_API_URL = "https://api.github.com"
26
29
  USAGE = <<~TEXT
30
+ #{Exec::USAGE.chomp}
31
+
32
+ #{Gate::USAGE.chomp}
33
+
27
34
  Usage: raider-reporter report [dir]
28
35
 
29
36
  Rebuilds <dir>/index.html (default raider-results) from every run*.json in it. Prints its path.
@@ -48,6 +55,31 @@ module RaiderReporter
48
55
  end
49
56
 
50
57
  def run
58
+ return run_exec if @argv.first == "exec"
59
+ return run_gate if @argv.first == "gate"
60
+
61
+ run_reporting
62
+ end
63
+
64
+ private
65
+
66
+ # exec decides the build's exit code; Exec owns its own failure handling.
67
+ def run_exec
68
+ command = @argv[1] == "--" ? @argv.drop(2) : []
69
+ Exec.new(command, err: @err).run
70
+ end
71
+
72
+ # gate decides the CI job's exit code; Gate owns its own failure handling.
73
+ def run_gate
74
+ if @argv.include?("--help") || @argv.include?("-h")
75
+ @err.puts(Gate::USAGE)
76
+ return 0
77
+ end
78
+
79
+ Gate.new(env: @env, out: @out, err: @err).run
80
+ end
81
+
82
+ def run_reporting
51
83
  return report if @argv.first == "report" && !@argv.include?("--help") && !@argv.include?("-h")
52
84
 
53
85
  command = @argv.first
@@ -62,8 +94,6 @@ module RaiderReporter
62
94
  0
63
95
  end
64
96
 
65
- private
66
-
67
97
  def report
68
98
  dir = File.expand_path(@argv[1] || "raider-results")
69
99
  path = Writers::Html.new.write(dir)
@@ -145,8 +175,7 @@ module RaiderReporter
145
175
  end
146
176
 
147
177
  def azure_pr_url(pr)
148
- "#{AzureEnv.collection_base(@env)}#{AzureEnv.value(@env, 'SYSTEM_TEAMPROJECTID')}/_apis/git/repositories/" \
149
- "#{AzureEnv.value(@env, 'BUILD_REPOSITORY_ID')}/pullRequests/#{pr['number']}"
178
+ AzureEnv.pull_request_api_url(@env, pr["number"])
150
179
  end
151
180
 
152
181
  def azure_forbidden_message
@@ -177,11 +206,10 @@ module RaiderReporter
177
206
  end
178
207
 
179
208
  def api_url
180
- url = @env["GITHUB_API_URL"].to_s.strip
181
- url.empty? ? DEFAULT_API_URL : url
209
+ Comment::GithubClient.api_url(@env)
182
210
  end
183
211
 
184
- # "**Raider: failed** - 2 failed, 148 passed, 3 skipped (1m 12s)" -> "2 failed, 148 passed, 3 skipped".
212
+ # "**Raider: failed** - 2 failed, 1 quarantined, 148 passed (1m 12s)" -> "2 failed, 1 quarantined, 148 passed".
185
213
  # Falls back to a fixed text per state when the Markdown carries no headline counts.
186
214
  def description(summary)
187
215
  counts = headline_counts(summary.markdown)
@@ -193,7 +221,7 @@ module RaiderReporter
193
221
  line = markdown.to_s.lines.find { |l| l.include?("Raider:") }
194
222
  return nil unless line
195
223
 
196
- counts = line.scan(/\b\d+ (?:failed|passed|skipped|pending)\b/)
224
+ counts = line.scan(/\b\d+ (?:failed|quarantined|passed|skipped|pending)\b/)
197
225
  counts.empty? ? nil : counts.join(", ")
198
226
  end
199
227
 
@@ -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)
@@ -38,9 +38,9 @@ module RaiderReporter
38
38
  end
39
39
  end
40
40
 
41
- def set_status(state:, description:, target_url:)
41
+ def set_status(state:, description:, target_url:, name: "tests")
42
42
  request(:post, "/statuses", { state: state, description: description.to_s[0, DESCRIPTION_LIMIT],
43
- targetUrl: target_url, context: { genre: "raider", name: "tests" } },
43
+ targetUrl: target_url, context: { genre: "raider", name: name } },
44
44
  version: STATUS_API_VERSION)
45
45
  nil
46
46
  end
@@ -15,8 +15,15 @@ module RaiderReporter
15
15
  MAX_PAGES = 10
16
16
  PER_PAGE = 100
17
17
  DESCRIPTION_LIMIT = 140
18
+ DEFAULT_API_URL = "https://api.github.com"
18
19
 
19
- def initialize(token:, repository:, api_url: "https://api.github.com", timeout: 30)
20
+ # GITHUB_API_URL from a CI env, falling back to github.com when unset or blank.
21
+ def self.api_url(env)
22
+ url = env["GITHUB_API_URL"].to_s.strip
23
+ url.empty? ? DEFAULT_API_URL : url
24
+ end
25
+
26
+ def initialize(token:, repository:, api_url: DEFAULT_API_URL, timeout: 30)
20
27
  @token = token
21
28
  @repository = repository
22
29
  @api_url = api_url.to_s.strip.sub(%r{/+\z}, "")
@@ -4,7 +4,10 @@ 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, :html_report, :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")
@@ -14,12 +17,19 @@ module RaiderReporter
14
17
  @html_report = true
15
18
  @environment = {}
16
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")
17
22
  end
18
23
 
19
24
  def upload?
20
25
  present?(token) && present?(endpoint)
21
26
  end
22
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
+
23
33
  private
24
34
 
25
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
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raider_reporter/azure_env"
4
+ require "raider_reporter/ci_detector"
5
+ require "raider_reporter/gate_client"
6
+ require "raider_reporter/gate_status"
7
+
8
+ module RaiderReporter
9
+ # `raider-reporter gate` (spec §5): waits for the Raider server's frozen quality gate
10
+ # verdict of this CI build. Exits 0 when it passed or the project has no gate rules,
11
+ # 1 when it failed, and 2 for every way it gets no verdict. Unlike `exec` and
12
+ # `comment`, it fails closed. The tokens are never printed.
13
+ class Gate
14
+ PREFIX = "raider-reporter gate:"
15
+ PASSED = 0
16
+ FAILED = 1
17
+ NO_VERDICT = 2
18
+ DEFAULT_TIMEOUT = 600
19
+ TIMEOUT_RANGE = (1..3600)
20
+ INTERVAL = 10
21
+ PROGRESS_EVERY = 60
22
+ LABELS = { "pass_rate" => "pass rate", "new_failures" => "new failures", "new_flaky" => "new flaky" }.freeze
23
+ MARKS = { "passed" => "✓", "failed" => "✗", "skipped" => "–" }.freeze
24
+ USAGE = <<~TEXT
25
+ Usage: raider-reporter gate
26
+
27
+ Waits for the Raider quality gate verdict of this CI build and posts a "raider/gate" status.
28
+ Exits 0 when the gate passed or the project has no gate rules, 1 when it failed, and 2 when no
29
+ verdict arrived (timeout, Raider unreachable, token rejected, build not found).
30
+ Environment: RAIDER_TOKEN, RAIDER_ENDPOINT, RAIDER_GATE_TIMEOUT (seconds, 1-3600, default 600).
31
+ Status: GITHUB_TOKEN (GitHub Actions) or SYSTEM_ACCESSTOKEN (Azure Repos pull requests).
32
+ TEXT
33
+
34
+ def initialize(env: ENV, out: $stdout, err: $stderr, clock: nil, sleeper: nil)
35
+ @env = env
36
+ @out = out
37
+ @err = err
38
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
39
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
40
+ @status = nil
41
+ @url = nil
42
+ @detail = nil
43
+ @last_progress = nil
44
+ end
45
+
46
+ def run
47
+ build_key = CiDetector.new(env: @env, git: ->(*) {}).ci&.dig("build_key")
48
+ return refuse("no CI build key (run this in GitHub Actions or Azure Pipelines)") unless build_key
49
+
50
+ token = AzureEnv.value(@env, "RAIDER_TOKEN")
51
+ endpoint = AzureEnv.value(@env, "RAIDER_ENDPOINT")
52
+ return refuse("RAIDER_TOKEN and RAIDER_ENDPOINT are required") unless token && endpoint
53
+
54
+ timeout = parse_timeout
55
+ return refuse("RAIDER_GATE_TIMEOUT must be a whole number of seconds from 1 to 3600") unless timeout
56
+
57
+ @status = GateStatus.new(env: @env, warn: ->(message) { say(@err, "#{PREFIX} #{message}") })
58
+ @status.post(:pending, "Waiting for the Raider verdict")
59
+ finish(poll(GateClient.new(endpoint: endpoint, token: token), build_key, timeout), timeout)
60
+ rescue StandardError => e
61
+ no_verdict("#{e.class}: #{e.message}")
62
+ end
63
+
64
+ private
65
+
66
+ def parse_timeout
67
+ raw = AzureEnv.value(@env, "RAIDER_GATE_TIMEOUT")
68
+ return DEFAULT_TIMEOUT if raw.nil?
69
+
70
+ value = Integer(raw, 10, exception: false)
71
+ value if value && TIMEOUT_RANGE.cover?(value)
72
+ end
73
+
74
+ # The last response that ends the wait, or nil when the time limit ran out.
75
+ def poll(client, build_key, timeout)
76
+ deadline = @clock.call + timeout
77
+ loop do
78
+ response = client.fetch(build_key)
79
+ return response unless response.kind == :retry || pending?(response)
80
+
81
+ note(response)
82
+ remaining = deadline - @clock.call
83
+ return nil if remaining <= 0
84
+
85
+ @sleeper.call([INTERVAL, remaining].min)
86
+ end
87
+ end
88
+
89
+ def pending?(response)
90
+ response.kind == :verdict && response.data["state"] == "pending"
91
+ end
92
+
93
+ def note(response)
94
+ if response.kind == :retry
95
+ @detail = response.reason
96
+ return
97
+ end
98
+
99
+ remember_url(response.data)
100
+ @detail = pending_detail(response.data)
101
+ now = @clock.call
102
+ return if @last_progress && now - @last_progress < PROGRESS_EVERY
103
+
104
+ @last_progress = now
105
+ say(@out, "#{PREFIX} waiting for the verdict (#{@detail})")
106
+ end
107
+
108
+ def pending_detail(data)
109
+ shards = data["shards"].is_a?(Hash) ? data["shards"] : {}
110
+ received = shards["received"]
111
+ expected = shards["expected"]
112
+ if received.is_a?(Integer) && expected.is_a?(Integer) && received < expected
113
+ "build incomplete: #{received} of #{expected} shards"
114
+ else
115
+ "analysing results"
116
+ end
117
+ end
118
+
119
+ def finish(response, timeout)
120
+ return no_verdict("no verdict after #{timeout}s (#{@detail || 'no response'})") unless response
121
+
122
+ case response.kind
123
+ when :rejected then no_verdict("token rejected (HTTP #{response.code})")
124
+ when :not_found then no_verdict("build not found (did the tests upload?)")
125
+ when :error then no_verdict(response.reason)
126
+ else report(response.data)
127
+ end
128
+ end
129
+
130
+ def report(data)
131
+ remember_url(data)
132
+ rules = data["rules"].select { |rule| rule.is_a?(Hash) }
133
+ case data["state"]
134
+ when "none"
135
+ say(@out, "#{PREFIX} no gate rules for this project")
136
+ @status.post(:success, "No gate rules", target_url: @url)
137
+ PASSED
138
+ when "passed"
139
+ say(@out, "#{PREFIX} passed")
140
+ rules.each { |rule| say(@out, rule_line(rule)) }
141
+ @status.post(:success, "Gate passed", target_url: @url)
142
+ PASSED
143
+ else
144
+ report_failed(rules.select { |rule| rule["outcome"] == "failed" })
145
+ end
146
+ end
147
+
148
+ def report_failed(failed)
149
+ say(@err, "#{PREFIX} failed")
150
+ failed.each { |rule| say(@err, rule_line(rule)) }
151
+ say(@err, " #{@url}") if @url
152
+ description = failed.map { |rule| rule_text(rule) }.join("; ")
153
+ @status.post(:failure, description.empty? ? "Gate failed" : description, target_url: @url)
154
+ FAILED
155
+ end
156
+
157
+ def rule_line(rule)
158
+ " #{MARKS.fetch(rule['outcome'].to_s, '?')} #{rule_text(rule)}"
159
+ end
160
+
161
+ def rule_text(rule)
162
+ name = rule["rule"].to_s
163
+ "#{LABELS.fetch(name, name)}: #{rule['detail']}"
164
+ end
165
+
166
+ def remember_url(data)
167
+ @url = data["url"] if data["url"].is_a?(String) && !data["url"].empty?
168
+ end
169
+
170
+ # Exit 2 before polling: nothing was posted, so there is no status to clear.
171
+ def refuse(message)
172
+ say(@err, "#{PREFIX} #{message}")
173
+ NO_VERDICT
174
+ end
175
+
176
+ # Exit 2 after the pending status: replace it, so a required check is never left pending.
177
+ def no_verdict(message)
178
+ say(@err, "#{PREFIX} #{message}")
179
+ @status&.post(:error, "No verdict", target_url: @url)
180
+ NO_VERDICT
181
+ end
182
+
183
+ def say(io, text)
184
+ io.puts(redact(text))
185
+ end
186
+
187
+ def redact(text)
188
+ secrets = [AzureEnv.value(@env, "RAIDER_TOKEN"), AzureEnv.value(@env, "GITHUB_TOKEN"),
189
+ AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN")].compact
190
+ secrets.reduce(text.to_s) { |memo, secret| memo.gsub(secret, "[REDACTED]") }
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "timeout"
8
+ require "uri"
9
+ require "raider_reporter/version"
10
+
11
+ module RaiderReporter
12
+ # One GET /api/v1/builds/gate call for `raider-reporter gate` (spec §5), classified
13
+ # so the caller can decide: a verdict, retry (429, 5xx, network), or stop.
14
+ class GateClient
15
+ class InvalidResponse < StandardError; end
16
+
17
+ PATH = "/api/v1/builds/gate"
18
+ TIMEOUT = 10
19
+ STATES = %w[pending none passed failed].freeze
20
+ NETWORK_ERRORS = [Timeout::Error, SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError,
21
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError].freeze
22
+
23
+ # kind: :verdict, :retry, :rejected, :not_found or :error.
24
+ Response = Data.define(:kind, :data, :code, :reason)
25
+
26
+ def initialize(endpoint:, token:)
27
+ @endpoint = endpoint
28
+ @token = token
29
+ end
30
+
31
+ def fetch(build_key)
32
+ classify(request(build_key))
33
+ rescue *NETWORK_ERRORS => e
34
+ Response.new(kind: :retry, data: nil, code: nil, reason: redact("#{e.class}: #{e.message}"))
35
+ end
36
+
37
+ private
38
+
39
+ def request(build_key)
40
+ uri = URI.parse("#{@endpoint.to_s.strip.chomp('/')}#{PATH}?build_key=#{CGI.escape(build_key.to_s)}")
41
+ raise ArgumentError, "RAIDER_ENDPOINT must start with http:// or https://" unless uri.is_a?(URI::HTTP)
42
+
43
+ req = Net::HTTP::Get.new(uri)
44
+ req["Authorization"] = "Bearer #{@token}"
45
+ req["Accept"] = "application/json"
46
+ req["User-Agent"] = "raider_reporter/#{VERSION}"
47
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT,
48
+ read_timeout: TIMEOUT, max_retries: 0) { |http| http.request(req) }
49
+ end
50
+
51
+ def classify(response)
52
+ code = response.code.to_i
53
+ case code
54
+ when 200 then parse(response.body)
55
+ when 401, 403 then Response.new(kind: :rejected, data: nil, code: code, reason: nil)
56
+ when 404 then Response.new(kind: :not_found, data: nil, code: code, reason: nil)
57
+ when 429, 500..599 then Response.new(kind: :retry, data: nil, code: code, reason: "HTTP #{code}")
58
+ else error_response(code, "unexpected HTTP #{code}")
59
+ end
60
+ end
61
+
62
+ def parse(body)
63
+ data = JSON.parse(body.to_s)
64
+ return error_response(200, "invalid gate response") unless valid?(data)
65
+
66
+ Response.new(kind: :verdict, data: data, code: 200, reason: nil)
67
+ rescue JSON::ParserError => e
68
+ Response.new(kind: :error, data: nil, code: 200, reason: redact("#{e.class}: #{e.message}"))
69
+ end
70
+
71
+ def valid?(data)
72
+ data.is_a?(Hash) && STATES.include?(data["state"]) && data["rules"].is_a?(Array)
73
+ end
74
+
75
+ # Spec §5 "other error" form: <class>: <message>.
76
+ def error_response(code, message)
77
+ Response.new(kind: :error, data: nil, code: code, reason: "#{InvalidResponse}: #{message}")
78
+ end
79
+
80
+ def redact(text)
81
+ @token.to_s.empty? ? text : text.gsub(@token.to_s, "[REDACTED]")
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raider_reporter/version"
4
+ require "raider_reporter/azure_env"
5
+ require "raider_reporter/ci_detector"
6
+ require "raider_reporter/comment/github_client"
7
+ require "raider_reporter/comment/azure_client"
8
+
9
+ module RaiderReporter
10
+ # Posts the "raider/gate" status for `raider-reporter gate` (spec §5): a GitHub commit
11
+ # status (PR head SHA on pull_request events, else GITHUB_SHA) or an Azure Repos PR
12
+ # status (genre raider, name gate). Best effort: a failed post warns and never raises.
13
+ class GateStatus
14
+ CONTEXT = "raider/gate"
15
+ AZURE_NAME = "gate"
16
+ TIMEOUT = 10
17
+ GITHUB_STATES = { pending: "pending", success: "success", failure: "failure", error: "error" }.freeze
18
+ AZURE_STATES = { pending: "pending", success: "succeeded", failure: "failed", error: "error" }.freeze
19
+
20
+ def initialize(env:, warn:)
21
+ @env = env
22
+ @warn = warn
23
+ @provider = begin
24
+ detect
25
+ rescue StandardError
26
+ nil
27
+ end
28
+ end
29
+
30
+ def active?
31
+ !@provider.nil?
32
+ end
33
+
34
+ # state: :pending, :success, :failure or :error.
35
+ def post(state, description, target_url: nil)
36
+ case @provider
37
+ when :github
38
+ github_client.set_status(@sha, state: GITHUB_STATES.fetch(state), target_url: target_url,
39
+ description: description, context: CONTEXT)
40
+ when :azure
41
+ azure_client.set_status(state: AZURE_STATES.fetch(state), description: description, target_url: target_url,
42
+ name: AZURE_NAME)
43
+ end
44
+ nil
45
+ rescue StandardError => e
46
+ @warn.call("could not post the #{CONTEXT} status (#{e.class}: #{e.message})")
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ def detect
53
+ detector = CiDetector.new(env: @env, git: ->(*) {})
54
+ if @env["GITHUB_ACTIONS"] == "true"
55
+ @sha = detector.git&.dig("commit_sha")
56
+ :github if [@env["GITHUB_TOKEN"], @env["GITHUB_REPOSITORY"], @sha].none? { |value| value.to_s.strip.empty? }
57
+ elsif AzureEnv.azure?(@env)
58
+ pr = detector.git&.dig("pr")
59
+ @azure_url = pr && AzureEnv.pull_request_api_url(@env, pr["number"])
60
+ :azure if @azure_url && AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN")
61
+ end
62
+ end
63
+
64
+ def github_client
65
+ Comment::GithubClient.new(token: @env["GITHUB_TOKEN"], repository: @env["GITHUB_REPOSITORY"],
66
+ api_url: Comment::GithubClient.api_url(@env), timeout: TIMEOUT)
67
+ end
68
+
69
+ def azure_client
70
+ Comment::AzureClient.new(token: AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN"), base_url: @azure_url, timeout: TIMEOUT)
71
+ end
72
+ end
73
+ 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.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -16,7 +16,7 @@ module RaiderReporter
16
16
  MARKER = '<meta name="generator" content="raider_reporter">'
17
17
  ROW_LIMIT = 2_000
18
18
  RUN_FILE = /\Arun(-[^\/]+)?\.json\z/
19
- SECTIONS = [%w[failed Failed], %w[passed Passed], %w[pending Pending], %w[skipped Skipped]].freeze
19
+ SECTIONS = [%w[failed Failed], %w[quarantined Quarantined], %w[passed Passed], %w[pending Pending], %w[skipped Skipped]].freeze
20
20
  PROVIDERS = { "github_actions" => "GitHub Actions", "azure_pipelines" => "Azure Pipelines" }.freeze
21
21
 
22
22
  def self.ours?(path)
@@ -140,7 +140,11 @@ module RaiderReporter
140
140
  flaky = counts["flaky"]
141
141
  decided = passed + counts["failed"]
142
142
  rate = decided.zero? ? "—" : "#{(passed * 100.0 / decided).round}%"
143
- parts = SECTIONS.map { |status, _| %(<span class="b #{status}">#{counts[status]} #{status}</span>) }
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
144
148
  parts << %(<span class="b flaky">#{flaky} flaky</span>) if flaky.positive?
145
149
  "#{parts.join(' ')} <span class=\"rate\">Pass rate #{rate}</span>"
146
150
  end
@@ -257,8 +261,8 @@ module RaiderReporter
257
261
  def h(value) = ERB::Util.html_escape(value.to_s)
258
262
 
259
263
  CSS = <<~CSS.gsub(/\s*\n\s*/, "")
260
- :root{--bg:#fff;--fg:#1f2328;--mute:#656d76;--bdr:#d0d7de;--card:#f6f8fa;--ruby:#C14420;--ok:#1a7f37;--warn:#9a6700}
261
- @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--mute:#8d96a0;--bdr:#30363d;--card:#161b22;--ok:#3fb950;--warn:#d29922}}
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}}
262
266
  *{box-sizing:border-box}
263
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}
264
268
  header,main,footer{max-width:1100px;margin:0 auto}
@@ -266,6 +270,7 @@ module RaiderReporter
266
270
  .meta,.l,footer,.more{color:var(--mute);font-size:12px}
267
271
  .b{display:inline-block;padding:0 6px;border-radius:9px;font-size:11px;font-weight:600;border:1px solid var(--bdr)}
268
272
  .b.failed{background:var(--ruby);color:#fff;border-color:var(--ruby)}
273
+ .b.quarantined{color:var(--q);border-color:var(--q)}
269
274
  .b.passed{color:var(--ok)}.b.pending,.b.skipped,.b.flaky{color:var(--warn)}
270
275
  .rate{font-weight:600;margin-left:6px}
271
276
  #q{width:100%;margin:12px 0;padding:8px;border:1px solid var(--bdr);border-radius:6px;background:var(--card);color:var(--fg)}
@@ -8,6 +8,8 @@ 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"
@@ -40,6 +42,7 @@ module RaiderReporter
40
42
  @configuration = nil
41
43
  @collector = nil
42
44
  @project_root = nil
45
+ @quarantined_keys = nil
43
46
  end
44
47
 
45
48
  def start_collector!
@@ -47,10 +50,16 @@ module RaiderReporter
47
50
  @project_root = Dir.pwd
48
51
  @collector = Collector.new(
49
52
  max_attachment_bytes: configuration.max_attachment_bytes,
50
- warn: ->(message) { warn(message) }
53
+ warn: ->(message) { warn(message) },
54
+ quarantine: -> { quarantined_keys }
51
55
  )
52
56
  end
53
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
+
54
63
  def attach(name, path, type: nil)
55
64
  unless collector&.in_test?
56
65
  warn("attach(#{name.inspect}) called outside a running example; ignored")
@@ -82,6 +91,8 @@ module RaiderReporter
82
91
  def finish!(output = $stderr)
83
92
  return nil if dry_run?
84
93
 
94
+ write_exec_summary
95
+
85
96
  detector = CiDetector.new
86
97
  ci = detector.ci
87
98
  shard = ShardDetector.new(job: ci&.fetch("job", nil)).shard
@@ -108,6 +119,19 @@ module RaiderReporter
108
119
 
109
120
  private
110
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
+
111
135
  def write_html_report
112
136
  return unless configuration.html_report
113
137
 
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Augustin Gottlieb
@@ -36,9 +36,15 @@ 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
41
+ - lib/raider_reporter/gate.rb
42
+ - lib/raider_reporter/gate_client.rb
43
+ - lib/raider_reporter/gate_status.rb
39
44
  - lib/raider_reporter/github_event.rb
40
45
  - lib/raider_reporter/models.rb
41
46
  - lib/raider_reporter/payload.rb
47
+ - lib/raider_reporter/quarantine.rb
42
48
  - lib/raider_reporter/rspec.rb
43
49
  - lib/raider_reporter/rspec/formatter.rb
44
50
  - lib/raider_reporter/shard_detector.rb