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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +4 -0
- data/lib/raider_reporter/app_frame.rb +37 -12
- data/lib/raider_reporter/cucumber/formatter.rb +33 -5
- 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.rb +6 -1
- metadata +2 -1
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,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
|
-
|
|
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.
|
|
@@ -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
|
|
@@ -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(
|
|
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
|
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,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.
|
|
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
|