1#!/usr/bin/env python3 2 3# 4# Copyright 2024, The Android Open Source Project 5# 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19import http.server 20import socketserver 21import json 22import re 23import urllib.parse 24from os import path 25import socket 26import argparse 27import os 28import subprocess 29import sys 30import tempfile 31import webbrowser 32import mimetypes 33import hashlib 34import shutil 35import secrets 36import datetime 37import glob 38import gzip 39 40 41from collections import defaultdict 42 43 44def main(): 45 parser = argparse.ArgumentParser( 46 "Watches a connected device for golden file updates." 47 ) 48 49 parser.add_argument( 50 "--port", 51 default=find_free_port(), 52 type=int, 53 help="Port to run test at watcher web UI on.", 54 ) 55 56 parser.add_argument( 57 "--atest", 58 default=False, 59 help="Watches atest output", 60 ) 61 62 parser.add_argument( 63 "--studioTest", 64 default=False, 65 help="Watch for artifacs generated when a deviceless test is run via SysUi Studio", 66 ) 67 68 parser.add_argument( 69 "--serial", 70 default=os.environ.get("ANDROID_SERIAL"), 71 help="The ADB device serial to pull goldens from.", 72 ) 73 74 parser.add_argument( 75 "--android_build_top", 76 default=os.environ.get("ANDROID_BUILD_TOP"), 77 help="The root directory of the android checkout.", 78 ) 79 80 parser.add_argument( 81 "--client_url", 82 default="http://motion.teams.x20web.corp.google.com/", 83 help="The URL where the client app is deployed.", 84 ) 85 86 args = parser.parse_args() 87 88 if args.android_build_top is None or not os.path.exists(args.android_build_top): 89 print("ANDROID_BUILD_TOP not set. Have you sourced envsetup.sh?") 90 sys.exit(1) 91 92 global android_build_top 93 android_build_top = args.android_build_top 94 95 with tempfile.TemporaryDirectory() as tmpdir: 96 global golden_watcher, this_server_address 97 98 if args.atest: 99 print("ATEST is running.") 100 user = os.environ.get("USER") 101 golden_watcher = AtestGoldenWatcher( 102 tmpdir, f"/tmp/atest_result_{user}/LATEST/" 103 ) 104 105 elif args.studioTest: 106 print("Running for devicess sysui studio test") 107 golden_watcher = StudioGoldenWatcher( 108 tmpdir, f"/tmp/motion/" 109 ) 110 else: 111 serial = args.serial 112 if not serial: 113 devices_response = subprocess.run( 114 ["adb", "devices"], check=True, capture_output=True 115 ).stdout.decode("utf-8") 116 lines = [s for s in devices_response.splitlines() if s.strip()] 117 118 if len(lines) == 1: 119 print("no adb devices found") 120 sys.exit(1) 121 122 if len(lines) > 2: 123 print("multiple adb devices found, specify --serial") 124 sys.exit(1) 125 126 serial = lines[1].split("\t")[0] 127 128 adb_client = AdbClient(serial) 129 if not adb_client.run_as_root(): 130 sys.exit(1) 131 golden_watcher = GoldenFileWatcher(tmpdir, adb_client) 132 133 this_server_address = f"http://localhost:{args.port}" 134 135 with socketserver.TCPServer( 136 ("localhost", args.port), WatchWebAppRequestHandler, golden_watcher 137 ) as httpd: 138 uiAddress = f"{args.client_url}?token={secret_token}&port={args.port}" 139 print(f"Open UI at {uiAddress}") 140 webbrowser.open(uiAddress) 141 try: 142 httpd.serve_forever() 143 except KeyboardInterrupt: 144 httpd.shutdown() 145 print("Shutting down") 146 147 148GOLDEN_ACCESS_TOKEN_HEADER = "Golden-Access-Token" 149GOLDEN_ACCESS_TOKEN_LOCATION = os.path.expanduser("~/.config/motion-golden/.token") 150 151secret_token = None 152android_build_top = None 153golden_watcher = None 154this_server_address = None 155 156 157class WatchWebAppRequestHandler(http.server.BaseHTTPRequestHandler): 158 159 def __init__(self, *args, **kwargs): 160 self.root_directory = path.abspath(path.dirname(__file__)) 161 super().__init__(*args, **kwargs) 162 163 def verify_access_token(self): 164 token = self.headers.get(GOLDEN_ACCESS_TOKEN_HEADER) 165 if not token or token != secret_token: 166 self.send_response(403, "Bad authorization token!") 167 return False 168 169 return True 170 171 def do_OPTIONS(self): 172 self.send_response(200) 173 self.send_header("Allow", "GET,POST,PUT") 174 self.add_standard_headers() 175 self.end_headers() 176 self.wfile.write(b"GET,POST,PUT") 177 178 def do_GET(self): 179 180 parsed = urllib.parse.urlparse(self.path) 181 182 if parsed.path == "/service/list": 183 self.service_list_goldens() 184 return 185 elif parsed.path.startswith("/golden/"): 186 requested_file_start_index = parsed.path.find("/", len("/golden/") + 1) 187 requested_file = parsed.path[requested_file_start_index + 1 :] 188 self.serve_file(golden_watcher.temp_dir, requested_file) 189 return 190 elif parsed.path.startswith("/expected/"): 191 golden_id = parsed.path[len("/expected/") :] 192 193 goldens = golden_watcher.cached_goldens.values() 194 for golden in goldens: 195 if golden.id != golden_id: 196 continue 197 198 self.serve_file( 199 android_build_top, golden.golden_repo_path, "application/json" 200 ) 201 return 202 203 self.send_error(404) 204 205 def do_POST(self): 206 if not self.verify_access_token(): 207 return 208 209 content_type = self.headers.get("Content-Type") 210 211 # refuse to receive non-json content 212 if content_type != "application/json": 213 self.send_response(400) 214 return 215 216 length = int(self.headers.get("Content-Length")) 217 message = json.loads(self.rfile.read(length)) 218 219 parsed = urllib.parse.urlparse(self.path) 220 if parsed.path == "/service/refresh": 221 self.service_refresh_goldens(message["clear"]) 222 else: 223 self.send_error(404) 224 225 def do_PUT(self): 226 if not self.verify_access_token(): 227 return 228 229 parsed = urllib.parse.urlparse(self.path) 230 query_params = urllib.parse.parse_qs(parsed.query) 231 232 if parsed.path == "/service/update": 233 self.service_update_golden(query_params["id"][0]) 234 else: 235 self.send_error(404) 236 237 def serve_file(self, root_directory, file_relative_to_root, mime_type=None): 238 resolved_path = path.abspath(path.join(root_directory, file_relative_to_root)) 239 240 if path.commonprefix( 241 [resolved_path, root_directory] 242 ) == root_directory and path.isfile(resolved_path): 243 self.send_response(200) 244 self.send_header( 245 "Content-type", mime_type or mimetypes.guess_type(resolved_path)[0] 246 ) 247 self.add_standard_headers() 248 self.end_headers() 249 with open(resolved_path, "rb") as f: 250 self.wfile.write(f.read()) 251 252 else: 253 self.send_error(404) 254 255 def service_list_goldens(self): 256 if not self.verify_access_token(): 257 return 258 259 goldens_list = [] 260 261 for golden in golden_watcher.cached_goldens.values(): 262 263 golden_data = {} 264 golden_data["id"] = golden.id 265 golden_data["result"] = golden.result 266 golden_data["label"] = golden.golden_identifier 267 golden_data["goldenRepoPath"] = golden.golden_repo_path 268 golden_data["updated"] = golden.updated 269 golden_data["testClassName"] = golden.test_class_name 270 golden_data["testMethodName"] = golden.test_method_name 271 golden_data["testTime"] = golden.test_time 272 273 golden_data["actualUrl"] = ( 274 f"{this_server_address}/golden/{golden.checksum}/{golden.local_file[len(golden_watcher.temp_dir) + 1 :]}" 275 ) 276 expected_file = path.join(android_build_top, golden.golden_repo_path) 277 if os.path.exists(expected_file): 278 golden_data["expectedUrl"] = ( 279 f"{this_server_address}/expected/{golden.id}" 280 ) 281 282 golden_data["videoUrl"] = ( 283 f"{this_server_address}/golden/{golden.checksum}/{golden.video_location}" 284 ) 285 286 goldens_list.append(golden_data) 287 288 self.send_json(goldens_list) 289 290 def service_refresh_goldens(self, clear): 291 if clear: 292 golden_watcher.clean() 293 golden_watcher.refresh_golden_files() 294 self.service_list_goldens() 295 296 def service_update_golden(self, id): 297 goldens = golden_watcher.cached_goldens.values() 298 for golden in goldens: 299 if golden.id != id: 300 print("skip", golden.id) 301 continue 302 303 dst = path.join(android_build_top, golden.golden_repo_path) 304 if not path.exists(path.dirname(dst)): 305 os.makedirs(path.dirname(dst)) 306 307 shutil.copyfile(golden.local_file, dst) 308 309 golden.updated = True 310 self.send_json({"result": "OK"}) 311 return 312 313 self.send_error(400) 314 315 def send_json(self, data): 316 # Replace this with code that generates your JSON data 317 data_encoded = json.dumps(data).encode("utf-8") 318 self.send_response(200) 319 self.send_header("Content-type", "application/json") 320 self.add_standard_headers() 321 self.end_headers() 322 self.wfile.write(data_encoded) 323 324 def add_standard_headers(self): 325 self.send_header("Access-Control-Allow-Origin", "*") 326 self.send_header("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS") 327 self.send_header( 328 "Access-Control-Allow-Headers", 329 GOLDEN_ACCESS_TOKEN_HEADER 330 + ", Content-Type, Content-Length, Range, Accept-ranges", 331 ) 332 # Accept-ranges: bytes is needed for chrome to allow seeking the 333 # video. At this time, won't handle ranges on subsequent gets, 334 # but that is likely OK given the size of these videos and that 335 # its local only. 336 self.send_header("Accept-ranges", "bytes") 337 338 339class GoldenFileWatcher: 340 341 def __init__(self, temp_dir, adb_client): 342 self.temp_dir = temp_dir 343 self.adb_client = adb_client 344 345 # name -> CachedGolden 346 self.cached_goldens = {} 347 self.refresh_golden_files() 348 349 def clean(self): 350 self.cached_goldens = {} 351 352 def refresh_golden_files(self): 353 command = f"find /data/user/0/ -type f -name *.actual.json" 354 updated_goldens = self.run_adb_command(["shell", command]).splitlines() 355 print(f"Updating goldens - found {len(updated_goldens)} files") 356 357 for golden_remote_file in updated_goldens: 358 local_file = self.adb_pull(golden_remote_file) 359 360 golden = CachedGolden(golden_remote_file, local_file) 361 if golden.video_location: 362 self.adb_pull_image(golden.device_local_path, golden.video_location) 363 364 self.cached_goldens[golden_remote_file] = golden 365 366 def adb_pull(self, remote_file): 367 baseName = os.path.basename(remote_file) 368 filename, ext = os.path.splitext(baseName) 369 remoteFilenameHash = hashlib.md5(remote_file.encode("utf-8")).hexdigest() 370 local_file = os.path.join(self.temp_dir, f'{filename}_{remoteFilenameHash}{ext}') 371 self.run_adb_command(["pull", remote_file, local_file]) 372 self.run_adb_command(["shell", "rm", remote_file]) 373 return local_file 374 375 def adb_pull_image(self, remote_dir, remote_file): 376 remote_path = os.path.join(remote_dir, remote_file) 377 local_path = os.path.join(self.temp_dir, remote_file) 378 os.makedirs(os.path.dirname(local_path), exist_ok=True) 379 self.run_adb_command(["pull", remote_path, local_path]) 380 self.run_adb_command(["shell", "rm", remote_path]) 381 return local_path 382 383 def run_adb_command(self, args): 384 return self.adb_client.run_adb_command(args) 385 386 387class StudioGoldenWatcher: 388 389 def __init__(self, temp_dir, latest_dir): 390 self.temp_dir = temp_dir 391 self.latest_dir = latest_dir 392 self.cached_goldens = {} 393 self.refresh_golden_files() 394 395 def refresh_golden_files(self): 396 for filename in glob.iglob( 397 f"{self.latest_dir}//**/*.actual.json", recursive=True 398 ): 399 baseName = os.path.basename(filename) 400 baseFilename, ext = os.path.splitext(baseName) 401 timeHash = hashlib.md5(datetime.datetime.now().isoformat().encode("utf-8")).hexdigest() 402 local_file = os.path.join(self.temp_dir, f'copy_{baseFilename}_{timeHash}{ext}') 403 self.copy_file(filename, local_file) 404 golden = CachedGolden(filename, local_file) 405 self.cached_goldens[filename] = golden 406 407 def copy_file(self, source, target): 408 os.makedirs(os.path.dirname(target), exist_ok=True) 409 shutil.copyfile(source, target) 410 411 def clean(self): 412 self.cached_goldens = {} 413 414class AtestGoldenWatcher: 415 416 def __init__(self, temp_dir, atest_latest_dir): 417 self.temp_dir = temp_dir 418 self.atest_latest_dir = atest_latest_dir 419 420 # name -> CachedGolden 421 self.cached_goldens = {} 422 self.refresh_golden_files() 423 424 def clean(self): 425 self.cached_goldens = {} 426 427 def refresh_golden_files(self): 428 429 # Atest writes the files with a wide variety of filenames. Examples 430 # log/stub/local_atest/inv_8184127433410125702/light_portrait_pagingRight.actual.json_4383267726505225616.txt.gz 431 # log/invocation_3042186109657619915/inv_5155363728971335727/recordMotion_captureCrossfade.actual_10536896158799342698.json 432 # log/stub/local_atest/inv_6860054371355660320/light_portrait_noOverscrollRight.actual_118505410949600545.json.gz 433 434 # log/stub/local_atest/inv_6860054371355660320/light_portrait_noOverscrollRight.actual_12613191689435798576.mp4 435 # log/invocation_3042186109657619915/inv_5155363728971335727/recordMotion_captureCrossfade.actual_1988198704080929506.mp4 436 # log/stub/local_atest/inv_8184127433410125702/light_portrait_pagingRight.actual.mp4_1617964025478041468.txt.gz 437 438 pattern_type = ( 439 r".*/(?P<name>.*)\.actual((\.(?P<ext1>[a-zA-Z0-9]+)_(?P<hash1>\d+)\.txt)|(_(?P<hash2>\d+)\.(?P<ext2>[a-zA-Z0-9]+)))(?P<compressed>\.gz)?" 440 ) 441 442 # Output from on-device runs 443 # Modifying the search regex to handle files not ending with json as given above. 444 for filename in glob.iglob( 445 f"{self.atest_latest_dir}//**/*.actual*json*", recursive=True 446 ): 447 448 match = re.search(pattern_type, filename) 449 450 if not match: 451 continue 452 453 golden_name = match.group("name") 454 ext = match.group("ext1") or match.group("ext2") 455 hash = match.group("hash1") or match.group("hash2") 456 is_compressed = match.group("compressed") == ".gz" 457 458 local_file = os.path.join(self.temp_dir, f"{golden_name}_{hash}.actual.json") 459 self.copy_file(filename, local_file, is_compressed) 460 golden = CachedGolden(filename, local_file) 461 462 if golden.video_location: 463 for video_filename in glob.iglob( 464 f"{self.atest_latest_dir}/**/{golden_name}.actual*.mp4*", 465 recursive=True, 466 ): 467 468 local_video_file = os.path.join( 469 self.temp_dir, golden.video_location 470 ) 471 video_is_compressed = video_filename.endswith(".gz") 472 self.copy_file( 473 video_filename, local_video_file, video_is_compressed 474 ) 475 476 break 477 478 self.cached_goldens[filename] = golden 479 480 def copy_file(self, source, target, is_compressed): 481 os.makedirs(os.path.dirname(target), exist_ok=True) 482 483 if is_compressed: 484 with gzip.open(source, "rb") as f_in: 485 with open(target, "wb") as f_out: 486 shutil.copyfileobj(f_in, f_out) 487 else: 488 shutil.copyfile(source, target) 489 490class CachedGolden: 491 492 def __init__(self, remote_file, local_file): 493 self.id = hashlib.md5(remote_file.encode("utf-8")).hexdigest() 494 self.remote_file = remote_file 495 self.local_file = local_file 496 self.updated = False 497 self.test_time = datetime.datetime.now().isoformat() 498 # Checksum is the time the test data was loaded, forcing unique URLs 499 # every time the golden is reloaded 500 self.checksum = hashlib.md5(self.test_time.encode("utf-8")).hexdigest() 501 502 motion_golden_data = None 503 with open(local_file, "r") as json_file: 504 motion_golden_data = json.load(json_file) 505 metadata = motion_golden_data["//metadata"] 506 507 self.result = metadata["result"] 508 self.golden_repo_path = metadata["goldenRepoPath"] 509 self.golden_identifier = metadata["goldenIdentifier"] 510 self.test_class_name = metadata["testClassName"] 511 self.test_method_name = metadata["testMethodName"] 512 self.device_local_path = metadata["deviceLocalPath"] 513 self.video_location = None 514 if "videoLocation" in metadata: 515 self.video_location = metadata["videoLocation"] 516 517 with open(local_file, "w") as json_file: 518 del motion_golden_data["//metadata"] 519 json.dump(motion_golden_data, json_file, indent=2) 520 521 522class AdbClient: 523 def __init__(self, adb_serial): 524 self.adb_serial = adb_serial 525 526 def run_as_root(self): 527 root_result = self.run_adb_command(["root"]) 528 if "restarting adbd as root" in root_result: 529 self.wait_for_device() 530 return True 531 if "adbd is already running as root" in root_result: 532 return True 533 534 print(f"run_as_root returned [{root_result}]") 535 536 return False 537 538 def wait_for_device(self): 539 self.run_adb_command(["wait-for-device"]) 540 541 def run_adb_command(self, args): 542 command = ["adb"] 543 command += ["-s", self.adb_serial] 544 command += args 545 return subprocess.run(command, check=True, capture_output=True).stdout.decode( 546 "utf-8" 547 ) 548 549 550def find_free_port(): 551 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 552 s.bind(("", 0)) # Bind to a random free port provided by the OS 553 return s.getsockname()[1] # Get the port number 554 555 556def get_token() -> str: 557 try: 558 with open(GOLDEN_ACCESS_TOKEN_LOCATION, "r") as token_file: 559 token = token_file.readline() 560 return token 561 except IOError: 562 token = secrets.token_hex(32) 563 os.makedirs(os.path.dirname(GOLDEN_ACCESS_TOKEN_LOCATION), exist_ok=True) 564 try: 565 with open(GOLDEN_ACCESS_TOKEN_LOCATION, "w") as token_file: 566 token_file.write(token) 567 os.chmod(GOLDEN_ACCESS_TOKEN_LOCATION, 0o600) 568 except IOError: 569 print( 570 "Unable to save persistent token {} to {}".format( 571 token, GOLDEN_ACCESS_TOKEN_LOCATION 572 ) 573 ) 574 return token 575 576 577if __name__ == "__main__": 578 secret_token = get_token() 579 main() 580