Skip to content
Draft
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
63 changes: 63 additions & 0 deletions e2e/lib/browserstack_client.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion e2e/lib/e2e_github_reporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 13 additions & 39 deletions e2e/scripts/execute_browserstack_run
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -100,15 +91,15 @@ 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

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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
File renamed without changes.
30 changes: 10 additions & 20 deletions scripts/tophat/bitrise_client.rb
Original file line number Diff line number Diff line change
@@ -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
Loading