initial commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze a complete passing build; keep earlier version evidence separate."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def sha(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2])
|
||||
parser.add_argument('--build', type=Path, required=True)
|
||||
parser.add_argument('--ctest', type=Path, required=True)
|
||||
parser.add_argument('--installed', type=Path)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
cases = ET.parse(args.ctest / 'tests.xml').findall('.//testcase')
|
||||
assert len(cases) == 22 and all(c.find('failure') is None and c.find('skipped') is None for c in cases)
|
||||
files = [args.source / name for name in ('CMakeLists.txt', 'resources.qrc')]
|
||||
for folder in ('src', 'qml', 'cmake', 'resources', 'tools', 'tests'):
|
||||
files += [path for path in (args.source / folder).rglob('*')
|
||||
if path.is_file() and not path.is_symlink() and '__pycache__' not in path.parts
|
||||
and 'results' not in path.parts and 'fixtures' not in path.parts]
|
||||
names = ['docview', 'docview-pdf-worker', 'docview-archive-worker', 'test_app',
|
||||
'test_core', 'test_pdf', 'test_pdf_canvas', 'test_web_qml', 'test_translations']
|
||||
value = {
|
||||
'recordedAtUtc': datetime.now(timezone.utc).isoformat(),
|
||||
'scope': 'Full CTest after URL representation fix; Xvfb/xcb, software rendering, internal sandboxes enabled',
|
||||
'os': platform.freedesktop_os_release(), 'kernel': platform.release(),
|
||||
'compiler': subprocess.check_output(['c++', '--version'], text=True).splitlines()[0],
|
||||
'binaries': {name: sha(args.build / name) for name in names},
|
||||
'sourceSha256': {str(path.relative_to(args.source)): sha(path) for path in sorted(set(files))},
|
||||
'ctest': {'groups': len(cases), 'failures': 0, 'junitSha256': sha(args.ctest / 'tests.xml'),
|
||||
'logSha256': sha(args.ctest / 'LastTest.log'),
|
||||
'cases': [case.attrib for case in cases]},
|
||||
'qtTestTotalsIncludingSetupAndCleanup': re.findall(r'Totals: .*', (args.ctest / 'LastTest.log').read_text()),
|
||||
'runnerSha256': sha(Path(__file__)),
|
||||
}
|
||||
if args.installed:
|
||||
value['installedBinaries'] = {name: sha(args.installed / 'bin' / name) for name in names[:3]}
|
||||
with args.output.open('x') as stream:
|
||||
stream.write(json.dumps(value, ensure_ascii=False, indent=2) + '\n')
|
||||
print(json.dumps({'ctestGroups': len(cases), 'docviewSha256': value['binaries']['docview']}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the URL-fix evidence and publish a current index with explicit history."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RESULTS = ROOT / 'tests/results/portal'
|
||||
|
||||
|
||||
def sha(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def read(name):
|
||||
return json.loads((RESULTS / name).read_text())
|
||||
|
||||
|
||||
def main():
|
||||
builds = {name: read(name + '-build-record.json') for name in ('arch', 'ubuntu')}
|
||||
current_sources = {str(path.relative_to(ROOT)): sha(path) for directory in ('src', 'qml', 'cmake', 'resources')
|
||||
for path in (ROOT / directory).rglob('*') if path.is_file() and '__pycache__' not in path.parts}
|
||||
current_sources.update({name: sha(ROOT / name) for name in ('CMakeLists.txt', 'resources.qrc')})
|
||||
for name, value in builds.items():
|
||||
assert all(value['sourceSha256'].get(path) == digest for path, digest in current_sources.items())
|
||||
for path in ('tests/test_web_qml.cpp', 'tests/webqml/tst_reader.qml'):
|
||||
assert value['sourceSha256'][path] == sha(ROOT / path)
|
||||
ctest = RESULTS / (name + '-ctest')
|
||||
assert sha(ctest / 'tests.xml') == value['ctest']['junitSha256']
|
||||
assert sha(ctest / 'LastTest.log') == value['ctest']['logSha256']
|
||||
cases = ET.parse(ctest / 'tests.xml').findall('.//testcase')
|
||||
assert len(cases) == 22 and all(row.find('failure') is None and row.find('skipped') is None for row in cases)
|
||||
assert all(sha(ROOT / 'build' / name) == digest for name, digest in builds['arch']['binaries'].items())
|
||||
package = read('ubuntu-package/report.json')
|
||||
repeated = read('ubuntu-package/repeat-report.json')
|
||||
verified = read('ubuntu-package/verification.json')
|
||||
archive = ROOT / 'build-ubuntu-vm/packages' / package['archive']
|
||||
assert package['success'] and repeated['success'] and verified['success']
|
||||
assert package['archiveSha256'] == repeated['archiveSha256'] == sha(archive)
|
||||
assert verified['archiveSha256'] == package['archiveSha256']
|
||||
assert verified['allSizesModesAndHashesMatch'] and verified['rootOwnership']
|
||||
assert verified['filesVerified'] == package['payloadFiles']
|
||||
assert package['packagerSha256'] == sha(ROOT / 'tools/package_ubuntu.py')
|
||||
assert package['executableSha256'] == builds['ubuntu']['installedBinaries']
|
||||
assert package['manifestSha256'] == sha(RESULTS / 'ubuntu-package/package-manifest.json')
|
||||
portal = {}
|
||||
for platform, directory in {'x11': 'portal-x11-final', 'wayland': 'portal-wayland-fixed-keymap'}.items():
|
||||
report = read(directory + '/report.json')
|
||||
assert report['success'] and report['executablesUnchanged'] and not report['remainingTaggedChildren']
|
||||
assert report['executables'] == package['executableSha256']
|
||||
assert report['packageManifestSha256'] == package['manifestSha256']
|
||||
assert report['runnerSha256'] == sha(RESULTS / directory / 'runner-at-start.py')
|
||||
assert report['portalResponses'] == ['2', '0', '0', '0', '0']
|
||||
assert [row['case'] for row in report['cases']] == ['cancel', 'pdf', 'html', 'htmlzip', 'epub']
|
||||
assert all(row['focusRestored'] for row in report['cases'])
|
||||
portal[platform] = {key: report[key] for key in ('success', 'platform', 'executables', 'portalResponses', 'cases', 'scope')}
|
||||
lifecycle = {phase: read('package-vm/' + phase + '/report.json') for phase in ('install', 'smoke', 'remove')}
|
||||
assert all(row['success'] and row['archiveSha256'] == package['archiveSha256'] for row in lifecycle.values())
|
||||
assert lifecycle['install']['executables'] == package['executableSha256']
|
||||
assert lifecycle['install']['installationMode'] == 'upgrade'
|
||||
assert lifecycle['install']['runnerSha256'] == sha(ROOT / 'tests/ubuntu_vm/clean_package.py')
|
||||
assert lifecycle['smoke']['smokeConditions'] == 12
|
||||
assert lifecycle['remove']['payloadRemoved'] and lifecycle['remove']['kernelRestrictionUnchanged']
|
||||
performance = read('performance-suite/summary.json')
|
||||
assert performance['success'] and len(performance['cases']) == 10
|
||||
assert all(builds['arch']['binaries'][name] == digest for name, digest in performance['buildSha256'].items())
|
||||
for row in performance['cases']:
|
||||
assert sha(RESULTS / 'performance-suite' / row['record']) == row['recordSha256']
|
||||
assert performance['interactionRecordSha256'] == sha(RESULTS / 'performance-suite/interaction/record.json')
|
||||
assert performance['stressRecordSha256'] == sha(RESULTS / 'performance-suite/stress/report.json')
|
||||
prior = read('prior-validation-record.json')
|
||||
record = {
|
||||
'schemaVersion': 2, 'recordedAtUtc': datetime.now(timezone.utc).isoformat(),
|
||||
'scope': 'Current URL-fix build: Arch/Ubuntu full CTest, Ubuntu package and XDG/GTK file chooser integration; Arch ten-condition performance, interaction and 1 GiB PDF stress',
|
||||
'ctestSkipScope': 'All 22 CTest groups pass. Each App group explicitly skips its opt-in performance case; Ubuntu Python 3.12 skips 5 zstd provenance cases. These are not counted as executed acceptance tests.',
|
||||
'goalComplete': False, 'sourceSha256': current_sources,
|
||||
'validationToolsSha256': {name: sha(ROOT / name) for name in (
|
||||
'tests/portal/record_build.py', 'tests/portal/record_validation.py', 'tests/portal/run.py',
|
||||
'tests/portal/run_performance.py', 'tests/portal/summarize_performance.py', 'tests/ubuntu_vm/clean_package.py')},
|
||||
'executables': {name: builds['arch']['binaries'][name] for name in package['executableSha256']},
|
||||
'builds': builds, 'ubuntuPackage': package, 'portal': portal,
|
||||
'packageLifecycle': lifecycle,
|
||||
'performance': performance,
|
||||
'historicalEvidence': {
|
||||
'record': 'tests/results/portal/prior-validation-record.json',
|
||||
'sha256': sha(RESULTS / 'prior-validation-record.json'),
|
||||
'changedCompiledSources': [name for name, digest in current_sources.items() if prior['sourceSha256'].get(name) != digest],
|
||||
'unchangedArchWorkers': [name for name in ('docview-pdf-worker', 'docview-archive-worker')
|
||||
if prior['executables'][name] == builds['arch']['binaries'][name]],
|
||||
'scope': 'Prior full-app performance, GPU, IME, high-density PDF and package measurements are versioned history. Current ten-condition performance and 1 GiB stress are recorded separately above.',
|
||||
},
|
||||
'remainingAcceptance': [
|
||||
'Windows 11 native MSVC/Qt build, LPAC/pipe/PE execution and installer',
|
||||
'Target-OS physical GPU/display/input, reference hardware and long-duration acceptance',
|
||||
'Human G-RENDER reference-image approval',
|
||||
'Complete notices/corresponding sources, public license and release distribution decision',
|
||||
],
|
||||
'evidenceSha256': {str(path.relative_to(ROOT)): sha(path) for path in sorted(RESULTS.rglob('*'))
|
||||
if path.is_file() and path.suffix != '.pyc' and path != RESULTS / 'record.json'},
|
||||
}
|
||||
encoded = json.dumps(record, ensure_ascii=False, indent=2) + '\n'
|
||||
(RESULTS / 'record.json').write_text(encoded)
|
||||
(ROOT / 'docs/validation-record.json').write_text(encoded)
|
||||
print(json.dumps({'archCtestGroups': 22, 'ubuntuCtestGroups': 22, 'portalCases': 10,
|
||||
'packageSmokeConditions': 12, 'performanceConditions': 10, 'goalComplete': False}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the packaged viewer through real XDG/GTK file chooser portals.
|
||||
|
||||
Only private Xvfb/Openbox or headless Sway desktops and generated files are
|
||||
used. No mocked portal replies, Qt test hooks, or user desktop are involved.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def sha(path):
|
||||
with Path(path).open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def wait_for(probe, description, seconds=15):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
value = probe()
|
||||
if value: return value
|
||||
time.sleep(.1)
|
||||
raise RuntimeError('Timed out: ' + description)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--platform', choices=['xcb', 'wayland'], required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
out = args.output.resolve()
|
||||
if not args.private_session:
|
||||
if out.exists(): parser.error('Choose a new evidence directory')
|
||||
with tempfile.TemporaryDirectory(prefix='docview-portal-') as temporary:
|
||||
private = Path(temporary)
|
||||
env = {key: value for key, value in os.environ.items() if key in ['HOME', 'PATH', 'USER', 'LOGNAME', 'LANG']}
|
||||
env.update(XDG_RUNTIME_DIR=str(private), XDG_CONFIG_HOME=str(private / 'config'),
|
||||
XDG_STATE_HOME=str(private / 'state'), XDG_CACHE_HOME=str(private / 'cache'),
|
||||
XDG_DATA_HOME=str(private / 'data'), XDG_CURRENT_DESKTOP='DocViewTest',
|
||||
QT_QPA_PLATFORM=args.platform, QT_QPA_PLATFORMTHEME='xdgdesktopportal',
|
||||
QT_USE_PORTAL='1', QT_QUICK_BACKEND='software', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu',
|
||||
QT_SCALE_FACTOR='1', QT_FONT_DPI='96', LANG='C.UTF-8', NO_AT_BRIDGE='1')
|
||||
config = private / 'config/xdg-desktop-portal'; config.mkdir(parents=True)
|
||||
(config / 'portals.conf').write_text('[preferred]\ndefault=gtk;\norg.freedesktop.impl.portal.FileChooser=gtk;\n')
|
||||
bus = private / 'bus.conf'
|
||||
bus.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="*"/>'
|
||||
'<allow eavesdrop="true"/></policy></busconfig>')
|
||||
display = ['xvfb-run', '-a', '-s', '-screen 0 1280x960x24 -dpi 96 -nolisten tcp'] if args.platform == 'xcb' else []
|
||||
return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', *display,
|
||||
sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
(out / 'runner-at-start.py').write_bytes(Path(__file__).read_bytes())
|
||||
env = dict(os.environ, DOCVIEW_PORTAL_CHILD_ID=str(uuid.uuid4()))
|
||||
tag = ('DOCVIEW_PORTAL_CHILD_ID=' + env['DOCVIEW_PORTAL_CHILD_ID']).encode()
|
||||
processes, streams, commands, cases = [], [], [], []
|
||||
identities = {name: sha(Path('/opt/docview/bin') / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']}
|
||||
report = {'success': False, 'platform': args.platform, 'executables': identities,
|
||||
'launcherSha256': sha('/usr/bin/docview'), 'runnerSha256': sha(Path(__file__)),
|
||||
'packageManifestSha256': sha('/opt/docview/share/doc/docview/package-manifest.json'),
|
||||
'scope': 'Actual packaged app and Ubuntu XDG/GTK file chooser on a private virtual desktop; no physical input or full desktop-session certification',
|
||||
'osRelease': Path('/etc/os-release').read_text(), 'cases': cases}
|
||||
|
||||
def run(command, checked=True, timeout=8):
|
||||
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=timeout)
|
||||
commands.append({'command': command, 'exitCode': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr})
|
||||
if checked and result.returncode:
|
||||
raise RuntimeError(str(command) + ': ' + result.stderr)
|
||||
return result
|
||||
|
||||
def start(name, command):
|
||||
stream = (out / (name + '.log')).open('w'); streams.append(stream)
|
||||
process = subprocess.Popen(command, env=env, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
def wayland_keyboard():
|
||||
# wtype constructs its map from all arguments before sending input.
|
||||
# Keep the same keysym-to-keycode mapping across the keeper and every
|
||||
# invocation, including GTK's startup map. Release-only entries declare
|
||||
# keys without typing them; actual input follows this prefix.
|
||||
command = ['wtype']
|
||||
for symbol in ['Escape', 'Return', 'Shift_L', 'Tab'] + [
|
||||
'U%04X' % ord(char) for char in ''.join(chr(n) for n in range(32, 127)) + '日本語選択資料']:
|
||||
command += ['-p', symbol]
|
||||
return command + ['-s', '150']
|
||||
|
||||
def keys(*values):
|
||||
if args.platform == 'xcb':
|
||||
run(['xdotool', 'key', '--clearmodifiers', '--delay', '90', *values])
|
||||
else:
|
||||
command = wayland_keyboard()
|
||||
for value in values:
|
||||
parts = value.split('+'); modifiers = {'ctrl':'ctrl', 'shift':'shift', 'alt':'alt'}
|
||||
for part in parts[:-1]: command += ['-M', modifiers[part.lower()]]
|
||||
command += ['-s', '40', '-P', parts[-1], '-s', '40', '-p', parts[-1]]
|
||||
for part in reversed(parts[:-1]): command += ['-m', modifiers[part.lower()]]
|
||||
command += ['-s', '90']
|
||||
run(command)
|
||||
|
||||
def text(value):
|
||||
if args.platform == 'xcb': run(['xdotool', 'type', '--clearmodifiers', '--delay', '12', value])
|
||||
else: run(wayland_keyboard() + ['-d', '12', value])
|
||||
time.sleep(.3) # Wait for the other process to consume the final key.
|
||||
|
||||
def windows():
|
||||
if args.platform == 'xcb':
|
||||
found = run(['xdotool', 'search', '--onlyvisible', '--name', '.'], checked=False)
|
||||
result = []
|
||||
for identifier in found.stdout.split():
|
||||
owner = run(['xdotool', 'getwindowpid', identifier], checked=False)
|
||||
if owner.returncode: continue
|
||||
title = run(['xdotool', 'getwindowname', identifier]).stdout.strip()
|
||||
result.append({'id': identifier, 'pid': int(owner.stdout), 'name': title})
|
||||
return result
|
||||
tree = json.loads(run(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_tree']).stdout)
|
||||
result = []
|
||||
def visit(node):
|
||||
if node.get('pid'): result.append({key: node.get(key) for key in ['id', 'pid', 'name', 'app_id', 'focused']})
|
||||
for child in node.get('nodes', []) + node.get('floating_nodes', []): visit(child)
|
||||
visit(tree)
|
||||
return result
|
||||
|
||||
def focused(pid):
|
||||
if args.platform == 'xcb':
|
||||
value = run(['xdotool', 'getwindowfocus', 'getwindowpid'], checked=False)
|
||||
return value.returncode == 0 and value.stdout.strip() == str(pid)
|
||||
return any(row['pid'] == pid and row.get('focused') for row in windows())
|
||||
|
||||
def capture(name):
|
||||
command = ['scrot', str(out / (name + '.png'))] if args.platform == 'xcb' else ['grim', '-o', 'HEADLESS-1', str(out / (name + '.png'))]
|
||||
run(command)
|
||||
|
||||
state_file = Path(env['XDG_STATE_HOME']) / 'docview/state.json'
|
||||
def state_entry(path):
|
||||
try:
|
||||
value = json.loads(state_file.read_text())
|
||||
return next((row for row in value['documents'] if row['identity']['canonicalPath'] == str(path) and row['location']), None)
|
||||
except (OSError, ValueError): return None
|
||||
|
||||
try:
|
||||
report['packages'] = run(['dpkg-query', '-W', 'docview', 'xdg-desktop-portal', 'xdg-desktop-portal-gtk',
|
||||
'openbox', 'xdotool', 'sway', 'wtype', 'grim', 'scrot']).stdout
|
||||
if args.platform == 'xcb':
|
||||
env['GDK_BACKEND'] = 'x11'
|
||||
start('openbox', ['openbox', '--sm-disable'])
|
||||
wait_for(lambda: run(['xprop', '-root', '_NET_SUPPORTING_WM_CHECK'], checked=False).stdout.find('window id') >= 0, 'X11 window manager')
|
||||
else:
|
||||
runtime = Path(env['XDG_RUNTIME_DIR']); config = runtime / 'sway.conf'
|
||||
config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\noutput HEADLESS-1 mode 1280x960\n'
|
||||
'for_window [app_id=".*"] floating enable\ndefault_border none\ndefault_floating_border none\n'
|
||||
'focus_follows_mouse no\nmouse_warping none\n')
|
||||
env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1', GDK_BACKEND='wayland', XDG_SESSION_TYPE='wayland')
|
||||
start('sway', ['sway', '-c', str(config)])
|
||||
wait_for(lambda: list(runtime.glob('sway-ipc.*.sock')) and any(p.is_socket() for p in runtime.glob('wayland-*')), 'Wayland compositor')
|
||||
env['WAYLAND_DISPLAY'] = next(p.name for p in runtime.glob('wayland-*') if p.is_socket())
|
||||
env['SWAYSOCK'] = str(next(runtime.glob('sway-ipc.*.sock')))
|
||||
start('keyboard', wayland_keyboard() + ['-s', '300000', '-k', 'Shift_L'])
|
||||
wait_for(lambda: any(row.get('type') == 'keyboard' for row in json.loads(run(['swaymsg', '-t', 'get_inputs']).stdout)), 'persistent virtual keyboard')
|
||||
start('dconf', ['/usr/libexec/dconf-service'])
|
||||
start('permission-store', ['/usr/libexec/xdg-permission-store'])
|
||||
start('documents', ['/usr/libexec/xdg-document-portal'])
|
||||
backend = start('gtk-portal', ['/usr/libexec/xdg-desktop-portal-gtk'])
|
||||
start('desktop-portal', ['/usr/libexec/xdg-desktop-portal', '--verbose'])
|
||||
query = ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', '--object-path', '/org/freedesktop/portal/desktop',
|
||||
'--method', 'org.freedesktop.DBus.Properties.Get', 'org.freedesktop.portal.FileChooser', 'version']
|
||||
wait_for(lambda: run(query, checked=False, timeout=3).returncode == 0, 'real file chooser portal', 30)
|
||||
report['portalVersion'] = run(query).stdout.strip()
|
||||
monitor = start('portal-messages', ['stdbuf', '-oL', '-eL', 'dbus-monitor', '--session', "interface='org.freedesktop.portal.FileChooser'",
|
||||
"interface='org.freedesktop.portal.Request'"])
|
||||
documents = out / '選択 資料'; documents.mkdir()
|
||||
shutil.copytree(ROOT / 'tests/fixtures/web/html', documents / 'web')
|
||||
selections = []
|
||||
for kind, source, destination in [
|
||||
('pdf', ROOT / 'tests/fixtures/pdf/extended/japanese-layout.pdf', documents / '日本語 j k 123 #%.pdf'),
|
||||
('html', documents / 'web/index.html', documents / 'web/日本語 j k 123 #%.html'),
|
||||
('htmlzip', ROOT / 'tests/fixtures/web/html-book.zip', documents / '日本語 j k 123 #%.zip'),
|
||||
('epub', ROOT / 'tests/fixtures/web/reflow-rtl.epub', documents / '日本語 j k 123 #%.epub')]:
|
||||
shutil.copyfile(source, destination)
|
||||
selections.append((kind, destination, sha(destination)))
|
||||
report['documents'] = [{'format': kind, 'path': str(path), 'sha256': digest} for kind, path, digest in selections]
|
||||
config = Path(env['XDG_CONFIG_HOME']) / 'docview/config.toml'; config.parent.mkdir(parents=True)
|
||||
config.write_text('schema_version=1\n[privacy]\nremember_position=true\nrecent_documents=10\n')
|
||||
app = start('docview', ['/usr/bin/docview', '--config', str(config)])
|
||||
wait_for(lambda: any(row['pid'] == app.pid for row in windows()) and focused(app.pid), 'initial viewer focus')
|
||||
time.sleep(.5) # Qt queries the portal version asynchronously at startup.
|
||||
# Exercise cancellation through the real GTK window before any document opens.
|
||||
keys('ctrl+o')
|
||||
popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK cancel dialog')
|
||||
wait_for(lambda: focused(backend.pid), 'GTK dialog focus')
|
||||
keys('ctrl+l'); text('j k 123 ?'); capture('cancel-dialog')
|
||||
escape_count = 0
|
||||
# GTK can consume the first Escape to dismiss its completion popup.
|
||||
for _ in range(3):
|
||||
keys('Escape'); escape_count += 1; time.sleep(.3)
|
||||
if not any(row['pid'] == backend.pid for row in windows()): break
|
||||
wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'focus restored after cancel')
|
||||
assert not state_file.exists() or not json.loads(state_file.read_text())['documents']
|
||||
cases.append({'case': 'cancel', 'backendWindow': popup, 'focusRestored': True,
|
||||
'documentOpened': False, 'escapeKeyCount': escape_count})
|
||||
for kind, path, digest in selections:
|
||||
keys('ctrl+o')
|
||||
popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK ' + kind + ' dialog')
|
||||
wait_for(lambda: focused(backend.pid), 'GTK file entry focus')
|
||||
keys('ctrl+l'); text(str(path)); capture(kind + '-dialog')
|
||||
keys('Return')
|
||||
wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'viewer focus after ' + kind)
|
||||
entry = wait_for(lambda: state_entry(path), 'presented and persisted ' + kind, 20)
|
||||
title = wait_for(lambda: next((row['name'] for row in windows() if row['pid'] == app.pid and path.name in row['name']), None), kind + ' window title')
|
||||
if kind == 'pdf':
|
||||
assert entry['location']['kind'] == 'pdf'
|
||||
text('G')
|
||||
entry = wait_for(lambda: (value if (value := state_entry(path)) and value['location'].get('pageIndex') == 1 else None), 'PDF keyboard navigation after dialog', 15)
|
||||
else:
|
||||
assert entry['location'].get('resourcePath')
|
||||
if kind == 'epub': assert entry['location'].get('spineIdref')
|
||||
capture(kind + '-opened')
|
||||
assert sha(path) == digest
|
||||
cases.append({'case': kind, 'backendWindow': popup, 'windowTitle': title, 'savedPresentedLocation': entry['location'],
|
||||
'focusRestored': True, 'originalUnchanged': True})
|
||||
text(':q'); keys('Return'); app.wait(timeout=10)
|
||||
assert app.returncode == 0
|
||||
time.sleep(.2)
|
||||
messages = (out / 'portal-messages.log').read_text()
|
||||
responses = re.findall(r'interface=org.freedesktop.portal.Request; member=Response\n\s+uint32 (\d+)', messages)
|
||||
assert len(re.findall(r'interface=org.freedesktop.portal.FileChooser; member=OpenFile', messages)) == 5
|
||||
# GTK 1.15.1 maps Escape's GTK_RESPONSE_DELETE_EVENT to portal code 2;
|
||||
# code 1 is reserved for its explicit Cancel button. Both reject in Qt.
|
||||
# See upstream src/filechooser.c:file_chooser_response.
|
||||
assert responses == ['2', '0', '0', '0', '0'], responses
|
||||
for _, path, _ in selections: assert path.as_uri() in messages, str(path)
|
||||
report['portalResponses'] = responses
|
||||
report['actualGtkBackendPid'] = backend.pid
|
||||
report['applicationExitCode'] = app.returncode
|
||||
report['success'] = True
|
||||
except Exception as error:
|
||||
report['error'] = str(error)
|
||||
if 'app' in locals() and app.poll() is None:
|
||||
try: capture('failure')
|
||||
except Exception as capture_error: report['failureCaptureError'] = str(capture_error)
|
||||
finally:
|
||||
report['commands'] = commands
|
||||
for process in reversed(processes):
|
||||
if process.poll() is None:
|
||||
try: os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
for process in reversed(processes):
|
||||
try: process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
try: os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
process.wait(timeout=3)
|
||||
for stream in streams: stream.close()
|
||||
remaining = []
|
||||
for directory in Path('/proc').glob('[0-9]*'):
|
||||
try:
|
||||
if int(directory.name) != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'):
|
||||
remaining.append(int(directory.name))
|
||||
except (OSError, ValueError): pass
|
||||
report['remainingTaggedChildren'] = remaining
|
||||
for pid in remaining:
|
||||
try: os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
report['executablesUnchanged'] = all(sha(Path('/opt/docview/bin') / name) == digest for name, digest in identities.items())
|
||||
report['success'] = report['success'] and report['executablesUnchanged'] and not remaining
|
||||
if state_file.exists(): shutil.copyfile(state_file, out / 'generated-document-state.json')
|
||||
(out / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
||||
print(json.dumps({key: report.get(key) for key in ['success', 'platform', 'error', 'portalResponses']}))
|
||||
return 0 if report['success'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repeat the existing ten-condition workload for the current production build."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def sha(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
|
||||
(out / 'runner-at-start.py').write_bytes(Path(__file__).read_bytes())
|
||||
env = dict(os.environ)
|
||||
for name in ('DISPLAY', 'WAYLAND_DISPLAY', 'QT_SCREEN_SCALE_FACTORS', 'QT_STYLE_OVERRIDE', 'QT_SCALE_FACTOR_ROUNDING_POLICY'):
|
||||
env.pop(name, None)
|
||||
env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu',
|
||||
QT_SCALE_FACTOR='1', QT_FONT_DPI='96', QT_AUTO_SCREEN_SCALE_FACTOR='0')
|
||||
record = {'success': False, 'scope': 'Arch Xvfb 1100x760 DPR1/software; OS file cache retained; development measurements, not reference hardware acceptance',
|
||||
'binaries': {name: sha(ROOT / 'build' / name) for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker', 'test_app')},
|
||||
'runnerSha256': sha(Path(__file__)), 'commands': [],
|
||||
'displayEnvironment': {name: env[name] for name in ('QT_QPA_PLATFORM', 'QT_QUICK_BACKEND', 'QTWEBENGINE_CHROMIUM_FLAGS', 'QT_SCALE_FACTOR', 'QT_FONT_DPI', 'QT_AUTO_SCREEN_SCALE_FACTOR')}}
|
||||
jobs = [(corpus, ['python3', 'tests/benchmark.py', '--binary', 'build/docview', '--corpus', corpus,
|
||||
'--operations', '125', '--cold-process-runs', '30', '--output', str(out / corpus)])
|
||||
for corpus in ('smoke', 'standard', 'fonts')]
|
||||
jobs += [('interaction', ['python3', 'tests/measure_interaction.py', '--binary', 'build/test_app', '--output', str(out / 'interaction')]),
|
||||
('stress', ['python3', 'tests/run_stress_pdf.py', '--binary', 'build/docview', '--output', str(out / 'stress')])]
|
||||
try:
|
||||
for name, command in jobs:
|
||||
wrapper = ['dbus-run-session', '--', 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp']
|
||||
started = time.monotonic()
|
||||
with (out / (name + '.log')).open('w') as log:
|
||||
result = subprocess.run(wrapper + command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1500)
|
||||
row = {'name': name, 'command': wrapper + command, 'exitCode': result.returncode, 'seconds': time.monotonic() - started,
|
||||
'measurementRunnerSha256': sha(ROOT / command[1])}
|
||||
record['commands'].append(row)
|
||||
(out / 'progress.json').write_text(json.dumps(record, indent=2) + '\n')
|
||||
print(json.dumps(row), flush=True)
|
||||
if result.returncode: raise RuntimeError('Failed workload: ' + name)
|
||||
assert all(sha(ROOT / 'build' / name) == digest for name, digest in record['binaries'].items())
|
||||
record['success'] = True
|
||||
finally:
|
||||
(out / 'report.json').write_text(json.dumps(record, indent=2) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check and summarize all ten completed URL-fix measurements."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT = ROOT / 'tests/results/portal/performance-suite'
|
||||
|
||||
|
||||
def sha(path):
|
||||
with path.open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
run = json.loads((OUT / 'report.json').read_text())
|
||||
assert run['success'] and len(run['commands']) == 5 and all(row['exitCode'] == 0 for row in run['commands'])
|
||||
assert run['runnerSha256'] == sha(OUT / 'runner-at-start.py')
|
||||
binaries = {name: sha(ROOT / 'build' / name) for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker')}
|
||||
assert all(run['binaries'][name] == value for name, value in binaries.items())
|
||||
rows, operation_count = [], 0
|
||||
for corpus, formats in [('smoke', ['pdf', 'html', 'zip', 'epub']),
|
||||
('standard', ['pdf', 'pdf-headings', 'html', 'zip', 'epub']), ('fonts', ['pdf'])]:
|
||||
for kind in formats:
|
||||
relative = corpus + '/' + kind + '.json'
|
||||
value = json.loads((OUT / relative).read_text())
|
||||
assert value['success'] and value['originalUnchanged'] and all(value['measurementChecks'].values())
|
||||
assert value['executableSha256'] == binaries and value['runnerSha256'] == sha(ROOT / 'tests/benchmark.py')
|
||||
assert len(value['openFrameMs']) == 30 and value['coldProcessOpen']['requested'] == 30 and value['coldProcessOpen']['failed'] == 0
|
||||
assert value['summary']['uniform_open_close_cycles'] == 29 and value['summary']['all_closes_empty']
|
||||
display = value['display']
|
||||
assert display['screenLogicalWidth'] == 1100 and display['screenLogicalHeight'] == 760 and display['windowDevicePixelRatio'] == 1
|
||||
cold = list((OUT / corpus / 'cold' / kind).glob('[0-9][0-9][0-9].json'))
|
||||
assert len(cold) == 30
|
||||
for path in cold:
|
||||
sample = json.loads(path.read_text())
|
||||
assert sample['success'] and sample['documentSha256'] == value['documentSha256']
|
||||
assert sample['display'] == display and sample['maximumMemoryPressureLevel'] == 0
|
||||
operation_count += len(value['operations'])
|
||||
summary = value['summary']
|
||||
exceeded = [name for name, observation in value['proposedBudgetObservations'].items()
|
||||
if observation is False or isinstance(observation, dict) and observation.get('observed_met') is False]
|
||||
rows.append({'corpus': corpus, 'format': kind, 'record': relative, 'recordSha256': sha(OUT / relative),
|
||||
'coldOpenP95Ms': summary['fresh_process_fresh_app_cache_open']['p95_ms'],
|
||||
'responseP95Ms': summary['response']['p95_ms'], 'finalQualityP95Ms': summary['final_quality']['p95_ms'],
|
||||
'scrollP95Ms': summary['continuous_scroll_frames']['p95_ms'],
|
||||
'processGroupPeakMiB': value['processGroupRssPeakBytes'] / 1024**2,
|
||||
'budgetObservationsExceeded': exceeded, 'navigationCoverage': value['navigationCoverage']})
|
||||
interaction = json.loads((OUT / 'interaction/record.json').read_text())
|
||||
stress = json.loads((OUT / 'stress/report.json').read_text())
|
||||
measured = {row['path']: row['sha256'] for row in interaction['binaries']}
|
||||
assert interaction['success'] and measured['test_app'] == sha(ROOT / 'build/test_app')
|
||||
assert all(measured[name] == digest for name, digest in binaries.items())
|
||||
assert stress['success'] and stress['originalUnchanged'] and stress['sourceRemoved']
|
||||
assert stress['executableSha256'] == binaries and stress['executablesUnchanged']
|
||||
result = {'success': True, 'buildSha256': binaries, 'cases': rows, 'coldOpens': 300, 'sameProcessOpens': 300,
|
||||
'summaryGeneratorSha256': sha(Path(__file__)),
|
||||
'navigationOperations': operation_count, 'scope': run['scope'],
|
||||
'interactionRecordSha256': sha(OUT / 'interaction/record.json'), 'stressRecordSha256': sha(OUT / 'stress/report.json'),
|
||||
'proposedBudgetExceedances': sum(len(row['budgetObservationsExceeded']) for row in rows)}
|
||||
(OUT / 'summary.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
|
||||
lines = ['# URL比較修正後の性能測定', '',
|
||||
'10条件で新規プロセス・空のXDGアプリキャッシュ30回、同一プロセス30回、適用される移動系列125操作、連続scroll240入力を実施した。原本不変、全closeの状態解放、メモリ圧迫なしを確認した。', '',
|
||||
'ArchのXvfb 1100×760、DPR1、software描画。OSファイルキャッシュは保持。起動後のopenを起点とし、プロセス起動時間を含めない。Qt提示通知の測定で、Webの対象画素や物理scanoutの直接測定ではない。基準機・対象OSの性能受入とは区別する。', '',
|
||||
'| 資料 | 形式 | 冷open p95 ms | 応答 p95 ms | 最終品質 p95 ms | scroll p95 ms | 群RSS最大 MiB |',
|
||||
'|---|---|---:|---:|---:|---:|---:|']
|
||||
for row in rows:
|
||||
lines.append(f"| {row['corpus']} | [{row['format']}]({row['record']}) | {row['coldOpenP95Ms']:.2f} | {row['responseP95Ms']:.2f} | {row['finalQualityP95Ms']:.2f} | {row['scrollP95Ms']:.2f} | {row['processGroupPeakMiB']:.2f} |")
|
||||
lines += ['', f"新規open 300回・同一プロセスopen 300回・移動系列{operation_count}操作。暫定予算の参考超過は{result['proposedBudgetExceedances']}件。元の予算値は変更していない。未測定の系列は各JSONのnavigationCoverageで区別する。", '',
|
||||
'[実行コマンド](report.json)、[機械集計](summary.json)、[操作負荷](interaction/record.json)、[約1GB・5,000ページPDF](stress/report.json)を保存した。重いPDFは3open・100操作の追加負荷であり、30回の性能受入標本ではない。', '']
|
||||
(OUT / 'README.md').write_text('\n'.join(lines))
|
||||
print(json.dumps({key: result[key] for key in ('success', 'coldOpens', 'sameProcessOpens', 'navigationOperations', 'proposedBudgetExceedances')}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user