initial commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run DocView on a private headless Wayland compositor with its sandboxes intact.
|
||||
|
||||
Software and explicitly selected OpenGL paths use an offscreen compositor.
|
||||
This is not a physical display, input-device, or reference-hardware acceptance.
|
||||
No user desktop socket, DocView configuration/history, or D-Bus session is used.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER_SOURCE = Path(__file__).read_bytes()
|
||||
|
||||
|
||||
def sha(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def graphics_evidence(output, runs):
|
||||
compositor_log = (output / 'compositor.log').read_text(errors='replace')
|
||||
compositor = sorted(set(re.findall(r'GL renderer: ([^\r\n]+)', compositor_log)))
|
||||
groups = {}
|
||||
for name in runs:
|
||||
log = (output / (name + '.log')).read_text(errors='replace')
|
||||
renderers = sorted(set(re.findall(r'OpenGL VENDOR: ([^\r\n]+)', log)))
|
||||
groups[name] = {'rendererDescriptions': renderers,
|
||||
'openGLWindowCreations': len(re.findall(r'Creating QRhi with backend OpenGL for window', log))}
|
||||
software = any(marker in text.lower() for text in compositor + [v for row in groups.values() for v in row['rendererDescriptions']]
|
||||
for marker in ('llvmpipe', 'softpipe', 'swiftshader', 'software rasterizer'))
|
||||
return {'compositorRenderers': compositor, 'groups': groups,
|
||||
'softwareRendererDetected': software,
|
||||
'nonSoftwareOpenGLObserved': bool(compositor) and all(row['rendererDescriptions'] for row in groups.values()) and not software,
|
||||
'scope': 'Renderer strings from this private compositor and tested Qt processes; no physical scanout or reference-machine evidence'}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--build-dir', type=Path, default=ROOT / 'build')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--mode', choices=['smoke', 'gui'], default='smoke')
|
||||
parser.add_argument('--smoke-binary', type=Path,
|
||||
help='Installed launcher to use for smoke; --build-dir still identifies the actual three binaries')
|
||||
parser.add_argument('--renderer', choices=['software', 'opengl'], default='software')
|
||||
parser.add_argument('--render-node', type=Path,
|
||||
help='Explicit /dev/dri/renderD* device; required for OpenGL')
|
||||
parser.add_argument('--output-size', default='1920x1080',
|
||||
help='Headless output dimensions; default matches the X11 test desktop')
|
||||
parser.add_argument('--app-test', action='append', default=[],
|
||||
help='QtTest app function[:data-tag] to run alone; repeatable; requires --mode gui')
|
||||
parser.add_argument('--suite', action='append', choices=['app', 'web', 'pdf_canvas'], default=[],
|
||||
help='Run selected GUI suites; defaults to all three')
|
||||
parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.smoke_binary and args.mode != 'smoke':
|
||||
parser.error('--smoke-binary requires --mode smoke')
|
||||
if args.renderer == 'opengl':
|
||||
if args.render_node is None or not re.fullmatch(r'/dev/dri/renderD[0-9]+', str(args.render_node)):
|
||||
parser.error('--renderer opengl requires an explicit /dev/dri/renderD* device')
|
||||
if (args.render_node.is_symlink() or not args.render_node.exists() or
|
||||
not stat.S_ISCHR(args.render_node.stat().st_mode)):
|
||||
parser.error('--render-node must be an existing render character device')
|
||||
elif args.render_node is not None:
|
||||
parser.error('--render-node requires --renderer opengl')
|
||||
try:
|
||||
dimensions = [int(value) for value in args.output_size.split('x')]
|
||||
if len(dimensions) != 2 or any(value < 320 or value > 4096 for value in dimensions):
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
parser.error('--output-size must be WIDTHxHEIGHT with each dimension between 320 and 4096')
|
||||
if args.app_test and args.mode != 'gui':
|
||||
parser.error('--app-test requires --mode gui')
|
||||
if args.suite and (args.mode != 'gui' or args.app_test):
|
||||
parser.error('--suite requires --mode gui and cannot be combined with --app-test')
|
||||
for program in ['sway', 'swaymsg', 'dbus-run-session']:
|
||||
if not shutil.which(program):
|
||||
parser.error('Required program missing: ' + program)
|
||||
flags = shlex.split(os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', ''))
|
||||
if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or any(
|
||||
flag.split('=', 1)[0] in ['--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--disable-seccomp-filter-sandbox'] for flag in flags):
|
||||
parser.error('Sandbox-disabling settings are not allowed')
|
||||
if not args.private_session:
|
||||
if args.output.exists():
|
||||
parser.error('--output must be a new directory to preserve earlier evidence')
|
||||
env = os.environ.copy()
|
||||
for name in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'SWAYSOCK',
|
||||
'I3SOCK', 'DBUS_SESSION_BUS_ADDRESS', 'QT_QPA_PLATFORMTHEME',
|
||||
'QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH']:
|
||||
env.pop(name, None)
|
||||
# A separate bus without activation directories cannot launch host
|
||||
# portal services using the user's interactive-session configuration.
|
||||
# Native portals and physical input are outside this headless test.
|
||||
with tempfile.TemporaryDirectory(prefix='docview-wayland-bus-') as directory:
|
||||
private = Path(directory)
|
||||
private.chmod(0o700)
|
||||
config = private / 'bus.conf'
|
||||
config.write_text('<busconfig><type>session</type><auth>EXTERNAL</auth>'
|
||||
f'<listen>unix:tmpdir={private}</listen>'
|
||||
'<policy context="default"><allow send_destination="*"/>'
|
||||
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||
for name, leaf in [('XDG_CONFIG_HOME', 'config'), ('XDG_STATE_HOME', 'state'),
|
||||
('XDG_CACHE_HOME', 'cache'), ('XDG_DATA_HOME', 'data')]:
|
||||
env[name] = str(private / leaf)
|
||||
env['XDG_RUNTIME_DIR'] = str(private)
|
||||
return subprocess.run(['dbus-run-session', '--config-file', str(config), '--',
|
||||
sys.executable, str(Path(__file__).resolve()), *sys.argv[1:],
|
||||
'--private-session'], env=env).returncode
|
||||
output, build = args.output.resolve(), args.build_dir.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
(output / 'runner-at-start.py').write_bytes(RUNNER_SOURCE)
|
||||
binaries = ['docview', 'docview-pdf-worker', 'docview-archive-worker']
|
||||
if args.mode == 'gui':
|
||||
binaries += ['test_app', 'test_web_qml', 'test_pdf_canvas']
|
||||
identities = {name: sha(build / name) for name in binaries}
|
||||
launcher = args.smoke_binary.resolve(strict=True) if args.smoke_binary else build / 'docview'
|
||||
launcher_hash = sha(launcher)
|
||||
record = {'success': False, 'mode': args.mode, 'platform': platform.platform(),
|
||||
'swayVersion': subprocess.check_output(['sway', '--version'], text=True).strip(),
|
||||
'executables': identities, 'runs': [], 'scope':
|
||||
'Private headless Sway/Wayland; no physical display/IME or reference-hardware acceptance',
|
||||
'requestedRenderer': args.renderer,
|
||||
'renderNode': str(args.render_node) if args.render_node else None,
|
||||
'applicationSandboxesEnabled': True, 'usesUserDesktop': False,
|
||||
'selectedAppTests': args.app_test,
|
||||
'selectedSuites': args.suite,
|
||||
'sessionBusServiceActivation': 'disabled; native desktop portals are not tested'}
|
||||
record['smokeLauncher'] = {'path': str(launcher), 'sha256': launcher_hash} if args.mode == 'smoke' else None
|
||||
compositor = None
|
||||
runtime_directory = tempfile.TemporaryDirectory(prefix='docview-wayland-')
|
||||
started = time.monotonic()
|
||||
try:
|
||||
# Keep the socket directory alive until the compositor has been reaped.
|
||||
runtime = Path(runtime_directory.name)
|
||||
runtime.chmod(0o700)
|
||||
config = runtime / 'sway.conf'
|
||||
config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\n'
|
||||
f'output HEADLESS-1 mode {dimensions[0]}x{dimensions[1]}\n'
|
||||
'for_window [app_id=".*"] floating enable\n'
|
||||
'default_border none\ndefault_floating_border none\n'
|
||||
'focus_follows_mouse no\nmouse_warping none\n')
|
||||
(output / 'sway.conf').write_bytes(config.read_bytes())
|
||||
env = os.environ.copy()
|
||||
for name in ['WLR_DRM_DEVICES', 'WLR_RENDER_DRM_DEVICE', 'WLR_RENDERER_ALLOW_SOFTWARE',
|
||||
'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO',
|
||||
'QT_OPENGL', 'QSG_RHI_BACKEND', 'QSG_RHI_PREFER_SOFTWARE_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE', 'MESA_LOADER_DRIVER_OVERRIDE', 'GALLIUM_DRIVER', 'DRI_PRIME']:
|
||||
env.pop(name, None)
|
||||
env.update({'XDG_RUNTIME_DIR': str(runtime), 'XDG_CONFIG_HOME': str(runtime / 'config'),
|
||||
'XDG_STATE_HOME': str(runtime / 'state'), 'XDG_CACHE_HOME': str(runtime / 'cache'),
|
||||
'XDG_CURRENT_DESKTOP': 'sway', 'XDG_SESSION_TYPE': 'wayland',
|
||||
'WLR_BACKENDS': 'headless', 'WLR_RENDERER': 'pixman', 'WLR_HEADLESS_OUTPUTS': '1',
|
||||
'DOCVIEW_PRIVATE_WAYLAND_TEST': '1',
|
||||
'QT_QPA_PLATFORM': 'wayland', 'QT_QUICK_BACKEND': 'software',
|
||||
'QTWEBENGINE_CHROMIUM_FLAGS': '--disable-gpu', 'QT_USE_PORTAL': '0',
|
||||
'QT_SCALE_FACTOR': '1', 'QT_FONT_DPI': '96', 'QT_FORCE_STDERR_LOGGING':'1'})
|
||||
if args.renderer == 'opengl':
|
||||
env.pop('QT_QUICK_BACKEND', None)
|
||||
env.update({'WLR_RENDERER':'gles2', 'WLR_RENDER_DRM_DEVICE':str(args.render_node),
|
||||
'QSG_RHI_BACKEND':'opengl', 'QSG_INFO':'1', 'QTWEBENGINE_CHROMIUM_FLAGS':'',
|
||||
'QT_LOGGING_RULES':'qt.scenegraph.general=true;qt.rhi.general=true;qt.webenginecontext.debug=true'})
|
||||
record['requestedGraphicsEnvironment'] = {name:env.get(name, '') for name in
|
||||
['WLR_BACKENDS','WLR_RENDERER','WLR_RENDER_DRM_DEVICE','QT_QUICK_BACKEND',
|
||||
'QSG_RHI_BACKEND','QTWEBENGINE_CHROMIUM_FLAGS','QT_SCALE_FACTOR']}
|
||||
with (output / 'compositor.log').open('wb') as log:
|
||||
compositor = subprocess.Popen(['sway', *(['-d'] if args.renderer == 'opengl' else []), '-c', str(config)], env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
deadline = time.monotonic() + 10
|
||||
sockets = []
|
||||
while time.monotonic() < deadline and compositor.poll() is None:
|
||||
sockets = [p for p in runtime.glob('wayland-*') if p.is_socket()]
|
||||
ipc = list(runtime.glob('sway-ipc.*.sock'))
|
||||
if sockets and ipc:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not sockets or compositor.poll() is not None:
|
||||
raise RuntimeError('Private Wayland compositor did not start; see compositor.log')
|
||||
env['WAYLAND_DISPLAY'] = sockets[0].name
|
||||
env['SWAYSOCK'] = str(ipc[0])
|
||||
outputs = subprocess.check_output(['swaymsg', '-s', str(ipc[0]), '-t', 'get_outputs'], env=env)
|
||||
record['outputs'] = json.loads(outputs)
|
||||
commands = [('smoke', [sys.executable, str(ROOT / 'tests/smoke.py'), '--binary', str(launcher),
|
||||
'--output', str(output / 'smoke')], 210)] if args.mode == 'smoke' else [
|
||||
('app', [str(build / 'test_app')], 150),
|
||||
('web', [str(build / 'test_web_qml')], 150),
|
||||
('pdf_canvas', [str(build / 'test_pdf_canvas')], 90)]
|
||||
if args.app_test:
|
||||
commands = [('app', [str(build / 'test_app'), *args.app_test], 150)]
|
||||
elif args.suite:
|
||||
commands = [row for row in commands if row[0] in args.suite]
|
||||
for name, command, timeout in commands:
|
||||
print('Wayland: ' + name, flush=True)
|
||||
before = time.monotonic()
|
||||
with (output / (name + '.log')).open('wb') as test_log:
|
||||
run = subprocess.Popen(command, cwd=ROOT, env=env, stdout=test_log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
timed_out = False
|
||||
try:
|
||||
run.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
finally:
|
||||
# Only terminate this test's dedicated process group.
|
||||
# This also collects children if a runner times out.
|
||||
try:
|
||||
os.killpg(run.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
run.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(run.pid, signal.SIGKILL)
|
||||
run.wait(timeout=5)
|
||||
record['runs'].append({'name': name, 'command': command, 'exitCode': run.returncode,
|
||||
'timedOut': timed_out,
|
||||
'seconds': time.monotonic() - before})
|
||||
failed = [row['name'] for row in record['runs'] if row['exitCode'] or row['timedOut']]
|
||||
if failed:
|
||||
raise RuntimeError(', '.join(failed) + ' failed; see preserved logs')
|
||||
if args.mode == 'smoke':
|
||||
cases = json.loads((output / 'smoke/results.json').read_text())
|
||||
if len(cases) != 6 or any(row['runtime']['qtPlatform'] != 'wayland' for row in cases):
|
||||
raise RuntimeError('Smoke evidence is incomplete or did not use Wayland')
|
||||
record['smokeCases'] = cases
|
||||
if args.renderer == 'opengl':
|
||||
record['graphicsEvidence'] = graphics_evidence(output, [r['name'] for r in record['runs']])
|
||||
if not record['graphicsEvidence']['nonSoftwareOpenGLObserved']:
|
||||
raise RuntimeError('OpenGL hardware path was not established by the preserved renderer logs')
|
||||
record['binariesUnchanged'] = identities == {name: sha(build / name) for name in binaries}
|
||||
record['success'] = record['binariesUnchanged']
|
||||
except Exception as error:
|
||||
record['failure'] = str(error)
|
||||
finally:
|
||||
if compositor is not None:
|
||||
if compositor.poll() is None:
|
||||
os.killpg(compositor.pid, signal.SIGTERM)
|
||||
try:
|
||||
compositor.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(compositor.pid, signal.SIGKILL)
|
||||
compositor.wait(timeout=5)
|
||||
record['compositorReaped'] = compositor.poll() is not None
|
||||
runtime_directory.cleanup()
|
||||
record['binariesUnchanged'] = identities == {name: sha(build / name) for name in binaries}
|
||||
if not record['binariesUnchanged']:
|
||||
record['success'] = False
|
||||
if args.mode == 'smoke':
|
||||
record['smokeLauncherUnchanged'] = launcher_hash == sha(launcher)
|
||||
record['success'] = record['success'] and record['smokeLauncherUnchanged']
|
||||
record['elapsedSeconds'] = time.monotonic() - started
|
||||
record['runnerSha256'] = hashlib.sha256(RUNNER_SOURCE).hexdigest()
|
||||
record['runnerFileUnchanged'] = RUNNER_SOURCE == Path(__file__).read_bytes()
|
||||
(output / 'report.json').write_text(json.dumps(record, ensure_ascii=False, indent=2) + '\n')
|
||||
print(json.dumps({'success': record['success'], 'output': str(output), 'failure': record.get('failure')}))
|
||||
return 0 if record['success'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user