raider_reporter 0.2.0 → 0.4.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: 7c1a8baed9855a5cba10100e57b9052184f6f748b7f015d630541d52905031c6
4
- data.tar.gz: cc40652b897de2d3832a3d5da58d8bdb6f8e3a6873b11da927b68fd5a0478e24
3
+ metadata.gz: 61be0da46b6e8038ccb7da397ce371e20e398792aaa42aff9e93e2e765efd73b
4
+ data.tar.gz: 9d2859ced0b58e73ccd0f8d90fece82b3d8f379bca46ef958edb8089e81ae30e
5
5
  SHA512:
6
- metadata.gz: e16586146bc9d569b5cd827c5533e6aae6cdef3bec2bc4ae3d6390c230a193ad56be6cc8fd01d11e90e933522a7f2f2ae54613450acb5a95daa609364634af1e
7
- data.tar.gz: 134a1d1de73ff5b93c00740996ffea7cc8cde10f7be727bbb2ab8320b3158b710ac2df413d3e68ce334bff3fc7b3d40cd5cf2150fcc4cb6cd21d9b0e79521554
6
+ metadata.gz: a805ca67f914f9413cb95913b2015c7a56020564bf709a45aadd96c25f183a374927fd572b6dab80cf2969adca7b2d6305edd04b617d0414f1af2caba108ddc4
7
+ data.tar.gz: 1f9ba4407c3677d5166ec09aced32f3d2c43ac4a599468eda510f3e7327ce417d6eea25af8de47ea308bacde60e45b9c8ba36414434bd0bd0d938bb2ecdd1bfc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-11
4
+
5
+ - Add a Cucumber reporter (Cucumber >= 9, < 12; not a gem dependency). `require "raider_reporter/cucumber"` from `features/support/` registers it in plugin mode (recommended, works with any `--format` and no `--out`); `--format RaiderReporter::Cucumber::Formatter --out <file>` also works. Using both registers one listener
6
+ - Identity: `file_path` is project-relative (`features/login.feature`), `full_name` is `"<Feature>: <Scenario>"` (outline rows add `(<Examples name>, row <n>)`; when several Examples tables share a label, each is numbered: `(Examples 1, row 1)`, `(Examples 2, row 1)`), `identifier` is `<file>:<line>`
7
+ - Report the failing step (or Before/After hook) with its location in the failure message; ambiguous maps to failed, undefined to pending
8
+ - Retries: only the final attempt is kept, with `attempts` set and earlier attachments dropped
9
+ - Attachments: `RaiderReporter.attach` and Cucumber's `attach(path, media_type)` with an existing file path are captured; inline data and `log` messages are ignored
10
+ - Dry runs (`cucumber --dry-run`) write and upload nothing; `parallel_tests` shards write `run-<n>.json`
11
+ - No breaking changes: RSpec behaviour is unchanged
12
+
13
+ ## 0.3.0 — 2026-10-11
14
+
15
+ - Report pull request identity: `run.ci.repository`, `run.git.default_branch` and `run.git.pr` (number, base/head branch, head SHA) on `pull_request` and `pull_request_target` events. Requires a Raider server that accepts `git.pr`
16
+ - Add `raider-reporter comment`: posts (and updates) one results comment on the pull request and sets the `raider/tests` commit status; fail-open, always exits 0, skips fork PRs
17
+ - Print the `Raider report: <url>` line to stderr instead of stdout, so stdout-parsing tools (such as `--format json`) stay clean
18
+ - Report the PR head commit's SHA and message on pull request events instead of the merge commit's
19
+ - Harden the GitHub client: only a bot-authored comment starting with the marker is updated, any number of trailing slashes in `GITHUB_API_URL` are accepted, and the token is redacted from error messages
20
+
3
21
  ## 0.2.0 — 2026-10-11
4
22
 
5
23
  - Report the CI build key, attempt and job so runs of one build are grouped (GitHub Actions)
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # raider_reporter
2
2
 
3
- Raider-schema test reporting for RSpec. `raider_reporter` is an RSpec formatter that records every example's result, failure details, screenshots and videos, writes them to a local `run.json` (schema v1), and can optionally upload the run to a Raider server. It replaces Allure for Ruby Raider projects.
3
+ Raider-schema test reporting for RSpec and Cucumber. `raider_reporter` is an RSpec formatter and a Cucumber reporter that records every example's result, failure details, screenshots and videos, writes them to a local `run.json` (schema v1), and can optionally upload the run to a Raider server. It replaces Allure for Ruby Raider projects.
4
4
 
5
5
  It has no runtime dependencies and requires Ruby 3.2 or newer.
6
6
 
@@ -26,7 +26,80 @@ Enable the formatter in `.rspec`:
26
26
  --format RaiderReporter::RSpec::Formatter
