Skip to content

Commit 5b48786

Browse files
Report E2E results to GitHub
Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3
1 parent 99235c3 commit 5b48786

4 files changed

Lines changed: 268 additions & 4 deletions

File tree

e2e/BITRISE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
8383
| `BROWSERSTACK_USERNAME` | BrowserStack API username |
8484
| `BROWSERSTACK_ACCESS_KEY` | BrowserStack API access key |
8585

86+
## GitHub reporting
87+
88+
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.
89+
90+
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`.
91+
92+
If the project is not using the Bitrise GitHub App, configure `GITHUB_TOKEN` as a Bitrise secret with equivalent permissions.
93+
94+
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.
95+
8696
## PR trigger
8797

8898
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: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,29 @@ workflows:
343343
e2e-report:
344344
steps:
345345
- git-clone@8: {}
346+
- restore-cache@3:
347+
inputs:
348+
- key: |-
349+
e2e-ruby-linux-{{ checksum ".ruby-version" }}
346350
- pull-intermediate-files@1:
347351
inputs:
348-
- artifact_sources: |-
349-
e2e-run-browserstack
352+
- artifact_sources: e2e-run-browserstack.*
353+
- bundle::setup-ruby: {}
350354
- script@1:
351-
title: Placeholder E2E report
355+
title: Report E2E results to GitHub
356+
timeout: 900
357+
no_output_timeout: 900
352358
inputs:
353359
- content: |-
354360
set -euo pipefail
355-
echo "Phase 2 placeholder for aggregate E2E reporting"
361+
source e2e/scripts/bitrise_ci_helpers
362+
e2e_log "Checking GitHub reporting configuration"
363+
if [ -z "${GITHUB_TOKEN:-}" ]; then
364+
: "${GIT_HTTP_PASSWORD:?GITHUB_TOKEN or Bitrise GitHub App GIT_HTTP_PASSWORD is required. Enable Project settings > Repository > Extend GitHub App permissions to builds.}"
365+
export GITHUB_TOKEN="$GIT_HTTP_PASSWORD"
366+
fi
367+
: "${BITRISE_GIT_COMMIT:?BITRISE_GIT_COMMIT is required}"
368+
: "${BITRISE_PULL_REQUEST:?BITRISE_PULL_REQUEST is required}"
369+
results_root="${E2E_BROWSERSTACK_RESULTS_DIR:-$BITRISE_DEPLOY_DIR/e2e/results}"
370+
e2e_log "Reporting E2E results to GitHub"
371+
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)