Skip to content

Commit ffa1eee

Browse files
Run BrowserStack Maestro from E2E matrix rows
Assisted-By: devx/396ca0c4-44ad-415d-8d80-7200df4e42a4
1 parent b1b75a8 commit ffa1eee

8 files changed

Lines changed: 450 additions & 16 deletions

e2e/BITRISE.md

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

69-
## Future secrets
69+
## BrowserStack secrets
7070

71-
Do not add BrowserStack credentials until the BrowserStack integration phase.
72-
73-
Future secret names:
71+
BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
7472

7573
| Secret | Purpose |
7674
|---|---|
@@ -87,6 +85,17 @@ React Native iOS IPA generation uses Bitrise's certificate and profile installer
8785

8886
Upload the required signing certificate and provisioning profile for the React Native sample app to the Bitrise app before running the iOS artifact workflow.
8987

88+
## BrowserStack execution
89+
90+
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.
91+
92+
The initial launch smoke sends only non-sensitive Maestro environment values to BrowserStack:
93+
94+
- `E2E_APP_ID`
95+
- `E2E_READY_MARKER`
96+
97+
Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards.
98+
9099
## Caching
91100

92101
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: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ workflows:
3838
steps:
3939
- git-clone@8: {}
4040
- script@1:
41-
title: Validate E2E matrix and create suite placeholder
41+
title: Validate E2E matrix and package Maestro suite
4242
inputs:
4343
- content: |-
4444
set -euo pipefail
4545
ruby e2e/scripts/e2e_matrix validate
4646
ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
4747
mkdir -p "$BITRISE_DEPLOY_DIR/e2e"
48-
echo "Phase 2 placeholder for BrowserStack Maestro suite zip" > "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
49-
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
48+
suite_zip="$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
49+
e2e/scripts/package_maestro_suite --output "$suite_zip"
50+
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$suite_zip"
5051
- deploy-to-bitrise-io@2:
5152
inputs:
5253
- pipeline_intermediate_files: |-
@@ -166,13 +167,20 @@ workflows:
166167
e2e-package-suite
167168
e2e-build-react-native-*
168169
- script@1:
169-
title: Resolve E2E matrix row placeholder
170+
title: Run BrowserStack Maestro for matrix row
170171
inputs:
171172
- content: |-
172173
set -euo pipefail
174+
: "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}"
175+
: "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}"
173176
run_index="${BITRISE_IO_PARALLEL_INDEX:-0}"
174-
mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results"
175-
ruby e2e/scripts/e2e_matrix expand --index "$run_index" > "$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}.json"
177+
results_dir="$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}"
178+
mkdir -p "$results_dir"
179+
envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir"
180+
e2e/scripts/browserstack_maestro \
181+
--index "$run_index" \
182+
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
183+
--output-dir "$results_dir"
176184
- deploy-to-bitrise-io@2:
177185
inputs:
178186
- 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)