27
27
  ```
28
28
 
29
- Run `bundle exec rspec` as usual. Results land in `raider-results/run.json`. A dry run (`rspec --dry-run`) writes and uploads nothing.
29
+ Run `bundle exec rspec` as usual. (For Cucumber, see [Cucumber](#cucumber).) Results land in `raider-results/run.json`. A dry run (`rspec --dry-run`) writes and uploads nothing.
30
+
31
+ ## Cucumber
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.
34
+
35
+ ### Plugin mode (recommended)
36
+
37
+ Require the reporter from a file under `features/support/`, for example `features/support/raider.rb`:
38
+
39
+ ```ruby
40
+ require "raider_reporter/cucumber"
41
+ ```
42
+
43
+ Then run Cucumber with any normal formatter and no `--out`:
44
+
45
+ ```
46
+ # cucumber.yml
47
+ default: --format pretty
48
+ ```
49
+
50
+ The require registers one listener on Cucumber's event bus, so it sits next to `pretty`, `progress` or any other formatter. Results land in `raider-results/run.json`, and a dry run (`cucumber --dry-run`) writes and uploads nothing. Plugin mode registers once per Ruby process, because `require` is cached: a second in-process `Cucumber::Cli::Main` run in the same process does not register again.
51
+
52
+ ### Formatter mode
53
+
54
+ You can also name the formatter explicitly:
55
+
56
+ ```
57
+ cucumber --format pretty --format RaiderReporter::Cucumber::Formatter --out raider-results/cucumber.log
58
+ ```
59
+
60
+ `--out` is required here. Cucumber rejects a second formatter on the same output stream ("All but one formatter must use --out", also in 9.2.1), and this formatter writes nothing to its stream, so give it any file path (it is never created). If both modes are active, only one listener is registered.
61
+
62
+ ### How tests are identified
63
+
64
+ - `file_path` is relative to the project root (`features/login.feature`), even when Cucumber is given `./features/login.feature` or an absolute path.
65
+ - `full_name` is `"<Feature name>: <Scenario name>"`. Scenario Outline rows are `"<Feature name>: <Scenario name> (<Examples name or 'Examples'>, row <n>)"`, where `n` is the 1-based data row of that Examples table. When two or more Examples tables of one outline share a label (for example two unnamed ones), every table with that label is numbered by its position among them, `(Examples 1, row 1)`, `(Examples 2, row 1)`, so each row keeps a distinct name; tables with a unique label are unchanged.
66
+ - `identifier` is `"<file_path>:<line>"`: the scenario line, or the Examples row line for outline rows.
67
+ - `tags` are the feature, scenario and Examples tags, merged, sorted, unique, without `@`.
68
+
69
+ Statuses: passed, failed and ambiguous (reported as failed), pending and undefined (reported as pending), skipped.
70
+
71
+ ### Failures
72
+
73
+ A failed test reports the step that failed, not the whole scenario:
74
+
75
+ ```
76
+ When I fail on purpose (features/login.feature:9)
77
+
78
+ expected 5, got 4
79
+ ```
80
+
81
+ A failing `Before` or `After` hook is named `Before hook` or `After hook` in place of the step, so an `After` hook that raises after every step passed is still a failed test. Messages are capped at 10,000 characters and backtraces at 50 lines.
82
+
83
+ ### Retries
84
+
85
+ With `--retry n`, only the final attempt of each scenario is reported, and `attempts` is the number of attempts. Each attempt starts with fresh attachments; attachments from earlier attempts are dropped.
86
+
87
+ ### Attachments
88
+
89
+ Both of these are captured while a scenario runs:
90
+
91
+ ```ruby
92
+ After do |scenario|
93
+ attach(path, "image/png") if scenario.failed? # Cucumber's own attach, with an existing file path (a Pathname works too)
94
+ RaiderReporter.attach("screenshot", path, type: :png) # or the Raider helper, as in RSpec
95
+ end
96
+ ```
97
+
98
+ Cucumber's `attach` is captured only when given the path of an existing file. Inline data (`attach("some text", "text/plain")`) and `log` messages are ignored.
99
+
100
+ ### Parallel runs with Cucumber
101
+
102
+ `parallel_tests` works with no configuration for Cucumber too: each process writes `run-<n>.json`. See [Parallel runs](#parallel-runs).
30
103
 
31
104
  ## Attaching screenshots and videos
32
105
 
@@ -84,7 +157,7 @@ If both a token and an endpoint are set, the run (plus attachments) is uploaded
84
157
  RAIDER_TOKEN=... RAIDER_ENDPOINT=https://your-raider-server bundle exec rspec
85
158
  ```
86
159
 
87
- On success the formatter prints `Raider report: <url>`, 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.
160
+ 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.
88
161
 
89
162
  An upload carries at most 45 MB of attachments and at most 1000 attachment files. Attachments past that budget are left out of the upload (in order, with one warning) but stay in the local `raider-results/`.
90
163
 
@@ -92,6 +165,36 @@ Reporting is fail-open: no error inside the gem, network failure, timeout or non
92
165
 
93
166
  The hosted Raider service is not public yet; uploads go to a Raider server whose address you provide in `RAIDER_ENDPOINT`.
94
167
 
168
+ ## Pull request comments
169
+
170
+ On pull requests, `raider-reporter comment` posts one comment with the build's results (and sets a `raider/tests` commit status on the PR head commit). Run it in a job that runs after your test jobs, so every shard has uploaded. Use the workflow's `GITHUB_TOKEN` (or a GitHub App token): a personal access token posts as a user, so the step adds a new comment on every run instead of updating the existing one.
171
+
172
+ ```yaml
173
+ raider-comment:
174
+ needs: [test] # every test job id
175
+ if: always() && github.event_name == 'pull_request'
176
+ runs-on: ubuntu-latest
177
+ permissions:
178
+ contents: read
179
+ pull-requests: write
180
+ statuses: write
181
+ steps:
182
+ - uses: actions/checkout@v4
183
+ - uses: ruby/setup-ruby@v1
184
+ with: { ruby-version: "3.4", bundler-cache: true }
185
+ - name: Comment Raider results on the pull request
186
+ run: bundle exec raider-reporter comment
187
+ env:
188
+ RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
189
+ RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
190
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
191
+ ```
192
+
193
+ - 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.
194
+ - Pull requests from forks are skipped: GitHub gives their workflows a read-only token, so the command prints a notice and exits 0.
195
+ - 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.
196
+ - It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
197
+
95
198
  ## Parallel runs
96
199
 
97
200
  When a suite is split across several processes or CI jobs, each one uploads its own run. Raider groups runs of the same CI build, so the shards show up together as one build. Each run records its shard (`index`, `total` and a `label`) and, on GitHub Actions, a build key made from the repository, run id and run attempt. A re-run attempt of a workflow is a separate build.
@@ -153,6 +256,7 @@ Tip: run `rm -rf raider-results` before a parallel run. Each process only cleans
153
256
  ## Examples
154
257
 
155
258
  - `examples/rspec_basic` is a runnable RSpec project with a passing, a failing (with an attachment) and a skipped example. Run it with `cd examples/rspec_basic && bundle install && bundle exec rspec`; the failure is deliberate.
259
+ - `examples/cucumber_basic` is the same for Cucumber (plugin mode): a passing scenario, a failing one with an attachment, and an outline. Run it with `cd examples/cucumber_basic && bundle install && bundle exec cucumber`; the failure is deliberate.
156
260
  - `examples/github_actions.yml` shows the workflow steps for CI.
