Skip to content

Commit 5153e7b

Browse files
Run BrowserStack Maestro from E2E matrix rows
Assisted-By: devx/6c1e3ad5-96c8-4972-b087-da7ff7b195c3
1 parent b19f86c commit 5153e7b

8 files changed

Lines changed: 456 additions & 19 deletions

e2e/BITRISE.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,9 @@ Secrets still need to be configured in Bitrise.io.
7575
| `STOREFRONT_DOMAIN` | Required by `scripts/setup_storefront_env` for sample app builds. |
7676
| `STOREFRONT_ACCESS_TOKEN` | Required by `scripts/setup_storefront_env` for sample app builds. |
7777

78-
## Future secrets
78+
## BrowserStack secrets
7979

80-
Do not add BrowserStack credentials until the BrowserStack integration phase.
81-
82-
Future secret names:
80+
BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
8381

8482
| Secret | Purpose |
8583
|---|---|
@@ -98,6 +96,17 @@ The React Native iOS artifact workflow overrides the default Linux stack and run
9896

9997
Upload the required signing certificate and provisioning profile for the React Native sample app to the Bitrise app before running the iOS artifact workflow. The default provisioning profile specifier is `bitrise-checkout-kit-e2e`; update `E2E_IOS_PROVISIONING_PROFILE_SPECIFIER` in `e2e/bitrise.yml` if the Bitrise-installed profile uses a different name.
10098

99+
## BrowserStack execution
100+
101+
The BrowserStack workflow resolves the Bitrise parallel index into an E2E matrix row, resolves a BrowserStack device dynamically, uploads the app artifact and Maestro suite zip, runs the selected Maestro flow, and stores raw plus normalized result JSON as artifacts.
102+
103+
The initial launch smoke sends only non-sensitive Maestro environment values to BrowserStack:
104+
105+
- `E2E_APP_ID`
106+
- `E2E_READY_MARKER`
107+
108+
Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards.
109+
101110
## Caching
102111

103112
React Native Android E2E builds use the released native Maven artifact versions declared by the React Native sample and module configuration. Do not set local native SDK override flags for these builds.

