Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions e2e/BITRISE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand Down
34 changes: 22 additions & 12 deletions e2e/bitrise.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -204,10 +202,22 @@ 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_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: |-
Expand Down
81 changes: 81 additions & 0 deletions e2e/lib/browserstack_device_resolver.rb
Original file line number Diff line number Diff line change
@@ -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
206 changes: 206 additions & 0 deletions e2e/scripts/execute_browserstack_run
Original file line number Diff line number Diff line change
@@ -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
Comment thread
kiftio marked this conversation as resolved.
# 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"]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These will be coming in to play with another PR I am preparing to give us a way to trigger runs for specific devices and OS's (e.g. iOS 27 Beta)

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")),
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
Loading
Loading