raider_reporter 0.8.0 → 0.10.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 +45 -2
- data/lib/raider_reporter/azure_env.rb +10 -0
- data/lib/raider_reporter/cli.rb +17 -5
- data/lib/raider_reporter/collector.rb +6 -3
- 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 +3 -1
- data/lib/raider_reporter/cucumber/formatter.rb +19 -2
- data/lib/raider_reporter/cucumber.rb +1 -1
- 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/version.rb +1 -1
- data/lib/raider_reporter/writers/html.rb +5 -1
- data/lib/raider_reporter/writers/uploader.rb +43 -14
- data/lib/raider_reporter.rb +8 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 291269e17dbda3cec36ff4822af7ba9b16b85f5f1d231eed8b96fb9b89f9e338
|
|
4
|
+
data.tar.gz: 16e1c480d70fc174b25f7b91fcd5f9b242b04f1e7b751172e796050a0c405a39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6bc649c77eaa3a22110dd19b9a5eb28f1e60aab2d6a2877985a8254c85ad647f58d83427e298f79d48a5e420893afc12c06c54a05526a0b0488ca43c015a6510
|
|
7
|
+
data.tar.gz: 1590461029cc94956c3e28fffa9003252d8de1f064211dbcc9d3dcdfe0cbe11c4f9125fafb459978f77f082419b80c36a4702d0a7917ba480d4ed6a740a5f8fb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Video attachments play inline in the HTML report: `video/*` attachments render as a `<video controls>` player (metadata preloaded only) with a download link, instead of a plain link. Unsafe paths are still dropped
|
|
6
|
+
- Videos get their own size cap, `config.max_video_bytes` (default 50 MB); the 5 MB `max_attachment_bytes` still applies to everything else. A video over its cap is skipped with a warning
|
|
7
|
+
- Uploads keep non-video attachments (screenshots, logs) first; videos then fill whatever room is left in the upload budget. A video that does not fit is skipped and later, smaller ones are still tried
|
|
8
|
+
- The Cucumber formatter works on Cucumber 12, which removed `config.strict`: it no longer logs `undefined method 'strict'` and keeps its failure counts (Cucumber 10 and 11 behave as before)
|
|
9
|
+
- No breaking changes
|
|
10
|
+
|
|
11
|
+
## 0.9.0 — 2026-10-24
|
|
12
|
+
|
|
13
|
+
- `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
|
|
14
|
+
- 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
|
|
15
|
+
- `exec`, uploads and `comment` are unchanged and stay fail-open
|
|
16
|
+
- Needs a Raider server with the gate endpoint
|
|
17
|
+
|
|
3
18
|
## 0.8.0 — 2026-10-24
|
|
4
19
|
|
|
5
20
|
- 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
|
data/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Run `bundle exec rspec` as usual. (For Cucumber, see [Cucumber](#cucumber).) Res
|
|
|
30
30
|
|
|
31
31
|
## Cucumber
|
|
32
32
|
|
|
33
|
-
Requirements: Cucumber 9, 10 or
|
|
33
|
+
Requirements: Cucumber 9, 10, 11 or 12 (12 needs Ruby 3.3+) in your own Gemfile (`gem "cucumber"`). It is not a dependency of this gem; requiring `raider_reporter/cucumber` without Cucumber raises a `LoadError` that says so. Cucumber 9.2.1 on Ruby 4 also needs `gem "logger"` in your Gemfile, because Cucumber requires it without declaring it.
|
|
34
34
|
|
|
35
35
|
### Plugin mode (recommended)
|
|
36
36
|
|
|
@@ -120,7 +120,11 @@ end
|
|
|
120
120
|
|
|
121
121
|
`attach` copies the file right away, so the attachment keeps the bytes the file had at that moment even if the same path is overwritten by a later example. Using a path per example (as above) still makes the files on disk easier to find.
|
|
122
122
|
|
|
123
|
-
`attach` returns `true` on success. It returns `false` and prints a `[raider_reporter]` warning, never raising, if it is called outside a running example (for instance when the formatter is not loaded), if the file does not exist, or if the file is larger than 5 MB.
|
|
123
|
+
`attach` returns `true` on success. It returns `false` and prints a `[raider_reporter]` warning, never raising, if it is called outside a running example (for instance when the formatter is not loaded), if the file does not exist, or if the file is larger than its size cap: 5 MB (`max_attachment_bytes`), or 50 MB for videos (`video/*`, `max_video_bytes`).
|
|
124
|
+
|
|
125
|
+
Videos (`.webm`, `.mp4`) are attached the same way, for example `RaiderReporter.attach("video", path, type: :webm)`. In the local HTML report they play inline, with a download link.
|
|
126
|
+
|
|
127
|
+
When a run is uploaded, screenshots and other non-video attachments are sent first; videos then fill the room left in the upload budget. A video that does not fit is skipped and later, smaller videos are still tried. The local `raider-results/attachments/` folder keeps every attachment that passed its size cap.
|
|
124
128
|
|
|
125
129
|
## Configuration
|
|
126
130
|
|
|
@@ -139,6 +143,9 @@ end
|
|
|
139
143
|
| `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
144
|
| `html_report` | `true` | Write `index.html`, the local HTML report, into `output_dir` after each run. See [Local report](#local-report). |
|
|
141
145
|
| `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). |
|
|
146
|
+
| `max_attachment_bytes` | `5 MB` | Largest non-video attachment `attach` accepts. |
|
|
147
|
+
| `max_video_bytes` | `50 MB` | Largest `video/*` attachment `attach` accepts. |
|
|
148
|
+
| `exec_summary_path` | `ENV["RAIDER_EXEC_SUMMARY"]` | File where the run writes its failure counts (framework failures, quarantined, errors outside tests) for `raider-reporter exec` to read when it decides the exit code. `exec` sets it for the command it runs; leave it unset otherwise. Nothing is written when it is unset. See [Quarantine](#quarantine). |
|
|
142
149
|
| `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
|
|
143
150
|
|
|
144
151
|
## Local output
|
|
@@ -228,6 +235,42 @@ On pull requests, `raider-reporter comment` posts one comment with the build's r
|
|
|
228
235
|
- 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.
|
|
229
236
|
- It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
|
|
230
237
|
|
|
238
|
+
## Quality gate
|
|
239
|
+
|
|
240
|
+
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:
|
|
241
|
+
|
|
242
|
+
```yaml
|
|
243
|
+
raider-gate:
|
|
244
|
+
needs: [test] # every test job id
|
|
245
|
+
if: always() && vars.RAIDER_GATE == '1'
|
|
246
|
+
runs-on: ubuntu-latest
|
|
247
|
+
permissions:
|
|
248
|
+
contents: read
|
|
249
|
+
statuses: write
|
|
250
|
+
steps:
|
|
251
|
+
- uses: actions/checkout@v4
|
|
252
|
+
- uses: ruby/setup-ruby@v1
|
|
253
|
+
with: { ruby-version: "3.4", bundler-cache: true }
|
|
254
|
+
- name: Wait for the Raider quality gate
|
|
255
|
+
run: bundle exec raider-reporter gate
|
|
256
|
+
env:
|
|
257
|
+
RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
|
|
258
|
+
RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
|
|
259
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
| Outcome | Exit | Output |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| passed | 0 | stdout: `raider-reporter gate: passed` and one line per rule |
|
|
265
|
+
| no gate rules | 0 | stdout: `raider-reporter gate: no gate rules for this project` |
|
|
266
|
+
| failed | 1 | stderr: `raider-reporter gate: failed`, one line per failed rule (` ✗ pass rate: 96% < 98%`) and the build URL |
|
|
267
|
+
| no verdict | 2 | stderr: timeout, Raider unreachable, token rejected, build not found, or another error |
|
|
268
|
+
|
|
269
|
+
- 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)`.
|
|
270
|
+
- 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.
|
|
271
|
+
- 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.
|
|
272
|
+
- 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.
|
|
273
|
+
|
|
231
274
|
## Azure Pipelines
|
|
232
275
|
|
|
233
276
|
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.
|
|
@@ -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
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "raider_reporter"
|
|
4
4
|
require "raider_reporter/exec"
|
|
5
|
+
require "raider_reporter/gate"
|
|
5
6
|
|
|
6
7
|
module RaiderReporter
|
|
7
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).
|
|
8
10
|
# `raider-reporter comment` posts the build summary as a PR comment and sets a commit status
|
|
9
11
|
# (fail-open: always exits 0, notices go to stderr). `raider-reporter report [dir]` rebuilds the
|
|
10
12
|
# local HTML report (exits 1 when there are no run files or the report cannot be written).
|
|
@@ -24,10 +26,11 @@ module RaiderReporter
|
|
|
24
26
|
"failed" => "Raider: tests failed",
|
|
25
27
|
"incomplete" => "Raider: build incomplete"
|
|
26
28
|
}.freeze
|
|
27
|
-
DEFAULT_API_URL = "https://api.github.com"
|
|
28
29
|
USAGE = <<~TEXT
|
|
29
30
|
#{Exec::USAGE.chomp}
|
|
30
31
|
|
|
32
|
+
#{Gate::USAGE.chomp}
|
|
33
|
+
|
|
31
34
|
Usage: raider-reporter report [dir]
|
|
32
35
|
|
|
33
36
|
Rebuilds <dir>/index.html (default raider-results) from every run*.json in it. Prints its path.
|
|
@@ -53,6 +56,7 @@ module RaiderReporter
|
|
|
53
56
|
|
|
54
57
|
def run
|
|
55
58
|
return run_exec if @argv.first == "exec"
|
|
59
|
+
return run_gate if @argv.first == "gate"
|
|
56
60
|
|
|
57
61
|
run_reporting
|
|
58
62
|
end
|
|
@@ -65,6 +69,16 @@ module RaiderReporter
|
|
|
65
69
|
Exec.new(command, err: @err).run
|
|
66
70
|
end
|
|
67
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
|
+
|
|
68
82
|
def run_reporting
|
|
69
83
|
return report if @argv.first == "report" && !@argv.include?("--help") && !@argv.include?("-h")
|
|
70
84
|
|
|
@@ -161,8 +175,7 @@ module RaiderReporter
|
|
|
161
175
|
end
|
|
162
176
|
|
|
163
177
|
def azure_pr_url(pr)
|
|
164
|
-
|
|
165
|
-
"#{AzureEnv.value(@env, 'BUILD_REPOSITORY_ID')}/pullRequests/#{pr['number']}"
|
|
178
|
+
AzureEnv.pull_request_api_url(@env, pr["number"])
|
|
166
179
|
end
|
|
167
180
|
|
|
168
181
|
def azure_forbidden_message
|
|
@@ -193,8 +206,7 @@ module RaiderReporter
|
|
|
193
206
|
end
|
|
194
207
|
|
|
195
208
|
def api_url
|
|
196
|
-
|
|
197
|
-
url.empty? ? DEFAULT_API_URL : url
|
|
209
|
+
Comment::GithubClient.api_url(@env)
|
|
198
210
|
end
|
|
199
211
|
|
|
200
212
|
# "**Raider: failed** - 2 failed, 1 quarantined, 148 passed (1m 12s)" -> "2 failed, 1 quarantined, 148 passed".
|
|
@@ -8,9 +8,11 @@ require "set"
|
|
|
8
8
|
module RaiderReporter
|
|
9
9
|
# Accumulates one suite run. Framework formatters call it; writers read the Run it returns.
|
|
10
10
|
class Collector
|
|
11
|
-
def initialize(max_attachment_bytes:, warn:,
|
|
11
|
+
def initialize(max_attachment_bytes:, warn:, max_video_bytes: Configuration::DEFAULT_MAX_VIDEO_BYTES,
|
|
12
|
+
clock: -> { Time.now.utc }, uuid: -> { SecureRandom.uuid },
|
|
12
13
|
quarantine: -> { Quarantine::EMPTY })
|
|
13
14
|
@max_attachment_bytes = max_attachment_bytes
|
|
15
|
+
@max_video_bytes = max_video_bytes
|
|
14
16
|
@warn = warn
|
|
15
17
|
@clock = clock
|
|
16
18
|
@uuid = uuid
|
|
@@ -118,8 +120,9 @@ module RaiderReporter
|
|
|
118
120
|
@warn.call("attachment #{path} no longer exists; skipped")
|
|
119
121
|
return false
|
|
120
122
|
end
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
limit = attachment.content_type.to_s.start_with?("video/") ? @max_video_bytes : @max_attachment_bytes
|
|
124
|
+
if File.size(path) > limit
|
|
125
|
+
@warn.call("attachment #{path} is larger than #{limit} bytes; skipped")
|
|
123
126
|
return false
|
|
124
127
|
end
|
|
125
128
|
true
|
|
@@ -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}, "")
|
|
@@ -5,9 +5,10 @@ require "raider_reporter/azure_env"
|
|
|
5
5
|
module RaiderReporter
|
|
6
6
|
class Configuration
|
|
7
7
|
TRUTHY = %w[1 true].freeze
|
|
8
|
+
DEFAULT_MAX_VIDEO_BYTES = 50 * 1024 * 1024
|
|
8
9
|
|
|
9
10
|
attr_accessor :token, :endpoint, :output_dir, :clean_output_dir, :html_report, :environment, :max_attachment_bytes,
|
|
10
|
-
:quarantine, :exec_summary_path
|
|
11
|
+
:max_video_bytes, :quarantine, :exec_summary_path
|
|
11
12
|
|
|
12
13
|
def initialize(env = ENV)
|
|
13
14
|
@token = AzureEnv.value(env, "RAIDER_TOKEN")
|
|
@@ -17,6 +18,7 @@ module RaiderReporter
|
|
|
17
18
|
@html_report = true
|
|
18
19
|
@environment = {}
|
|
19
20
|
@max_attachment_bytes = 5 * 1024 * 1024
|
|
21
|
+
@max_video_bytes = DEFAULT_MAX_VIDEO_BYTES
|
|
20
22
|
@quarantine = TRUTHY.include?(AzureEnv.value(env, "RAIDER_QUARANTINE").to_s.downcase)
|
|
21
23
|
@exec_summary_path = AzureEnv.value(env, "RAIDER_EXEC_SUMMARY")
|
|
22
24
|
end
|
|
@@ -160,8 +160,8 @@ module RaiderReporter
|
|
|
160
160
|
return if @outcomes_broken
|
|
161
161
|
|
|
162
162
|
key = "#{test_case.location}|#{test_case.name}"
|
|
163
|
-
failed = !
|
|
164
|
-
flaky = !failed && @failed_before[key] &&
|
|
163
|
+
failed = !result_ok?(result)
|
|
164
|
+
flaky = !failed && @failed_before[key] && strict_flaky?
|
|
165
165
|
@failed_before[key] ||= failed
|
|
166
166
|
@outcomes[key] = failed || flaky ? true : false
|
|
167
167
|
rescue StandardError => e
|
|
@@ -169,6 +169,23 @@ module RaiderReporter
|
|
|
169
169
|
RaiderReporter.warn("#{e.class}: #{e.message}")
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
+
# Cucumber 10 and 11 carry a strict configuration (config.strict) that result.ok?
|
|
173
|
+
# takes as a keyword. Cucumber 12 removed it: result.ok? takes nothing, pending and
|
|
174
|
+
# undefined always fail, and a flaky scenario never does. Detect, never compare versions.
|
|
175
|
+
def strict_configuration
|
|
176
|
+
@config.strict if @config.respond_to?(:strict)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def result_ok?(result)
|
|
180
|
+
strict = strict_configuration
|
|
181
|
+
strict ? result.ok?(strict: strict) : result.ok?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def strict_flaky?
|
|
185
|
+
strict = strict_configuration
|
|
186
|
+
strict ? strict.strict?(:flaky) : false
|
|
187
|
+
end
|
|
188
|
+
|
|
172
189
|
# Scenarios that make cucumber's run fail; one error outside tests when the run
|
|
173
190
|
# stopped before every scenario finished (--fail-fast) (spec §4.4).
|
|
174
191
|
def record_framework_counts
|
|
@@ -5,7 +5,7 @@ begin
|
|
|
5
5
|
rescue LoadError => e
|
|
6
6
|
raise unless e.path == "cucumber"
|
|
7
7
|
|
|
8
|
-
raise LoadError, "raider_reporter/cucumber needs the cucumber gem (>= 9, <
|
|
8
|
+
raise LoadError, "raider_reporter/cucumber needs the cucumber gem (>= 9, < 13) in your Gemfile"
|
|
9
9
|
end
|
|
10
10
|
require "raider_reporter"
|
|
11
11
|
require_relative "cucumber/formatter"
|
|
@@ -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
|
|
@@ -224,8 +224,11 @@ module RaiderReporter
|
|
|
224
224
|
href = safe_href(attachment["path"])
|
|
225
225
|
next unless href
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
content_type = attachment["content_type"].to_s
|
|
228
|
+
if content_type.start_with?("image/")
|
|
228
229
|
%(<a href="#{href}"><img loading="lazy" src="#{href}" alt="#{h(attachment['name'])}"></a>)
|
|
230
|
+
elsif content_type.start_with?("video/")
|
|
231
|
+
%(<video controls preload="metadata" src="#{href}"></video><a href="#{href}" download>#{h(attachment['name'])}</a>)
|
|
229
232
|
else
|
|
230
233
|
%(<a href="#{href}">#{h(attachment['name'])}</a>)
|
|
231
234
|
end
|
|
@@ -280,6 +283,7 @@ module RaiderReporter
|
|
|
280
283
|
.r p{margin:2px 0;overflow-wrap:anywhere}.n{font-weight:500}.x{color:var(--ruby);font-weight:600}
|
|
281
284
|
pre{margin:4px 0;padding:8px;background:var(--card);border-radius:6px;overflow-x:auto;white-space:pre-wrap;font-size:12px}
|
|
282
285
|
.a img{max-width:100%;max-height:240px;border:1px solid var(--bdr);border-radius:4px;margin-top:4px}
|
|
286
|
+
.a video{max-width:100%;max-height:360px;display:block}
|
|
283
287
|
a{color:var(--ruby)}.empty{padding:24px 0;color:var(--mute)}
|
|
284
288
|
CSS
|
|
285
289
|
|
|
@@ -48,30 +48,59 @@ module RaiderReporter
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
# Keeps the
|
|
52
|
-
#
|
|
53
|
-
#
|
|
51
|
+
# Keeps the attachments that fit the byte and count budget, non-video
|
|
52
|
+
# attachments first and videos in whatever room is left, and rebuilds the
|
|
53
|
+
# payload without the rest, so part names stay contiguous. Survivors keep
|
|
54
|
+
# their original order. The local run.json is written from the original
|
|
55
|
+
# payload and keeps all.
|
|
54
56
|
def within_budget(payload)
|
|
55
57
|
entries = payload.attachment_entries
|
|
56
|
-
|
|
57
|
-
keep
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
end || entries.size
|
|
61
|
-
keep = [keep, MAX_UPLOAD_ATTACHMENTS].min
|
|
62
|
-
return payload if keep == entries.size
|
|
63
|
-
|
|
64
|
-
dropped = entries.size - keep
|
|
58
|
+
keep = budgeted(entries)
|
|
59
|
+
return payload if keep.size == entries.size
|
|
60
|
+
|
|
61
|
+
dropped = entries.size - keep.size
|
|
65
62
|
@warn.call("not uploading #{dropped} #{dropped == 1 ? 'attachment' : 'attachments'}: over the upload " \
|
|
66
63
|
"budget of #{MAX_UPLOAD_ATTACHMENT_BYTES / (1024 * 1024)} MB / " \
|
|
67
64
|
"#{MAX_UPLOAD_ATTACHMENTS} files; they remain in #{@config.output_dir}")
|
|
68
|
-
seen = 0
|
|
69
65
|
run = payload.run
|
|
70
66
|
Payload.new(run.with(results: run.results.map do |result|
|
|
71
|
-
result.with(attachments: result.attachments.select { (
|
|
67
|
+
result.with(attachments: result.attachments.select { |attachment| keep.key?(attachment) })
|
|
72
68
|
end))
|
|
73
69
|
end
|
|
74
70
|
|
|
71
|
+
# Identity-keyed set of the attachments to upload. Non-videos come first
|
|
72
|
+
# and stop at the first byte overflow; videos then fill the room left,
|
|
73
|
+
# a video that does not fit is skipped and later ones are still tried.
|
|
74
|
+
def budgeted(entries)
|
|
75
|
+
videos, others = entries.partition { |entry| video?(entry[:attachment]) }
|
|
76
|
+
keep = {}.compare_by_identity
|
|
77
|
+
bytes = 0
|
|
78
|
+
others.each do |entry|
|
|
79
|
+
bytes += size_of(entry)
|
|
80
|
+
break if bytes > MAX_UPLOAD_ATTACHMENT_BYTES || keep.size >= MAX_UPLOAD_ATTACHMENTS
|
|
81
|
+
|
|
82
|
+
keep[entry[:attachment]] = true
|
|
83
|
+
end
|
|
84
|
+
videos.each do |entry|
|
|
85
|
+
break if keep.size >= MAX_UPLOAD_ATTACHMENTS
|
|
86
|
+
|
|
87
|
+
size = size_of(entry)
|
|
88
|
+
next if bytes + size > MAX_UPLOAD_ATTACHMENT_BYTES
|
|
89
|
+
|
|
90
|
+
bytes += size
|
|
91
|
+
keep[entry[:attachment]] = true
|
|
92
|
+
end
|
|
93
|
+
keep
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def video?(attachment)
|
|
97
|
+
attachment.content_type.to_s.start_with?("video/")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def size_of(entry)
|
|
101
|
+
File.size?(entry[:attachment].source_path).to_i
|
|
102
|
+
end
|
|
103
|
+
|
|
75
104
|
def add_field(body, boundary, name, value)
|
|
76
105
|
body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n".b
|
|
77
106
|
body << value.to_s.b << "\r\n".b
|
data/lib/raider_reporter.rb
CHANGED
|
@@ -50,6 +50,7 @@ module RaiderReporter
|
|
|
50
50
|
@project_root = Dir.pwd
|
|
51
51
|
@collector = Collector.new(
|
|
52
52
|
max_attachment_bytes: configuration.max_attachment_bytes,
|
|
53
|
+
max_video_bytes: configuration.max_video_bytes,
|
|
53
54
|
warn: ->(message) { warn(message) },
|
|
54
55
|
quarantine: -> { quarantined_keys }
|
|
55
56
|
)
|
|
@@ -70,14 +71,15 @@ module RaiderReporter
|
|
|
70
71
|
return false
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
content_type = ContentTypes.resolve(type, path)
|
|
75
|
+
limit = attachment_limit(content_type)
|
|
74
76
|
if File.size(path.to_s) > limit
|
|
75
77
|
warn("attach(#{name.inspect}): file #{path} is larger than #{limit} bytes; ignored")
|
|
76
78
|
return false
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
collector.attach(Attachment.new(name: name.to_s, source_path: collector.stage(path.to_s),
|
|
80
|
-
content_type:
|
|
82
|
+
content_type: content_type))
|
|
81
83
|
rescue StandardError => e
|
|
82
84
|
warn("attach(#{name.inspect}) failed: #{e.class}: #{e.message}")
|
|
83
85
|
false
|
|
@@ -119,6 +121,10 @@ module RaiderReporter
|
|
|
119
121
|
|
|
120
122
|
private
|
|
121
123
|
|
|
124
|
+
def attachment_limit(content_type)
|
|
125
|
+
content_type.start_with?("video/") ? configuration.max_video_bytes : configuration.max_attachment_bytes
|
|
126
|
+
end
|
|
127
|
+
|
|
122
128
|
# First, so a later reporting problem cannot lose it. A failure here only means
|
|
123
129
|
# no summary, and exec then keeps the command's exit code.
|
|
124
130
|
def write_exec_summary
|
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.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Augustin Gottlieb
|
|
@@ -38,6 +38,9 @@ files:
|
|
|
38
38
|
- lib/raider_reporter/cucumber/formatter.rb
|
|
39
39
|
- lib/raider_reporter/exec.rb
|
|
40
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
|
|
41
44
|
- lib/raider_reporter/github_event.rb
|
|
42
45
|
- lib/raider_reporter/models.rb
|
|
43
46
|
- lib/raider_reporter/payload.rb
|
|
@@ -71,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
74
|
- !ruby/object:Gem::Version
|
|
72
75
|
version: '0'
|
|
73
76
|
requirements: []
|
|
74
|
-
rubygems_version: 4.0.
|
|
77
|
+
rubygems_version: 4.0.20
|
|
75
78
|
specification_version: 4
|
|
76
79
|
summary: RSpec and Cucumber formatters that write Raider reports locally and upload
|
|
77
80
|
them to Raider
|