raider_reporter 0.10.0 → 0.12.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: 291269e17dbda3cec36ff4822af7ba9b16b85f5f1d231eed8b96fb9b89f9e338
4
- data.tar.gz: 16e1c480d70fc174b25f7b91fcd5f9b242b04f1e7b751172e796050a0c405a39
3
+ metadata.gz: f5caf918bfe636da31412f0d9befd2122cdb81c1c207ec594b5e60f11a9aa930
4
+ data.tar.gz: 96b844a743d018f47b54cbb80c3552ab2a76e2be6b247095099efa2457f07a3a
5
5
  SHA512:
6
- metadata.gz: 6bc649c77eaa3a22110dd19b9a5eb28f1e60aab2d6a2877985a8254c85ad647f58d83427e298f79d48a5e420893afc12c06c54a05526a0b0488ca43c015a6510
7
- data.tar.gz: 1590461029cc94956c3e28fffa9003252d8de1f064211dbcc9d3dcdfe0cbe11c4f9125fafb459978f77f082419b80c36a4702d0a7917ba480d4ed6a740a5f8fb
6
+ metadata.gz: 0c030e57458d0a854dd9d52fef8086301b52d62cfa181e689c5a30f88cc6b6feb108510c767183d3abb3cac7ed0deeb1037e0ed7b9b4a80357e95c1ca805bf06
7
+ data.tar.gz: '0382b224926f9a6595f66355102afd825ced75a0e107bdf65c37c8cb7af8d5a93cfef66ef8a5f7952bb63351c9c9f16c226afd28ca5c46112daf29240da29343'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 — 2026-11-01
4
+
5
+ - Raider traces: the content type `application/vnd.raider.trace+zip` (`type: :trace`) gets its own size cap, `config.max_trace_bytes` (default 25 MB). The 5 MB `max_attachment_bytes` and the 50 MB `max_video_bytes` still apply to everything else. A trace over its cap is skipped with a warning
6
+ - `RaiderReporter.attach(name, path, type:, attempt:)` takes an optional `attempt`, written to the attachment's entry in `run.json` and in the upload when given
7
+ - Uploads send screenshots and other files first, then traces, then videos, in the room left in the budget. A trace that does not fit is skipped and later, smaller ones are still tried
8
+ - The local HTML report shows a trace as a download link ("Trace (open in Raider Desktop)", with the attempt when known)
9
+ - `.zip` files resolve to `application/zip`
10
+ - No breaking changes
11
+
12
+ ## 0.11.0 — 2026-10-29
13
+
14
+ - 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
15
+ - Backtrace frames inside the project are written project-relative (`spec/login_spec.rb:12:in ...`); gem, stdlib and `vendor/` frames are unchanged
16
+ - No breaking changes
17
+
3
18
  ## 0.10.0 — 2026-10-26
4
19
 
5
20
  - 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
data/README.md CHANGED
@@ -101,7 +101,7 @@ Cucumber's `attach` is captured only when given the path of an existing file. In
101
101
 
