From 4653cddc3476835c52149cc00764f67b8449d181 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 10 Jul 2026 09:59:11 +0100 Subject: [PATCH] refactor: extract shared browserstack client and reuse http client --- e2e/lib/browserstack_client.rb | 63 ++++++++++++++++++++++++ e2e/lib/e2e_github_reporter.rb | 2 +- e2e/scripts/execute_browserstack_run | 52 +++++-------------- {e2e => scripts}/lib/json_http_client.rb | 0 scripts/tophat/bitrise_client.rb | 30 ++++------- 5 files changed, 87 insertions(+), 60 deletions(-) create mode 100644 e2e/lib/browserstack_client.rb rename {e2e => scripts}/lib/json_http_client.rb (100%) diff --git a/e2e/lib/browserstack_client.rb b/e2e/lib/browserstack_client.rb new file mode 100644 index 00000000..cee0bdb4 --- /dev/null +++ b/e2e/lib/browserstack_client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "net/http" +require "securerandom" +require_relative "../../scripts/lib/json_http_client" + +# BrowserStack App Automate API client. Owns endpoint paths, HTTP, and response +# parsing; callers assemble request payloads and orchestrate build lifecycles. +class BrowserStackClient + API_HOST = "api-cloud.browserstack.com" + + def initialize(username:, access_key:, retries: 0) + @client = JsonHttpClient.new( + host: API_HOST, + error_label: "BrowserStack", + retries: retries, + retryable: ->(response) { response.code.to_i == 429 || response.code.to_i >= 500 } + ) do |request| + request.basic_auth(username, access_key) + end + end + + def list_devices + @client.get("/app-automate/devices.json") + end + + def list_device_tier_limits + @client.get("/app-automate/device_tier_limits.json") + end + + def upload(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 + @client.execute(request) + end + + def start_build(platform, body) + @client.post_json("/app-automate/maestro/v2/#{platform}/build", body) + end + + def get_build(build_id) + @client.get("/app-automate/maestro/v2/builds/#{build_id}") + end + + def stop_build(build_id) + @client.post_json("/app-automate/maestro/builds/#{build_id}/stop", {}) + end + + def get_session(build_id, session_id) + @client.get("/app-automate/maestro/v2/builds/#{build_id}/sessions/#{session_id}") + end +end diff --git a/e2e/lib/e2e_github_reporter.rb b/e2e/lib/e2e_github_reporter.rb index 06b3ceab..13d386a1 100644 --- a/e2e/lib/e2e_github_reporter.rb +++ b/e2e/lib/e2e_github_reporter.rb @@ -2,7 +2,7 @@ require "json" require "uri" -require_relative "json_http_client" +require_relative "../../scripts/lib/json_http_client" # Publishes normalized E2E run results back to GitHub as commit statuses, # check runs, and a sticky pull request comment with Tophat install links. diff --git a/e2e/scripts/execute_browserstack_run b/e2e/scripts/execute_browserstack_run index 4eba7eb3..207409f1 100755 --- a/e2e/scripts/execute_browserstack_run +++ b/e2e/scripts/execute_browserstack_run @@ -1,33 +1,24 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "base64" require "fileutils" require "json" -require "net/http" require "optparse" -require "securerandom" +require_relative "../lib/browserstack_client" require_relative "../lib/browserstack_device_resolver" -require_relative "../lib/json_http_client" # 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") - @client = JsonHttpClient.new( - host: API_HOST, - error_label: "BrowserStack", - retries: ENV.fetch("E2E_BROWSERSTACK_API_RETRIES", "1").to_i, - retryable: ->(response) { response.code.to_i == 429 || response.code.to_i >= 500 } - ) do |request| - request.basic_auth(@username, @access_key) - end + @client = BrowserStackClient.new( + username: ENV.fetch("BROWSERSTACK_USERNAME"), + access_key: ENV.fetch("BROWSERSTACK_ACCESS_KEY"), + retries: ENV.fetch("E2E_BROWSERSTACK_API_RETRIES", "1").to_i + ) end def run @@ -75,13 +66,13 @@ class BrowserStackRunExecutor } end - devices = @client.get("/app-automate/devices.json") - limits = @client.get("/app-automate/device_tier_limits.json") + devices = @client.list_devices + limits = @client.list_device_tier_limits 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) + response = @client.upload(path, file_path, custom_id) write_json("#{custom_id}.json", response) response end @@ -100,7 +91,7 @@ class BrowserStackRunExecutor E2E_READY_MARKER: run.fetch("ready_marker") } } - response = @client.post_json("/app-automate/maestro/v2/#{run.fetch("platform")}/build", body) + response = @client.start_build(run.fetch("platform"), body) write_json("build-start.json", response) response end @@ -108,7 +99,7 @@ class BrowserStackRunExecutor def poll_build(build_id) deadline = Time.now + ENV.fetch("E2E_BROWSERSTACK_TIMEOUT_SECONDS", "1800").to_i loop do - response = @client.get("/app-automate/maestro/v2/builds/#{build_id}") + response = @client.get_build(build_id) write_json("build-status.json", response) return response if TERMINAL_STATUSES.include?(response.fetch("status").to_s.downcase) if Time.now >= deadline @@ -121,7 +112,7 @@ class BrowserStackRunExecutor end def stop_build(build_id) - @client.post_json("/app-automate/maestro/builds/#{build_id}/stop", {}) + @client.stop_build(build_id) rescue StandardError => error warn "Unable to stop BrowserStack build #{build_id}: #{error.message}" end @@ -130,7 +121,7 @@ class BrowserStackRunExecutor build_id = build_status.fetch("id") build_status.fetch("devices", []).flat_map do |device| device.fetch("sessions", []).map do |session| - @client.get("/app-automate/maestro/v2/builds/#{build_id}/sessions/#{session.fetch("id")}") + @client.get_session(build_id, session.fetch("id")) end end end @@ -188,23 +179,6 @@ class BrowserStackRunExecutor } 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 - @client.execute(request) - 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 diff --git a/e2e/lib/json_http_client.rb b/scripts/lib/json_http_client.rb similarity index 100% rename from e2e/lib/json_http_client.rb rename to scripts/lib/json_http_client.rb diff --git a/scripts/tophat/bitrise_client.rb b/scripts/tophat/bitrise_client.rb index 7e12267f..e57c96d4 100644 --- a/scripts/tophat/bitrise_client.rb +++ b/scripts/tophat/bitrise_client.rb @@ -1,43 +1,33 @@ # frozen_string_literal: true -require "json" -require "net/http" require "uri" +require_relative "../lib/json_http_client" class BitriseClient API_HOST = "api.bitrise.io" API_BASE = "/v0.1" def initialize(token:, app_slug:) - @token = token @app_slug = app_slug + @transport = JsonHttpClient.new(host: API_HOST, error_label: "Bitrise") do |request| + request["Authorization"] = token + end end def authorized? - get("#{API_BASE}/me").is_a?(Net::HTTPSuccess) + @transport.get("#{API_BASE}/me") + true + rescue RuntimeError + false end def latest_build(branch:, workflow:) query = URI.encode_www_form(branch: branch, workflow: workflow, limit: 1) - get_json("#{API_BASE}/apps/#{@app_slug}/builds?#{query}").fetch("data", []).first + @transport.get("#{API_BASE}/apps/#{@app_slug}/builds?#{query}").fetch("data", []).first end def artifact_titles(build_slug) path = "#{API_BASE}/apps/#{@app_slug}/builds/#{build_slug}/artifacts" - get_json(path).fetch("data", []).map { |artifact| artifact.fetch("title") } - end - - private - - def get_json(path) - response = get(path) - raise "Bitrise request failed #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) - JSON.parse(response.body) - end - - def get(path) - request = Net::HTTP::Get.new(path) - request["Authorization"] = @token - Net::HTTP.start(API_HOST, 443, use_ssl: true) { |http| http.request(request) } + @transport.get(path).fetch("data", []).map { |artifact| artifact.fetch("title") } end end