Skip to content

Commit 8a06f1d

Browse files
Report E2E results to GitHub
Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3
1 parent fa6cb5f commit 8a06f1d

5 files changed

Lines changed: 381 additions & 4 deletions

File tree

e2e/BITRISE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ The non-secret E2E defaults are defined in `e2e/bitrise.yml` under `app.envs`. D
6666
| `E2E_ANDROID_COMMAND_TIMEOUT_SECONDS` | `1800` | Per-command timeout for React Native Android artifact commands. |
6767
| `E2E_IOS_COMMAND_TIMEOUT_SECONDS` | `1800` | Per-command timeout for React Native iOS dependency, CocoaPods, archive, and export commands. |
6868
| `E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS` | `300` | Per-command timeout for package-suite matrix and suite commands. |
69+
| `E2E_REPORT_COMMAND_TIMEOUT_SECONDS` | `300` | Timeout for the GitHub reporting command. |
6970
| `E2E_RUBY_INSTALL_TIMEOUT_SECONDS` | `1800` | Timeout for installing the exact repository Ruby version from `.ruby-version`. |
7071

7172
Secrets still need to be configured in Bitrise.io.
@@ -84,6 +85,16 @@ BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
8485
| `BROWSERSTACK_USERNAME` | BrowserStack API username |
8586
| `BROWSERSTACK_ACCESS_KEY` | BrowserStack API access key |
8687

88+
## GitHub reporting
89+
90+
The report workflow uses a GitHub API token to create commit statuses, Check Runs, and sticky PR comments. Prefer the short-lived token generated by the Bitrise GitHub App over a long-lived personal access token.
91+
92+
For Bitrise GitHub App projects, enable **Project settings > Repository > Extend GitHub App permissions to builds**. Bitrise then exposes the build-scoped GitHub App token as `GIT_HTTP_PASSWORD`; the report workflow maps it to `GITHUB_TOKEN` before running `e2e/scripts/report_e2e_results`.
93+
94+
If the project is not using the Bitrise GitHub App, configure `GITHUB_TOKEN` as a Bitrise secret with equivalent permissions.
95+
96+
Green runs update statuses and Check Runs without creating new PR comments. Failing runs update a sticky PR failure comment with direct BrowserStack evidence links.
97+
8798
## PR trigger
8899

89100
Configure the Bitrise app to run the `e2e` pipeline for pull requests once the skeleton is ready to validate in Bitrise.

e2e/bitrise.yml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ app:
2222
- E2E_ANDROID_COMMAND_TIMEOUT_SECONDS: "1800"
2323
- E2E_IOS_COMMAND_TIMEOUT_SECONDS: "1800"
2424
- E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS: "300"
25+
- E2E_REPORT_COMMAND_TIMEOUT_SECONDS: "300"
2526
- E2E_RUBY_INSTALL_TIMEOUT_SECONDS: "1800"
2627

2728
pipelines:
@@ -335,13 +336,27 @@ workflows:
335336
e2e-report:
336337
steps:
337338
- git-clone@8: {}
339+
- restore-cache@3:
340+
inputs:
341+
- key: |-
342+
e2e-ruby-linux-{{ checksum ".ruby-version" }}
338343
- pull-intermediate-files@1:
339344
inputs:
340-
- artifact_sources: |-
341-
e2e-run-browserstack
345+
- artifact_sources: e2e-run-browserstack.*
342346
- script@1:
343-
title: Placeholder E2E report
347+
title: Report E2E results to GitHub
344348
inputs:
345349
- content: |-
346350
set -euo pipefail
347-
echo "Phase 2 placeholder for aggregate E2E reporting"
351+
source e2e/scripts/bitrise_ci_helpers
352+
e2e_log "Checking GitHub reporting configuration"
353+
if [ -z "${GITHUB_TOKEN:-}" ]; then
354+
: "${GIT_HTTP_PASSWORD:?GITHUB_TOKEN or Bitrise GitHub App GIT_HTTP_PASSWORD is required. Enable Project settings > Repository > Extend GitHub App permissions to builds.}"
355+
export GITHUB_TOKEN="$GIT_HTTP_PASSWORD"
356+
fi
357+
: "${BITRISE_GIT_COMMIT:?BITRISE_GIT_COMMIT is required}"
358+
: "${BITRISE_PULL_REQUEST:?BITRISE_PULL_REQUEST is required}"
359+
e2e_prepare_ruby
360+
results_root="${E2E_BROWSERSTACK_RESULTS_DIR:-$BITRISE_DEPLOY_DIR/e2e/results}"
361+
e2e_log "Reporting E2E results to GitHub"
362+
e2e_run_with_timeout "${E2E_REPORT_COMMAND_TIMEOUT_SECONDS:-300}" e2e/scripts/report_e2e_results --results-root "$results_root"

