Skip to content

Commit 6484336

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

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
@@ -71,11 +71,9 @@ Secrets still need to be configured in Bitrise.io.
7171
| `STOREFRONT_DOMAIN` | Required by `scripts/setup_storefront_env` for sample app builds. |
7272
| `STOREFRONT_ACCESS_TOKEN` | Required by `scripts/setup_storefront_env` for sample app builds. |
7373

74-
## Future secrets
74+
## BrowserStack secrets
7575

76-
Do not add BrowserStack credentials until the BrowserStack integration phase.
77-
78-
Future secret names:
76+
BrowserStack credentials are required by the `e2e-run-browserstack` workflow.
7977

8078
| Secret | Purpose |
8179
|---|---|
@@ -94,6 +92,17 @@ The React Native iOS artifact workflow overrides the default Linux stack and run
9492

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

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

99108
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
@@ -42,7 +42,7 @@ workflows:
4242
steps:
4343
- git-clone@8: {}
4444
- script@1:
45-
title: Validate E2E matrix and create suite placeholder
45+
title: Validate E2E matrix and package Maestro suite
4646
inputs:
4747
- content: |-
4848
set -euo pipefail
@@ -51,11 +51,12 @@ workflows:
5151
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix validate
5252
e2e_log "Expanding E2E matrix"
5353
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" ruby e2e/scripts/e2e_matrix expand > "$BITRISE_DEPLOY_DIR/e2e-matrix.json"
54-
e2e_log "Creating Maestro suite placeholder"
54+
e2e_log "Packaging Maestro suite"
5555
mkdir -p "$BITRISE_DEPLOY_DIR/e2e"
56-
echo "Phase 2 placeholder for BrowserStack Maestro suite zip" > "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
56+
suite_zip="$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
57+
e2e_run_with_timeout "${E2E_PACKAGE_COMMAND_TIMEOUT_SECONDS:-300}" e2e/scripts/package_maestro_suite --output "$suite_zip"
5758
e2e_log "Publishing Maestro suite path"
58-
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/maestro-suite.zip"
59+
envman add --key E2E_MAESTRO_SUITE_ZIP --value "$suite_zip"
5960
- deploy-to-bitrise-io@2:
6061
inputs:
6162
- pipeline_intermediate_files: |-
@@ -206,16 +207,23 @@ workflows:
206207
e2e-package-suite
207208
e2e-build-react-native-*
208209
- script@1:
209-
title: Resolve E2E matrix row placeholder
210+
title: Run BrowserStack Maestro for matrix row
210211
inputs:
211212
- content: |-
212213
set -euo pipefail
213214
source e2e/scripts/bitrise_ci_helpers
214215
e2e_prepare_ruby
216+
: "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}"
217+
: "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}"
215218
run_index="${BITRISE_IO_PARALLEL_INDEX:-0}"
216-
mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results"
217-
e2e_log "Resolving E2E matrix row ${run_index}"
218-
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"
219+
results_dir="$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}"
220+
mkdir -p "$results_dir"
221+
envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir"
222+
e2e_log "Running BrowserStack Maestro for matrix row ${run_index}"
223+
e2e/scripts/browserstack_maestro \
224+
--index "$run_index" \
225+
--suite-zip "$E2E_MAESTRO_SUITE_ZIP" \
226+
--output-dir "$results_dir"
219227
- deploy-to-bitrise-io@2:
220228
inputs:
221229
- 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)