157
261
 
158
262
  ## Schema
@@ -161,7 +265,7 @@ Tip: run `rm -rf raider-results` before a parallel run. Each process only cleans
161
265
 
162
266
  ## Contributing
163
267
 
164
- Bug reports and pull requests are welcome at https://github.com/RaiderHQ/raider_reporter. Run `bundle install && bundle exec rake` to run the specs.
268
+ Bug reports and pull requests are welcome at https://github.com/RaiderHQ/raider_reporter. Run `bundle install && bundle exec rake` to run the specs. To test against another Cucumber, set `CUCUMBER_VERSION` (for example `CUCUMBER_VERSION="~> 9.0" bundle update cucumber && bundle exec rspec`).
165
269
 
166
270
  ## License
167
271
 
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "raider_reporter"
6
+ require "raider_reporter/cli"
7
+
8
+ exit RaiderReporter::CLI.new(ARGV).run
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "raider_reporter/github_event"
4
5
 
5
6
  module RaiderReporter
6
7
  class CiDetector
7
- def initialize(env: ENV, git: method(:git_command))
8
+ PR_EVENTS = %w[pull_request pull_request_target].freeze
9
+
10
+ def initialize(env: ENV, git: method(:git_command), event_reader: nil)
8
11
  @env = env
9
12
  @git = git
13
+ @event_reader = event_reader || -> { GithubEvent.load(@env) }
10
14
  end
11
15
 
12
16
  def ci
@@ -20,17 +24,69 @@ module RaiderReporter
20
24
  repo = @env["GITHUB_REPOSITORY"]
21
25
  build_key = ("github_actions:#{repo}:#{run_id}:#{attempt}" if repo && run_id)
22
26
  { "provider" => "github_actions", "build_url" => build_url, "build_id" => run_id,
23
- "attempt" => attempt, "job" => @env["GITHUB_JOB"], "build_key" => build_key }.compact
27
+ "attempt" => attempt, "job" => @env["GITHUB_JOB"], "build_key" => build_key,
28
+ "repository" => (repo unless repo.to_s.empty?) }.compact
24
29
  end
25
30
 
26
31
  def git
27
- info = { "branch" => branch, "commit_sha" => sha, "commit_message" => @git.call("log", "-1", "--pretty=%s") }
32
+ pr = pull_request
33
+ info = { "branch" => branch, "commit_sha" => pr ? pr["head_sha"] : sha, "commit_message" => commit_message(pr),
34
+ "default_branch" => default_branch, "pr" => pr }
28
35
  info = info.compact
29
36
  info.empty? ? nil : info
30
37
  end
31
38
 
32
39
  private
33
40
 
41
+ def event
42
+ return @event if defined?(@event)
43
+
44
+ loaded = begin
45
+ @event_reader.call
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ @event = loaded.is_a?(Hash) ? loaded : nil
50
+ end
51
+
52
+ def default_branch
53
+ value = event&.dig("repository", "default_branch")
54
+ value.is_a?(String) && !value.empty? ? value : nil
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def pull_request
60
+ return nil unless event && PR_EVENTS.include?(@env["GITHUB_EVENT_NAME"])
61
+
62
+ pr = event["pull_request"]
63
+ return nil unless pr.is_a?(Hash)
64
+
65
+ number = pr["number"] || event["number"]
66
+ base = pr.dig("base", "ref")
67
+ head = pr.dig("head", "ref")
68
+ sha = pr.dig("head", "sha")
69
+ return nil unless number.is_a?(Integer) && number >= 1
70
+ return nil unless [base, head, sha].all? { |v| v.is_a?(String) && !v.empty? }
71
+
72
+ { "number" => number, "base_branch" => base, "head_branch" => head, "head_sha" => sha }
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ # On a PR, GitHub checks out a synthetic merge commit; its message is not the author's.
78
+ def commit_message(pr)
79
+ if pr && merge_commit?
80
+ message = @git.call("log", "-1", "--format=%s", "HEAD^2")
81
+ return message unless message.to_s.empty?
82
+ end
83
+ @git.call("log", "-1", "--pretty=%s")
84
+ end
85
+
86
+ def merge_commit?
87
+ @git.call("rev-list", "--parents", "-n", "1", "HEAD").to_s.split.size == 3
88
+ end
89
+
34
90
  def branch
35
91
  head_ref = @env["GITHUB_HEAD_REF"].to_s
