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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 300255531548132ae2fedcd7f10e8e9832925a204f5790ed8accb1d272ab64c6
4
- data.tar.gz: ae60aacce4ecd140c8d771b92e8d83b704ec945a3339917d79fb7aeb50d49da9
3
+ metadata.gz: 291269e17dbda3cec36ff4822af7ba9b16b85f5f1d231eed8b96fb9b89f9e338
4
+ data.tar.gz: 16e1c480d70fc174b25f7b91fcd5f9b242b04f1e7b751172e796050a0c405a39
5
5
  SHA512:
6
- metadata.gz: 0213a3b50dbeeedb77ac4e335afc632bf6a25ec815e875cabd4f54bed79fb4164b23770968f38911503055bdb6bc941460f379f08c7e34238f4c379740352f8e
7
- data.tar.gz: 6c2c8759eee7dccfb81f93095d4a5c522b6029680ba999ab56073a91a76f5b7e7524da659c9350ea22f8185e1d9c1f294a75ee53aea4ceced898c36b699e0a8d
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 11 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.
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/")
@@ -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
- "#{AzureEnv.collection_base(@env)}#{AzureEnv.value(@env, 'SYSTEM_TEAMPROJECTID')}/_apis/git/repositories/" \
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
- url = @env["GITHUB_API_URL"].to_s.strip
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:, clock: -> { Time.now.utc }, uuid: -> { SecureRandom.uuid },
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
- if File.size(path) > @max_attachment_bytes
122
- @warn.call("attachment #{path} is larger than #{@max_attachment_bytes} bytes; skipped")
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: "tests" } },
43
+ targetUrl: target_url, context: { genre: "raider", name: name } },
44
44
  version: STATUS_API_VERSION)
45
45
  nil
46
46
  end
@@ -15,8 +15,15 @@ module RaiderReporter
15
15
  MAX_PAGES = 10
16
16
  PER_PAGE = 100
17
17
  DESCRIPTION_LIMIT = 140
18
+ DEFAULT_API_URL = "https://api.github.com"
18
19
 
19
- def initialize(token:, repository:, api_url: "https://api.github.com", timeout: 30)
20
+ # GITHUB_API_URL from a CI env, falling back to github.com when unset or blank.
21
+ def self.api_url(env)
22
+ url = env["GITHUB_API_URL"].to_s.strip
23
+ url.empty? ? DEFAULT_API_URL : url
24
+ end
25
+
26
+ def initialize(token:, repository:, api_url: DEFAULT_API_URL, timeout: 30)
20
27
  @token = token
21
28
  @repository = repository
22
29
  @api_url = api_url.to_s.strip.sub(%r{/+\z}, "")
@@ -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 = !result.ok?(strict: @config.strict)
164
- flaky = !failed && @failed_before[key] && @config.strict.strict?(:flaky)
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, < 12) in your Gemfile"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -224,8 +224,11 @@ module RaiderReporter
224
224
  href = safe_href(attachment["path"])
225
225
  next unless href
226
226
 
227
- if attachment["content_type"].to_s.start_with?("image/")
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 leading attachments that fit the byte and count budget and
52
- # rebuilds the payload without the rest, so part names stay contiguous.
53
- # The local run.json is written from the original payload and keeps all.
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
- bytes = 0
57
- keep = entries.index do |entry|
58
- bytes += File.size?(entry[:attachment].source_path).to_i
59
- bytes > MAX_UPLOAD_ATTACHMENT_BYTES
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 { (seen += 1) <= keep })
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
@@ -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
- limit = configuration.max_attachment_bytes
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: ContentTypes.resolve(type, path)))
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.8.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.21
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