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