initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+272
View File
@@ -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())