raider_reporter 0.9.0 → 0.11.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 +14 -0
- data/README.md +13 -2
- data/lib/raider_reporter/app_frame.rb +37 -12
- data/lib/raider_reporter/collector.rb +6 -3
- data/lib/raider_reporter/configuration.rb +3 -1
- data/lib/raider_reporter/cucumber/formatter.rb +52 -7
- data/lib/raider_reporter/cucumber.rb +1 -1
- data/lib/raider_reporter/event_stream.rb +78 -0
- data/lib/raider_reporter/rspec/formatter.rb +46 -12
- 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 +14 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f4191162d0ff126c9b45a552005131bc1c93b9c2c4abf97d8ae347bd8f9d21d
|
|
4
|
+
data.tar.gz: f1537e8415dee111fd49a2005e88282a34b2f0279ba74945aacab16034531f80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7763eb9aecea9e4c65d6727bf02be613720eb994bbd763f1755d23073f852faf5bcb4ca5986954a1327b80ce3a2d67b1a147f33c64cede3ab5318304897542a2
|
|
7
|
+
data.tar.gz: f06d92ce870157d73411f53ad368ecd6a01ce23294df319d49e7028f3d29d15677a8087c39c116a3d939fb58c6c0d5681aab286490e3fe94890771597438a540
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
- Live events: when `RAIDER_EVENTS_DIR` is set, the RSpec formatter and the Cucumber formatter/plugin append NDJSON lines (`plan`, `started`, `finished`, `done`, each with `"v":1`) to `<dir>/worker-<n>.ndjson`, where `n` is the shard index (1 without a shard). Cucumber writes `started` and `finished` once per attempt under `--retry`. The Raider desktop app tails them during a run. The files are created owner-only (0600) and a symlinked file is refused. With the variable unset, nothing changes. A write error switches the stream off after one warning and never fails the suite
|
|
6
|
+
- Backtrace frames inside the project are written project-relative (`spec/login_spec.rb:12:in ...`); gem, stdlib and `vendor/` frames are unchanged
|
|
7
|
+
- No breaking changes
|
|
8
|
+
|
|
9
|
+
## 0.10.0 — 2026-10-26
|
|
10
|
+
|
|
11
|
+
- 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
|
|
12
|
+
- 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
|
|
13
|
+
- 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
|
|
14
|
+
- 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)
|
|
15
|
+
- No breaking changes
|
|
16
|
+
|
|
3
17
|
## 0.9.0 — 2026-10-24
|
|
4
18
|
|
|
5
19
|
- `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
|
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,15 @@ 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.
|
|
128
|
+
|
|
129
|
+
## Live events
|
|
130
|
+
|
|
131
|
+
Set `RAIDER_EVENTS_DIR` and the RSpec formatter, or the Cucumber formatter/plugin, appends one JSON line per event to `<dir>/worker-<n>.ndjson` (`n` is the shard index, or 1): `plan` (the examples that will run), `started`, `finished` (status, duration, attempts; Cucumber writes one `started`/`finished` pair per `--retry` attempt) and `done`. Raider desktop tails these files for live results. Unset, nothing is written; a write error turns the stream off with one warning.
|
|
124
132
|
|
|
125
133
|
## Configuration
|
|
126
134
|
|
|
@@ -139,6 +147,9 @@ end
|
|
|
139
147
|
| `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
148
|
| `html_report` | `true` | Write `index.html`, the local HTML report, into `output_dir` after each run. See [Local report](#local-report). |
|
|
141
149
|
| `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). |
|
|
150
|
+
| `max_attachment_bytes` | `5 MB` | Largest non-video attachment `attach` accepts. |
|
|
151
|
+
| `max_video_bytes` | `50 MB` | Largest `video/*` attachment `attach` accepts. |
|
|
152
|
+
| `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
153
|
| `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
|
|
143
154
|
|
|
144
155
|
## Local output
|
|
@@ -17,23 +17,48 @@ module RaiderReporter
|
|
|
17
17
|
excluded = excluded_prefixes
|
|
18
18
|
Array(backtrace).each do |line|
|
|
19
19
|
line = line.to_s
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
relative = relative_path(line, base, excluded)
|
|
21
|
+
return Text.utf8("#{relative}#{line[line[/\A(.+?):\d+/, 1].length..]}")[0, MAX_LENGTH] if relative
|
|
22
|
+
end
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Rewrites every backtrace line that lies in project code to
|
|
27
|
+
# "relative/path.rb:42:in '...'"; all other lines are returned unchanged.
|
|
28
|
+
def self.relativize(backtrace, root:)
|
|
29
|
+
return [] if backtrace.nil? || backtrace.empty?
|
|
30
|
+
return backtrace if root.nil?
|
|
31
|
+
|
|
32
|
+
base = "#{File.expand_path(root.to_s).chomp("/")}/"
|
|
33
|
+
excluded = excluded_prefixes
|
|
34
|
+
Array(backtrace).map do |line|
|
|
35
|
+
text = line.to_s.scrub
|
|
36
|
+
relative = relative_path(text, base, excluded)
|
|
37
|
+
relative ? "#{relative}#{text[text[/\A(.+?):\d+/, 1].length..]}" : line
|
|
38
|
+
rescue StandardError
|
|
39
|
+
line
|
|
40
|
+
end
|
|
41
|
+
end
|
|
22
42
|
|
|
23
|
-
|
|
24
|
-
|
|
43
|
+
# The project-relative path of a backtrace line's file, or nil when the
|
|
44
|
+
# line is not in project code.
|
|
45
|
+
def self.relative_path(line, base, excluded)
|
|
46
|
+
path = line[/\A(.+?):\d+/, 1]
|
|
47
|
+
return nil if path.nil? || path.start_with?("<internal:", "(", "-")
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
next if excluded.any? { |prefix| absolute.start_with?(prefix) }
|
|
49
|
+
absolute = File.expand_path(path, base)
|
|
50
|
+
return nil unless File.file?(absolute)
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
absolute = inside_root(absolute, base)
|
|
53
|
+
return nil unless absolute
|
|
54
|
+
return nil if excluded.any? { |prefix| absolute.start_with?(prefix) }
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
relative = absolute.delete_prefix(base)
|
|
57
|
+
return nil if relative.start_with?("vendor/", ".bundle/") || relative.include?("/.bundle/")
|
|
58
|
+
|
|
59
|
+
relative
|
|
36
60
|
end
|
|
61
|
+
private_class_method :relative_path
|
|
37
62
|
|
|
38
63
|
# Returns the path (rewritten under base when only the real paths match,
|
|
39
64
|
# e.g. a symlinked tmpdir) or nil when it lies outside the root.
|
|
@@ -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
|
|
@@ -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
|
|
@@ -100,6 +100,7 @@ module RaiderReporter
|
|
|
100
100
|
RaiderReporter.dry_run = config.dry_run?
|
|
101
101
|
config.on_event(:test_run_started) { |_event| guard { @collector.start_run } }
|
|
102
102
|
config.on_event(:test_run_started) { |event| guard { expect_cases(event.test_cases) } }
|
|
103
|
+
config.on_event(:test_run_started) { |event| guard { RaiderReporter.event_stream&.plan(event.test_cases.map { |tc| event_test(tc) }) } }
|
|
103
104
|
config.on_event(:test_case_finished) { |event| count_outcome(event.test_case, event.result) }
|
|
104
105
|
config.on_event(:test_case_started) { |event| guard { case_started(event.test_case) } }
|
|
105
106
|
config.on_event(:test_step_finished) { |event| guard { step_finished(event.test_step, event.result) } }
|
|
@@ -113,6 +114,7 @@ module RaiderReporter
|
|
|
113
114
|
def case_started(test_case)
|
|
114
115
|
@collector.start_test
|
|
115
116
|
@attempt = { test_case: test_case }
|
|
117
|
+
RaiderReporter.event_stream&.started(event_test(test_case))
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
# The first non-passed step explains why the scenario did not pass.
|
|
@@ -133,10 +135,16 @@ module RaiderReporter
|
|
|
133
135
|
status = "quarantined" if status == "failed" && quarantined?(fields)
|
|
134
136
|
key = "#{test_case.location}|#{test_case.name}"
|
|
135
137
|
@attempts[key] += 1
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
begin
|
|
139
|
+
# The newest attempt replaces the older one, taking its attachments with it.
|
|
140
|
+
@buffer[key] = @collector.build_test(
|
|
141
|
+
**fields, status: status, duration_ms: duration_ms(result), attempts: @attempts[key], failure: failure
|
|
142
|
+
)
|
|
143
|
+
ensure
|
|
144
|
+
# Even if building the result failed: the desktop app must still see the attempt end
|
|
145
|
+
RaiderReporter.event_stream&.finished(id: fields[:identifier], status: status,
|
|
146
|
+
duration_ms: duration_ms(result), attempts: @attempts[key])
|
|
147
|
+
end
|
|
140
148
|
end
|
|
141
149
|
|
|
142
150
|
def run_finished
|
|
@@ -145,9 +153,23 @@ module RaiderReporter
|
|
|
145
153
|
safely(nil) { record_framework_counts }
|
|
146
154
|
RaiderReporter.finish!($stderr)
|
|
147
155
|
ensure
|
|
156
|
+
# Even if finish! raised, so the desktop app sees the stream end
|
|
157
|
+
safely(nil) { finish_events }
|
|
148
158
|
self.class.release(@config)
|
|
149
159
|
end
|
|
150
160
|
|
|
161
|
+
def finish_events
|
|
162
|
+
stream = RaiderReporter.event_stream
|
|
163
|
+
stream&.done
|
|
164
|
+
stream&.close
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def event_test(test_case)
|
|
168
|
+
fields = identity_for(test_case)
|
|
169
|
+
{ id: fields[:identifier], file: fields[:file_path], line: fields[:line_number],
|
|
170
|
+
description: Text.utf8(test_case.name), full_name: fields[:full_name] }
|
|
171
|
+
end
|
|
172
|
+
|
|
151
173
|
def expect_cases(test_cases)
|
|
152
174
|
@expected_cases = test_cases.map { |test_case| "#{test_case.location}|#{test_case.name}" }.uniq.size
|
|
153
175
|
end
|
|
@@ -160,8 +182,8 @@ module RaiderReporter
|
|
|
160
182
|
return if @outcomes_broken
|
|
161
183
|
|
|
162
184
|
key = "#{test_case.location}|#{test_case.name}"
|
|
163
|
-
failed = !
|
|
164
|
-
flaky = !failed && @failed_before[key] &&
|
|
185
|
+
failed = !result_ok?(result)
|
|
186
|
+
flaky = !failed && @failed_before[key] && strict_flaky?
|
|
165
187
|
@failed_before[key] ||= failed
|
|
166
188
|
@outcomes[key] = failed || flaky ? true : false
|
|
167
189
|
rescue StandardError => e
|
|
@@ -169,6 +191,23 @@ module RaiderReporter
|
|
|
169
191
|
RaiderReporter.warn("#{e.class}: #{e.message}")
|
|
170
192
|
end
|
|
171
193
|
|
|
194
|
+
# Cucumber 10 and 11 carry a strict configuration (config.strict) that result.ok?
|
|
195
|
+
# takes as a keyword. Cucumber 12 removed it: result.ok? takes nothing, pending and
|
|
196
|
+
# undefined always fail, and a flaky scenario never does. Detect, never compare versions.
|
|
197
|
+
def strict_configuration
|
|
198
|
+
@config.strict if @config.respond_to?(:strict)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def result_ok?(result)
|
|
202
|
+
strict = strict_configuration
|
|
203
|
+
strict ? result.ok?(strict: strict) : result.ok?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def strict_flaky?
|
|
207
|
+
strict = strict_configuration
|
|
208
|
+
strict ? strict.strict?(:flaky) : false
|
|
209
|
+
end
|
|
210
|
+
|
|
172
211
|
# Scenarios that make cucumber's run fail; one error outside tests when the run
|
|
173
212
|
# stopped before every scenario finished (--fail-fast) (spec §4.4).
|
|
174
213
|
def record_framework_counts
|
|
@@ -242,11 +281,17 @@ module RaiderReporter
|
|
|
242
281
|
Failure.new(
|
|
243
282
|
message: Text.utf8("#{step_label(step_info, test_case)}\n\n#{exception.message}")[0, Text::MAX_MESSAGE],
|
|
244
283
|
exception_class: Text.utf8(exception.class.name),
|
|
245
|
-
backtrace: (exception.backtrace || []).first(Text::MAX_BACKTRACE).map { |line| Text.utf8(line) },
|
|
284
|
+
backtrace: relativize(exception.backtrace || []).first(Text::MAX_BACKTRACE).map { |line| Text.utf8(line) },
|
|
246
285
|
app_frame: app_frame_for(exception)
|
|
247
286
|
)
|
|
248
287
|
end
|
|
249
288
|
|
|
289
|
+
def relativize(backtrace)
|
|
290
|
+
AppFrame.relativize(backtrace, root: RaiderReporter.project_root || Dir.pwd)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
backtrace
|
|
293
|
+
end
|
|
294
|
+
|
|
250
295
|
def app_frame_for(exception)
|
|
251
296
|
AppFrame.from(exception.backtrace, root: RaiderReporter.project_root || Dir.pwd)
|
|
252
297
|
rescue StandardError
|
|
@@ -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,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "raider_reporter/text"
|
|
6
|
+
require "raider_reporter/shard_detector"
|
|
7
|
+
|
|
8
|
+
module RaiderReporter
|
|
9
|
+
# Live NDJSON events for the Raider desktop app, one file per worker. On only
|
|
10
|
+
# when RAIDER_EVENTS_DIR is set. A write error switches it off after one warning.
|
|
11
|
+
class EventStream
|
|
12
|
+
VERSION = 1
|
|
13
|
+
# Owner-only, and never through a symlink someone planted in the events dir.
|
|
14
|
+
OPEN_FLAGS = File::WRONLY | File::APPEND | File::CREAT | (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
15
|
+
|
|
16
|
+
def self.open(warn:, env: ENV, shard: ShardDetector.new.shard)
|
|
17
|
+
dir = env["RAIDER_EVENTS_DIR"].to_s
|
|
18
|
+
return nil if dir.empty?
|
|
19
|
+
|
|
20
|
+
FileUtils.mkdir_p(dir)
|
|
21
|
+
file = File.open(File.join(dir, "worker-#{shard ? shard["index"] : 1}.ndjson"), OPEN_FLAGS, 0o600)
|
|
22
|
+
file.sync = true
|
|
23
|
+
new(file, warn: warn)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn.call("events stream disabled: #{e.class}: #{e.message}")
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(file, warn:)
|
|
30
|
+
@file = file
|
|
31
|
+
@warn = warn
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def plan(tests)
|
|
35
|
+
emit("plan", tests: tests.map { |test| test_fields(test) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def started(test)
|
|
39
|
+
emit("started", **test_fields(test))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finished(id:, status:, duration_ms:, attempts:)
|
|
43
|
+
emit("finished", id: cap(id), status: status, duration_ms: duration_ms, attempts: attempts)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def done
|
|
47
|
+
emit("done")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
@file&.close
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
ensure
|
|
55
|
+
@file = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def test_fields(test)
|
|
61
|
+
{ id: cap(test[:id]), file: cap(test[:file]), line: test[:line],
|
|
62
|
+
description: cap(test[:description]), full_name: cap(test[:full_name]) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cap(value)
|
|
66
|
+
Text.utf8(value)[0, Text::MAX_MESSAGE]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def emit(type, **fields)
|
|
70
|
+
return unless @file
|
|
71
|
+
|
|
72
|
+
@file.write("#{JSON.generate({ v: VERSION, type: type }.merge(fields))}\n")
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
@warn.call("events stream disabled: #{e.class}: #{e.message}")
|
|
75
|
+
close
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -24,10 +24,12 @@ module RaiderReporter
|
|
|
24
24
|
|
|
25
25
|
def start(_notification)
|
|
26
26
|
guard { @collector.start_run }
|
|
27
|
+
guard { RaiderReporter.event_stream&.plan(planned_tests) }
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
def example_started(
|
|
30
|
+
def example_started(notification)
|
|
30
31
|
guard { @collector.start_test }
|
|
32
|
+
guard { RaiderReporter.event_stream&.started(event_test(notification.example)) }
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def example_passed(notification)
|
|
@@ -53,6 +55,10 @@ module RaiderReporter
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def close(_notification)
|
|
58
|
+
guard do
|
|
59
|
+
RaiderReporter.event_stream&.done
|
|
60
|
+
RaiderReporter.event_stream&.close
|
|
61
|
+
end
|
|
56
62
|
guard { RaiderReporter.finish! }
|
|
57
63
|
end
|
|
58
64
|
|
|
@@ -66,17 +72,38 @@ module RaiderReporter
|
|
|
66
72
|
failure = status == "failed" ? failure_for(execution.exception) : nil
|
|
67
73
|
# The key is computed from the payload's own file_path and full_name (spec §4.2).
|
|
68
74
|
status = "quarantined" if status == "failed" && @collector.quarantined?(file_path, full_name)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
attempts = metadata.fetch(:retry_attempts, 0).to_i + 1
|
|
76
|
+
duration_ms = ((execution.run_time || 0) * 1000).round
|
|
77
|
+
begin
|
|
78
|
+
@collector.finish_test(
|
|
79
|
+
identifier: example.id,
|
|
80
|
+
full_name: full_name,
|
|
81
|
+
file_path: file_path,
|
|
82
|
+
line_number: metadata[:line_number],
|
|
83
|
+
status: status,
|
|
84
|
+
duration_ms: duration_ms,
|
|
85
|
+
attempts: attempts,
|
|
86
|
+
tags: metadata.select { |key, value| value == true && !NON_TAG_KEYS.include?(key) }.keys.map(&:to_s).sort,
|
|
87
|
+
failure: failure
|
|
88
|
+
)
|
|
89
|
+
ensure
|
|
90
|
+
# Even if recording failed: the desktop app must still see the example end
|
|
91
|
+
RaiderReporter.event_stream&.finished(id: example.id, status: status, duration_ms: duration_ms,
|
|
92
|
+
attempts: attempts)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The examples this run will execute (after tag/line filters), in run order.
|
|
97
|
+
def planned_tests
|
|
98
|
+
world = ::RSpec.world
|
|
99
|
+
world.ordered_example_groups.flat_map(&:descendants)
|
|
100
|
+
.flat_map { |group| world.filtered_examples[group] }.map { |example| event_test(example) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def event_test(example)
|
|
104
|
+
{ id: example.id, file: example.metadata[:file_path].to_s.delete_prefix("./"),
|
|
105
|
+
line: example.metadata[:line_number], description: example.description,
|
|
106
|
+
full_name: example.full_description }
|
|
80
107
|
end
|
|
81
108
|
|
|
82
109
|
# Errors RSpec reported outside examples, plus one when it stopped early
|
|
@@ -91,6 +118,7 @@ module RaiderReporter
|
|
|
91
118
|
return nil unless exception
|
|
92
119
|
|
|
93
120
|
backtrace = ::RSpec.configuration.backtrace_formatter.format_backtrace(exception.backtrace || [])
|
|
121
|
+
backtrace = relativize(backtrace)
|
|
94
122
|
Failure.new(
|
|
95
123
|
message: utf8(exception.message)[0, MAX_MESSAGE],
|
|
96
124
|
exception_class: utf8(exception.class.name),
|
|
@@ -108,6 +136,12 @@ module RaiderReporter
|
|
|
108
136
|
exception.all_exceptions.first&.backtrace
|
|
109
137
|
end
|
|
110
138
|
|
|
139
|
+
def relativize(backtrace)
|
|
140
|
+
AppFrame.relativize(backtrace, root: RaiderReporter.project_root || Dir.pwd)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
backtrace
|
|
143
|
+
end
|
|
144
|
+
|
|
111
145
|
def app_frame_for(exception)
|
|
112
146
|
AppFrame.from(raw_backtrace(exception), root: RaiderReporter.project_root || Dir.pwd)
|
|
113
147
|
rescue StandardError
|
|
@@ -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
|
@@ -16,6 +16,7 @@ require_relative "raider_reporter/writers/local"
|
|
|
16
16
|
require_relative "raider_reporter/writers/html"
|
|
17
17
|
require_relative "raider_reporter/ci_detector"
|
|
18
18
|
require_relative "raider_reporter/shard_detector"
|
|
19
|
+
require_relative "raider_reporter/event_stream"
|
|
19
20
|
require_relative "raider_reporter/writers/uploader"
|
|
20
21
|
require_relative "raider_reporter/comment/summary_client"
|
|
21
22
|
require_relative "raider_reporter/comment/github_client"
|
|
@@ -23,7 +24,7 @@ require_relative "raider_reporter/comment/azure_client"
|
|
|
23
24
|
|
|
24
25
|
module RaiderReporter
|
|
25
26
|
class << self
|
|
26
|
-
attr_reader :collector, :project_root
|
|
27
|
+
attr_reader :collector, :project_root, :event_stream
|
|
27
28
|
|
|
28
29
|
def configuration
|
|
29
30
|
@configuration ||= Configuration.new
|
|
@@ -39,6 +40,8 @@ module RaiderReporter
|
|
|
39
40
|
def reset!
|
|
40
41
|
@dry_run = false
|
|
41
42
|
@collector&.cleanup
|
|
43
|
+
@event_stream&.close
|
|
44
|
+
@event_stream = nil
|
|
42
45
|
@configuration = nil
|
|
43
46
|
@collector = nil
|
|
44
47
|
@project_root = nil
|
|
@@ -48,8 +51,11 @@ module RaiderReporter
|
|
|
48
51
|
def start_collector!
|
|
49
52
|
@dry_run = false
|
|
50
53
|
@project_root = Dir.pwd
|
|
54
|
+
@event_stream&.close
|
|
55
|
+
@event_stream = EventStream.open(warn: ->(message) { warn(message) })
|
|
51
56
|
@collector = Collector.new(
|
|
52
57
|
max_attachment_bytes: configuration.max_attachment_bytes,
|
|
58
|
+
max_video_bytes: configuration.max_video_bytes,
|
|
53
59
|
warn: ->(message) { warn(message) },
|
|
54
60
|
quarantine: -> { quarantined_keys }
|
|
55
61
|
)
|
|
@@ -70,14 +76,15 @@ module RaiderReporter
|
|
|
70
76
|
return false
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
content_type = ContentTypes.resolve(type, path)
|
|
80
|
+
limit = attachment_limit(content_type)
|
|
74
81
|
if File.size(path.to_s) > limit
|
|
75
82
|
warn("attach(#{name.inspect}): file #{path} is larger than #{limit} bytes; ignored")
|
|
76
83
|
return false
|
|
77
84
|
end
|
|
78
85
|
|
|
79
86
|
collector.attach(Attachment.new(name: name.to_s, source_path: collector.stage(path.to_s),
|
|
80
|
-
content_type:
|
|
87
|
+
content_type: content_type))
|
|
81
88
|
rescue StandardError => e
|
|
82
89
|
warn("attach(#{name.inspect}) failed: #{e.class}: #{e.message}")
|
|
83
90
|
false
|
|
@@ -119,6 +126,10 @@ module RaiderReporter
|
|
|
119
126
|
|
|
120
127
|
private
|
|
121
128
|
|
|
129
|
+
def attachment_limit(content_type)
|
|
130
|
+
content_type.start_with?("video/") ? configuration.max_video_bytes : configuration.max_attachment_bytes
|
|
131
|
+
end
|
|
132
|
+
|
|
122
133
|
# First, so a later reporting problem cannot lose it. A failure here only means
|
|
123
134
|
# no summary, and exec then keeps the command's exit code.
|
|
124
135
|
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.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Augustin Gottlieb
|
|
@@ -36,6 +36,7 @@ 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/event_stream.rb
|
|
39
40
|
- lib/raider_reporter/exec.rb
|
|
40
41
|
- lib/raider_reporter/exec_summary.rb
|
|
41
42
|
- lib/raider_reporter/gate.rb
|
|
@@ -74,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
75
|
- !ruby/object:Gem::Version
|
|
75
76
|
version: '0'
|
|
76
77
|
requirements: []
|
|
77
|
-
rubygems_version: 4.0.
|
|
78
|
+
rubygems_version: 4.0.20
|
|
78
79
|
specification_version: 4
|
|
79
80
|
summary: RSpec and Cucumber formatters that write Raider reports locally and upload
|
|
80
81
|
them to Raider
|