Skip to content

Commit 3545355

Browse files
Report E2E results to GitHub
Assisted-By: devx/396ca0c4-44ad-415d-8d80-7200df4e42a4
1 parent 07c670c commit 3545355

6 files changed

Lines changed: 324 additions & 2 deletions

File tree

e2e/BITRISE.md

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

78+
## GitHub reporting
79+
80+
The report workflow requires a token that can create commit statuses, check runs, and PR comments.
81+
82+
| Secret | Purpose |
83+
|---|---|
84+
| `GITHUB_TOKEN` | GitHub API token for E2E statuses, Check Runs, and sticky failure comments |
85+
86+
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.
87+
7888
## PR trigger
7989

8090
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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,12 @@ workflows:
193193
- artifact_sources: |-
194194
e2e-run-browserstack
195195
- script@1:
196-
title: Placeholder E2E report
196+
title: Report E2E results to GitHub
197197
inputs:
198198
- content: |-
199199
set -euo pipefail
200-
echo "Phase 2 placeholder for aggregate E2E reporting"
200+
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
201+
: "${BITRISE_GIT_COMMIT:?BITRISE_GIT_COMMIT is required}"
202+
: "${BITRISE_PULL_REQUEST:?BITRISE_PULL_REQUEST is required}"
203+
results_root="${E2E_BROWSERSTACK_RESULTS_DIR:-$BITRISE_DEPLOY_DIR/e2e/results}"
204+
e2e/scripts/report_e2e_results --results-root "$results_root"

e2e/lib/e2e_github_reporter.rb

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 << "| Run | Status | Target | Platform | OS track | Device | Suite |"
29+
lines << "|---|---|---|---|---|---|---|"
30+
@results.each do |result|
31+
lines << "| `#{result.fetch("id")}` | #{status_icon(result)} #{result.fetch("status")} | #{result.fetch("target")} | #{result.fetch("platform")} | #{result.fetch("os_track")} | #{result.fetch("device_selector")}#{result.fetch("resolved_device")} | `#{result.fetch("execute")}` |"
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.concat(failure_lines)
38+
end
39+
lines.join("\n")
40+
end
41+
42+
def commit_status_payloads
43+
@results.map do |result|
44+
{
45+
state: result.fetch("passed") ? "success" : "failure",
46+
context: result.fetch("status_context"),
47+
description: status_description(result),
48+
target_url: browserstack_build_url(result)
49+
}
50+
end
51+
end
52+
53+
def failure_comment_body
54+
return nil if failed_results.empty?
55+
56+
[COMMENT_MARKER, markdown_summary].join("\n")
57+
end
58+
59+
private
60+
61+
def check_run_payload
62+
conclusion = failed_results.empty? ? "success" : "failure"
63+
{
64+
name: "Checkout Kit E2E",
65+
head_sha: @sha,
66+
status: "completed",
67+
conclusion: conclusion,
68+
output: {
69+
title: "Checkout Kit E2E #{conclusion}",
70+
summary: markdown_summary
71+
}
72+
}
73+
end
74+
75+
def sync_failure_comment
76+
body = failure_comment_body
77+
existing = existing_failure_comment
78+
if body
79+
if existing
80+
patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: body})
81+
else
82+
post_json("/repos/#{@repository}/issues/#{@pr_number}/comments", {body: body})
83+
end
84+
elsif existing
85+
patch_json("/repos/#{@repository}/issues/comments/#{existing.fetch("id")}", {body: "#{COMMENT_MARKER}\n✅ Checkout Kit E2E failures resolved."})
86+
end
87+
end
88+
89+
def existing_failure_comment
90+
get_json("/repos/#{@repository}/issues/#{@pr_number}/comments").find do |comment|
91+
comment.fetch("body", "").include?(COMMENT_MARKER)
92+
end
93+
end
94+
95+
def failed_results
96+
@results.reject { |result| result.fetch("passed") }
97+
end
98+
99+
def failure_details(result)
100+
lines = []
101+
lines << ""
102+
lines << "### `#{result.fetch("id")}`"
103+
lines << ""
104+
lines << "- Device selector: `#{result.fetch("device_selector")}`"
105+
lines << "- Resolved device: `#{result.fetch("resolved_device")}`"
106+
lines << "- Suite: `#{result.fetch("execute")}`"
107+
lines << "- BrowserStack build: #{browserstack_build_url(result)}"
108+
result.fetch("failed_tests", []).each do |testcase|
109+
lines << "- Failed test: `#{testcase.fetch("name", "unknown")}` (`#{testcase.fetch("status", "unknown")}`)"
110+
link_fields.each do |label, key|
111+
value = testcase[key]
112+
lines << " - #{label}: #{value}" if value && !value.empty?
113+
end
114+
end
115+
lines
116+
end
117+
118+
def link_fields
119+
{
120+
"Video" => "video",
121+
"Screenshot" => "screenshots",
122+
"Maestro commands" => "maestro_commands",
123+
"Maestro log" => "maestro_log",
124+
"Device log" => "device_log",
125+
"Network log" => "network_log"
126+
}
127+
end
128+
129+
def status_icon(result)
130+
result.fetch("passed") ? "✅" : "❌"
131+
end
132+
133+
def status_description(result)
134+
description = result.fetch("passed") ? "passed" : "#{result.fetch("status")} on #{result.fetch("resolved_device")}"
135+
description[0, 140]
136+
end
137+
138+
def browserstack_build_url(result)
139+
"https://app-automate.browserstack.com/dashboard/v2/builds/#{result.fetch("build_id")}"
140+
end
141+
142+
def get_json(path)
143+
execute_request(Net::HTTP::Get.new(path))
144+
end
145+
146+
def post_json(path, body)
147+
request = Net::HTTP::Post.new(path)
148+
request.body = JSON.generate(body)
149+
execute_json_request(request)
150+
end
151+
152+
def patch_json(path, body)
153+
request = Net::HTTP::Patch.new(path)
154+
request.body = JSON.generate(body)
155+
execute_json_request(request)
156+
end
157+
158+
def execute_json_request(request)
159+
request["Content-Type"] = "application/json"
160+
execute_request(request)
161+
end
162+
163+
def execute_request(request)
164+
raise "GitHub token is required" unless @token
165+
166+
request["Accept"] = "application/vnd.github+json"
167+
request["Authorization"] = "Bearer #{@token}"
168+
response = Net::HTTP.start("api.github.com", 443, use_ssl: true) { |http| http.request(request) }
169+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
170+
return body if response.is_a?(Net::HTTPSuccess)
171+
172+
raise "GitHub request failed #{response.code}: #{body}"
173+
end
174+
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