36
92
  return head_ref unless head_ref.empty?
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raider_reporter"
4
+
5
+ module RaiderReporter
6
+ # `raider-reporter comment`: posts the build summary as a PR comment and sets a commit status.
7
+ # Fail-open: #run always returns 0, notices go to stderr, nothing goes to stdout.
8
+ class CLI
9
+ MARKER = "<!-- raider-reporter:build-comment -->"
10
+ CONTEXT = "raider/tests"
11
+ REQUIRED_ENV = %w[GITHUB_TOKEN GITHUB_REPOSITORY GITHUB_EVENT_PATH RAIDER_TOKEN RAIDER_ENDPOINT].freeze
12
+ STATUS_STATES = { "passed" => "success", "failed" => "failure", "incomplete" => "pending" }.freeze
13
+ FALLBACK_DESCRIPTIONS = {
14
+ "passed" => "Raider: tests passed",
15
+ "failed" => "Raider: tests failed",
16
+ "incomplete" => "Raider: build incomplete"
17
+ }.freeze
18
+ DEFAULT_API_URL = "https://api.github.com"
19
+ USAGE = <<~TEXT
20
+ Usage: raider-reporter comment
21
+
22
+ Posts (or updates) the Raider build summary as a comment on the current pull request
23
+ and sets the "#{CONTEXT}" commit status. Intended for GitHub Actions; always exits 0.
24
+
25
+ Environment: #{REQUIRED_ENV.join(', ')} (GITHUB_API_URL optional).
26
+ TEXT
27
+
28
+ def initialize(argv, env: ENV, out: $stdout, err: $stderr)
29
+ @argv = argv
30
+ @env = env
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ command = @argv.first
37
+ if command == "comment" && !@argv.include?("--help") && !@argv.include?("-h")
38
+ comment
39
+ else
40
+ @err.puts(USAGE)
41
+ end
42
+ 0
43
+ rescue StandardError => e
44
+ notice("comment failed: #{e.class}: #{e.message}")
45
+ 0
46
+ end
47
+
48
+ private
49
+
50
+ def comment
51
+ missing = REQUIRED_ENV.select { |name| @env[name].to_s.strip.empty? }
52
+ return notice("skipped: missing #{missing.join(', ')}") unless missing.empty?
53
+
54
+ event = GithubEvent.load(@env)
55
+ detector = CiDetector.new(env: @env, event_reader: -> { event })
56
+ pr = detector.git&.dig("pr")
57
+ return notice("skipped: not a pull request event") unless pr
58
+
59
+ build_key = detector.ci&.dig("build_key")
60
+ return notice("skipped: could not determine the build key") unless build_key
61
+
62
+ publish(pr, build_key)
63
+ rescue Comment::GithubClient::Forbidden
64
+ notice("skipped: token cannot write to this PR")
65
+ rescue StandardError => e
66
+ notice("comment failed: #{e.class}: #{e.message}")
67
+ end
68
+
69
+ def publish(pr, build_key)
70
+ summaries = Comment::SummaryClient.new(endpoint: @env["RAIDER_ENDPOINT"], token: @env["RAIDER_TOKEN"])
71
+ summary = summaries.fetch(build_key)
72
+ return notice("summary unavailable: #{summaries.last_error}") unless summary
73
+
74
+ github = Comment::GithubClient.new(token: @env["GITHUB_TOKEN"], repository: @env["GITHUB_REPOSITORY"],
75
+ api_url: api_url)
76
+ html_url = github.upsert_comment(pr["number"], summary.markdown, MARKER)
77
+ github.set_status(pr["head_sha"], state: STATUS_STATES.fetch(summary.state, "pending"),
78
+ target_url: summary.url, description: description(summary),
79
+ context: CONTEXT)
80
+ notice("PR comment updated: #{html_url}")
81
+ end
82
+
83
+ def api_url
84
+ url = @env["GITHUB_API_URL"].to_s.strip
85
+ url.empty? ? DEFAULT_API_URL : url
86
+ end
87
+
88
+ # "**Raider: failed** - 2 failed, 148 passed, 3 skipped (1m 12s)" -> "2 failed, 148 passed, 3 skipped".
89
+ # Falls back to a fixed text per state when the Markdown carries no headline counts.
90
+ def description(summary)
91
+ counts = headline_counts(summary.markdown)
92
+ text = counts || FALLBACK_DESCRIPTIONS.fetch(summary.state, FALLBACK_DESCRIPTIONS["incomplete"])
93
+ text[0, Comment::GithubClient::DESCRIPTION_LIMIT]
94
+ end
95
+
96
+ def headline_counts(markdown)
97
+ line = markdown.to_s.lines.find { |l| l.include?("Raider:") }
98
+ return nil unless line
99
+
100
+ counts = line.scan(/\b\d+ (?:failed|passed|skipped|pending)\b/)
101
+ counts.empty? ? nil : counts.join(", ")
102
+ end
103
+
104
+ def notice(message)
105
+ @err.puts("[raider_reporter] #{message}")
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -55,8 +55,18 @@ module RaiderReporter
55
55
  end
56
56
 
57
57
  def finish_test(**fields)
58
+ add_result(build_test(**fields))
59
+ end
60
+
61
+ # Closes the current test and returns its result without recording it, so a
62
+ # framework that retries can hold one result per test and replace it per attempt.
63
+ def build_test(**fields)
58
64
  result = TestResult.new(**fields, attachments: @pending_attachments || [])
59
65
  @pending_attachments = nil
66
+ result
67
+ end
68
+
69
+ def add_result(result)
60
70
  @results << result
61
71
  result
