startup-test-android.py (7737B)
1 #!/usr/bin/env python3 2 import argparse 3 import json 4 import os 5 import sys 6 import time 7 from datetime import datetime, timedelta 8 from enum import Enum 9 10 import requests 11 12 """ 13 This script runs Android tests on BrowserStack using the BrowserStack App Automate Espresso API. 14 15 Usage: 16 startup-test-android.py --devices <devices> [--tests <tests>] [--app_file_path <app_file_path>] [--test_file_path <test_file_path>] 17 18 Arguments: 19 --devices: Comma-separated list of devices to test on (required). 20 --tests: Comma-separated list of tests to run (optional). If not provided, all tests will run. 21 --app_file_path: Path to the app file (optional). If not provided, yesterday's nightly will be downloaded. 22 --test_file_path: Path to the test file (optional). If not provided, yesterday's nightly will be downloaded. 23 24 Environment Variables: 25 BROWSERSTACK_USERNAME: BrowserStack username (required). 26 BROWSERSTACK_API_KEY: BrowserStack API key (required). 27 28 Description: 29 - If app and test file paths are not provided, the script downloads the latest nightly build from the Tor Project. 30 - Uploads the app and test files to BrowserStack. 31 - Triggers the test run on the specified devices. 32 - Polls for the test status until completion or timeout. 33 - Prints the test results and exits with an appropriate status code. 34 """ 35 36 parser = argparse.ArgumentParser( 37 description="Run Android startup tests on BrowserStack." 38 ) 39 parser.add_argument( 40 "--devices", 41 type=str, 42 help="Comma-separated list of devices to test on", 43 required=True, 44 ) 45 parser.add_argument("--tests", type=str, help="Comma-separated list of tests to run") 46 parser.add_argument("--app_file_path", type=str, help="Path to the app file") 47 parser.add_argument("--test_file_path", type=str, help="Path to the test file") 48 49 args = parser.parse_args() 50 51 if args.app_file_path: 52 app_file_path = args.app_file_path 53 test_file_path = args.test_file_path 54 if not test_file_path: 55 print( 56 "\033[1;31mIf either app or test file paths are provided, both must be provided.\033[0m" 57 ) 58 else: 59 60 def download_file(url, dest_path): 61 try: 62 response = requests.get(url, stream=True) 63 response.raise_for_status() 64 with open(dest_path, "wb") as f: 65 for chunk in response.iter_content(chunk_size=8192): 66 f.write(chunk) 67 except Exception as e: 68 print(f"\033[1;31mFailed to download file from {url}.\033[0m") 69 print(e) 70 sys.exit(1) 71 72 yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y.%m.%d") 73 download_url_base = f"https://nightlies.tbb.torproject.org/nightly-builds/tor-browser-builds/tbb-nightly.{yesterday}/nightly-android-aarch64" 74 print( 75 f"No file paths provided, downloading yesterday's nightly from {download_url_base}" 76 ) 77 78 app_file_url = f"{download_url_base}/tor-browser-noopt-android-aarch64-tbb-nightly.{yesterday}.apk" 79 test_file_url = ( 80 f"{download_url_base}/tor-browser-tbb-nightly.{yesterday}-androidTest.apk" 81 ) 82 83 # BrowserStack will fail if there are `.` in the file name other than before the extension. 84 yesterday = yesterday.replace(".", "-") 85 app_file_path = f"/tmp/nightly-{yesterday}.apk" 86 test_file_path = f"/tmp/nightly-test-{yesterday}.apk" 87 88 download_file(app_file_url, app_file_path) 89 download_file(test_file_url, test_file_path) 90 91 devices = [device.strip() for device in args.devices.split(",")] 92 tests = args.tests.split(",") if args.tests else [] 93 94 browserstack_username = os.getenv("BROWSERSTACK_USERNAME") 95 browserstack_api_key = os.getenv("BROWSERSTACK_API_KEY") 96 if not browserstack_username or not browserstack_api_key: 97 print( 98 "\033[1;31mEnvironment variables BROWSERSTACK_USERNAME and BROWSERSTACK_API_KEY must be set.\033[0m" 99 ) 100 sys.exit(1) 101 102 # Upload app file 103 with open(app_file_path, "rb") as app_file: 104 response = requests.post( 105 "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", 106 auth=(browserstack_username, browserstack_api_key), 107 files={"file": app_file}, 108 ) 109 110 if response.status_code != 200: 111 print("\033[1;31mFailed to upload app file.\033[0m") 112 print(response.text) 113 sys.exit(1) 114 115 bs_app_url = response.json().get("app_url") 116 print("\033[1;32mSuccessfully uploaded app file.\033[0m") 117 print(f"App URL: {bs_app_url}") 118 119 # Upload test file 120 with open(test_file_path, "rb") as test_file: 121 response = requests.post( 122 "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", 123 auth=(browserstack_username, browserstack_api_key), 124 files={"file": test_file}, 125 ) 126 127 if response.status_code != 200: 128 print("\033[1;31mFailed to upload test file.\033[0m") 129 print(response.text) 130 sys.exit(1) 131 132 bs_test_url = response.json().get("test_suite_url") 133 print("\033[1;32mSuccessfully uploaded test file.\033[0m") 134 print(f"Test URL: {bs_test_url}") 135 136 # Trigger tests 137 test_params = { 138 "app": bs_app_url, 139 "testSuite": bs_test_url, 140 "devices": devices, 141 "class": tests, 142 } 143 144 response = requests.post( 145 "https://api-cloud.browserstack.com/app-automate/espresso/v2/build", 146 auth=(browserstack_username, browserstack_api_key), 147 headers={"Content-Type": "application/json"}, 148 data=json.dumps(test_params), 149 ) 150 151 if response.status_code != 200: 152 print("\033[1;31mFailed to trigger test run.\033[0m") 153 print(response.text) 154 sys.exit(1) 155 156 build_id = response.json().get("build_id") 157 print("\033[1;32mSuccessfully triggered test run.\033[0m") 158 print( 159 f"Test status also available at: https://app-automate.browserstack.com/builds/{build_id}\n===" 160 ) 161 162 # Poll for status 163 POLLING_TIMEOUT = 30 * 60 # 30min 164 POLLING_INTERVAL = 30 # 30s 165 166 167 class TestStatus(Enum): 168 QUEUED = "queued" 169 RUNNING = "running" 170 ERROR = "error" 171 FAILED = "failed" 172 PASSED = "passed" 173 TIMED_OUT = "timed out" 174 SKIPPED = "skipped" 175 176 @classmethod 177 def from_string(cls, s): 178 try: 179 return cls[s.upper().replace(" ", "_")] 180 except KeyError: 181 raise ValueError(f"\033[1;31m'{s}' is not a valid test status.\033[0m") 182 183 def is_terminal(self): 184 return self not in {TestStatus.QUEUED, TestStatus.RUNNING} 185 186 def is_success(self): 187 return self in {TestStatus.PASSED, TestStatus.SKIPPED} 188 189 def color_print(self): 190 if self == TestStatus.PASSED: 191 return f"\033[1;32m{self.value}\033[0m" 192 193 if self in {TestStatus.ERROR, TestStatus.FAILED, TestStatus.TIMED_OUT}: 194 return f"\033[1;31m{self.value}\033[0m" 195 196 if self == TestStatus.SKIPPED: 197 return f"\033[1;33m{self.value}\033[0m" 198 199 return self.value 200 201 202 start_time = time.time() 203 elapsed_time = 0 204 test_status = None 205 while elapsed_time <= POLLING_TIMEOUT: 206 response = requests.get( 207 f"https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/{build_id}", 208 auth=(browserstack_username, browserstack_api_key), 209 ) 210 211 if response.status_code != 200: 212 print("\033[1;31mFailed to get test status.\033[0m") 213 print(response.text) 214 sys.exit(1) 215 216 test_status = TestStatus.from_string(response.json().get("status")) 217 if test_status.is_terminal(): 218 print(f"===\nTest finished. Result: {test_status.color_print()}") 219 break 220 else: 221 elapsed_time = time.time() - start_time 222 print(f"Test status: {test_status.value} ({elapsed_time:.2f}s)") 223 224 if elapsed_time > POLLING_TIMEOUT: 225 print("===\n\033[1;33mWaited for tests for too long.\033[0m") 226 break 227 228 time.sleep(POLLING_INTERVAL) 229 230 if test_status is None or not test_status.is_success(): 231 sys.exit(1)