• 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 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