102
102
  `parallel_tests` works with no configuration for Cucumber too: each process writes `run-<n>.json`. See [Parallel runs](#parallel-runs).
103
103
 
104
- ## Attaching screenshots and videos
104
+ ## Attaching screenshots, videos and traces
105
105
 
106
106
  Call `RaiderReporter.attach` while an example is running, for example from an `after` hook:
107
107
 
@@ -120,11 +120,17 @@ 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 its size cap: 5 MB (`max_attachment_bytes`), or 50 MB for videos (`video/*`, `max_video_bytes`).
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`), 50 MB for videos (`video/*`, `max_video_bytes`), or 25 MB for traces (`max_trace_bytes`).
124
124
 
125
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
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.
127
+ Raider traces (`trace.zip`, content type `application/vnd.raider.trace+zip`, written by ruby_raider projects for failing tests) are attached with `RaiderReporter.attach("trace", path, type: :trace, attempt: 2)`. `attempt` is optional on any attachment. When given, it is written to the attachment's entry in `run.json` and in the upload, so a viewer can tell the traces of different retries apart. In the local HTML report a trace is a download link; open it in Raider Desktop. The format is described in the [trace viewer design](https://github.com/RaiderHQ/raider_planning/blob/main/docs/superpowers/specs/2026-11-01-trace-viewer-design.md).
128
+
129
+ When a run is uploaded, screenshots and other attachments are sent first, then traces, then videos, in the room left in the upload budget. A trace or video that does not fit is skipped and later, smaller ones are still tried. The local `raider-results/attachments/` folder keeps every attachment that passed its size cap.
130
+
131
+ ## Live events
132
+
133
+ 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.
128
134
 
129
135
  ## Configuration
130
136
 
@@ -138,13 +144,14 @@ end
138
144
  | Option | Default | Description |
139
145
  | --- | --- | --- |
140
146
  | `token` | `ENV["RAIDER_TOKEN"]` | API token used for uploads. |
141
- | `endpoint` | `ENV["RAIDER_ENDPOINT"]` | Base URL of the Raider server to upload to. |
147
+ | `endpoint` | `ENV["RAIDER_ENDPOINT"]` | Base URL of your Raider Hideout server (what its setup page or bin/rails hideout:setup prints as RAIDER_ENDPOINT). |
142
148
  | `output_dir` | `"raider-results"` | Directory for local output. |
143
149
  | `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`. |
144
150
  | `html_report` | `true` | Write `index.html`, the local HTML report, into `output_dir` after each run. See [Local report](#local-report). |
145
151
  | `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
152
  | `max_attachment_bytes` | `5 MB` | Largest non-video attachment `attach` accepts. |
147
153
  | `max_video_bytes` | `50 MB` | Largest `video/*` attachment `attach` accepts. |
154
+ | `max_trace_bytes` | `25 MB` | Largest Raider trace (`application/vnd.raider.trace+zip`) `attach` accepts. |
148
155
  | `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). |
149
156
  | `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
150
157
 
@@ -176,7 +183,7 @@ Set `config.html_report = false` to turn it off. An `index.html` that raider_rep
176
183
  If both a token and an endpoint are set, the run (plus attachments) is uploaded when the suite finishes:
177
184
 
178
185
  ```
179
- RAIDER_TOKEN=... RAIDER_ENDPOINT=https://your-raider-server bundle exec rspec
186
+ RAIDER_TOKEN=... RAIDER_ENDPOINT=https://hideout.example.com bundle exec rspec
180
187
  ```
181
188
 
182
189
  On success the formatter prints `Raider report: <url>` to stderr (so tools that parse RSpec's stdout, such as `--format json`, stay clean), using the build URL when the server returns one and the run URL otherwise (or `Raider report uploaded (run <id>)` if the response has no URL). If either value is missing, nothing is uploaded and the local files are still written.
@@ -185,7 +192,7 @@ An upload carries at most 45 MB of attachments and at most 1000 attachment files
185
192
 
186
193
  Reporting is fail-open: no error inside the gem, network failure, timeout or non-2xx response will raise or change RSpec's exit status. Each connect/write/read step of the upload times out after 30 seconds. Problems are printed as warnings prefixed with `[raider_reporter]`. An endpoint without `http://` or `https://` (for example `localhost:3000`) produces a warning and nothing is uploaded; a trailing slash is fine.
187
194
 
188
- The hosted Raider service is not public yet; uploads go to a Raider server whose address you provide in `RAIDER_ENDPOINT`.
195
+ Raider Hideout is self-hosted: run your own server and set `RAIDER_ENDPOINT` to its URL. There is no hosted service.
189
196
 
190
197
  ## Quarantine
191
198
 
@@ -230,10 +237,10 @@ On pull requests, `raider-reporter comment` posts one comment with the build's r
230
237
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
231
238
  ```
232
239
 
233
- - The comment is rendered by the Raider server (`GET /api/v1/builds/summary`); the gem only fetches the Markdown and posts it. The same comment is updated on every run instead of adding a new one.
240
+ - The comment is rendered by the Raider Hideout server (`GET /api/v1/builds/summary`); the gem only fetches the Markdown and posts it. The same comment is updated on every run instead of adding a new one.
234
241
  - Pull requests from forks are skipped: GitHub gives their workflows a read-only token, so the command prints a notice and exits 0.
235
242
  - 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.
236
- - It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
243
+ - It requires a Raider Hideout server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
237
244
 
238
245
  ## Quality gate
239
246
 
@@ -313,7 +320,7 @@ jobs:
313
320
  - **Artifact names:** pipeline artifacts cannot be overwritten within a run, so the name includes the job position and attempt. A fixed name would fail when you re-run failed jobs or use `strategy: parallel`.
314
321
  - **Azure Repos only:** the PR comment works for repositories hosted in Azure Repos. A GitHub-hosted repository built by Azure Pipelines still uploads results, but `comment` prints `skipped: not an Azure Repos pull request`.
315
322
  - **Parallel jobs:** `strategy: parallel: N` shards are detected automatically and grouped into one build, like GitHub matrix jobs.
316
- - The command never fails your pipeline: it always exits 0 and prints notices to stderr, prefixed `[raider_reporter]`. It needs `SYSTEM_ACCESSTOKEN`, `RAIDER_TOKEN` and `RAIDER_ENDPOINT`, and requires a Raider server that supports the summary endpoint.
323
+ - The command never fails your pipeline: it always exits 0 and prints notices to stderr, prefixed `[raider_reporter]`. It needs `SYSTEM_ACCESSTOKEN`, `RAIDER_TOKEN` and `RAIDER_ENDPOINT`, and requires a Raider Hideout server that supports the summary endpoint.
317
324
 
318
325
  ## Parallel runs
319
326
 
@@ -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
- path = line[/\A(.+?):\d+/, 1]
21
- next if path.nil? || path.start_with?("<internal:", "(", "-")
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
- absolute = File.expand_path(path, base)
24
- next unless File.file?(absolute)
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
- absolute = inside_root(absolute, base)
27
- next unless absolute
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
- relative = absolute.delete_prefix(base)
31
- next if relative.start_with?("vendor/", ".bundle/") || relative.include?("/.bundle/")
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
- return Text.utf8("#{relative}#{line[path.length..]}")[0, MAX_LENGTH]
34
- end
35
- nil
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.
@@ -4,15 +4,18 @@ require "securerandom"
4
4
  require "tmpdir"
5
5
  require "fileutils"
6
6
  require "set"
7
+ require_relative "content_types"
7
8
 
8
9
  module RaiderReporter
9
10
  # Accumulates one suite run. Framework formatters call it; writers read the Run it returns.
10
11
  class Collector
11
12
  def initialize(max_attachment_bytes:, warn:, max_video_bytes: Configuration::DEFAULT_MAX_VIDEO_BYTES,
13
+ max_trace_bytes: Configuration::DEFAULT_MAX_TRACE_BYTES,
12
14
  clock: -> { Time.now.utc }, uuid: -> { SecureRandom.uuid },
13
15
  quarantine: -> { Quarantine::EMPTY })
14
16
  @max_attachment_bytes = max_attachment_bytes
15
17
  @max_video_bytes = max_video_bytes
18
+ @max_trace_bytes = max_trace_bytes
16
19
  @warn = warn
17
20
  @clock = clock
18
21
  @uuid = uuid
@@ -120,12 +123,19 @@ module RaiderReporter
120
123
  @warn.call("attachment #{path} no longer exists; skipped")
121
124
  return false
122
125
  end
123
- limit = attachment.content_type.to_s.start_with?("video/") ? @max_video_bytes : @max_attachment_bytes
126
+ limit = limit_for(attachment.content_type)
124
127
  if File.size(path) > limit
125
128
  @warn.call("attachment #{path} is larger than #{limit} bytes; skipped")
126
129
  return false
127
130
  end
128
131
  true
129
132
  end
133
+
134
+ def limit_for(content_type)
135
+ return @max_trace_bytes if ContentTypes.trace?(content_type)
136
+ return @max_video_bytes if ContentTypes.video?(content_type)
137
+
138
+ @max_attachment_bytes
139
+ end
130
140
  end
131
141
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "raider_reporter/azure_env"
4
+ require "raider_reporter/content_types"
4
5
 
5
6
  module RaiderReporter
6
7
  class Configuration
7
8
  TRUTHY = %w[1 true].freeze
8
9
  DEFAULT_MAX_VIDEO_BYTES = 50 * 1024 * 1024
10
+ DEFAULT_MAX_TRACE_BYTES = 25 * 1024 * 1024
9
11
 
10
12
  attr_accessor :token, :endpoint, :output_dir, :clean_output_dir, :html_report, :environment, :max_attachment_bytes,
11
- :max_video_bytes, :quarantine, :exec_summary_path
13
+ :max_video_bytes, :max_trace_bytes, :quarantine, :exec_summary_path
12
14
 
13
15
  def initialize(env = ENV)
14
16
  @token = AzureEnv.value(env, "RAIDER_TOKEN")
@@ -19,10 +21,19 @@ module RaiderReporter
19
21
  @environment = {}
20
22
  @max_attachment_bytes = 5 * 1024 * 1024
21
23
  @max_video_bytes = DEFAULT_MAX_VIDEO_BYTES
24
+ @max_trace_bytes = DEFAULT_MAX_TRACE_BYTES
22
25
  @quarantine = TRUTHY.include?(AzureEnv.value(env, "RAIDER_QUARANTINE").to_s.downcase)
23
26
  @exec_summary_path = AzureEnv.value(env, "RAIDER_EXEC_SUMMARY")
24
27
  end
25
28
 
29
+ # The size cap for one attachment of this content type.
30
+ def attachment_limit(content_type)
31
+ return max_trace_bytes if ContentTypes.trace?(content_type)
32
+ return max_video_bytes if ContentTypes.video?(content_type)
33
+
34
+ max_attachment_bytes
35
+ end
36
+
26
37
  def upload?
27
38
  present?(token) && present?(endpoint)
28
39
  end
@@ -2,18 +2,26 @@
2
2
 
3
3
  module RaiderReporter
4
4
  module ContentTypes
5
+ TRACE = "application/vnd.raider.trace+zip"
6
+
5
7
  BY_EXTENSION = {
6
8
  ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
7
9
  ".webp" => "image/webp", ".mp4" => "video/mp4", ".webm" => "video/webm", ".mov" => "video/quicktime",
8
- ".txt" => "text/plain", ".log" => "text/plain", ".html" => "text/html", ".json" => "application/json"
10
+ ".txt" => "text/plain", ".log" => "text/plain", ".html" => "text/html", ".json" => "application/json",
11
+ ".zip" => "application/zip"
9
12
  }.freeze
10
13
  BY_SYMBOL = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", mp4: "video/mp4",
11
- webm: "video/webm", text: "text/plain", html: "text/html", json: "application/json" }.freeze
14
+ webm: "video/webm", text: "text/plain", html: "text/html", json: "application/json",
15
+ trace: TRACE }.freeze
12
16
 
13
17
  MIME = %r{\A[\w.+-]+/[\w.+-]+\z}
14
18
 
15
19
  module_function
16
20
 
21
+ def trace?(content_type) = content_type.to_s == TRACE
22
+
23
+ def video?(content_type) = content_type.to_s.start_with?("video/")
24
+
17
25
  def resolve(type, path)
18
26
  if type.is_a?(String)
19
27
  return type if type.match?(MIME)
@@ -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
- # The newest attempt replaces the older one, taking its attachments with it.
137
- @buffer[key] = @collector.build_test(
138
- **fields, status: status, duration_ms: duration_ms(result), attempts: @attempts[key], failure: failure
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
@@ -259,11 +281,17 @@ module RaiderReporter
259
281
  Failure.new(
260
282
  message: Text.utf8("#{step_label(step_info, test_case)}\n\n#{exception.message}")[0, Text::MAX_MESSAGE],
261
283
  exception_class: Text.utf8(exception.class.name),
262
- 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) },
263
285
  app_frame: app_frame_for(exception)
264
286
  )
265
287
  end
266
288
 
289
+ def relativize(backtrace)
290
+ AppFrame.relativize(backtrace, root: RaiderReporter.project_root || Dir.pwd)
291
+ rescue StandardError
292
+ backtrace
293
+ end
294
+
267
295
  def app_frame_for(exception)
268
296
  AppFrame.from(exception.backtrace, root: RaiderReporter.project_root || Dir.pwd)
269
297
  rescue StandardError
@@ -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
@@ -9,7 +9,12 @@ module RaiderReporter
9
9
  super
10
10
  end
11
11
  end
12
- Attachment = Data.define(:name, :source_path, :content_type)
12
+ # attempt: which try of the test produced it, when the caller knows (traces); nil otherwise.
13
+ Attachment = Data.define(:name, :source_path, :content_type, :attempt) do
14
+ def initialize(name:, source_path:, content_type:, attempt: nil)
15
+ super
16
+ end
17
+ end
13
18
  TestResult = Data.define(
14
19
  :identifier, :full_name, :file_path, :line_number, :status,
15
20
  :duration_ms, :attempts, :tags, :failure, :attachments
@@ -88,7 +88,10 @@ module RaiderReporter
88
88
 
89
89
  def attachment_hash(entry, mode)
90
90
  ref = mode == :local ? { "path" => entry[:path] } : { "part" => entry[:part] }
91
- { "name" => entry[:attachment].name, "content_type" => entry[:attachment].content_type }.merge(ref)
91
+ attachment = entry[:attachment]
92
+ hash = { "name" => attachment.name, "content_type" => attachment.content_type }.merge(ref)
93
+ hash["attempt"] = attachment.attempt if attachment.attempt
94
+ hash
92
95
  end
93
96
  end
94
97
  end
@@ -11,7 +11,7 @@ module RaiderReporter
11
11
  NUL = "\u0000"
12
12
  NUL_REPLACEMENT = "\u{FFFD}"
13
13
 
14
- # The server's test identity: raider_saas TestCase.key_for over the payload's
14
+ # The server's test identity: Raider Hideout's TestCase.key_for over the payload's
15
15
  # file_path and full_name, after ingest replaced NUL in each of them.
16
16
  def self.test_key(file_path, full_name)
17
17
  parts = [file_path, full_name].map { |part| part.to_s.tr(NUL, NUL_REPLACEMENT) }
@@ -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(_notification)
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
- @collector.finish_test(
70
- identifier: example.id,
71
- full_name: full_name,
72
- file_path: file_path,
73
- line_number: metadata[:line_number],
74
- status: status,
75
- duration_ms: ((execution.run_time || 0) * 1000).round,
76
- attempts: metadata.fetch(:retry_attempts, 0).to_i + 1,
77
- tags: metadata.select { |key, value| value == true && !NON_TAG_KEYS.include?(key) }.keys.map(&:to_s).sort,
78
- failure: failure
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -4,6 +4,7 @@ require "erb"
4
4
  require "fileutils"
5
5
  require "json"
6
6
  require "time"
7
+ require_relative "../content_types"
7
8
 
8
9
  module RaiderReporter
9
10
  module Writers
@@ -229,6 +230,8 @@ module RaiderReporter
229
230
  %(<a href="#{href}"><img loading="lazy" src="#{href}" alt="#{h(attachment['name'])}"></a>)
230
231
  elsif content_type.start_with?("video/")
231
232
  %(<video controls preload="metadata" src="#{href}"></video><a href="#{href}" download>#{h(attachment['name'])}</a>)
233
+ elsif ContentTypes.trace?(content_type)
234
+ %(<a href="#{href}" download>#{h(trace_label(attachment['attempt']))}</a>)
232
235
  else
233
236
  %(<a href="#{href}">#{h(attachment['name'])}</a>)
234
237
  end
@@ -236,6 +239,10 @@ module RaiderReporter
236
239
  items.empty? ? "" : %(<div class="a">#{items.join(' ')}</div>)
237
240
  end
238
241
 
242
+ def trace_label(attempt)
243
+ attempt.is_a?(Integer) ? "Trace, attempt #{attempt} (open in Raider Desktop)" : "Trace (open in Raider Desktop)"
244
+ end
245
+
239
246
  # Relative paths inside the results folder only, each segment URL-encoded.
240
247
  def safe_href(path)
241
248
  path = path.to_s
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "securerandom"
7
+ require_relative "../content_types"
7
8
 
8
9
  module RaiderReporter
9
10
  module Writers
@@ -48,8 +49,9 @@ module RaiderReporter
48
49
 
49
50
  private
50
51
 
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
52
+ # Keeps the attachments that fit the byte and count budget: screenshots,
53
+ # logs and other files first, then traces, then videos in whatever room
54
+ # is left. It rebuilds the
53
55
  # payload without the rest, so part names stay contiguous. Survivors keep
54
56
  # their original order. The local run.json is written from the original
55
57
  # payload and keeps all.
@@ -68,11 +70,13 @@ module RaiderReporter
68
70
  end))
69
71
  end
70
72
 
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.
73
+ # Identity-keyed set of the attachments to upload. Other files come first
74
+ # and stop at the first byte overflow; traces and then videos fill the
75
+ # room left, and one that does not fit is skipped while later ones are
76
+ # still tried.
74
77
  def budgeted(entries)
75
- videos, others = entries.partition { |entry| video?(entry[:attachment]) }
78
+ traces, rest = entries.partition { |entry| ContentTypes.trace?(entry[:attachment].content_type) }
79
+ videos, others = rest.partition { |entry| ContentTypes.video?(entry[:attachment].content_type) }
76
80
  keep = {}.compare_by_identity
77
81
  bytes = 0
78
82
  others.each do |entry|
@@ -81,7 +85,7 @@ module RaiderReporter
81
85
 
82
86
  keep[entry[:attachment]] = true
83
87
  end
84
- videos.each do |entry|
88
+ (traces + videos).each do |entry|
85
89
  break if keep.size >= MAX_UPLOAD_ATTACHMENTS
86
90
 
87
91
  size = size_of(entry)
@@ -93,10 +97,6 @@ module RaiderReporter
93
97
  keep
94
98
  end
95
99
 
96
- def video?(attachment)
97
- attachment.content_type.to_s.start_with?("video/")
98
- end
99
-
100
100
  def size_of(entry)
101
101
  File.size?(entry[:attachment].source_path).to_i
102
102
  end
@@ -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,9 +51,12 @@ 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,
53
58
  max_video_bytes: configuration.max_video_bytes,
59
+ max_trace_bytes: configuration.max_trace_bytes,
54
60
  warn: ->(message) { warn(message) },
55
61
  quarantine: -> { quarantined_keys }
56
62
  )
@@ -61,7 +67,8 @@ module RaiderReporter
61
67
  @quarantined_keys ||= Quarantine.load(configuration, warn: ->(message) { warn(message) })
62
68
  end
63
69
 
64
- def attach(name, path, type: nil)
70
+ # attempt: the try of the test that produced the file, for attachments kept per attempt (traces).
71
+ def attach(name, path, type: nil, attempt: nil)
65
72
  unless collector&.in_test?
66
73
  warn("attach(#{name.inspect}) called outside a running example; ignored")
67
74
  return false
@@ -72,14 +79,14 @@ module RaiderReporter
72
79
  end
73
80
 
74
81
  content_type = ContentTypes.resolve(type, path)
75
- limit = attachment_limit(content_type)
82
+ limit = configuration.attachment_limit(content_type)
76
83
  if File.size(path.to_s) > limit
77
84
  warn("attach(#{name.inspect}): file #{path} is larger than #{limit} bytes; ignored")
78
85
  return false
79
86
  end
80
87
 
81
88
  collector.attach(Attachment.new(name: name.to_s, source_path: collector.stage(path.to_s),
82
- content_type: content_type))
89
+ content_type: content_type, attempt: attempt))
83
90
  rescue StandardError => e
84
91
  warn("attach(#{name.inspect}) failed: #{e.class}: #{e.message}")
85
92
  false
@@ -121,10 +128,6 @@ module RaiderReporter
121
128
 
122
129
  private
123
130
 
124
- def attachment_limit(content_type)
125
- content_type.start_with?("video/") ? configuration.max_video_bytes : configuration.max_attachment_bytes
126
- end
127
-
128
131
  # First, so a later reporting problem cannot lose it. A failure here only means
129
132
  # no summary, and exec then keeps the command's exit code.
130
133
  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.10.0
4
+ version: 0.12.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