• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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