raider_reporter 0.10.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: 291269e17dbda3cec36ff4822af7ba9b16b85f5f1d231eed8b96fb9b89f9e338
4
- data.tar.gz: 16e1c480d70fc174b25f7b91fcd5f9b242b04f1e7b751172e796050a0c405a39
3
+ metadata.gz: 8f4191162d0ff126c9b45a552005131bc1c93b9c2c4abf97d8ae347bd8f9d21d
4
+ data.tar.gz: f1537e8415dee111fd49a2005e88282a34b2f0279ba74945aacab16034531f80
5
5
  SHA512:
6
- metadata.gz: 6bc649c77eaa3a22110dd19b9a5eb28f1e60aab2d6a2877985a8254c85ad647f58d83427e298f79d48a5e420893afc12c06c54a05526a0b0488ca43c015a6510
7
- data.tar.gz: 1590461029cc94956c3e28fffa9003252d8de1f064211dbcc9d3dcdfe0cbe11c4f9125fafb459978f77f082419b80c36a4702d0a7917ba480d4ed6a740a5f8fb
6
+ metadata.gz: 7763eb9aecea9e4c65d6727bf02be613720eb994bbd763f1755d23073f852faf5bcb4ca5986954a1327b80ce3a2d67b1a147f33c64cede3ab5318304897542a2
7
+ data.tar.gz: f06d92ce870157d73411f53ad368ecd6a01ce23294df319d49e7028f3d29d15677a8087c39c116a3d939fb58c6c0d5681aab286490e3fe94890771597438a540
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.10.0 — 2026-10-26
4
10
 
5
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
data/README.md CHANGED
@@ -126,6 +126,10 @@ Videos (`.webm`, `.mp4`) are attached the same way, for example `RaiderReporter.
126
126
 
127
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
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.
132
+
129
133
  ## Configuration
130
134
 
131
135
  ```ruby
@@ -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.
@@ -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
@@ -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.11.0"
5
5
  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,6 +51,8 @@ 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,
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.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