mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
348 lines
14 KiB
Python
348 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# MobileGL - tools/piglit-android/run_piglit_android.py
|
|
# Copyright (c) 2025-2026 MobileGL-Dev
|
|
# Licensed under the GNU Lesser General Public License v3.0:
|
|
# https://www.gnu.org/licenses/gpl-3.0.txt
|
|
# https://www.gnu.org/licenses/lgpl-3.0.txt
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
# End of Source File Header
|
|
"""Run piglit GL tests on a connected Android device against MobileGL.
|
|
|
|
Pipeline:
|
|
1. Read a test list produced by `piglit print-cmd` ("name ::: command" lines).
|
|
2. Rewrite host paths to on-device paths and collect referenced data files.
|
|
3. Push binaries/libs/data to the device (incremental, marker-based).
|
|
4. Execute tests serially on-device in chunked shell scripts under `timeout`,
|
|
with MobileGL served to waffle via WAFFLE_EGL_LIBRARY=libMobileGL.so.
|
|
5. Parse "PIGLIT: {...}" result lines, classify, and write results + summary.
|
|
|
|
The device never needs python or an APK; everything runs as the adb shell user
|
|
from /data/local/tmp.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
RESULT_LINE = re.compile(rb'PIGLIT: *({.*})')
|
|
MARK_START = re.compile(rb'@@@T (\d+) START')
|
|
MARK_EXIT = re.compile(rb'@@@T (\d+) EXIT (\d+)')
|
|
|
|
# toybox timeout exit codes: 124 = timed out (SIGTERM), 137 = SIGKILL after -k.
|
|
TIMEOUT_EXITS = {124, 137, 142}
|
|
|
|
|
|
def adb(args, serial=None, **kw):
|
|
cmd = ['adb'] + (['-s', serial] if serial else []) + args
|
|
return subprocess.run(cmd, **kw)
|
|
|
|
|
|
def adb_check(args, serial=None):
|
|
r = adb(args, serial=serial, capture_output=True)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"adb {' '.join(args)} failed: {r.stderr.decode(errors='replace')}")
|
|
return r.stdout
|
|
|
|
|
|
def parse_list(path):
|
|
tests = []
|
|
for line in Path(path).read_text().splitlines():
|
|
line = line.strip()
|
|
if not line or ' ::: ' not in line:
|
|
continue
|
|
name, cmd = line.split(' ::: ', 1)
|
|
tests.append((name.strip(), shlex.split(cmd)))
|
|
return tests
|
|
|
|
|
|
def rewrite_cmd(argv, piglit_root, build_dir, device_dir):
|
|
"""Map host paths in a test command to device paths.
|
|
|
|
Returns (device_argv, referenced_host_files).
|
|
"""
|
|
build_abs = str((piglit_root / build_dir).resolve())
|
|
src_abs = str(piglit_root.resolve())
|
|
out = []
|
|
refs = []
|
|
for i, arg in enumerate(argv):
|
|
a = arg
|
|
if i == 0:
|
|
# program: "build-android/bin/foo" or absolute
|
|
prog = a if os.path.isabs(a) else str((piglit_root / a).resolve())
|
|
refs.append(prog)
|
|
out.append(f'{device_dir}/bin/{os.path.basename(prog)}')
|
|
continue
|
|
p = a if os.path.isabs(a) else None
|
|
if p and p.startswith(build_abs + '/generated_tests/'):
|
|
refs.append(p)
|
|
out.append(p.replace(build_abs + '/generated_tests', device_dir + '/generated_tests', 1))
|
|
elif p and p.startswith(build_abs + '/tests/'):
|
|
# Serialized profiles record data paths under the build dir, but
|
|
# only *generated* data lives there in an out-of-tree build;
|
|
# plain test files stay in the source tests/ dir.
|
|
if not os.path.exists(p):
|
|
p = p.replace(build_abs + '/tests', src_abs + '/tests', 1)
|
|
refs.append(p)
|
|
out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
|
|
else:
|
|
refs.append(p)
|
|
out.append(p.replace(build_abs + '/tests', device_dir + '/build-tests', 1))
|
|
elif p and p.startswith(src_abs + '/tests/'):
|
|
refs.append(p)
|
|
out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
|
|
else:
|
|
out.append(a)
|
|
return out, refs
|
|
|
|
|
|
def backend_env(backend, device_dir, use_angle=False):
|
|
env = {
|
|
'LD_LIBRARY_PATH': f'{device_dir}/lib',
|
|
'WAFFLE_EGL_LIBRARY': 'libMobileGL.so',
|
|
'WAFFLE_GL_LIBRARY': 'libMobileGL.so',
|
|
# Upgrade low compat context requests to what MobileGL implements;
|
|
# without this, piglit's supports_gl_compat_version=10 tests get the
|
|
# raw backend version string and skip themselves.
|
|
'WAFFLE_FORCE_GL_CONTEXT_VERSION': '33core',
|
|
'PIGLIT_PLATFORM': 'surfaceless_egl',
|
|
'PIGLIT_SOURCE_DIR': device_dir,
|
|
'MOBILEGL_BACKEND_TYPE': backend,
|
|
'MOBILEGL_LOG_FILE_PATH': f'{device_dir}/mobilegl.log',
|
|
}
|
|
if backend == 'DirectGLES':
|
|
env['MOBILEGL_USE_ANGLE'] = '1' if use_angle else '0'
|
|
if backend == 'DirectVulkan':
|
|
# The device ICD usually lacks VK_EXT_headless_surface, which the
|
|
# MobileGL pbuffer path needs; use a real ANativeWindow from
|
|
# AImageReader instead (patched waffle surfaceless_egl platform).
|
|
env['WAFFLE_ANDROID_WINDOW'] = 'imagereader'
|
|
return env
|
|
|
|
|
|
def push_tree(host, dev, serial, marker_name, force=False):
|
|
"""Push a directory once, tracked by a marker file on the device."""
|
|
marker = f'{dev}.pushed-{marker_name}'
|
|
if not force:
|
|
r = adb(['shell', f'test -e {shlex.quote(marker)} && echo yes'],
|
|
serial=serial, capture_output=True)
|
|
if r.stdout.strip() == b'yes':
|
|
return False
|
|
print(f' pushing {host} -> {dev}')
|
|
adb_check(['shell', f'rm -rf {shlex.quote(dev)}'], serial=serial)
|
|
adb_check(['push', str(host), dev], serial=serial)
|
|
adb_check(['shell', f'touch {shlex.quote(marker)}'], serial=serial)
|
|
return True
|
|
|
|
|
|
def push_files(files, strip_prefix, dev_prefix, serial):
|
|
"""Tar up referenced data files (relative to strip_prefix) and unpack on device."""
|
|
files = sorted(set(files))
|
|
if not files:
|
|
return
|
|
with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as tf:
|
|
tar_path = tf.name
|
|
with tarfile.open(tar_path, 'w') as tar:
|
|
for f in files:
|
|
rel = os.path.relpath(f, strip_prefix)
|
|
if rel.startswith('..'):
|
|
raise RuntimeError(f'file {f} not under {strip_prefix}')
|
|
tar.add(f, arcname=rel)
|
|
dev_tar = dev_prefix + '/.data.tar'
|
|
adb_check(['shell', f'mkdir -p {shlex.quote(dev_prefix)}'], serial=serial)
|
|
adb_check(['push', tar_path, dev_tar], serial=serial)
|
|
adb_check(['shell', f'cd {shlex.quote(dev_prefix)} && tar xf .data.tar && rm .data.tar'],
|
|
serial=serial)
|
|
os.unlink(tar_path)
|
|
|
|
|
|
def classify(exit_code, piglit_results, timed_out):
|
|
if timed_out:
|
|
return 'timeout'
|
|
result = None
|
|
for r in piglit_results:
|
|
if 'result' in r:
|
|
result = r['result']
|
|
if result is None:
|
|
return 'crash' if exit_code != 0 else 'notrun'
|
|
if exit_code not in (0, 1):
|
|
return 'crash'
|
|
return result
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument('--piglit-root', required=True, type=Path,
|
|
help='piglit source checkout (with the Android build dir inside)')
|
|
ap.add_argument('--build-dir', default='build-android',
|
|
help='Android build dir name inside piglit root')
|
|
ap.add_argument('--list', required=True,
|
|
help='test list file from `piglit print-cmd` (name ::: cmd)')
|
|
ap.add_argument('--backend', required=True, choices=['DirectGLES', 'DirectVulkan'])
|
|
ap.add_argument('--use-angle', action='store_true',
|
|
help='DirectGLES only: let MobileGL load ANGLE instead of the system driver')
|
|
ap.add_argument('--mobilegl-lib', required=True, type=Path,
|
|
help='stripped libMobileGL.so for the device')
|
|
ap.add_argument('--waffle-lib', required=True, type=Path,
|
|
help='cross-built libwaffle-1.so')
|
|
ap.add_argument('--device-dir', default='/data/local/tmp/piglit-mgl')
|
|
ap.add_argument('--out', required=True, type=Path, help='host results directory')
|
|
ap.add_argument('--serial', default=None, help='adb device serial')
|
|
ap.add_argument('--timeout', type=int, default=60, help='per-test timeout (seconds)')
|
|
ap.add_argument('--chunk', type=int, default=200, help='tests per device-side script')
|
|
ap.add_argument('--repush', action='store_true', help='force re-push of bin/lib/tests trees')
|
|
args = ap.parse_args()
|
|
|
|
piglit_root = args.piglit_root.resolve()
|
|
build = piglit_root / args.build_dir
|
|
dev = args.device_dir.rstrip('/')
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
|
|
tests = parse_list(args.list)
|
|
if not tests:
|
|
print('no tests in list', file=sys.stderr)
|
|
return 2
|
|
|
|
print(f'[1/4] {len(tests)} tests; rewriting paths')
|
|
dev_cmds = []
|
|
data_refs = []
|
|
for name, argv in tests:
|
|
dcmd, refs = rewrite_cmd(argv, piglit_root, args.build_dir, dev)
|
|
dev_cmds.append((name, dcmd))
|
|
data_refs.extend(r for r in refs if not r.split('/')[-2:][0] == 'bin')
|
|
|
|
print('[2/4] pushing artifacts')
|
|
adb_check(['shell', f'mkdir -p {dev} {dev}/lib {dev}/chunks {dev}/logs'], serial=args.serial)
|
|
push_tree(build / 'bin', f'{dev}/bin', args.serial, 'bin', args.repush)
|
|
adb_check(['shell', f'chmod -R 755 {dev}/bin'], serial=args.serial)
|
|
# libs: piglit utils + waffle + MobileGL in one LD_LIBRARY_PATH dir
|
|
for lib in sorted((build / 'lib').glob('*.so')):
|
|
adb_check(['push', str(lib), f'{dev}/lib/'], serial=args.serial)
|
|
adb_check(['push', str(args.waffle_lib), f'{dev}/lib/libwaffle-1.so'], serial=args.serial)
|
|
adb_check(['push', str(args.mobilegl_lib), f'{dev}/lib/libMobileGL.so'], serial=args.serial)
|
|
# data files referenced by this run (tests/, build-tests/, generated_tests/)
|
|
src_abs = str(piglit_root)
|
|
build_abs = str(build)
|
|
groups = {
|
|
(src_abs + '/tests', f'{dev}/tests'): [],
|
|
(build_abs + '/tests', f'{dev}/build-tests'): [],
|
|
(build_abs + '/generated_tests', f'{dev}/generated_tests'): [],
|
|
}
|
|
for r in set(data_refs):
|
|
for (host_prefix, dev_prefix), bucket in groups.items():
|
|
if r.startswith(host_prefix + '/'):
|
|
bucket.append(r)
|
|
break
|
|
for (host_prefix, dev_prefix), bucket in groups.items():
|
|
push_files(bucket, host_prefix, dev_prefix, args.serial)
|
|
|
|
env = backend_env(args.backend, dev, args.use_angle)
|
|
env_lines = '\n'.join(f'export {k}={shlex.quote(v)}' for k, v in env.items())
|
|
|
|
print(f'[3/4] running {len(tests)} tests on {args.backend} '
|
|
f'(timeout {args.timeout}s/test, chunks of {args.chunk})')
|
|
raw_log = (args.out / 'raw.log').open('wb')
|
|
t0 = time.time()
|
|
outcomes = {}
|
|
chunks = [dev_cmds[i:i + args.chunk] for i in range(0, len(dev_cmds), args.chunk)]
|
|
idx_base = 0
|
|
for ci, chunk in enumerate(chunks):
|
|
lines = ['#!/system/bin/sh', env_lines, f'cd {dev}']
|
|
for j, (name, dcmd) in enumerate(chunk):
|
|
idx = idx_base + j
|
|
quoted = ' '.join(shlex.quote(a) for a in dcmd)
|
|
lines.append(f'echo "@@@T {idx} START"')
|
|
lines.append(f'timeout -k 5 {args.timeout} {quoted} </dev/null 2>&1')
|
|
lines.append(f'echo "@@@T {idx} EXIT $?"')
|
|
script = '\n'.join(lines) + '\n'
|
|
with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as tf:
|
|
tf.write(script)
|
|
host_script = tf.name
|
|
dev_script = f'{dev}/chunks/chunk{ci}.sh'
|
|
adb_check(['push', host_script, dev_script], serial=args.serial)
|
|
os.unlink(host_script)
|
|
r = adb(['shell', f'sh {dev_script}'], serial=args.serial, capture_output=True)
|
|
raw_log.write(r.stdout)
|
|
raw_log.flush()
|
|
|
|
# parse this chunk
|
|
cur = None
|
|
cur_results = []
|
|
cur_out = []
|
|
for line in r.stdout.splitlines():
|
|
m = MARK_START.search(line)
|
|
if m:
|
|
cur = int(m.group(1))
|
|
cur_results = []
|
|
cur_out = []
|
|
continue
|
|
m = MARK_EXIT.search(line)
|
|
if m and cur is not None and int(m.group(1)) == cur:
|
|
code = int(m.group(2))
|
|
name = dev_cmds[cur][0]
|
|
status = classify(code, cur_results, code in TIMEOUT_EXITS)
|
|
outcomes[name] = {
|
|
'status': status,
|
|
'exit': code,
|
|
'subtests': {k: v for r_ in cur_results if 'subtest' in r_
|
|
for k, v in r_['subtest'].items()},
|
|
'tail': b'\n'.join(cur_out[-8:]).decode(errors='replace')
|
|
if status in ('crash', 'timeout', 'fail', 'notrun') else '',
|
|
}
|
|
cur = None
|
|
continue
|
|
if cur is not None:
|
|
cur_out.append(line)
|
|
m = RESULT_LINE.search(line)
|
|
if m:
|
|
try:
|
|
cur_results.append(json.loads(m.group(1)))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# tests whose markers never appeared (adb drop, device reboot)
|
|
for j, (name, _) in enumerate(chunk):
|
|
outcomes.setdefault(dev_cmds[idx_base + j][0], {
|
|
'status': 'missing', 'exit': -1, 'subtests': {}, 'tail': ''})
|
|
idx_base += len(chunk)
|
|
done = idx_base
|
|
print(f' chunk {ci + 1}/{len(chunks)} done ({done}/{len(dev_cmds)}, '
|
|
f'{time.time() - t0:.0f}s elapsed)')
|
|
raw_log.close()
|
|
|
|
print('[4/4] writing results')
|
|
counts = {}
|
|
for name, o in outcomes.items():
|
|
counts[o['status']] = counts.get(o['status'], 0) + 1
|
|
result_doc = {
|
|
'backend': args.backend,
|
|
'use_angle': args.use_angle,
|
|
'device_dir': dev,
|
|
'timeout': args.timeout,
|
|
'elapsed_sec': round(time.time() - t0, 1),
|
|
'totals': counts,
|
|
'tests': outcomes,
|
|
}
|
|
(args.out / 'results.json').write_text(json.dumps(result_doc, indent=1, sort_keys=True))
|
|
lines = [f"backend: {args.backend} tests: {len(outcomes)} elapsed: {result_doc['elapsed_sec']}s"]
|
|
lines.append('totals: ' + ', '.join(f'{k}={v}' for k, v in sorted(counts.items())))
|
|
for status in ('crash', 'timeout', 'fail', 'missing', 'notrun', 'warn'):
|
|
bad = sorted(n for n, o in outcomes.items() if o['status'] == status)
|
|
if bad:
|
|
lines.append(f'\n== {status} ({len(bad)}):')
|
|
lines.extend(f' {n}' for n in bad)
|
|
(args.out / 'summary.txt').write_text('\n'.join(lines) + '\n')
|
|
print('\n'.join(lines[:2]))
|
|
print(f"results: {args.out / 'results.json'}")
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|