Skip to content

Commit f3c8201

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

5 files changed

Lines changed: 373 additions & 15 deletions

File tree

e2e/BITRISE.md

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

77-
## Future secrets
77+
## BrowserStack secrets
7878

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

8381
| Secret | Purpose |
8482
|---|---|
@@ -97,6 +95,17 @@ The React Native iOS artifact workflow overrides the default Linux stack and run
9795

9896
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.
9997

98+
## BrowserStack execution
99+
100+
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.
101+
102+
The initial launch smoke sends only non-sensitive Maestro environment values to BrowserStack:
103+
104+
- `E2E_APP_ID`
105+
- `E2E_READY_MARKER`
106+
107+
Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards.
108+
100109
## Caching
101110

102111
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: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ workflows:
5959
e2e-ruby-linux-{{ checksum ".ruby-version" }}
6060
- bundle::setup-ruby: {}
6161
- script@1:
62-
title: Validate E2E matrix and create suite placeholder
62+
title: Validate E2E matrix and package Maestro suite
6363
timeout: 1800
6464
no_output_timeout: 1800
6565
inputs:
@@ -71,11 +71,12 @@ workflows:
7171
ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
7272
e2e_log "Publishing E2E matrix row count"
7373
envman add --key E2E_MATRIX_ROW_COUNT --value "$(ruby e2e/scripts/e2e_matrix count)"
74-
e2e_log "Creating Maestro suite placeholder"
74+
e2e_log "Packaging Maestro suite"
7575
mkdir -p "$BITRISE_DEPLOY_DIR/e2e"
76-
echo "Phase 2 placeholder for BrowserStack Maestro suite zip" > "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
76+
suite_zip="$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
77+
e2e/scripts/package_maestro_suite --output "$suite_zip"
7778
e2e_log "Publishing Maestro suite path"
78-
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
79+
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$suite_zip"
7980
- share-pipeline-variable@1:
8081
inputs:
8182
- variables: E2E_MATRIX_ROW_COUNT
@@ -316,22 +317,31 @@ workflows:
316317
e2e-ruby-linux-{{ checksum ".ruby-version" }}
317318
- pull-intermediate-files@1:
318319
inputs:
319-
- artifact_sources: |-
320-
e2e-package-suite
321-
e2e-build-react-native-*
320+
- artifact_sources: e2e-package-suite,e2e-build-react-native-.*
322321
- bundle::setup-ruby: {}
323322
- script@1:
324-
title: Resolve E2E matrix row placeholder
323+
title: Run BrowserStack Maestro for matrix row
325324
timeout: 2700
326325
no_output_timeout: 2700
327326
inputs:
328327
- content: |-
329328
set -euo pipefail
330329
source e2e/scripts/bitrise_ci_helpers
331330
run_index="${BITRISE_IO_PARALLEL_INDEX:-0}"
332-
mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results"
333-
e2e_log "Resolving E2E matrix row ${run_index}"
334-
ruby e2e/scripts/e2e_matrix expand --index "$run_index" > "$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}.json"
331+
results_root="$BITRISE_DEPLOY_DIR/e2e/results"
332+
results_dir="$results_root/run-${run_index}"
333+
mkdir -p "$results_dir"
334+
envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir"
335+
: "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}"
336+
: "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}"
337+
: "${E2E_MAESTRO_SUITE_ZIP:?E2E_MAESTRO_SUITE_ZIP is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
338+
: "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
339+
: "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
340+
e2e_log "Running BrowserStack Maestro for matrix row ${run_index}"
341+
e2e/scripts/browserstack_maestro \
342+
--index "$run_index" \
343+
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
344+
--output-dir "$results_dir"
335345
- save-cache@1:
336346
is_always_run: true
337347
inputs:
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)