1# Copyright 2022 The ANGLE Project Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import contextlib 6import functools 7import glob 8import hashlib 9import json 10import logging 11import os 12import pathlib 13import posixpath 14import random 15import re 16import subprocess 17import tarfile 18import tempfile 19import threading 20import time 21 22import angle_path_util 23 24# Currently we only support a single test package name. 25TEST_PACKAGE_NAME = 'com.android.angle.test' 26 27ANGLE_TRACE_TEST_SUITE = 'angle_trace_tests' 28 29 30class _Global(object): 31 initialized = False 32 is_android = False 33 current_suite = None 34 lib_extension = None 35 traces_outside_of_apk = False 36 temp_dir = None 37 38 39def _ApkPath(suite_name): 40 return os.path.join('%s_apk' % suite_name, '%s-debug.apk' % suite_name) 41 42 43@functools.lru_cache() 44def _FindAapt(): 45 build_tools = ( 46 pathlib.Path(angle_path_util.ANGLE_ROOT_DIR) / 'third_party' / 'android_sdk' / 'public' / 47 'build-tools') 48 latest_build_tools = sorted(build_tools.iterdir())[-1] 49 aapt = str(latest_build_tools / 'aapt') 50 aapt_info = subprocess.check_output([aapt, 'version']).decode() 51 logging.info('aapt version: %s', aapt_info.strip()) 52 return aapt 53 54 55def _RemovePrefix(str, prefix): 56 assert str.startswith(prefix) 57 return str[len(prefix):] 58 59 60def _FindPackageName(apk_path): 61 aapt = _FindAapt() 62 badging = subprocess.check_output([aapt, 'dump', 'badging', apk_path]).decode() 63 package_name = next( 64 _RemovePrefix(item, 'name=').strip('\'') 65 for item in badging.split() 66 if item.startswith('name=')) 67 logging.debug('Package name: %s' % package_name) 68 return package_name 69 70 71def _InitializeAndroid(apk_path): 72 if _GetAdbRoot(): 73 # /data/local/tmp/ is not writable by apps.. So use the app path 74 _Global.temp_dir = '/data/data/' + TEST_PACKAGE_NAME + '/tmp/' 75 else: 76 # /sdcard/ is slow (see https://crrev.com/c/3615081 for details) 77 # logging will be fully-buffered, can be truncated on crashes 78 _Global.temp_dir = '/sdcard/Download/' 79 80 assert _FindPackageName(apk_path) == TEST_PACKAGE_NAME 81 82 apk_files = subprocess.check_output([_FindAapt(), 'list', apk_path]).decode().split() 83 apk_so_libs = [posixpath.basename(f) for f in apk_files if f.endswith('.so')] 84 if 'libangle_util.cr.so' in apk_so_libs: 85 _Global.lib_extension = '.cr.so' 86 else: 87 assert 'libangle_util.so' in apk_so_libs 88 _Global.lib_extension = '.so' 89 # When traces are outside of the apk this lib is also outside 90 interpreter_so_lib = 'libangle_trace_interpreter' + _Global.lib_extension 91 _Global.traces_outside_of_apk = interpreter_so_lib not in apk_so_libs 92 93 if logging.getLogger().isEnabledFor(logging.DEBUG): 94 logging.debug(_AdbShell('dumpsys nfc | grep mScreenState || true').decode()) 95 logging.debug(_AdbShell('df -h').decode()) 96 97 98def Initialize(suite_name): 99 if _Global.initialized: 100 return 101 102 apk_path = _ApkPath(suite_name) 103 if os.path.exists(apk_path): 104 _Global.is_android = True 105 _InitializeAndroid(apk_path) 106 107 _Global.initialized = True 108 109 110def IsAndroid(): 111 assert _Global.initialized, 'Initialize not called' 112 return _Global.is_android 113 114 115def _EnsureTestSuite(suite_name): 116 assert IsAndroid() 117 118 if _Global.current_suite != suite_name: 119 _PrepareTestSuite(suite_name) 120 _Global.current_suite = suite_name 121 122 123def _Run(cmd): 124 logging.debug('Executing command: %s', cmd) 125 startupinfo = None 126 if hasattr(subprocess, 'STARTUPINFO'): 127 # Prevent console window popping up on Windows 128 startupinfo = subprocess.STARTUPINFO() 129 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 130 startupinfo.wShowWindow = subprocess.SW_HIDE 131 output = subprocess.check_output(cmd, startupinfo=startupinfo) 132 return output 133 134 135@functools.lru_cache() 136def _FindAdb(): 137 platform_tools = ( 138 pathlib.Path(angle_path_util.ANGLE_ROOT_DIR) / 'third_party' / 'android_sdk' / 'public' / 139 'platform-tools') 140 adb = str(platform_tools / 'adb') if platform_tools.exists() else 'adb' 141 adb_info = ', '.join(subprocess.check_output([adb, '--version']).decode().strip().split('\n')) 142 logging.info('adb --version: %s', adb_info) 143 return adb 144 145 146def _AdbRun(args): 147 return _Run([_FindAdb()] + args) 148 149 150def _AdbShell(cmd): 151 return _Run([_FindAdb(), 'shell', cmd]) 152 153 154def _GetAdbRoot(): 155 shell_id, su_path = _AdbShell('id -u; which su || echo noroot').decode().strip().split('\n') 156 if int(shell_id) == 0: 157 logging.info('adb already got root') 158 return True 159 160 if su_path == 'noroot': 161 logging.warning('adb root not available on this device') 162 return False 163 164 logging.info('Getting adb root (may take a few seconds)') 165 _AdbRun(['root']) 166 for _ in range(20): # `adb root` restarts adbd which can take quite a few seconds 167 time.sleep(0.5) 168 id_out = _AdbShell('id -u').decode('ascii').strip() 169 if id_out == '0': 170 logging.info('adb root succeeded') 171 return True 172 173 # Device has "su" but we couldn't get adb root. Something is wrong. 174 raise Exception('Failed to get adb root') 175 176 177def _ReadDeviceFile(device_path): 178 with _TempLocalFile() as tempfile_path: 179 _AdbRun(['pull', device_path, tempfile_path]) 180 with open(tempfile_path, 'rb') as f: 181 return f.read() 182 183 184def _RemoveDeviceFile(device_path): 185 _AdbShell('rm -f ' + device_path + ' || true') # ignore errors 186 187 188def _AddRestrictedTracesJson(): 189 def add(tar, fn): 190 assert (fn.startswith('../../')) 191 tar.add(fn, arcname=fn.replace('../../', '')) 192 193 with _TempLocalFile() as tempfile_path: 194 with tarfile.open(tempfile_path, 'w', format=tarfile.GNU_FORMAT) as tar: 195 for f in glob.glob('../../src/tests/restricted_traces/*/*.json', recursive=True): 196 add(tar, f) 197 add(tar, '../../src/tests/restricted_traces/restricted_traces.json') 198 _AdbRun(['push', tempfile_path, '/sdcard/chromium_tests_root/t.tar']) 199 200 _AdbShell('r=/sdcard/chromium_tests_root; tar -xf $r/t.tar -C $r/ && rm $r/t.tar') 201 202 203def _GetDeviceApkPath(): 204 pm_path = _AdbShell('pm path %s || true' % TEST_PACKAGE_NAME).decode().strip() 205 if not pm_path: 206 logging.debug('No installed path found for %s' % TEST_PACKAGE_NAME) 207 return None 208 device_apk_path = _RemovePrefix(pm_path, 'package:') 209 logging.debug('Device APK path is %s' % device_apk_path) 210 return device_apk_path 211 212 213def _CompareHashes(local_path, device_path): 214 if device_path.startswith('/data'): 215 # Use run-as for files that reside on /data, which aren't accessible without root 216 device_hash = _AdbShell('run-as ' + TEST_PACKAGE_NAME + ' sha256sum -b ' + device_path + 217 ' 2> /dev/null || true').decode().strip() 218 else: 219 device_hash = _AdbShell('sha256sum -b ' + device_path + 220 ' 2> /dev/null || true').decode().strip() 221 if not device_hash: 222 logging.debug('_CompareHashes: File not found on device') 223 return False # file not on device 224 225 h = hashlib.sha256() 226 try: 227 with open(local_path, 'rb') as f: 228 for data in iter(lambda: f.read(65536), b''): 229 h.update(data) 230 except Exception as e: 231 logging.error('An error occurred in _CompareHashes: %s' % e) 232 233 return h.hexdigest() == device_hash 234 235 236def _PrepareTestSuite(suite_name): 237 apk_path = _ApkPath(suite_name) 238 device_apk_path = _GetDeviceApkPath() 239 240 if device_apk_path and _CompareHashes(apk_path, device_apk_path): 241 logging.info('Skipping APK install because host and device hashes match') 242 else: 243 logging.info('Installing apk path=%s size=%s' % (apk_path, os.path.getsize(apk_path))) 244 _AdbRun(['install', '-r', '-d', apk_path]) 245 246 permissions = [ 247 'android.permission.CAMERA', 'android.permission.CHANGE_CONFIGURATION', 248 'android.permission.READ_EXTERNAL_STORAGE', 'android.permission.RECORD_AUDIO', 249 'android.permission.WRITE_EXTERNAL_STORAGE' 250 ] 251 _AdbShell('p=%s;' 252 'for q in %s;do pm grant "$p" "$q";done;' % 253 (TEST_PACKAGE_NAME, ' '.join(permissions))) 254 255 _AdbShell('appops set %s MANAGE_EXTERNAL_STORAGE allow || true' % TEST_PACKAGE_NAME) 256 257 _AdbShell('mkdir -p /sdcard/chromium_tests_root/') 258 _AdbShell('mkdir -p %s' % _Global.temp_dir) 259 260 if suite_name == ANGLE_TRACE_TEST_SUITE: 261 _AddRestrictedTracesJson() 262 263 if suite_name == 'angle_end2end_tests': 264 _AdbRun([ 265 'push', '../../src/tests/angle_end2end_tests_expectations.txt', 266 '/sdcard/chromium_tests_root/src/tests/angle_end2end_tests_expectations.txt' 267 ]) 268 269 270def PrepareRestrictedTraces(traces): 271 start = time.time() 272 total_size = 0 273 skipped = 0 274 275 # In order to get files to the app's home directory and loadable as libraries, we must first 276 # push them to tmp on the device. We then use `run-as` which allows copying files from tmp. 277 # Note that `mv` is not allowed with `run-as`. This means there will briefly be two copies 278 # of the trace on the device, so keep that in mind as space becomes a problem in the future. 279 app_tmp_path = '/data/local/tmp/angle_traces/' 280 281 def _HashesMatch(local_path, device_path): 282 nonlocal total_size, skipped 283 if _CompareHashes(local_path, device_path): 284 skipped += 1 285 return True 286 else: 287 total_size += os.path.getsize(local_path) 288 return False 289 290 def _Push(local_path, path_from_root): 291 device_path = '/sdcard/chromium_tests_root/' + path_from_root 292 if not _HashesMatch(local_path, device_path): 293 _AdbRun(['push', local_path, device_path]) 294 295 def _PushLibToAppDir(lib_name): 296 local_path = lib_name 297 device_path = '/data/user/0/com.android.angle.test/angle_traces/' + lib_name 298 if _HashesMatch(local_path, device_path): 299 return 300 301 tmp_path = posixpath.join(app_tmp_path, lib_name) 302 logging.debug('_PushToAppDir: Pushing %s to %s' % (local_path, tmp_path)) 303 try: 304 _AdbRun(['push', local_path, tmp_path]) 305 _AdbShell('run-as ' + TEST_PACKAGE_NAME + ' cp ' + tmp_path + ' ./angle_traces/') 306 _AdbShell('rm ' + tmp_path) 307 except Exception as e: 308 logging.error('An error occurred in _PushToAppDir: %s' % e) 309 finally: 310 _RemoveDeviceFile(tmp_path) 311 312 # Create the directories we need 313 _AdbShell('mkdir -p ' + app_tmp_path) 314 _AdbShell('run-as ' + TEST_PACKAGE_NAME + ' mkdir -p angle_traces') 315 316 # Set up each trace 317 for idx, trace in enumerate(sorted(traces)): 318 logging.info('Syncing %s trace (%d/%d)', trace, idx + 1, len(traces)) 319 320 path_from_root = 'src/tests/restricted_traces/' + trace + '/' + trace + '.angledata.gz' 321 _Push('../../' + path_from_root, path_from_root) 322 323 tracegz = 'gen/tracegz_' + trace + '.gz' 324 _Push(tracegz, tracegz) 325 326 if _Global.traces_outside_of_apk: 327 lib_name = 'libangle_restricted_traces_' + trace + _Global.lib_extension 328 _PushLibToAppDir(lib_name) 329 330 # Push one additional file when running outside the APK 331 if _Global.traces_outside_of_apk: 332 _PushLibToAppDir('libangle_trace_interpreter' + _Global.lib_extension) 333 334 logging.info('Synced files for %d traces (%.1fMB, %d files already ok) in %.1fs', len(traces), 335 total_size / 1e6, skipped, 336 time.time() - start) 337 338 339def _RandomHex(): 340 return hex(random.randint(0, 2**64))[2:] 341 342 343@contextlib.contextmanager 344def _TempDeviceDir(): 345 path = posixpath.join(_Global.temp_dir, 'temp_dir-%s' % _RandomHex()) 346 _AdbShell('mkdir -p ' + path) 347 try: 348 yield path 349 finally: 350 _AdbShell('rm -rf ' + path) 351 352 353@contextlib.contextmanager 354def _TempDeviceFile(): 355 path = posixpath.join(_Global.temp_dir, 'temp_file-%s' % _RandomHex()) 356 try: 357 yield path 358 finally: 359 _AdbShell('rm -f ' + path) 360 361 362@contextlib.contextmanager 363def _TempLocalFile(): 364 fd, path = tempfile.mkstemp() 365 os.close(fd) 366 try: 367 yield path 368 finally: 369 os.remove(path) 370 371 372def _RunInstrumentation(flags): 373 with _TempDeviceFile() as temp_device_file: 374 cmd = ' '.join([ 375 'p=%s;' % TEST_PACKAGE_NAME, 376 'ntr=org.chromium.native_test.NativeTestInstrumentationTestRunner;', 377 'am instrument -w', 378 '-e $ntr.NativeTestActivity "$p".AngleUnitTestActivity', 379 '-e $ntr.ShardNanoTimeout 2400000000000', 380 '-e org.chromium.native_test.NativeTest.CommandLineFlags "%s"' % ' '.join(flags), 381 '-e $ntr.StdoutFile ' + temp_device_file, 382 '"$p"/org.chromium.build.gtest_apk.NativeTestInstrumentationTestRunner', 383 ]) 384 385 _AdbShell(cmd) 386 return _ReadDeviceFile(temp_device_file) 387 388 389def _DumpDebugInfo(since_time): 390 logcat_output = _AdbRun(['logcat', '-t', since_time]).decode() 391 logging.info('logcat:\n%s', logcat_output) 392 393 pid_lines = [ 394 ln for ln in logcat_output.split('\n') 395 if 'org.chromium.native_test.NativeTest.StdoutFile' in ln 396 ] 397 if pid_lines: 398 debuggerd_output = _AdbShell('debuggerd %s' % pid_lines[-1].split(' ')[2]).decode() 399 logging.warning('debuggerd output:\n%s', debuggerd_output) 400 401 402def _RunInstrumentationWithTimeout(flags, timeout): 403 initial_time = _AdbShell('date +"%F %T.%3N"').decode().strip() 404 405 results = [] 406 407 def run(): 408 results.append(_RunInstrumentation(flags)) 409 410 t = threading.Thread(target=run) 411 t.daemon = True 412 t.start() 413 t.join(timeout=timeout) 414 415 if t.is_alive(): # join timed out 416 logging.warning('Timed out, dumping debug info') 417 _DumpDebugInfo(since_time=initial_time) 418 raise TimeoutError('Test run did not finish in %s seconds' % timeout) 419 420 return results[0] 421 422 423def AngleSystemInfo(args): 424 _EnsureTestSuite('angle_system_info_test') 425 426 with _TempDeviceDir() as temp_dir: 427 _RunInstrumentation(args + ['--render-test-output-dir=' + temp_dir]) 428 output_file = posixpath.join(temp_dir, 'angle_system_info.json') 429 return json.loads(_ReadDeviceFile(output_file)) 430 431 432def GetBuildFingerprint(): 433 return _AdbShell('getprop ro.build.fingerprint').decode('ascii').strip() 434 435 436def _PullDir(device_dir, local_dir): 437 files = _AdbShell('ls -1 %s' % device_dir).decode('ascii').split('\n') 438 for f in files: 439 f = f.strip() 440 if f: 441 _AdbRun(['pull', posixpath.join(device_dir, f), posixpath.join(local_dir, f)]) 442 443 444def _RemoveFlag(args, f): 445 matches = [a for a in args if a.startswith(f + '=')] 446 assert len(matches) <= 1 447 if matches: 448 original_value = matches[0].split('=')[1] 449 args.remove(matches[0]) 450 else: 451 original_value = None 452 453 return original_value 454 455 456def RunSmokeTest(): 457 _EnsureTestSuite(ANGLE_TRACE_TEST_SUITE) 458 459 test_name = 'TraceTest.words_with_friends_2' 460 run_instrumentation_timeout = 60 461 462 logging.info('Running smoke test (%s)', test_name) 463 464 trace_name = GetTraceFromTestName(test_name) 465 if not trace_name: 466 raise Exception('Cannot find trace name from %s.' % test_name) 467 468 PrepareRestrictedTraces([trace_name]) 469 470 with _TempDeviceFile() as device_test_output_path: 471 flags = [ 472 '--gtest_filter=' + test_name, '--no-warmup', '--steps-per-trial', '1', '--trials', 473 '1', '--isolated-script-test-output=' + device_test_output_path 474 ] 475 try: 476 output = _RunInstrumentationWithTimeout(flags, run_instrumentation_timeout) 477 except TimeoutError: 478 raise Exception('Smoke test did not finish in %s seconds' % 479 run_instrumentation_timeout) 480 481 test_output = _ReadDeviceFile(device_test_output_path) 482 483 output_json = json.loads(test_output) 484 if output_json['tests'][test_name]['actual'] != 'PASS': 485 raise Exception('Smoke test (%s) failed. Output:\n%s' % (test_name, output)) 486 487 logging.info('Smoke test passed') 488 489 490def RunTests(test_suite, args, stdoutfile=None, log_output=True): 491 _EnsureTestSuite(test_suite) 492 493 args = args[:] 494 test_output_path = _RemoveFlag(args, '--isolated-script-test-output') 495 perf_output_path = _RemoveFlag(args, '--isolated-script-test-perf-output') 496 test_output_dir = _RemoveFlag(args, '--render-test-output-dir') 497 498 result = 0 499 output = b'' 500 output_json = {} 501 try: 502 with contextlib.ExitStack() as stack: 503 device_test_output_path = stack.enter_context(_TempDeviceFile()) 504 args.append('--isolated-script-test-output=' + device_test_output_path) 505 506 if perf_output_path: 507 device_perf_path = stack.enter_context(_TempDeviceFile()) 508 args.append('--isolated-script-test-perf-output=%s' % device_perf_path) 509 510 if test_output_dir: 511 device_output_dir = stack.enter_context(_TempDeviceDir()) 512 args.append('--render-test-output-dir=' + device_output_dir) 513 514 output = _RunInstrumentationWithTimeout(args, timeout=10 * 60) 515 516 if '--list-tests' in args: 517 # When listing tests, there may be no output file. We parse stdout anyways. 518 test_output = '{"interrupted": false}' 519 else: 520 try: 521 test_output = _ReadDeviceFile(device_test_output_path) 522 except subprocess.CalledProcessError: 523 logging.error('Unable to read test json output. Stdout:\n%s', output.decode()) 524 result = 1 525 return result, output.decode(), None 526 527 if test_output_path: 528 with open(test_output_path, 'wb') as f: 529 f.write(test_output) 530 531 output_json = json.loads(test_output) 532 533 num_failures = output_json.get('num_failures_by_type', {}).get('FAIL', 0) 534 interrupted = output_json.get('interrupted', True) # Normally set to False 535 if num_failures != 0 or interrupted or output_json.get('is_unexpected', False): 536 logging.error('Tests failed: %s', test_output.decode()) 537 result = 1 538 539 if test_output_dir: 540 _PullDir(device_output_dir, test_output_dir) 541 542 if perf_output_path: 543 _AdbRun(['pull', device_perf_path, perf_output_path]) 544 545 if log_output: 546 logging.info(output.decode()) 547 548 if stdoutfile: 549 with open(stdoutfile, 'wb') as f: 550 f.write(output) 551 except Exception as e: 552 logging.exception(e) 553 result = 1 554 555 return result, output.decode(), output_json 556 557 558def GetTraceFromTestName(test_name): 559 if test_name.startswith('TraceTest.'): 560 return test_name[len('TraceTest.'):] 561 return None 562