Skip to content

Commit 94b3cbf

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

5 files changed

Lines changed: 374 additions & 16 deletions

File tree

e2e/BITRISE.md

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

79-
## Future secrets
79+
## BrowserStack secrets
8080

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

8583
| Secret | Purpose |
8684
|---|---|
@@ -99,6 +97,17 @@ The React Native iOS artifact workflow overrides the default Linux stack and run
9997

10098
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.
10199

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

104113
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: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ workflows:
4949
- key: |-
5050
e2e-ruby-linux-{{ checksum ".ruby-version" }}
5151
- script@1:
52-
title: Validate E2E matrix and create suite placeholder
52+
title: Validate E2E matrix and package Maestro suite
5353
inputs:
5454
- content: |-
5555
set -euo pipefail
@@ -60,11 +60,12 @@ workflows:
6060
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
6161
e2e_log "Publishing E2E matrix row count"
6262
envman add --key E2E_MATRIX_ROW_COUNT --value "$(ruby e2e/scripts/e2e_matrix count)"
63-
e2e_log "Creating Maestro suite placeholder"
63+
e2e_log "Packaging Maestro suite"
6464
mkdir -p "$BITRISE_DEPLOY_DIR/e2e"
65-
echo "Phase 2 placeholder for BrowserStack Maestro suite zip" > "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
65+
suite_zip="$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
66+
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" e2e/scripts/package_maestro_suite --output "$suite_zip"
6667
e2e_log "Publishing Maestro suite path"
67-
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
68+
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$suite_zip"
6869
- save-cache@1:
6970
is_always_run: true
7071
inputs:
@@ -296,20 +297,29 @@ workflows:
296297
e2e-ruby-linux-{{ checksum ".ruby-version" }}
297298
- pull-intermediate-files@1:
298299
inputs:
299-
- artifact_sources: |-
300-
e2e-package-suite
301-
e2e-build-react-native-*
300+
- artifact_sources: e2e-package-suite,e2e-build-react-native-.*
302301
- script@1:
303-
title: Resolve E2E matrix row placeholder
302+
title: Run BrowserStack Maestro for matrix row
304303
inputs:
305304
- content: |-
306305
set -euo pipefail
307306
source e2e/scripts/bitrise_ci_helpers
308-
e2e_prepare_ruby
309307
run_index="${BITRISE_IO_PARALLEL_INDEX:-0}"
310-
mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results"
311-
e2e_log "Resolving E2E matrix row ${run_index}"
312-
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"
308+
results_root="$BITRISE_DEPLOY_DIR/e2e/results"
309+
results_dir="$results_root/run-${run_index}"
310+
mkdir -p "$results_dir"
311+
envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir"
312+
e2e_prepare_ruby
313+
: "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}"
314+
: "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}"
315+
: "${E2E_MAESTRO_SUITE_ZIP:?E2E_MAESTRO_SUITE_ZIP is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
316+
: "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
317+
: "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-run-browserstack pull-intermediate-files artifact_sources.}"
318+
e2e_log "Running BrowserStack Maestro for matrix row ${run_index}"
319+
e2e/scripts/browserstack_maestro \
320+
--index "$run_index" \
321+
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
322+
--output-dir "$results_dir"
313323
- save-cache@1:
314324
is_always_run: true
315325
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)