e2e/bitrise.yml

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ workflows:
4646
steps:
4747
- git-clone@8: {}
4848
- script@1:
49-
title: Validate E2E matrix and create suite placeholder
49+
title: Validate E2E matrix and package Maestro suite
5050
inputs:
5151
- content: |-
5252
set -euo pipefail
@@ -55,11 +55,12 @@ workflows:
5555
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix validate
5656
e2e_log "Expanding E2E matrix"
5757
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
58-
e2e_log "Creating Maestro suite placeholder"
58+
e2e_log "Packaging Maestro suite"
5959
mkdir -p "$BITRISE_DEPLOY_DIR/e2e"
60-
echo "Phase 2 placeholder for BrowserStack Maestro suite zip" > "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
60+
suite_zip="$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
61+
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" e2e/scripts/package_maestro_suite --output "$suite_zip"
6162
e2e_log "Publishing Maestro suite path"
62-
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
63+
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$suite_zip"
6364
- deploy-to-bitrise-io@2:
6465
inputs:
6566
- pipeline_intermediate_files: |-
@@ -244,16 +245,23 @@ workflows:
244245
e2e-package-suite
245246
e2e-build-react-native-*
246247
- script@1:
247-
title: Resolve E2E matrix row placeholder
248+
title: Run BrowserStack Maestro for matrix row
248249
inputs:
249250
- content: |-
250251
set -euo pipefail
251252
source e2e/scripts/bitrise_ci_helpers
252253
e2e_prepare_ruby
254+
: "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}"
255+
: "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}"
253256
run_index="${BITRISE_IO_PARALLEL_INDEX:-0}"
254-
mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results"
255-
e2e_log "Resolving E2E matrix row ${run_index}"
256-
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix expand --index "$run_index" > "$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}.json"
257+
results_dir="$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}"
258+
mkdir -p "$results_dir"
259+
envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir"
260+
e2e_log "Running BrowserStack Maestro for matrix row ${run_index}"
261+
e2e/scripts/browserstack_maestro \
262+
--index "$run_index" \
263+
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
264+
--output-dir "$results_dir"
257265
- deploy-to-bitrise-io@2:
258266
inputs:
259267
- pipeline_intermediate_files: |-
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
class BrowserStackDeviceResolver
4+
def initialize(devices, limits = [])
5+
@devices = devices
6+
@limits = limits
7+
end
8+
9+
def resolve(selector)
10+
platform, form_factor, track = selector.split(":")
11+
raise ArgumentError, "unsupported device selector: #{selector}" unless platform && form_factor == "phone" && track
12+
13+
candidates = available_phone_devices(platform)
14+
os_version = os_version_for_track(candidates, track)
15+
device = candidates.select { |candidate| candidate.fetch("os_version") == os_version }
16+
.sort_by { |candidate| candidate.fetch("device") }
17+
.first
18+
19+
raise ArgumentError, "no BrowserStack device available for #{selector}" unless device
20+
21+
{
22+
"device_selector" => selector,
23+
"platform" => platform,
24+
"os_track" => track,
25+
"resolved_device" => device.fetch("device"),
26+
"resolved_os_version" => device.fetch("os_version"),
27+
"browserstack_device" => "#{device.fetch("device")}-#{device.fetch("os_version")}"
28+
}
29+
end
30+
31+
private
32+
33+
def available_phone_devices(platform)
34+
@devices.select do |device|
35+
device.fetch("os") == platform &&
36+
device.fetch("realMobile", true) &&
37+
phone?(device.fetch("device")) &&
38+
available?(device)
39+
end
40+
end
41+
42+
def phone?(device_name)
43+
!device_name.match?(/ipad|tablet| tab\b/i)
44+
end
45+
46+
def available?(device)
47+
limit = @limits.find do |candidate|
48+
candidate.fetch("os") == device.fetch("os") &&
49+
candidate.fetch("os_version") == device.fetch("os_version") &&
50+
candidate.fetch("device") == device.fetch("device")
51+
end
52+
53+
return true unless limit
54+
55+
limit.fetch("group_usage", 0).to_i < limit.fetch("device_limit", 1).to_i
56+
end
57+
58+
def os_version_for_track(candidates, track)
59+
versions = candidates.map { |candidate| candidate.fetch("os_version") }.uniq.sort_by { |version| version_segments(version) }
60+
raise ArgumentError, "no BrowserStack devices available" if versions.empty?
61+
62+
case track
63+
when "latest"
64+
versions.last
65+
when "previous"
66+
major_versions = versions.group_by { |version| version_segments(version).first }.keys.sort
67+
previous_major = major_versions[-2]
68+
raise ArgumentError, "no previous BrowserStack OS version available" unless previous_major
69+
70+
versions.select { |version| version_segments(version).first == previous_major }.last
71+
else
72+
raise ArgumentError, "unsupported OS track: #{track}"
73+
end
74+
end
75+
76+
def version_segments(version)
77+
version.to_s.split(".").map(&:to_i)
78+
end
79+
end