62
72
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RaiderReporter
8
+ module Comment
9
+ # Minimal GitHub REST client: upsert one marked PR comment and set a commit status.
10
+ class GithubClient
11
+ class Error < StandardError; end
12
+ # 403/404: the token cannot write here (e.g. a fork's read-only token).
13
+ class Forbidden < Error; end
14
+
15
+ MAX_PAGES = 10
16
+ PER_PAGE = 100
17
+ DESCRIPTION_LIMIT = 140
18
+
19
+ def initialize(token:, repository:, api_url: "https://api.github.com", timeout: 30)
20
+ @token = token
21
+ @repository = repository
22
+ @api_url = api_url.to_s.strip.sub(%r{/+\z}, "")
23
+ @timeout = timeout
24
+ end
25
+
26
+ def upsert_comment(pr_number, body, marker)
27
+ existing = find_comment(pr_number, marker)
28
+ response = if existing
29
+ request(:patch, "/repos/#{@repository}/issues/comments/#{existing['id']}", body: body)
30
+ else
31
+ request(:post, "/repos/#{@repository}/issues/#{pr_number}/comments", body: body)
32
+ end
33
+ html_url = response.is_a?(Hash) ? response["html_url"] : nil
34
+ raise Error, "GitHub response had no html_url" if html_url.to_s.empty?
35
+
36
+ html_url
37
+ end
38
+
39
+ def set_status(sha, state:, target_url:, description:, context:)
40
+ return nil if sha.to_s.strip.empty?
41
+
42
+ payload = { state: state, target_url: target_url, context: context,
43
+ description: description.to_s[0, DESCRIPTION_LIMIT] }
44
+ request(:post, "/repos/#{@repository}/statuses/#{sha}", payload: payload)
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def find_comment(pr_number, marker)
51
+ (1..MAX_PAGES).each do |page|
52
+ comments = request(:get, "/repos/#{@repository}/issues/#{pr_number}/comments?per_page=#{PER_PAGE}&page=#{page}")
53
+ raise Error, "unexpected comments response" unless comments.is_a?(Array)
54
+
55
+ found = comments.find { |c| ours?(c, marker) }
56
+ return found if found
57
+ return nil if comments.size < PER_PAGE
58
+ end
59
+ nil
60
+ end
61
+
62
+ # Only our own (bot-authored) marked comment is ever edited.
63
+ def ours?(comment, marker)
64
+ comment.is_a?(Hash) && comment["body"].to_s.start_with?(marker) &&
65
+ comment.dig("user", "type") == "Bot"
66
+ end
67
+
68
+ def redact(text)
69
+ @token.to_s.empty? ? text.to_s : text.to_s.gsub(@token.to_s, "[REDACTED]")
70
+ end
71
+
72
+ def request(verb, path, body: nil, payload: nil)
73
+ uri = URI.parse(@api_url + path)
74
+ raise Error, "GITHUB_API_URL must start with http:// or https://" unless uri.is_a?(URI::HTTP)
75
+
76
+ req = build_request(verb, uri, payload || ({ body: body } if body))
77
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
78
+ open_timeout: @timeout, read_timeout: @timeout,
79
+ write_timeout: @timeout) { |http| http.request(req) }
80
+ handle(response)
81
+ rescue Error
82
+ raise
83
+ rescue StandardError => e
84
+ raise Error, redact("#{e.class}: #{e.message}")
85
+ end
86
+
87
+ def build_request(verb, uri, json)
88
+ klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch }.fetch(verb)
89
+ req = klass.new(uri)
90
+ req["Authorization"] = "Bearer #{@token}"
91
+ req["Accept"] = "application/vnd.github+json"
92
+ req["X-GitHub-Api-Version"] = "2022-11-28"
93
+ req["User-Agent"] = "raider_reporter/#{VERSION}"
94
+ if json
95
+ req["Content-Type"] = "application/json"
96
+ req.body = JSON.generate(json)
97
+ end
98
+ req
99
+ end
100
+
101
+ def handle(response)
102
+ code = response.code.to_i
103
+ raise Forbidden, "HTTP #{code}" if [403, 404].include?(code)
104
+ raise Error, redact("HTTP #{code}: #{response.body.to_s[0, 200]}") unless (200..299).cover?(code)
105
+
106
+ JSON.parse(response.body.to_s)
107
+ rescue JSON::ParserError
108
+ raise Error, "GitHub returned invalid JSON"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "cgi/escape"
7
+
8
+ module RaiderReporter
9
+ module Comment
10
+ # Fetches the rendered build summary from the Raider API. Never raises:
11
+ # failures return nil and set #last_error to a printable reason.
12
+ class SummaryClient
13
+ Summary = Data.define(:state, :markdown, :url)
14
+
15
+ attr_reader :last_error
16
+
17
+ def initialize(endpoint:, token:, timeout: 30)
18
+ @endpoint = endpoint
19
+ @token = token
20
+ @timeout = timeout
21
+ @last_error = nil
22
+ end
23
+
24
+ def fetch(build_key)
25
+ @last_error = nil
26
+ uri = summary_uri(build_key)
27
+ request = Net::HTTP::Get.new(uri)
28
+ request["Authorization"] = "Bearer #{@token}"
29
+ request["Accept"] = "application/json"
30
+ request["User-Agent"] = "raider_reporter/#{VERSION}"
31
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
32
+ open_timeout: @timeout, read_timeout: @timeout,
33
+ write_timeout: @timeout) { |http| http.request(request) }
34
+ parse(response)
35
+ rescue StandardError => e
36
+ fail_with("#{e.class}: #{e.message}")
37
+ end
38
+
39
+ private
40
+
41
+ def summary_uri(build_key)
42
+ uri = URI.parse("#{@endpoint.to_s.strip.chomp('/')}/api/v1/builds/summary?build_key=#{CGI.escape(build_key.to_s)}")
43
+ raise ArgumentError, "endpoint must start with http:// or https://" unless uri.is_a?(URI::HTTP)
44
+
45
+ uri
46
+ end
47
+
48
+ def parse(response)
49
+ case response.code.to_i
50
+ when 200..299 then build_summary(response.body)
51
+ when 401 then fail_with("HTTP 401: unauthorized (check RAIDER_TOKEN)")
52
+ when 404 then fail_with("HTTP 404: build not found for this project")
53
+ else fail_with("HTTP #{response.code}: #{response.body.to_s[0, 200]}")
54
+ end
55
+ end
56
+
57
+ def build_summary(text)
58
+ data = JSON.parse(text.to_s)
59
+ unless data.is_a?(Hash) && %w[state markdown url].all? { |k| data[k].is_a?(String) }
60
+ return fail_with("invalid summary response")
61
+ end
62
+
63
+ Summary.new(state: data["state"], markdown: data["markdown"], url: data["url"])
64
+ rescue JSON::ParserError
65
+ fail_with("invalid summary response (not JSON)")
66
+ end
67
+
68
+ def fail_with(reason)
69
+ @last_error = @token.to_s.empty? ? reason : reason.gsub(@token.to_s, "[REDACTED]")
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "cucumber"
5
+ require "cucumber/formatter/ast_lookup"
6
+ require "raider_reporter"
7
+ require "raider_reporter/text"
8
+
9
+ module RaiderReporter
10
+ module Cucumber
11
+ # Translates Cucumber events into Collector calls. Every handler is guarded:
12
+ # a reporting bug must never fail or crash the user's run. The formatter
13
+ # writes nothing to its IO; the report goes to RaiderReporter's own writers.
14
+ #
15
+ # Inside this namespace `Cucumber` means RaiderReporter::Cucumber, so the
16
+ # gem's constants are always written ::Cucumber.
17
+ class Formatter
18
+ STATUSES = {
19
+ passed: "passed", failed: "failed", ambiguous: "failed",
20
+ pending: "pending", undefined: "pending", skipped: "skipped"
21
+ }.freeze
22
+
23
+ # Passed to the World's `attach`; reports an existing file as an attachment.
24
+ module AttachCapture
25
+ # Takes whatever Cucumber passes (file, media_type, filename, and any
26
+ # argument a later release adds) and forwards it untouched.
27
+ ruby2_keywords def attach(*args, &block)
28
+ result = super
29
+ Formatter.record_attachment(args[0], args[1])
30
+ result
31
+ end
32
+ end
33
+
34
+ @claimed = []
35
+
36
+ class << self
37
+ # One listener per run, whether it arrives as a --format or as a plugin.
38
+ # Cucumber copies the configuration for --out, but the event bus is shared.
39
+ def claim(config)
40
+ bus = config.event_bus
41
+ return false if @claimed.any? { |claimed| claimed.equal?(bus) }
42
+
43
+ @claimed << bus
44
+ true
45
+ end
46
+
47
+ # Inline text, IO objects and missing paths are ignored: only a file that
48
+ # exists can be copied. Never raises into the user's step.
49
+ def record_attachment(file, media_type)
50
+ # `log` is attach with this media type; a logged message is not a file.
51
+ return if media_type.to_s.start_with?("text/x.cucumber.log")
52
+
53
+ file = file.to_path if file.respond_to?(:to_path)
54
+ return unless file.is_a?(String) && RaiderReporter.collector&.in_test? && File.file?(file)
55
+
56
+ RaiderReporter.attach(File.basename(file), file, type: media_type)
57
+ rescue StandardError => e
58
+ RaiderReporter.warn("#{e.class}: #{e.message}")
59
+ end
60
+
61
+ # Called when the run ends, so the list does not outlive the run.
62
+ def release(config)
63
+ bus = config.event_bus
64
+ @claimed.reject! { |claimed| claimed.equal?(bus) }
65
+ RaiderReporter.dry_run = false
66
+ end
67
+
68
+ # Cucumber hands steps a World, not the formatter, so `attach` is observed
69
+ # by prepending to ProtoWorld. Installed once, at the first formatter.
70
+ # Event-bus alternatives were checked in cucumber 9.2.1, 10.2.0 and 11.1.1:
71
+ # there is no :attach_called event before 11, and even there (and in the
72
+ # :envelope attachment message) a file path has already been read into
73
+ # bytes with no name, so the basename the report needs is only available
74
+ # in ProtoWorld#attach, which exists unchanged in all three versions.
75
+ def capture_attach
76
+ return if @capturing
77
+
78
+ ::Cucumber::Glue::ProtoWorld.prepend(AttachCapture)
79
+ @capturing = true
80
+ end
81
+ end
82
+
83
+ def initialize(config)
84
+ @buffer = {}
85
+ @attempts = Hash.new(0)
86
+ @attempt = nil
87
+ guard { setup(config) if self.class.claim(config) }
88
+ end
89
+
90
+ private
91
+
92
+ def setup(config)
93
+ @config = config
94
+ @ast = ::Cucumber::Formatter::AstLookup.new(config)
95
+ @collector = RaiderReporter.start_collector!
96
+ RaiderReporter.dry_run = config.dry_run?
97
+ config.on_event(:test_run_started) { |_event| guard { @collector.start_run } }
98
+ config.on_event(:test_case_started) { |event| guard { case_started(event.test_case) } }
99
+ config.on_event(:test_step_finished) { |event| guard { step_finished(event.test_step, event.result) } }
100
+ config.on_event(:test_case_finished) { |event| guard { case_finished(event.test_case, event.result) } }
101
+ config.on_event(:test_run_finished) { |_event| guard { run_finished } }
102
+ # Last, and on its own: a broken attach hook may only lose attachments.
103
+ safely(nil) { self.class.capture_attach }
104
+ end
105
+
106
+ # Every attempt starts with an empty attachment list.
107
+ def case_started(test_case)
108
+ @collector.start_test
109
+ @attempt = { test_case: test_case }
110
+ end
111
+
112
+ # The first non-passed step explains why the scenario did not pass.
113
+ def step_finished(test_step, result)
114
+ return unless @attempt
115
+ return if @attempt[:problem] || result.passed?
116
+
117
+ @attempt[:problem] = { step: test_step, result: result }
118
+ end
119
+
120
+ def case_finished(test_case, result)
121
+ problem = @attempt && @attempt[:problem]
122
+ @attempt = nil
123
+ status = STATUSES.fetch(result.to_sym, "pending")
124
+ # Always buffer: a lookup problem may lose detail, never the scenario.
125
+ failure = status == "failed" ? safely(nil) { failure_for(problem, result, test_case) } : nil
126
+ fields = identity_for(test_case)
127
+ key = "#{test_case.location}|#{test_case.name}"
128
+ @attempts[key] += 1
129
+ # The newest attempt replaces the older one, taking its attachments with it.
130
+ @buffer[key] = @collector.build_test(
131
+ **fields, status: status, duration_ms: duration_ms(result), attempts: @attempts[key], failure: failure
132
+ )
133
+ end
134
+
135
+ def run_finished
136
+ @buffer.each_value { |result| @collector.add_result(result) }
137
+ @buffer.clear
138
+ RaiderReporter.finish!($stderr)
139
+ ensure
140
+ self.class.release(@config)
141
+ end
142
+
143
+ def identity_for(test_case)
144
+ file_path = relative_path(test_case.location.file)
145
+ line_number = test_case.location.lines.max
146
+ {
147
+ file_path: file_path,
148
+ line_number: line_number,
149
+ full_name: Text.utf8(full_name(test_case)),
150
+ identifier: "#{file_path}:#{line_number}",
151
+ tags: test_case.tags.map { |tag| tag.name.to_s.delete_prefix("@") }.uniq.sort
152
+ }
153
+ end
154
+
155
+ def full_name(test_case)
156
+ safely(test_case.name) do
157
+ feature = @ast.gherkin_document(test_case.location.file)&.feature
158
+ name = scenario_name(@ast.scenario_source(test_case))
159
+ next test_case.name unless name
160
+
161
+ feature ? "#{feature.name}: #{name}" : name
162
+ end
163
+ end
164
+
165
+ # nil when the scenario cannot be located in the AST.
166
+ def scenario_name(source)
167
+ case source&.type
168
+ when :ScenarioOutline
169
+ examples = source.examples
170
+ row = examples.table_body.index { |candidate| candidate.location.line == source.row.location.line }
171
+ row && "#{source.scenario_outline.name} (#{examples_label(source)}, row #{row + 1})"
172
+ when :Scenario
173
+ source.scenario.name
174
+ end
175
+ end
176
+
177
+ # "Examples" or the table's name. When several tables of one outline share a
178
+ # label (two unnamed ones, say), each gets its 1-based position among them,
179
+ # so rows of different tables never collide on full_name.
180
+ def examples_label(source)
181
+ tables = source.scenario_outline.examples
182
+ label = examples_name(source.examples)
183
+ same = tables.select { |table| examples_name(table) == label }
184
+ return label if same.size < 2
185
+
186
+ "#{label} #{same.index { |table| table.equal?(source.examples) || table.location.line == source.examples.location.line } + 1}"
187
+ end
188
+
189
+ def examples_name(table)
190
+ table.name.to_s.strip.empty? ? "Examples" : table.name.strip
191
+ end
192
+
193
+ # step_info is { step:, result: } for the first non-passed step, if any.
194
+ def failure_for(step_info, result, test_case)
195
+ exception = result.respond_to?(:exception) ? result.exception : nil
196
+ exception ||= step_info && step_info[:result].respond_to?(:exception) ? step_info[:result].exception : nil
197
+ return nil unless exception
198
+
199
+ Failure.new(
200
+ message: Text.utf8("#{step_label(step_info, test_case)}\n\n#{exception.message}")[0, Text::MAX_MESSAGE],
201
+ exception_class: Text.utf8(exception.class.name),
202
+ backtrace: (exception.backtrace || []).first(Text::MAX_BACKTRACE).map { |line| Text.utf8(line) }
203
+ )
204
+ end
205
+
206
+ def step_label(step_info, test_case)
207
+ return "Step" unless step_info
208
+
209
+ test_step = step_info[:step]
210
+ location = "(#{relative_path(test_step.location.file)}:#{test_step.location.lines.min})"
211
+ if test_step.hook?
212
+ "#{hook_kind(test_step, test_case)} hook #{location}"
213
+ else
214
+ "#{step_keyword(test_step)}#{test_step.text} #{location}"
215
+ end
216
+ end
217
+
218
+ def step_keyword(test_step)
219
+ safely("") { @ast.step_source(test_step).step.keyword.to_s }
220
+ end
221
+
222
+ # A hook with a real step before it runs after the scenario started.
223
+ def hook_kind(test_step, test_case)
224
+ steps = test_case.test_steps
225
+ index = steps.index { |candidate| candidate.equal?(test_step) }
226
+ index && steps.first(index).any? { |candidate| !candidate.hook? } ? "After" : "Before"
227
+ end
228
+
229
+ def duration_ms(result)
230
+ (result.duration.nanoseconds / 1_000_000.0).round
231
+ rescue StandardError
232
+ 0
233
+ end
234
+
235
+ def relative_path(path)
236
+ absolute = Pathname.new(File.expand_path(path.to_s, Dir.pwd))
237
+ relative = absolute.relative_path_from(Pathname.new(Dir.pwd)).to_s
238
+ relative.start_with?("..") ? path.to_s.delete_prefix("./") : relative
239
+ rescue ArgumentError
240
+ # Windows: a path on another drive has no relative form.
241
+ path.to_s.delete_prefix("./")
242
+ end
243
+
244
+ def safely(fallback)
245
+ yield
246
+ rescue StandardError => e
247
+ RaiderReporter.warn("#{e.class}: #{e.message}")
248
+ fallback
249
+ end
250
+
251
+ def guard
252
+ yield
253
+ rescue StandardError => e
254
+ RaiderReporter.warn("#{e.class}: #{e.message}")
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "cucumber"
5
+ rescue LoadError => e
6
+ raise unless e.path == "cucumber"
7
+
8
+ raise LoadError, "raider_reporter/cucumber needs the cucumber gem (>= 9, < 12) in your Gemfile"
9
+ end
10
+ require "raider_reporter"
11
+ require_relative "cucumber/formatter"
12
+
13
+ module RaiderReporter
14
+ module Cucumber
15
+ # Plugin mode: requiring this file from features/support registers the
16
+ # listener through Cucumber's InstallPlugin hook, so no --format is needed
17
+ # (and none of the "one formatter per stream" juggling). Outside a Cucumber
18
+ # run there is no step-definition registry and this is a no-op.
19
+ def self.install_plugin
20
+ # Cucumber's public InstallPlugin DSL method, called on a throwaway object
21
+ # (it is only an instance method of Dsl; main is extended with it).
22
+ Object.new.extend(::Cucumber::Glue::Dsl).InstallPlugin(name: "raider_reporter") do |config, *|
23
+ RaiderReporter::Cucumber::Formatter.new(config)
24
+ end
25
+ true
26
+ rescue StandardError => e
27
+ # Outside a run there is no registry (Dsl has no rb_language) and nothing to
28
+ # report. Inside one, losing the listener must not be silent.
29
+ RaiderReporter.warn("could not register the Cucumber plugin: #{e.class}: #{e.message}") if run_active?
30
+ false
31
+ end
32
+
33
+ def self.run_active?
34
+ !::Cucumber::Glue::Dsl.instance_variable_get(:@rb_language).nil?
35
+ end
36
+ private_class_method :run_active?
37
+ end
38
+ end
39
+
40
+ RaiderReporter::Cucumber.install_plugin
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RaiderReporter
6
+ # Reads the webhook payload GitHub Actions leaves at GITHUB_EVENT_PATH.
7
+ module GithubEvent
8
+ # Returns the parsed event Hash, or nil when missing, unreadable or invalid.
9
+ def self.load(env = ENV)
10
+ path = env["GITHUB_EVENT_PATH"].to_s
11
+ return nil if path.empty?
12
+
13
+ data = JSON.parse(File.read(path))
14
+ data.is_a?(Hash) ? data : nil
15
+ rescue StandardError
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rspec/core"
4
4
  require "raider_reporter"
