|
| 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