#!/usr/bin/env python3 """Run the supplemental heavy-PDF GUI test on a private X11 software display.""" import argparse import hashlib import json import os from pathlib import Path import platform import shlex import subprocess import sys import tempfile ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / 'tests')) from run_stress_pdf import run_observed from generate import generate def sha(path): with Path(path).open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--binary', type=Path, default=ROOT / 'build-heavy-pdf/test_heavy_pdf') parser.add_argument('--canonical-build', type=Path, default=ROOT / 'build') parser.add_argument('--output', type=Path, required=True) parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) args = parser.parse_args() if args.output.exists(): parser.error('Choose a new output directory to preserve previous evidence') 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: env = os.environ.copy() for name in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'DBUS_SESSION_BUS_ADDRESS', 'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', 'QT_SCALE_FACTOR_ROUNDING_POLICY', 'QT_ENABLE_HIGHDPI_SCALING', 'QT_QPA_PLATFORMTHEME']: env.pop(name, None) env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1', QT_FONT_DPI='96', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_FORCE_STDERR_LOGGING='1') with tempfile.TemporaryDirectory(prefix='docview-heavy-session-') as private: bus = Path(private) / 'bus.conf' bus.write_text('sessionEXTERNAL' f'unix:tmpdir={private}' '' '') env['XDG_RUNTIME_DIR'] = private return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp', sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode output = args.output.resolve(); output.mkdir(parents=True, exist_ok=False) binary, build = args.binary.resolve(), args.canonical_build.resolve() report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(), 'scope': 'Linux supplemental heavy-PDF GUI validation; not native Windows, physical display, reference hardware or long-duration acceptance'} watched = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker', 'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']] before = {os.path.relpath(p, ROOT): sha(p) for p in watched} source_files = [ROOT / 'resources.qrc'] for folder in ['src', 'qml', 'resources', 'tests/heavy_pdf']: source_files += [p for p in (ROOT / folder).rglob('*') if p.is_file() and '__pycache__' not in p.parts] sources = {str(p.relative_to(ROOT)): sha(p) for p in sorted(set(source_files))} try: # The executable resolves workers by application directory. Verify the # linked paths refer to the canonical tested files before any launch. for worker in ['docview-pdf-worker', 'docview-archive-worker']: assert sha(binary.parent / worker) == sha(build / worker) heavy = output / 'heavy.pdf' manifest = generate(heavy) (output / 'fixture.json').write_text(json.dumps(manifest, indent=2) + '\n') info = subprocess.check_output(['pdfinfo', str(heavy)], text=True, timeout=15) (output / 'pdfinfo.txt').write_text(info) extracted = subprocess.check_output(['pdftotext', str(heavy), '-'], text=True, timeout=30) (output / 'pdftotext.txt').write_text(extracted) assert 'DOCVIEW RASTER 4960 x 7016 RGB' in extracted and 'DOCVIEW COMPLEX 60000 TRANSPARENT PATHS' in extracted # Decode the actual image object with an independent parser. Store only # its digest/size; the 100 MiB temporary decoded file is reclaimed. with tempfile.TemporaryFile() as decoded: subprocess.run(['qpdf', '--show-object=4', '--filtered-stream-data', str(heavy)], stdout=decoded, stderr=subprocess.PIPE, check=True, timeout=30) decoded.seek(0, 2); decoded_size = decoded.tell(); decoded.seek(0) decoded_hash = hashlib.file_digest(decoded, 'sha256').hexdigest() assert decoded_size == manifest['decodedRasterBytes'] and decoded_hash == manifest['rasterSha256'] report['independentDecodedRaster'] = {'bytes': decoded_size, 'sha256': decoded_hash, 'method': 'qpdf --show-object=4 --filtered-stream-data'} env = os.environ.copy() light = ROOT / 'tests/fixtures/pdf/navigation.pdf' env.update(DOCVIEW_HEAVY_PDF=str(heavy), DOCVIEW_LIGHT_PDF=str(light), DOCVIEW_HEAVY_OUTPUT=str(output)) report['process'] = run_observed([str(binary)], env, output, 'test', timeout=180) measurement = json.loads((output / 'measurement.json').read_text()) if (output / 'measurement.json').exists() else {} report['success'] = report['process']['exitCode'] == 0 and measurement.get('success') is True report['independentFixtureMarkersVerified'] = True report['fixtureSha256'] = sha(heavy) report['measurementSha256'] = sha(output / 'measurement.json') if measurement else None except Exception as error: report['error'] = str(error) finally: report['binaries'] = before report['sourceSha256'] = sources report['binariesUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items()) report['sourcesUnchanged'] = all(sha(ROOT / name) == value for name, value in sources.items()) report['success'] = report['success'] and report['binariesUnchanged'] and report['sourcesUnchanged'] (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') print(json.dumps({'success': report['success'], 'output': str(output), 'error': report.get('error')})) return 0 if report['success'] else 1 if __name__ == '__main__': raise SystemExit(main())