5
+ require "raider_reporter/text"
5
6
 
6
7
  module RaiderReporter
7
8
  module RSpec
@@ -13,8 +14,8 @@ module RaiderReporter
13
14
  :example_failed, :example_pending, :close
14
15
 
15
16
  NON_TAG_KEYS = %i[pending skip].freeze
16
- MAX_MESSAGE = 10_000
17
- MAX_BACKTRACE = 50
17
+ MAX_MESSAGE = Text::MAX_MESSAGE
18
+ MAX_BACKTRACE = Text::MAX_BACKTRACE
18
19
 
19
20
  def initialize(output)
20
21
  @output = output
@@ -45,7 +46,7 @@ module RaiderReporter
45
46
  end
46
47
 
47
48
  def close(_notification)
48
- guard { RaiderReporter.finish!(@output) }
49
+ guard { RaiderReporter.finish! }
49
50
  end
50
51
 
51
52
  private
@@ -77,14 +78,8 @@ module RaiderReporter
77
78
  )
78
79
  end
79
80
 
80
- # JSON generation raises on invalid UTF-8, which would lose the whole run.
81
81
  def utf8(value)
82
- str = value.to_s
83
- if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
84
- str.dup.force_encoding(Encoding::UTF_8).scrub("?")
85
- else
86
- str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
87
- end
82
+ Text.utf8(value)
88
83
  end