e2e/scripts/browserstack_maestro

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "base64"
5+
require "fileutils"
6+
require "json"
7+
require "net/http"
8+
require "optparse"
9+
require "securerandom"
10+
require "uri"
11+
require_relative "../lib/browserstack_device_resolver"
12+
require_relative "../lib/e2e_matrix"
13+
14+
class BrowserStackMaestro
15+
API_HOST = "api-cloud.browserstack.com"
16+
TERMINAL_STATUSES = %w[passed failed error timedout stopped done].freeze
17+
18+
def initialize(options)
19+
@options = options
20+
@username = ENV.fetch("BROWSERSTACK_USERNAME")
21+
@access_key = ENV.fetch("BROWSERSTACK_ACCESS_KEY")
22+
end
23+
24+
def run
25+
FileUtils.mkdir_p(output_dir)
26+
run = E2EMatrix.load(matrix_path).run_at(run_index)
27+
app_path = ENV.fetch(run.fetch("artifact_env"))
28+
device = resolve_device(run)
29+
app = upload_file("/app-automate/maestro/v2/app", app_path, custom_id(run, "app"))
30+
suite = upload_file("/app-automate/maestro/v2/test-suite", suite_zip, custom_id(run, "suite"))
31+
build = start_build(run, app.fetch("app_url"), suite.fetch("test_suite_url"), device.fetch("browserstack_device"))
32+
build_status = poll_build(build.fetch("build_id"))
33+
sessions = fetch_sessions(build_status)
34+
result = normalize_result(run, device, app, suite, build, build_status, sessions)
35+
write_json("result.json", result)
36+
exit(result.fetch("passed") ? 0 : 1)
37+
end
38+
39+
private
40+
41+
def resolve_device(run)
42+
override = ENV["E2E_DEVICE_OVERRIDE"] || ENV["E2E_#{run.fetch("platform").upcase}_DEVICE_OVERRIDE"]
43+
if override && !override.empty?
44+
return {
45+
"device_selector" => run.fetch("device_selector"),
46+
"browserstack_device" => override,
47+
"resolved_device" => override.split("-").first,
48+
"resolved_os_version" => override.split("-").last
49+
}
50+
end
51+
52+
devices = get_json("/app-automate/devices.json")
53+
limits = get_json("/app-automate/device_tier_limits.json")
54+
BrowserStackDeviceResolver.new(devices, limits).resolve(run.fetch("device_selector"))
55+
end
56+
57+
def upload_file(path, file_path, custom_id)
58+
response = multipart_post(path, file_path, custom_id)
59+
write_json("#{custom_id}.json", response)
60+
response
61+
end
62+
63+
def start_build(run, app_url, test_suite_url, device)
64+
body = {
65+
app: app_url,
66+
testSuite: test_suite_url,
67+
project: ENV.fetch("E2E_BROWSERSTACK_PROJECT", "checkout-kit-e2e"),
68+
buildTag: ENV.fetch("BITRISE_GIT_COMMIT", "local"),
69+
customBuildName: run.fetch("id"),
70+
devices: [device],
71+
execute: [run.fetch("execute")],
72+
setEnvVariables: {
73+
E2E_APP_ID: run.fetch("app_id"),
74+
E2E_READY_MARKER: run.fetch("ready_marker")
75+
}
76+
}
77+
response = post_json("/app-automate/maestro/v2/#{run.fetch("platform")}/build", body)
78+
write_json("build-start.json", response)
79+
response
80+
end
81+
82+
def poll_build(build_id)
83+
deadline = Time.now + ENV.fetch("E2E_BROWSERSTACK_TIMEOUT_SECONDS", "1800").to_i
84+
loop do
85+
response = get_json("/app-automate/maestro/v2/builds/#{build_id}")
86+
write_json("build-status.json", response)
87+
return response if TERMINAL_STATUSES.include?(response.fetch("status").to_s.downcase)
88+
raise "BrowserStack build timed out: #{build_id}" if Time.now >= deadline
89+
90+
sleep ENV.fetch("E2E_BROWSERSTACK_POLL_SECONDS", "30").to_i
91+
end
92+
end
93+
94+
def fetch_sessions(build_status)
95+
build_id = build_status.fetch("id")
96+
build_status.fetch("devices", []).flat_map do |device|
97+
device.fetch("sessions", []).map do |session|
98+
get_json("/app-automate/maestro/v2/builds/#{build_id}/sessions/#{session.fetch("id")}")
99+
end
100+
end
101+
end
102+
103+
def normalize_result(run, device, app, suite, build, build_status, sessions)
104+
status = build_status.fetch("status").to_s.downcase
105+
failed_tests = sessions.flat_map do |session|
106+
session.dig("testcases", "data").to_a.flat_map do |group|
107+
group.fetch("testcases", []).select { |testcase| testcase.fetch("status", "") != "passed" }
108+
end
109+
end
110+
111+
{
112+
"id" => run.fetch("id"),
113+
"status_context" => run.fetch("status_context"),
114+
"platform" => run.fetch("platform"),
115+
"target" => run.fetch("target"),
116+
"os_track" => run.fetch("os_track"),
117+
"execute" => run.fetch("execute"),
118+
"device_selector" => device.fetch("device_selector"),
119+
"resolved_device" => device.fetch("browserstack_device"),
120+
"app_url" => app.fetch("app_url"),
121+
"test_suite_url" => suite.fetch("test_suite_url"),
122+
"build_id" => build.fetch("build_id"),
123+
"status" => status,
124+
"passed" => status == "passed" && failed_tests.empty?,
125+
"failed_tests" => failed_tests
126+
}
127+
end
128+
129+
def multipart_post(path, file_path, custom_id)
130+
boundary = "----checkout-kit-#{SecureRandom.hex(12)}"
131+
file = File.binread(file_path)
132+
body = +""
133+
body << "--#{boundary}\r\n"
134+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n\r\n"
135+
body << file
136+
body << "\r\n--#{boundary}\r\n"
137+
body << "Content-Disposition: form-data; name=\"custom_id\"\r\n\r\n"
138+
body << custom_id
139+
body << "\r\n--#{boundary}--\r\n"
140+
request = Net::HTTP::Post.new(path)
141+
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
142+
request.body = body
143+
execute_request(request)
144+
end
145+
146+
def get_json(path)
147+
execute_request(Net::HTTP::Get.new(path))
148+
end
149+
150+
def post_json(path, body)
151+
request = Net::HTTP::Post.new(path)
152+
request["Content-Type"] = "application/json"
153+
request.body = JSON.generate(body)
154+
execute_request(request)
155+
end
156+
157+
def execute_request(request)
158+
request.basic_auth(@username, @access_key)
159+
response = Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) }
160+
parsed = JSON.parse(response.body)
161+
return parsed if response.is_a?(Net::HTTPSuccess)
162+
163+
raise "BrowserStack request failed #{response.code}: #{parsed}"
164+
end
165+
166+
def custom_id(run, suffix)
167+
["checkout-kit", run.fetch("id"), ENV.fetch("BITRISE_GIT_COMMIT", "local"), suffix].join("-").gsub(/[^A-Za-z0-9._-]/, "-")[0, 100]
168+
end
169+
170+
def write_json(name, object)
171+
File.write(File.join(output_dir, name), JSON.pretty_generate(object))
172+
end
173+
174+
def output_dir
175+
@options.fetch(:output_dir)
176+
end
177+
178+
def matrix_path
179+
@options.fetch(:matrix_path)
180+
end
181+
182+
def run_index
183+
@options.fetch(:run_index)
184+
end
185+
186+
def suite_zip
187+
@options.fetch(:suite_zip)
188+
end
189+
end
190+
191+
options = {
192+
matrix_path: "e2e/config/matrix.yml",
193+
run_index: Integer(ENV.fetch("BITRISE_IO_PARALLEL_INDEX", "0")),
194+
suite_zip: ENV.fetch("E2E_MAESTRO_SUITE_ZIP"),
195+
output_dir: ENV.fetch("E2E_BROWSERSTACK_RESULTS_DIR", File.join(Dir.pwd, "e2e-results"))
196+
}
197+
198+
OptionParser.new do |opts|
199+
opts.on("--matrix PATH") { |path| options[:matrix_path] = path }
200+
opts.on("--index INDEX", Integer) { |index| options[:run_index] = index }
201+
opts.on("--suite-zip PATH") { |path| options[:suite_zip] = path }
202+
opts.on("--output-dir PATH") { |path| options[:output_dir] = path }
203+
end.parse!
204+
205+
BrowserStackMaestro.new(options).run

0 commit comments

Comments
 (0)