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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +59 -4
- data/lib/raider_reporter/azure_env.rb +10 -0
- data/lib/raider_reporter/cli.rb +37 -9
- data/lib/raider_reporter/collector.rb +32 -1
- data/lib/raider_reporter/comment/azure_client.rb +2 -2
- data/lib/raider_reporter/comment/github_client.rb +8 -1
- data/lib/raider_reporter/configuration.rb +11 -1
- data/lib/raider_reporter/cucumber/formatter.rb +43 -0
- data/lib/raider_reporter/exec.rb +101 -0
- data/lib/raider_reporter/exec_summary.rb +46 -0
- data/lib/raider_reporter/gate.rb +193 -0
- data/lib/raider_reporter/gate_client.rb +84 -0
- data/lib/raider_reporter/gate_status.rb +73 -0
- data/lib/raider_reporter/models.rb +2 -2
- data/lib/raider_reporter/payload.rb +6 -2
- data/lib/raider_reporter/quarantine.rb +79 -0
- data/lib/raider_reporter/rspec/formatter.rb +24 -4
- data/lib/raider_reporter/version.rb +1 -1
- data/lib/raider_reporter/writers/html.rb +9 -4
- data/lib/raider_reporter.rb +25 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dda0a98d0886eefbfd851be91d57c881e74abee367956b0272fc9b7a464045cf
|
|
4
|
+
data.tar.gz: 8012de5d41e890b7dae144ab5f208c20ed2ffa94736645a4308901f6abe24174
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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/")
|
data/lib/raider_reporter/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
181
|
-
url.empty? ? DEFAULT_API_URL : url
|
|
209
|
+
Comment::GithubClient.api_url(@env)
|
|
182
210
|
end
|
|
183
211
|
|
|
184
|
-
# "**Raider: failed** - 2 failed,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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" =>
|
|
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:
|
|
60
|
-
file_path:
|
|
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:
|
|
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
|
|
|
@@ -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.
|
|
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)}
|
data/lib/raider_reporter.rb
CHANGED
|
@@ -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.
|
|
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
|