89
84
 
90
85
  def guard
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RaiderReporter
4
+ # Text limits and cleaning shared by the framework formatters.
5
+ module Text
6
+ MAX_MESSAGE = 10_000
7
+ MAX_BACKTRACE = 50
8
+
9
+ # JSON generation raises on invalid UTF-8, which would lose the whole run.
10
+ def self.utf8(value)
11
+ str = value.to_s
12
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
13
+ str.dup.force_encoding(Encoding::UTF_8).scrub("?")
14
+ else
15
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require "time"
4
4
  require "rbconfig"
5
5
  require_relative "raider_reporter/version"
6
+ require_relative "raider_reporter/text"
6
7
  require_relative "raider_reporter/models"
7
8
  require_relative "raider_reporter/payload"
8
9
  require_relative "raider_reporter/configuration"
@@ -12,6 +13,8 @@ require_relative "raider_reporter/writers/local"
12
13
  require_relative "raider_reporter/ci_detector"
13
14
  require_relative "raider_reporter/shard_detector"
14
15
  require_relative "raider_reporter/writers/uploader"
16
+ require_relative "raider_reporter/comment/summary_client"
17
+ require_relative "raider_reporter/comment/github_client"
15
18
 
16
19
  module RaiderReporter
17
20
  class << self
@@ -25,13 +28,18 @@ module RaiderReporter
25
28
  yield configuration
