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 sys 18import tarfile 19import tempfile 20import threading 21import time 22 23import angle_path_util 24 25 26ANGLE_TRACE_TEST_SUITE = 'angle_trace_tests' 27 28 29class _Global(object): 30 initialized = False 31 is_android = False 32 current_suite = None 33 traces_outside_of_apk = False 34 temp_dir = None 35 use_run_as = True 36 37 38def _ApkPath(suite_name): 39 return os.path.join('%s_apk' % suite_name, '%s-debug.apk' % suite_name) 40 41 42@functools.lru_cache() 43def _FindAapt(): 44 build_tools = ( 45 pathlib.Path(angle_path_util.ANGLE_ROOT_DIR) / 'third_party' / 'android_sdk' / 'public' / 46 'build-tools') 47 latest_build_tools = sorted(build_tools.iterdir())[-1] 48 aapt = str(latest_build_tools / 'aapt') 49 aapt_info = subprocess.check_output([aapt, 'version']).decode() 50 logging.info('aapt version: %s', aapt_info.strip()) 51 return aapt 52 53 54def _RemovePrefix(str, prefix): 55 assert str.startswith(prefix), 'Expected prefix %s, got: %s' % (prefix, str) 56 return str[len(prefix):] 57 58 59def _InitializeAndroid(apk_path): 60 if _GetAdbRoot(): 61 # /data/local/tmp/ is not writable by apps.. So use the app path 62 _Global.temp_dir = '/data/data/com.android.angle.test/tmp/' 63 else: 64 # /sdcard/ is slow (see https://crrev.com/c/3615081 for details) 65 # logging will be fully-buffered, can be truncated on crashes 66 _Global.temp_dir = '/sdcard/Download/' 67 68 apk_files = subprocess.check_output([_FindAapt(), 'list', apk_path]).decode().split() 69 apk_so_libs = [posixpath.basename(f) for f in apk_files if f.endswith('.so')] 70 # When traces are outside of the apk this lib is also outside 71 interpreter_so_lib = 'libangle_trace_interpreter.so' 72 _Global.traces_outside_of_apk = interpreter_so_lib not in apk_so_libs 73 74 if logging.getLogger().isEnabledFor(logging.DEBUG): 75 logging.debug(_AdbShell('dumpsys nfc | grep mScreenState || true').decode()) 76 logging.debug(_AdbShell('df -h').decode()) 77 78 79def Initialize(suite_name): 80 if _Global.initialized: 81 return 82 83 apk_path = _ApkPath(suite_name) 84 if os.path.exists(apk_path): 85 _Global.is_android = True 86 _InitializeAndroid(apk_path) 87 88 _Global.initialized = True 89 90 91def IsAndroid(): 92 assert _Global.initialized, 'Initialize not called' 93 return _Global.is_android 94 95 96def _EnsureTestSuite(suite_name): 97 assert IsAndroid() 98 99 if _Global.current_suite != suite_name: 100 _PrepareTestSuite(suite_name) 101 _Global.current_suite = suite_name 102 103 104def _Run(cmd): 105 logging.debug('Executing command: %s', cmd) 106 startupinfo = None 107 if hasattr(subprocess, 'STARTUPINFO'): 108 # Prevent console window popping up on Windows 109 startupinfo = subprocess.STARTUPINFO() 110 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 111 startupinfo.wShowWindow = subprocess.SW_HIDE 112 output = subprocess.check_output(cmd, startupinfo=startupinfo) 113 return output 114 115 116@functools.lru_cache() 117def _FindAdb(): 118 platform_tools = ( 119 pathlib.Path(angle_path_util.ANGLE_ROOT_DIR) / 'third_party' / 'android_sdk' / 'public' / 120 'platform-tools') 121 adb = str(platform_tools / 'adb') if platform_tools.exists() else 'adb' 122 adb_info = ', '.join(subprocess.check_output([adb, '--version']).decode().strip().split('\n')) 123 logging.info('adb --version: %s', adb_info) 124 return adb 125 126 127def _AdbRun(args): 128 return _Run([_FindAdb()] + args) 129 130 131def _AdbShell(cmd): 132 return _Run([_FindAdb(), 'shell', cmd]) 133 134 135def _GetAdbRoot(): 136 shell_id, su_path, data_permissions = _AdbShell( 137 'id -u; which su || echo noroot; stat --format %a /data').decode().strip().split('\n') 138 139 if data_permissions.endswith('7'): 140 # run-as broken due to "/data readable or writable by others" 141 _Global.use_run_as = False 142 logging.warning('run-as not available due to /data permissions') 143 144 if int(shell_id) == 0: 145 logging.info('adb already got root') 146 return True 147 148 if su_path == 'noroot': 149 logging.warning('adb root not available on this device') 150 return False 151 152 logging.info('Getting adb root (may take a few seconds)') 153 _AdbRun(['root']) 154 for _ in range(20): # `adb root` restarts adbd which can take quite a few seconds 155 time.sleep(0.5) 156 id_out = _AdbShell('id -u').decode('ascii').strip() 157 if id_out == '0': 158 logging.info('adb root succeeded') 159 return True 160 161 # Device has "su" but we couldn't get adb root. Something is wrong. 162 raise Exception('Failed to get adb root') 163 164 165def _ReadDeviceFile(device_path): 166 with _TempLocalFile() as tempfile_path: 167 _AdbRun(['pull', device_path, tempfile_path]) 168 with open(tempfile_path, 'rb') as f: 169 return f.read() 170 171 172def _RemoveDeviceFile(device_path): 173 _AdbShell('rm -f ' + device_path + ' || true') # ignore errors 174 175 176def _MakeTar(path, patterns): 177 with _TempLocalFile() as tempfile_path: 178 with tarfile.open(tempfile_path, 'w', format=tarfile.GNU_FORMAT) as tar: 179 for p in patterns: 180 for f in glob.glob(p, recursive=True): 181 tar.add(f, arcname=f.replace('../../', '')) 182 _AdbRun(['push', tempfile_path, path]) 183 184 185def _AddRestrictedTracesJson(): 186 _MakeTar('/sdcard/chromium_tests_root/t.tar', [ 187 '../../src/tests/restricted_traces/*/*.json', 188 '../../src/tests/restricted_traces/restricted_traces.json' 189 ]) 190 _AdbShell('r=/sdcard/chromium_tests_root; tar -xf $r/t.tar -C $r/ && rm $r/t.tar') 191 192 193def _AddDeqpFiles(suite_name): 194 patterns = [ 195 '../../third_party/VK-GL-CTS/src/external/openglcts/data/gl_cts/data/mustpass/*/*/main/*.txt', 196 '../../src/tests/deqp_support/*.txt' 197 ] 198 if '_gles2_' in suite_name: 199 patterns.append('gen/vk_gl_cts_data/data/gles2/**') 200 elif '_gles3_' in suite_name: 201 patterns.append('gen/vk_gl_cts_data/data/gles3/**') 202 patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles3/**') 203 elif '_gles31_' in suite_name: 204 patterns.append('gen/vk_gl_cts_data/data/gles31/**') 205 patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles31/**') 206 elif '_gles32_' in suite_name: 207 patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles32/**') 208 else: 209 # Harness crashes if vk_gl_cts_data/data dir doesn't exist, so add a file 210 patterns.append('gen/vk_gl_cts_data/data/gles2/data/brick.png') 211 212 _MakeTar('/sdcard/chromium_tests_root/deqp.tar', patterns) 213 _AdbShell('r=/sdcard/chromium_tests_root; tar -xf $r/deqp.tar -C $r/ && rm $r/deqp.tar') 214 215 216def _GetDeviceApkPath(): 217 pm_path = _AdbShell('pm path com.android.angle.test || true').decode().strip() 218 if not pm_path: 219 logging.debug('No installed path found for com.android.angle.test') 220 return None 221 device_apk_path = _RemovePrefix(pm_path, 'package:') 222 logging.debug('Device APK path is %s' % device_apk_path) 223 return device_apk_path 224 225 226def _LocalFileHash(local_path, gz_tail_size): 227 h = hashlib.sha256() 228 with open(local_path, 'rb') as f: 229 if local_path.endswith('.gz'): 230 # equivalent of tail -c {gz_tail_size} 231 offset = os.path.getsize(local_path) - gz_tail_size 232 if offset > 0: 233 f.seek(offset) 234 for data in iter(lambda: f.read(65536), b''): 235 h.update(data) 236 return h.hexdigest() 237 238 239def _CompareHashes(local_path, device_path): 240 # The last 8 bytes of gzip contain CRC-32 and the initial file size and the preceding 241 # bytes should be affected by changes in the middle if we happen to run into a collision 242 gz_tail_size = 4096 243 244 if local_path.endswith('.gz'): 245 cmd = 'test -f {path} && tail -c {gz_tail_size} {path} | sha256sum -b || true'.format( 246 path=device_path, gz_tail_size=gz_tail_size) 247 else: 248 cmd = 'test -f {path} && sha256sum -b {path} || true'.format(path=device_path) 249 250 if _Global.use_run_as and device_path.startswith('/data'): 251 # Use run-as for files that reside on /data, which aren't accessible without root 252 cmd = "run-as com.android.angle.test sh -c '{cmd}'".format(cmd=cmd) 253 254 device_hash = _AdbShell(cmd).decode().strip() 255 if not device_hash: 256 logging.debug('_CompareHashes: File not found on device') 257 return False # file not on device 258 259 return _LocalFileHash(local_path, gz_tail_size) == device_hash 260 261 262def _CheckSameApkInstalled(apk_path): 263 device_apk_path = _GetDeviceApkPath() 264 265 try: 266 if device_apk_path and _CompareHashes(apk_path, device_apk_path): 267 return True 268 except subprocess.CalledProcessError as e: 269 # non-debuggable test apk installed on device breaks run-as 270 logging.warning('_CompareHashes of apk failed: %s' % e) 271 272 return False 273 274 275def _PrepareTestSuite(suite_name): 276 apk_path = _ApkPath(suite_name) 277 278 if _CheckSameApkInstalled(apk_path): 279 logging.info('Skipping APK install because host and device hashes match') 280 else: 281 logging.info('Installing apk path=%s size=%s' % (apk_path, os.path.getsize(apk_path))) 282 _AdbRun(['install', '-r', '-d', apk_path]) 283 284 permissions = [ 285 'android.permission.CAMERA', 'android.permission.CHANGE_CONFIGURATION', 286 'android.permission.READ_EXTERNAL_STORAGE', 'android.permission.RECORD_AUDIO', 287 'android.permission.WRITE_EXTERNAL_STORAGE' 288 ] 289 _AdbShell('for q in %s;do pm grant com.android.angle.test "$q";done;' % 290 (' '.join(permissions))) 291 292 _AdbShell('appops set com.android.angle.test MANAGE_EXTERNAL_STORAGE allow || true') 293 294 _AdbShell('mkdir -p /sdcard/chromium_tests_root/') 295 _AdbShell('mkdir -p %s' % _Global.temp_dir) 296 297 if suite_name == ANGLE_TRACE_TEST_SUITE: 298 _AddRestrictedTracesJson() 299 300 if '_deqp_' in suite_name: 301 _AddDeqpFiles(suite_name) 302 303 if suite_name == 'angle_end2end_tests': 304 _AdbRun([ 305 'push', '../../src/tests/angle_end2end_tests_expectations.txt', 306 '/sdcard/chromium_tests_root/src/tests/angle_end2end_tests_expectations.txt' 307 ]) 308 309 310def PrepareRestrictedTraces(traces): 311 start = time.time() 312 total_size = 0 313 skipped = 0 314 315 # In order to get files to the app's home directory and loadable as libraries, we must first 316 # push them to tmp on the device. We then use `run-as` which allows copying files from tmp. 317 # Note that `mv` is not allowed with `run-as`. This means there will briefly be two copies 318 # of the trace on the device, so keep that in mind as space becomes a problem in the future. 319 app_tmp_path = '/data/local/tmp/angle_traces/' 320 321 if _Global.use_run_as: 322 _AdbShell('mkdir -p ' + app_tmp_path + 323 ' && run-as com.android.angle.test mkdir -p angle_traces') 324 else: 325 _AdbShell('mkdir -p ' + app_tmp_path + ' /data/data/com.android.angle.test/angle_traces/') 326 327 def _HashesMatch(local_path, device_path): 328 nonlocal total_size, skipped 329 if _CompareHashes(local_path, device_path): 330 skipped += 1 331 return True 332 else: 333 total_size += os.path.getsize(local_path) 334 return False 335 336 def _Push(local_path, path_from_root): 337 device_path = '/sdcard/chromium_tests_root/' + path_from_root 338 if not _HashesMatch(local_path, device_path): 339 _AdbRun(['push', local_path, device_path]) 340 341 def _PushLibToAppDir(lib_name): 342 local_path = lib_name 343 if not os.path.exists(local_path): 344 print('Error: missing library: ' + local_path) 345 print('Is angle_restricted_traces set in gn args?') # b/294861737 346 sys.exit(1) 347 348 device_path = '/data/user/0/com.android.angle.test/angle_traces/' + lib_name 349 if _HashesMatch(local_path, device_path): 350 return 351 352 if _Global.use_run_as: 353 tmp_path = posixpath.join(app_tmp_path, lib_name) 354 logging.debug('_PushToAppDir: Pushing %s to %s' % (local_path, tmp_path)) 355 try: 356 _AdbRun(['push', local_path, tmp_path]) 357 _AdbShell('run-as com.android.angle.test cp ' + tmp_path + ' ./angle_traces/') 358 _AdbShell('rm ' + tmp_path) 359 finally: 360 _RemoveDeviceFile(tmp_path) 361 else: 362 _AdbRun(['push', local_path, '/data/data/com.android.angle.test/angle_traces/']) 363 364 # Set up each trace 365 for idx, trace in enumerate(sorted(traces)): 366 logging.info('Syncing %s trace (%d/%d)', trace, idx + 1, len(traces)) 367 368 path_from_root = 'src/tests/restricted_traces/' + trace + '/' + trace + '.angledata.gz' 369 _Push('../../' + path_from_root, path_from_root) 370 371 if _Global.traces_outside_of_apk: 372 lib_name = 'libangle_restricted_traces_' + trace + '.so' 373 _PushLibToAppDir(lib_name) 374 375 tracegz = 'gen/tracegz_' + trace + '.gz' 376 _Push(tracegz, tracegz) 377 378 # Push one additional file when running outside the APK 379 if _Global.traces_outside_of_apk: 380 _PushLibToAppDir('libangle_trace_interpreter.so') 381 382 logging.info('Synced files for %d traces (%.1fMB, %d files already ok) in %.1fs', len(traces), 383 total_size / 1e6, skipped, 384 time.time() - start) 385 386 387def _RandomHex(): 388 return hex(random.randint(0, 2**64))[2:] 389 390 391@contextlib.contextmanager 392def _TempDeviceDir(): 393 path = posixpath.join(_Global.temp_dir, 'temp_dir-%s' % _RandomHex()) 394 _AdbShell('mkdir -p ' + path) 395 try: 396 yield path 397 finally: 398 _AdbShell('rm -rf ' + path) 399 400 401@contextlib.contextmanager 402def _TempDeviceFile(): 403 path = posixpath.join(_Global.temp_dir, 'temp_file-%s' % _RandomHex()) 404 try: 405 yield path 406 finally: 407 _AdbShell('rm -f ' + path) 408 409 410@contextlib.contextmanager 411def _TempLocalFile(): 412 fd, path = tempfile.mkstemp() 413 os.close(fd) 414 try: 415 yield path 416 finally: 417 os.remove(path) 418 419 420def _SetCaptureProps(env, device_out_dir): 421 capture_var_map = { # src/libANGLE/capture/FrameCapture.cpp 422 'ANGLE_CAPTURE_ENABLED': 'debug.angle.capture.enabled', 423 'ANGLE_CAPTURE_FRAME_START': 'debug.angle.capture.frame_start', 424 'ANGLE_CAPTURE_FRAME_END': 'debug.angle.capture.frame_end', 425 'ANGLE_CAPTURE_TRIGGER': 'debug.angle.capture.trigger', 426 'ANGLE_CAPTURE_LABEL': 'debug.angle.capture.label', 427 'ANGLE_CAPTURE_COMPRESSION': 'debug.angle.capture.compression', 428 'ANGLE_CAPTURE_VALIDATION': 'debug.angle.capture.validation', 429 'ANGLE_CAPTURE_VALIDATION_EXPR': 'debug.angle.capture.validation_expr', 430 'ANGLE_CAPTURE_SOURCE_EXT': 'debug.angle.capture.source_ext', 431 'ANGLE_CAPTURE_SOURCE_SIZE': 'debug.angle.capture.source_size', 432 'ANGLE_CAPTURE_FORCE_SHADOW': 'debug.angle.capture.force_shadow', 433 } 434 empty_value = '""' 435 shell_cmds = [ 436 # out_dir is special because the corresponding env var is a host path not a device path 437 'setprop debug.angle.capture.out_dir ' + (device_out_dir or empty_value), 438 ] + [ 439 'setprop %s %s' % (v, env.get(k, empty_value)) for k, v in sorted(capture_var_map.items()) 440 ] 441 442 _AdbShell('\n'.join(shell_cmds)) 443 444 445def _RunInstrumentation(flags): 446 with _TempDeviceFile() as temp_device_file: 447 cmd = r''' 448am instrument -w \ 449 -e org.chromium.native_test.NativeTestInstrumentationTestRunner.StdoutFile {out} \ 450 -e org.chromium.native_test.NativeTest.CommandLineFlags "{flags}" \ 451 -e org.chromium.native_test.NativeTestInstrumentationTestRunner.ShardNanoTimeout "1000000000000000000" \ 452 -e org.chromium.native_test.NativeTestInstrumentationTestRunner.NativeTestActivity \ 453 com.android.angle.test.AngleUnitTestActivity \ 454 com.android.angle.test/org.chromium.build.gtest_apk.NativeTestInstrumentationTestRunner 455 '''.format( 456 out=temp_device_file, flags=r' '.join(flags)).strip() 457 458 capture_out_dir = os.environ.get('ANGLE_CAPTURE_OUT_DIR') 459 if capture_out_dir: 460 assert os.path.isdir(capture_out_dir) 461 with _TempDeviceDir() as device_out_dir: 462 _SetCaptureProps(os.environ, device_out_dir) 463 try: 464 _AdbShell(cmd) 465 finally: 466 _SetCaptureProps({}, None) # reset 467 _PullDir(device_out_dir, capture_out_dir) 468 else: 469 _AdbShell(cmd) 470 return _ReadDeviceFile(temp_device_file) 471 472 473def AngleSystemInfo(args): 474 _EnsureTestSuite('angle_system_info_test') 475 476 with _TempDeviceDir() as temp_dir: 477 _RunInstrumentation(args + ['--render-test-output-dir=' + temp_dir]) 478 output_file = posixpath.join(temp_dir, 'angle_system_info.json') 479 return json.loads(_ReadDeviceFile(output_file)) 480 481 482def GetBuildFingerprint(): 483 return _AdbShell('getprop ro.build.fingerprint').decode('ascii').strip() 484 485 486def _PullDir(device_dir, local_dir): 487 files = _AdbShell('ls -1 %s' % device_dir).decode('ascii').split('\n') 488 for f in files: 489 f = f.strip() 490 if f: 491 _AdbRun(['pull', posixpath.join(device_dir, f), posixpath.join(local_dir, f)]) 492 493 494def _RemoveFlag(args, f): 495 matches = [a for a in args if a.startswith(f + '=')] 496 assert len(matches) <= 1 497 if matches: 498 original_value = matches[0].split('=')[1] 499 args.remove(matches[0]) 500 else: 501 original_value = None 502 503 return original_value 504 505 506def RunTests(test_suite, args, stdoutfile=None, log_output=True): 507 _EnsureTestSuite(test_suite) 508 509 args = args[:] 510 test_output_path = _RemoveFlag(args, '--isolated-script-test-output') 511 perf_output_path = _RemoveFlag(args, '--isolated-script-test-perf-output') 512 test_output_dir = _RemoveFlag(args, '--render-test-output-dir') 513 514 result = 0 515 output = b'' 516 output_json = {} 517 try: 518 with contextlib.ExitStack() as stack: 519 device_test_output_path = stack.enter_context(_TempDeviceFile()) 520 args.append('--isolated-script-test-output=' + device_test_output_path) 521 522 if perf_output_path: 523 device_perf_path = stack.enter_context(_TempDeviceFile()) 524 args.append('--isolated-script-test-perf-output=%s' % device_perf_path) 525 526 if test_output_dir: 527 device_output_dir = stack.enter_context(_TempDeviceDir()) 528 args.append('--render-test-output-dir=' + device_output_dir) 529 530 output = _RunInstrumentation(args) 531 532 if '--list-tests' in args: 533 # When listing tests, there may be no output file. We parse stdout anyways. 534 test_output = b'{"interrupted": false}' 535 else: 536 try: 537 test_output = _ReadDeviceFile(device_test_output_path) 538 except subprocess.CalledProcessError: 539 logging.error('Unable to read test json output. Stdout:\n%s', output.decode()) 540 result = 1 541 return result, output.decode(), None 542 543 if test_output_path: 544 with open(test_output_path, 'wb') as f: 545 f.write(test_output) 546 547 output_json = json.loads(test_output) 548 549 num_failures = output_json.get('num_failures_by_type', {}).get('FAIL', 0) 550 interrupted = output_json.get('interrupted', True) # Normally set to False 551 if num_failures != 0 or interrupted or output_json.get('is_unexpected', False): 552 logging.error('Tests failed: %s', test_output.decode()) 553 result = 1 554 555 if test_output_dir: 556 _PullDir(device_output_dir, test_output_dir) 557 558 if perf_output_path: 559 _AdbRun(['pull', device_perf_path, perf_output_path]) 560 561 if log_output: 562 logging.info(output.decode()) 563 564 if stdoutfile: 565 with open(stdoutfile, 'wb') as f: 566 f.write(output) 567 except Exception as e: 568 logging.exception(e) 569 result = 1 570 571 return result, output.decode(), output_json 572 573 574def GetTraceFromTestName(test_name): 575 if test_name.startswith('TraceTest.'): 576 return test_name[len('TraceTest.'):] 577 return None 578 579 580def GetTemps(): 581 temps = _AdbShell( 582 'cat /dev/thermal/tz-by-name/*_therm/temp 2>/dev/null || true').decode().split() 583 logging.debug('tz-by-name temps: %s' % ','.join(temps)) 584 585 temps_celsius = [] 586 for t in temps: 587 try: 588 temps_celsius.append(float(t) / 1e3) 589 except ValueError: 590 pass 591 592 return temps_celsius 593