e2e/lib/e2e_github_reporter.rb

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "net/http"
5+
require "uri"
6+
7+
class E2EGitHubReporter
8+
COMMENT_MARKER = "<!-- checkout-kit-e2e-report -->"
9+
10+
def initialize(results, repository:, sha:, pr_number:, token: nil)
11+
@results = results
12+
@repository = repository
13+
@sha = sha
14+
@pr_number = pr_number
15+
@token = token
16+
end
17+
18+
def publish!
19+
commit_status_payloads.each { |payload| post_json("/repos/#{@repository}/statuses/#{@sha}", payload) }
20+
post_json("/repos/#{@repository}/check-runs", check_run_payload)
21+
sync_failure_comment
22+
end
23+
24+
def markdown_summary
25+
lines = []
26+
lines << "## Checkout Kit E2E results"
27+
lines << ""
28+
lines << "| Status | Suite | Target | Platform | OS track | Device |"
29+
lines << "|---|---|---|---|---|---|"
30+
@results.each do |result|
31+
lines << "| #{status_icon(result)} | `#{result.fetch("execute")}` | #{result.fetch("target")} | #{result.fetch("platform")} | #{result.fetch("os_track")} | #{device_cell(result)} |"
32+
end
33+
failure_lines = failed_results.flat_map { |result| failure_details(result) }
34+
unless failure_lines.empty?
35+
lines << ""
36+
lines << "## Failures"
37+
lines << ""
38+
lines << "> BrowserStack artifacts require BrowserStack access. Sign in to [BrowserStack App Automate](https://app-automate.browserstack.com/dashboard/v2/builds) before opening artifact links."
39+
lines.concat(failure_lines)
40+
end
41+
lines.join("\n")
42+
end
43+
44+
def commit_status_payloads
45+
@results.map do |result|
46+
{
47+
state: result.fetch("passed") ? "success" : "failure",
48+
context: result.fetch("status_context"),
49+
description: status_description(result),
50+
target_url: browserstack_build_url(result)
51+
}
52+
end
53+
end
54+
55+
def failure_comment_body
56+
return nil if failed_results.empty?
57+
58+
[COMMENT_MARKER, markdown_summary].join("\n")
59+
end
60+
61+
private
62+
63+
def check_run_payload
64+
conclusion = failed_results.empty? ? "success" : "failure"
65+
{
66+
name: "Checkout Kit E2E",
67+
head_sha: @sha,
68+
status: "completed",
69+
conclusion: conclusion,
70+
output: {
71+
title: "Checkout Kit E2E #{conclusion}",
72+
summary: markdown_summary
73+
}
74+
}
75+
end
76+
77+
def sync_failure_comment
78+
body = failure_comment_body
79+
existing = existing_failure_comment
80+
if body
81+
if existing
82+
patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: body})
83+
else
84+
post_json("/repos/#{@repository}/issues/#{@pr_number}/comments", {body: body})
85+
end
86+
elsif existing
87+
patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: "#{COMMENT_MARKER}\n✅ Checkout Kit E2E failures resolved."})
88+
end
89+
end
90+
91+
def existing_failure_comment
92+
get_json("/repos/#{@repository}/issues/#{@pr_number}/comments").find do |comment|
93+
comment.fetch("body", "").include?(COMMENT_MARKER)
94+
end
95+
end
96+
97+
def failed_results
98+
@results.reject { |result| result.fetch("passed") }
99+
end
100+
101+
def failure_details(result)
102+
lines = []
103+
lines << ""
104+
lines << "### #{failure_heading(result)}"
105+
lines << ""
106+
lines << "| Test | Status | Artifacts |"
107+
lines << "|---|---|---|"
108+
tests = result.fetch("failed_tests", [])
109+
if tests.empty?
110+
lines << "| — | #{status_icon(result)} | #{artifact_links(nil, result)} |"
111+
else
112+
tests.each do |testcase|
113+
lines << "| `#{testcase.fetch("name", "unknown")}` | ❌ | #{artifact_links(testcase, result)} |"
114+
end
115+
end
116+
lines
117+
end
118+
119+
def failure_heading(result)
120+
suite = File.basename(result.fetch("execute"), ".*")
121+
"#{os_label(result.fetch("platform"))}#{suite}"
122+
end
123+
124+
def artifact_links(testcase, result)
125+
links = ["[BrowserStack](#{browserstack_build_url(result)})"]
126+
if testcase
127+
link_fields.each do |label, key|
128+
value = testcase[key]
129+
links << "[#{label}](#{value})" if value && !value.empty?
130+
end
131+
end
132+
links.join(" · ")
133+
end
134+
135+
def link_fields
136+
{
137+
"Video" => "video",
138+
"Screenshot" => "screenshots",
139+
"Maestro commands" => "maestro_commands",
140+
"Maestro log" => "maestro_log",
141+
"Device log" => "device_log",
142+
"Network log" => "network_log"
143+
}
144+
end
145+
146+
def device_cell(result)
147+
name, version = result.fetch("resolved_device").split("-", 2)
148+
version ? "#{name}<br>#{os_label(result.fetch("platform"))} #{version}" : name
149+
end
150+
151+
def os_label(platform)
152+
platform == "ios" ? "iOS" : "Android"
153+
end
154+
155+
def status_icon(result)
156+
result.fetch("passed") ? "✅" : "❌"
157+
end
158+
159+
def status_description(result)
160+
description = result.fetch("passed") ? "passed" : "#{result.fetch("status")} on #{result.fetch("resolved_device")}"
161+
description[0, 140]
162+
end
163+
164+
def browserstack_build_url(result)
165+
"https://app-automate.browserstack.com/dashboard/v2/builds/#{result.fetch("build_id")}"
166+
end
167+
168+
def get_json(path)
169+
execute_request(Net::HTTP::Get.new(path))
170+
end
171+
172+
def post_json(path, body)
173+
request = Net::HTTP::Post.new(path)
174+
request.body = JSON.generate(body)
175+
execute_json_request(request)
176+
end
177+
178+
def patch_json(path, body)
179+
request = Net::HTTP::Patch.new(path)
180+
request.body = JSON.generate(body)
181+
execute_json_request(request)
182+
end
183+
184+
def execute_json_request(request)
185+
request["Content-Type"] = "application/json"
186+
execute_request(request)
187+
end
188+
189+
def execute_request(request)
190+
raise "GitHub token is required" unless @token
191+
192+
request["Accept"] = "application/vnd.github+json"
193+
request["Authorization"] = "Bearer #{@token}"
194+
response = Net::HTTP.start("api.github.com", 443, use_ssl: true) { |http| http.request(request) }
195+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
196+
return body if response.is_a?(Net::HTTPSuccess)
197+
198+
raise "GitHub request failed #{response.code}: #{body}"
199+
end
200+
end