26
29
  end
27
30
 
31
+ # Set by the Cucumber formatter when the run is a --dry-run.
32
+ attr_writer :dry_run
33
+
28
34
  def reset!
35
+ @dry_run = false
29
36
  @collector&.cleanup
30
37
  @configuration = nil
31
38
  @collector = nil
32
39
  end
33
40
 
34
41
  def start_collector!
42
+ @dry_run = false
35
43
  @collector = Collector.new(
36
44
  max_attachment_bytes: configuration.max_attachment_bytes,
37
45
  warn: ->(message) { warn(message) }
@@ -66,7 +74,7 @@ module RaiderReporter
66
74
  $stderr.puts("[raider_reporter] #{message}")
67
75
  end
68
76
 
69
- def finish!(output = $stdout)
77
+ def finish!(output = $stderr)
70
78
  return nil if dry_run?
71
79
 
72
80
  detector = CiDetector.new
@@ -102,8 +110,10 @@ module RaiderReporter
102
110
  "Raider report uploaded"
103
111
  end
104
112
 
105
- # `rspec --dry-run` reports every example as passed without running it.
113
+ # `rspec --dry-run` and `cucumber --dry-run` report every example as passed without running it.
106
114
  def dry_run?
115
+ return true if @dry_run
116
+
107
117
  defined?(::RSpec.configuration) && ::RSpec.configuration.respond_to?(:dry_run?) &&
108
118
  ::RSpec.configuration.dry_run?
109
119
  end
metadata CHANGED
@@ -1,35 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raider_reporter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Augustin Gottlieb
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: RSpec formatter that records results, screenshots and videos in the Raider
13
- schema, writes them locally, and optionally uploads them to a Raider server.
12
+ description: RSpec and Cucumber formatters that record results, screenshots and videos
13
+ in the Raider schema, write them locally, and optionally upload them to a Raider
14
+ server.
14
15
  email:
15
16
  - 33221555+aguspe@users.noreply.github.com
16
- executables: []
17
+ executables:
18
+ - raider-reporter
17
19
  extensions: []
18
20
  extra_rdoc_files: []
19
21
  files:
20
22
  - CHANGELOG.md
21
23
  - LICENSE
22
24
  - README.md
25
+ - exe/raider-reporter
23
26
  - lib/raider_reporter.rb
24
27
  - lib/raider_reporter/ci_detector.rb
28
+ - lib/raider_reporter/cli.rb
25
29
  - lib/raider_reporter/collector.rb
30
+ - lib/raider_reporter/comment/github_client.rb
31
+ - lib/raider_reporter/comment/summary_client.rb
26
32
  - lib/raider_reporter/configuration.rb
27
33
  - lib/raider_reporter/content_types.rb
34
+ - lib/raider_reporter/cucumber.rb
35
+ - lib/raider_reporter/cucumber/formatter.rb
36
+ - lib/raider_reporter/github_event.rb
28
37
  - lib/raider_reporter/models.rb
29
38
  - lib/raider_reporter/payload.rb
30
39
  - lib/raider_reporter/rspec.rb
31
40
  - lib/raider_reporter/rspec/formatter.rb
32
41
  - lib/raider_reporter/shard_detector.rb
42
+ - lib/raider_reporter/text.rb
33
43
  - lib/raider_reporter/version.rb
34
44
  - lib/raider_reporter/writers/local.rb
35
45
  - lib/raider_reporter/writers/uploader.rb
@@ -56,5 +66,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
56
66
  requirements: []
57
67
  rubygems_version: 4.0.6
58
68
  specification_version: 4
59
- summary: 'Test reporting for Ruby Raider: local run.json and Raider uploads'
69
+ summary: RSpec and Cucumber formatters that write Raider reports locally and upload
70
+ them to Raider
60
71
  test_files: []