e2e/test/bitrise_pipeline_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ def test_react_native_ios_workflow_does_not_use_local_native_sdk_overrides
132132
refute_includes script, "--local"
133133
end
134134

135+
def test_report_workflow_posts_github_results
136+
script = workflow_script("e2e-report")
137+
138+
assert_includes script, "GITHUB_TOKEN"
139+
assert_includes script, "BITRISE_GIT_COMMIT"
140+
assert_includes script, "BITRISE_PULL_REQUEST"
141+
assert_includes script, "e2e/scripts/report_e2e_results"
142+
refute_includes script, "Phase 2 placeholder for aggregate E2E reporting"
143+
end
144+
135145
def test_bitrise_setup_docs_exist
136146
assert File.exist?("e2e/BITRISE.md")
137147
end

e2e/test/github_reporter_test.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
require "minitest/autorun"
4+
require_relative "../lib/e2e_github_reporter"
5+
6+
class E2EGitHubReporterTest < Minitest::Test
7+
def test_markdown_summary_includes_actionable_failure_links
8+
reporter = E2EGitHubReporter.new([failed_result], repository: "Shopify/checkout-kit", sha: "abc123", pr_number: 1)
9+
10+
markdown = reporter.markdown_summary
11+
12+
assert_includes markdown, "react-native-ios-latest-launch-smoke"
13+
assert_includes markdown, "ios:phone:latest"
14+
assert_includes markdown, "iPhone 16-18.0"
15+
assert_includes markdown, "tests/shared/launch-smoke.yaml"
16+
assert_includes markdown, "https://example.com/video"
17+
assert_includes markdown, "https://example.com/commands"
18+
assert_includes markdown, "https://example.com/screenshot"
19+
end
20+
21+
def test_commit_status_payloads_use_stable_contexts
22+
reporter = E2EGitHubReporter.new([passed_result, failed_result], repository: "Shopify/checkout-kit", sha: "abc123", pr_number: 1)
23+
24+
statuses = reporter.commit_status_payloads
25+
26+
assert_equal "success", statuses[0].fetch(:state)
27+
assert_equal "checkout-kit/e2e/react-native-android/latest/launch-smoke", statuses[0].fetch(:context)
28+
assert_equal "failure", statuses[1].fetch(:state)
29+
assert_equal "checkout-kit/e2e/react-native-ios/latest/launch-smoke", statuses[1].fetch(:context)
30+
end
31+
32+
def test_sticky_comment_is_failure_only
33+
passing_reporter = E2EGitHubReporter.new([passed_result], repository: "Shopify/checkout-kit", sha: "abc123", pr_number: 1)
34+
failing_reporter = E2EGitHubReporter.new([failed_result], repository: "Shopify/checkout-kit", sha: "abc123", pr_number: 1)
35+
36+
assert_nil passing_reporter.failure_comment_body
37+
assert_includes failing_reporter.failure_comment_body, "<!-- checkout-kit-e2e-report -->"
38+
end
39+
40+
private
41+
42+
def passed_result
43+
{
44+
"id" => "react-native-android-latest-launch-smoke",
45+
"status_context" => "checkout-kit/e2e/react-native-android/latest/launch-smoke",
46+
"platform" => "android",
47+
"target" => "react-native",
48+
"os_track" => "latest",
49+
"execute" => "tests/shared/launch-smoke.yaml",
50+
"device_selector" => "android:phone:latest",
51+
"resolved_device" => "Samsung Galaxy S24-15.0",
52+
"build_id" => "android-build",
53+
"status" => "passed",
54+
"passed" => true,
55+
"failed_tests" => []
56+
}
57+
end
58+
59+
def failed_result
60+
{
61+
"id" => "react-native-ios-latest-launch-smoke",
62+
"status_context" => "checkout-kit/e2e/react-native-ios/latest/launch-smoke",
63+
"platform" => "ios",
64+
"target" => "react-native",
65+
"os_track" => "latest",
66+
"execute" => "tests/shared/launch-smoke.yaml",
67+
"device_selector" => "ios:phone:latest",
68+
"resolved_device" => "iPhone 16-18.0",
69+
"build_id" => "ios-build",
70+
"status" => "failed",
71+
"passed" => false,
72+
"failed_tests" => [
73+
{
74+
"name" => "launch-smoke",
75+
"status" => "failed",
76+
"video" => "https://example.com/video",
77+
"maestro_commands" => "https://example.com/commands",
78+
"maestro_log" => "https://example.com/maestro",
79+
"device_log" => "https://example.com/device",
80+
"network_log" => "https://example.com/network",
81+
"screenshots" => "https://example.com/screenshot"
82+
}
83+
]
84+
}
85+
end
86+
end

0 commit comments

Comments
 (0)