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