From a461face280f89925c5c3b3ceef0f8e26befffbb Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Mon, 29 Jun 2026 16:50:46 +0100 Subject: [PATCH 1/2] Document BrowserStack E2E Ruby classes Assisted-By: devx/9ae89f69-61a5-46e3-8faf-006d105b0cdd --- e2e/BITRISE.md | 20 +++ e2e/bitrise.yml | 35 ++-- e2e/lib/browserstack_device_resolver.rb | 81 ++++++++++ e2e/scripts/execute_browserstack_run | 206 ++++++++++++++++++++++++ e2e/scripts/zip_e2e_tests | 55 +++++++ 5 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 e2e/lib/browserstack_device_resolver.rb create mode 100755 e2e/scripts/execute_browserstack_run create mode 100755 e2e/scripts/zip_e2e_tests diff --git a/e2e/BITRISE.md b/e2e/BITRISE.md index c56025c9..ea9eb254 100644 --- a/e2e/BITRISE.md +++ b/e2e/BITRISE.md @@ -64,6 +64,15 @@ These secrets are configured in Bitrise.io; they cannot live in the repository. | `STOREFRONT_DOMAIN` | Storefront domain for sample app builds. | | `STOREFRONT_ACCESS_TOKEN` | Storefront access token for sample app builds. | +## BrowserStack secrets + +The `e2e-execute-browserstack-run` workflow authenticates with BrowserStack using these secrets, configured in Bitrise.io: + +| Secret | Purpose | +|---|---| +| `BROWSERSTACK_USERNAME` | BrowserStack API username. | +| `BROWSERSTACK_ACCESS_KEY` | BrowserStack API access key. | + ## Code signing React Native iOS IPA generation uses Bitrise's certificate and profile installer before running `xcodebuild archive` and `xcodebuild -exportArchive`. @@ -78,6 +87,17 @@ Upload the signing certificate and provisioning profile for the React Native sam | `E2E_IOS_CODE_SIGN_IDENTITY` | `Apple Development` | Code signing identity used for iOS archive and export signing. | | `E2E_IOS_PROVISIONING_PROFILE_SPECIFIER` | `bitrise-checkout-kit-e2e` | Provisioning profile specifier installed by Bitrise and passed to `xcodebuild`; override it if the Bitrise-installed profile uses a different name. | +## BrowserStack execution + +The `e2e-execute-browserstack-run` workflow resolves the Bitrise parallel index into a BrowserStack run plan row, resolves a BrowserStack device dynamically, uploads the app artifact and E2E tests zip, executes the selected flow, and stores raw plus normalized result JSON as artifacts. + +The launch smoke suite sends only non-sensitive Maestro environment values to BrowserStack: + +- `E2E_APP_ID` +- `E2E_READY_MARKER` + +Do not pass storefront tokens or customer data through BrowserStack Maestro environment variables without explicit review, because those values are visible in BrowserStack dashboards. + ## Caching React Native Android E2E builds use the released native Maven artifact versions declared by the React Native sample and module configuration. Do not pass the React Native `--local` flag or set local native SDK override environment variables for these builds. diff --git a/e2e/bitrise.yml b/e2e/bitrise.yml index 633745b4..c3e743f2 100644 --- a/e2e/bitrise.yml +++ b/e2e/bitrise.yml @@ -99,7 +99,7 @@ workflows: steps: - git-clone@8: {} - script@1: - title: Produce BrowserStack run plan placeholder + title: Produce BrowserStack run plan timeout: 1800 no_output_timeout: 900 inputs: @@ -111,11 +111,11 @@ workflows: ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan expand > "$BITRISE_DEPLOY_DIR/browserstack-run-plan.json" e2e_log "Publishing BrowserStack run plan count" envman add --key E2E_BROWSERSTACK_RUN_PLAN_COUNT --value "$(ruby e2e/scripts/e2e_matrix_to_browserstack_run_plan count)" - e2e_log "Creating E2E tests zip placeholder" - mkdir -p "$BITRISE_DEPLOY_DIR/e2e" - echo "Phase 2 placeholder for E2E tests zip" > "$BITRISE_DEPLOY_DIR/e2e/e2e-tests.zip" + e2e_log "Zipping E2E tests" + tests_zip="$(e2e_deploy_dir)/e2e-tests.zip" + e2e/scripts/zip_e2e_tests --output "$tests_zip" e2e_log "Publishing E2E tests zip path" - envman add --key E2E_TESTS_ZIP --value "$BITRISE_DEPLOY_DIR/e2e/e2e-tests.zip" + envman add --key E2E_TESTS_ZIP --value "$tests_zip" - share-pipeline-variable@1: inputs: - variables: E2E_BROWSERSTACK_RUN_PLAN_COUNT @@ -191,11 +191,9 @@ workflows: - git-clone@8: {} - pull-intermediate-files@1: inputs: - - artifact_sources: |- - e2e-produce-browserstack-run-plan - e2e-build-react-native-* + - artifact_sources: e2e-produce-browserstack-run-plan,e2e-build-react-native-.* - script@1: - title: Resolve BrowserStack run plan row placeholder + title: Execute BrowserStack run timeout: 2700 no_output_timeout: 1350 inputs: @@ -204,10 +202,23 @@ workflows: source e2e/scripts/bitrise_ci_helpers : "${BITRISE_IO_PARALLEL_INDEX:?BITRISE_IO_PARALLEL_INDEX is required for the parallel e2e-execute-browserstack-run workflow.}" run_index="$BITRISE_IO_PARALLEL_INDEX" - mkdir -p "$BITRISE_DEPLOY_DIR/e2e/results" + results_root="$BITRISE_DEPLOY_DIR/e2e/results" + results_dir="$results_root/run-${run_index}" + mkdir -p "$results_dir" + envman add --key E2E_BROWSERSTACK_RESULTS_DIR --value "$results_dir" + : "${BROWSERSTACK_USERNAME:?BROWSERSTACK_USERNAME is required}" + : "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}" + : "${E2E_TESTS_ZIP:?E2E_TESTS_ZIP is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" + : "${E2E_BROWSERSTACK_RUN_PLAN_JSON:?E2E_BROWSERSTACK_RUN_PLAN_JSON is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" : "${E2E_BROWSERSTACK_RUN_PLAN_JSON:?E2E_BROWSERSTACK_RUN_PLAN_JSON is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" - e2e_log "Resolving BrowserStack run plan row ${run_index}" - ruby -rjson -e 'runs = JSON.parse(File.read(ARGV.fetch(0))); puts JSON.pretty_generate(runs.fetch(Integer(ARGV.fetch(1))))' "$E2E_BROWSERSTACK_RUN_PLAN_JSON" "$run_index" > "$BITRISE_DEPLOY_DIR/e2e/results/run-${run_index}.json" + : "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" + : "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" + e2e_log "Executing BrowserStack run plan row ${run_index}" + e2e/scripts/execute_browserstack_run \ + --index "$run_index" \ + --run-plan "$E2E_BROWSERSTACK_RUN_PLAN_JSON" \ + --tests-zip "$E2E_TESTS_ZIP" \ + --output-dir "$results_dir" - deploy-to-bitrise-io@2: inputs: - pipeline_intermediate_files: |- diff --git a/e2e/lib/browserstack_device_resolver.rb b/e2e/lib/browserstack_device_resolver.rb new file mode 100644 index 00000000..4136b7b9 --- /dev/null +++ b/e2e/lib/browserstack_device_resolver.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Resolves abstract E2E device selectors, such as `ios:phone:latest`, to a +# concrete BrowserStack device and OS version that is currently available. +class BrowserStackDeviceResolver + def initialize(devices, limits = []) + @devices = devices + @limits = limits + end + + def resolve(selector) + platform, form_factor, version_tag = selector.split(":") + raise ArgumentError, "unsupported device selector: #{selector}" unless platform && form_factor == "phone" && version_tag + + candidates = available_phone_devices(platform) + os_version = os_version_for_tag(candidates, version_tag) + device = candidates.select { |candidate| candidate.fetch("os_version") == os_version } + .sort_by { |candidate| candidate.fetch("device") } + .first + + raise ArgumentError, "no BrowserStack device available for #{selector}" unless device + + { + "device_selector" => selector, + "platform" => platform, + "os_version_tag" => version_tag, + "resolved_device" => device.fetch("device"), + "resolved_os_version" => device.fetch("os_version"), + "browserstack_device" => "#{device.fetch("device")}-#{device.fetch("os_version")}" + } + end + + private + + def available_phone_devices(platform) + @devices.select do |device| + device.fetch("os") == platform && + device.fetch("realMobile", true) && + phone?(device.fetch("device")) && + available?(device) + end + end + + def phone?(device_name) + !device_name.match?(/ipad|tablet| tab\b/i) + end + + def available?(device) + limit = @limits.find do |candidate| + candidate.fetch("os") == device.fetch("os") && + candidate.fetch("os_version") == device.fetch("os_version") && + candidate.fetch("device") == device.fetch("device") + end + + return true unless limit + + limit.fetch("group_usage", 0).to_i < limit.fetch("device_limit", 1).to_i + end + + def os_version_for_tag(candidates, version_tag) + versions = candidates.map { |candidate| candidate.fetch("os_version") }.uniq.sort_by { |version| version_segments(version) } + raise ArgumentError, "no BrowserStack devices available" if versions.empty? + + case version_tag + when "latest" + versions.last + when "previous" + major_versions = versions.group_by { |version| version_segments(version).first }.keys.sort + previous_major = major_versions[-2] + raise ArgumentError, "no previous BrowserStack OS version available" unless previous_major + + versions.select { |version| version_segments(version).first == previous_major }.last + else + raise ArgumentError, "unsupported OS version tag: #{version_tag}" + end + end + + def version_segments(version) + version.to_s.split(".").map(&:to_i) + end +end diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run new file mode 100755 index 00000000..a196051c --- /dev/null +++ b/e2e/scripts/execute_browserstack_run @@ -0,0 +1,206 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "base64" +require "fileutils" +require "json" +require "net/http" +require "optparse" +require "securerandom" +require "uri" +require_relative "../lib/browserstack_device_resolver" + +# Executes one row from the BrowserStack run plan, from artifact upload through +# build polling and normalized result persistence. +class BrowserStackRunExecutor + API_HOST = "api-cloud.browserstack.com" + TERMINAL_STATUSES = %w[passed failed error timedout stopped done].freeze + + def initialize(options) + @options = options + @username = ENV.fetch("BROWSERSTACK_USERNAME") + @access_key = ENV.fetch("BROWSERSTACK_ACCESS_KEY") + end + + def run + FileUtils.mkdir_p(output_dir) + run = run_plan.fetch(run_index) + app_path = ENV.fetch(run.fetch("artifact_env")) + device = resolve_device(run) + app = upload_file("/app-automate/maestro/v2/app", app_path, custom_id(run, "app")) + suite = upload_file("/app-automate/maestro/v2/test-suite", tests_zip, custom_id(run, "suite")) + build = start_build(run, app.fetch("app_url"), suite.fetch("test_suite_url"), device.fetch("browserstack_device")) + build_status = poll_build(build.fetch("build_id")) + sessions = fetch_sessions(build_status) + result = normalize_result(run, device, app, suite, build, build_status, sessions) + write_json("result.json", result) + exit(result.fetch("passed") ? 0 : 1) + end + + private + + def resolve_device(run) + override = ENV["E2E_DEVICE_OVERRIDE"] || ENV["E2E_#{run.fetch("platform").upcase}_DEVICE_OVERRIDE"] + if override && !override.empty? + return { + "device_selector" => run.fetch("device_selector"), + "browserstack_device" => override, + "resolved_device" => override.split("-").first, + "resolved_os_version" => override.split("-").last + } + end + + devices = get_json("/app-automate/devices.json") + limits = get_json("/app-automate/device_tier_limits.json") + BrowserStackDeviceResolver.new(devices, limits).resolve(run.fetch("device_selector")) + end + + def upload_file(path, file_path, custom_id) + response = multipart_post(path, file_path, custom_id) + write_json("#{custom_id}.json", response) + response + end + + def start_build(run, app_url, test_suite_url, device) + body = { + app: app_url, + testSuite: test_suite_url, + project: ENV.fetch("E2E_BROWSERSTACK_PROJECT", "checkout-kit-e2e"), + buildTag: ENV.fetch("BITRISE_GIT_COMMIT", "local"), + customBuildName: run.fetch("id"), + devices: [device], + execute: [run.fetch("execute")], + setEnvVariables: { + E2E_APP_ID: run.fetch("app_id"), + E2E_READY_MARKER: run.fetch("ready_marker") + } + } + response = post_json("/app-automate/maestro/v2/#{run.fetch("platform")}/build", body) + write_json("build-start.json", response) + response + end + + def poll_build(build_id) + deadline = Time.now + ENV.fetch("E2E_BROWSERSTACK_TIMEOUT_SECONDS", "1800").to_i + loop do + response = get_json("/app-automate/maestro/v2/builds/#{build_id}") + write_json("build-status.json", response) + return response if TERMINAL_STATUSES.include?(response.fetch("status").to_s.downcase) + raise "BrowserStack build timed out: #{build_id}" if Time.now >= deadline + + sleep ENV.fetch("E2E_BROWSERSTACK_POLL_SECONDS", "30").to_i + end + end + + def fetch_sessions(build_status) + build_id = build_status.fetch("id") + build_status.fetch("devices", []).flat_map do |device| + device.fetch("sessions", []).map do |session| + get_json("/app-automate/maestro/v2/builds/#{build_id}/sessions/#{session.fetch("id")}") + end + end + end + + def normalize_result(run, device, app, suite, build, build_status, sessions) + status = build_status.fetch("status").to_s.downcase + failed_tests = sessions.flat_map do |session| + session.dig("testcases", "data").to_a.flat_map do |group| + group.fetch("testcases", []).select { |testcase| testcase.fetch("status", "") != "passed" } + end + end + + { + "id" => run.fetch("id"), + "status_context" => run.fetch("status_context"), + "platform" => run.fetch("platform"), + "target" => run.fetch("target"), + "os_version_tag" => run.fetch("os_version_tag"), + "execute" => run.fetch("execute"), + "device_selector" => device.fetch("device_selector"), + "resolved_device" => device.fetch("browserstack_device"), + "app_url" => app.fetch("app_url"), + "test_suite_url" => suite.fetch("test_suite_url"), + "build_id" => build.fetch("build_id"), + "status" => status, + "passed" => status == "passed" && failed_tests.empty?, + "failed_tests" => failed_tests + } + end + + def multipart_post(path, file_path, custom_id) + boundary = "----checkout-kit-#{SecureRandom.hex(12)}" + file = File.binread(file_path) + body = +"" + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n\r\n" + body << file + body << "\r\n--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"custom_id\"\r\n\r\n" + body << custom_id + body << "\r\n--#{boundary}--\r\n" + request = Net::HTTP::Post.new(path) + request["Content-Type"] = "multipart/form-data; boundary=#{boundary}" + request.body = body + execute_request(request) + end + + def get_json(path) + execute_request(Net::HTTP::Get.new(path)) + end + + def post_json(path, body) + request = Net::HTTP::Post.new(path) + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + execute_request(request) + end + + def execute_request(request) + request.basic_auth(@username, @access_key) + response = Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) } + parsed = JSON.parse(response.body) + return parsed if response.is_a?(Net::HTTPSuccess) + + raise "BrowserStack request failed #{response.code}: #{parsed}" + end + + def custom_id(run, suffix) + ["checkout-kit", run.fetch("id"), ENV.fetch("BITRISE_GIT_COMMIT", "local"), suffix].join("-").gsub(/[^A-Za-z0-9._-]/, "-")[0, 100] + end + + def write_json(name, object) + File.write(File.join(output_dir, name), JSON.pretty_generate(object)) + end + + def output_dir + @options.fetch(:output_dir) + end + + def run_plan + @run_plan ||= JSON.parse(File.read(@options.fetch(:run_plan_path))) + end + + def run_index + @options.fetch(:run_index) + end + + def tests_zip + @options.fetch(:tests_zip) + end +end + +options = { + run_plan_path: ENV.fetch("E2E_BROWSERSTACK_RUN_PLAN_JSON", "browserstack-run-plan.json"), + run_index: Integer(ENV.fetch("BITRISE_IO_PARALLEL_INDEX", "0")), + tests_zip: ENV.fetch("E2E_TESTS_ZIP"), + output_dir: ENV.fetch("E2E_BROWSERSTACK_RESULTS_DIR", File.join(Dir.pwd, "e2e-results")) +} + +OptionParser.new do |opts| + opts.on("--run-plan PATH") { |path| options[:run_plan_path] = path } + opts.on("--index INDEX", Integer) { |index| options[:run_index] = index } + opts.on("--tests-zip PATH") { |path| options[:tests_zip] = path } + opts.on("--output-dir PATH") { |path| options[:output_dir] = path } +end.parse! + +BrowserStackRunExecutor.new(options).run diff --git a/e2e/scripts/zip_e2e_tests b/e2e/scripts/zip_e2e_tests new file mode 100755 index 00000000..287ba1ce --- /dev/null +++ b/e2e/scripts/zip_e2e_tests @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +output="" +parent="checkout-kit-e2e-tests" + +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output="$2" + shift 2 + ;; + *) + echo "Usage: e2e/scripts/zip_e2e_tests --output PATH" >&2 + exit 1 + ;; + esac +done + +if [ -z "$output" ]; then + echo "Usage: e2e/scripts/zip_e2e_tests --output PATH" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/checkout-kit-e2e-tests.XXXXXX")" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +suite_root="$tmpdir/$parent" +mkdir -p "$suite_root" + +if [ -d "$repo_root/e2e/tests" ]; then + cp -R "$repo_root/e2e/tests" "$suite_root/tests" +fi + +if [ -d "$repo_root/e2e/flows" ]; then + cp -R "$repo_root/e2e/flows" "$suite_root/flows" +fi + +if [ -f "$repo_root/e2e/config.yaml" ]; then + cp "$repo_root/e2e/config.yaml" "$suite_root/config.yaml" +fi + +mkdir -p "$(dirname "$output")" +rm -f "$output" +( + cd "$tmpdir" + zip -qr "$output" "$parent" +) + +echo "$output" From 1f288fab3ec436375074d2afccf79cb09a2759bb Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 17:10:32 +0100 Subject: [PATCH 2/2] Require BITRISE_IO_PARALLEL_INDEX in browserstack run script --- e2e/bitrise.yml | 1 - e2e/scripts/execute_browserstack_run | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/bitrise.yml b/e2e/bitrise.yml index c3e743f2..2f4fa903 100644 --- a/e2e/bitrise.yml +++ b/e2e/bitrise.yml @@ -210,7 +210,6 @@ workflows: : "${BROWSERSTACK_ACCESS_KEY:?BROWSERSTACK_ACCESS_KEY is required}" : "${E2E_TESTS_ZIP:?E2E_TESTS_ZIP is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" : "${E2E_BROWSERSTACK_RUN_PLAN_JSON:?E2E_BROWSERSTACK_RUN_PLAN_JSON is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" - : "${E2E_BROWSERSTACK_RUN_PLAN_JSON:?E2E_BROWSERSTACK_RUN_PLAN_JSON is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" : "${E2E_REACT_NATIVE_IOS_APP_PATH:?E2E_REACT_NATIVE_IOS_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" : "${E2E_REACT_NATIVE_ANDROID_APP_PATH:?E2E_REACT_NATIVE_ANDROID_APP_PATH is required. Check e2e-execute-browserstack-run pull-intermediate-files artifact_sources.}" e2e_log "Executing BrowserStack run plan row ${run_index}" diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index a196051c..f55cef07 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -191,7 +191,7 @@ end options = { run_plan_path: ENV.fetch("E2E_BROWSERSTACK_RUN_PLAN_JSON", "browserstack-run-plan.json"), - run_index: Integer(ENV.fetch("BITRISE_IO_PARALLEL_INDEX", "0")), + run_index: Integer(ENV.fetch("BITRISE_IO_PARALLEL_INDEX")), tests_zip: ENV.fetch("E2E_TESTS_ZIP"), output_dir: ENV.fetch("E2E_BROWSERSTACK_RESULTS_DIR", File.join(Dir.pwd, "e2e-results")) }