e2e/scripts/report_e2e_results

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "optparse"
6+
require_relative "../lib/e2e_github_reporter"
7+
8+
options = {
9+
results_root: ENV.fetch("E2E_BROWSERSTACK_RESULTS_DIR", File.join(Dir.pwd, "e2e-results")),
10+
repository: ENV.fetch("GITHUB_REPOSITORY", "Shopify/checkout-kit"),
11+
sha: ENV.fetch("BITRISE_GIT_COMMIT"),
12+
pr_number: Integer(ENV.fetch("BITRISE_PULL_REQUEST")),
13+
token: ENV.fetch("GITHUB_TOKEN")
14+
}
15+
16+
OptionParser.new do |opts|
17+
opts.on("--results-root PATH") { |path| options[:results_root] = path }
18+
opts.on("--repository REPOSITORY") { |repository| options[:repository] = repository }
19+
opts.on("--sha SHA") { |sha| options[:sha] = sha }
20+
opts.on("--pr NUMBER", Integer) { |number| options[:pr_number] = number }
21+
end.parse!
22+
23+
result_paths = Dir.glob(File.join(options.fetch(:results_root), "**", "result.json"))
24+
if result_paths.empty?
25+
warn "No E2E result files found under #{options.fetch(:results_root)}"
26+
exit 1
27+
end
28+
29+
results = result_paths.sort.map { |path| JSON.parse(File.read(path)) }
30+
reporter = E2EGitHubReporter.new(
31+
results,
32+
repository: options.fetch(:repository),
33+
sha: options.fetch(:sha),
34+
pr_number: options.fetch(:pr_number),
35+
token: options.fetch(:token)
36+
)
37+
reporter.publish!
38+
puts reporter.markdown_summary

0 commit comments

Comments
 (0)