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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dda0a98d0886eefbfd851be91d57c881e74abee367956b0272fc9b7a464045cf
4
- data.tar.gz: 8012de5d41e890b7dae144ab5f208c20ed2ffa94736645a4308901f6abe24174
3
+ metadata.gz: 8f4191162d0ff126c9b45a552005131bc1c93b9c2c4abf97d8ae347bd8f9d21d
4
+ data.tar.gz: f1537e8415dee111fd49a2005e88282a34b2f0279ba74945aacab16034531f80
5
5
  SHA512:
6
- metadata.gz: fa44978dfb6d93f9bbb316fe36d837b3ecb1ae668c7292b7f7e6ac345646b4c9099dd30375d8881e466f8f21eeb4ae6ae01d53e76652f975ffeaaa83b263939c
7
- data.tar.gz: 6a43fa45d7a90997e64e95b9f5e40e978c398343695ce74950809f9cadbafd7420edce162c4560d01dd67892861b61180c5c7938afc2cf28a6191f1d1e18a54b
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 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,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
- 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.
@@ -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
@@ -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
- # 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
@@ -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 = !result.ok?(strict: @config.strict)
164
- flaky = !failed && @failed_before[key] && @config.strict.strict?(:flaky)
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, < 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,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(_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.9.0"
4
+ VERSION = "0.11.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
@@ -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
- limit = configuration.max_attachment_bytes
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: ContentTypes.resolve(type, path)))
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.9.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.6
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