initial commit
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user