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
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Exercise production Qt input controls through IBus/Mozc on private X11 or Wayland."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
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 f:
return hashlib.file_digest(f, 'sha256').hexdigest()
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--binary', type=Path, required=True)
p.add_argument('--canonical-build', type=Path, required=True)
p.add_argument('--output', type=Path, required=True)
p.add_argument('--platform', choices=['xcb', 'wayland'], default='xcb')
p.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS)
a = p.parse_args()
if a.output.exists():
p.error('Choose a new output directory')
if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or 'sandbox' in os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', ''):
p.error('Sandbox overrides are not permitted')
if not a.private_session:
with tempfile.TemporaryDirectory(prefix='docview-ime-') as private:
env = os.environ.copy()
for key in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'SWAYSOCK', 'I3SOCK', 'DBUS_SESSION_BUS_ADDRESS', 'IBUS_ADDRESS',
'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', 'QT_QPA_PLATFORMTHEME']:
env.pop(key, None)
env.update(XDG_RUNTIME_DIR=private, XDG_CONFIG_HOME=private + '/config', XDG_STATE_HOME=private + '/state',
XDG_CACHE_HOME=private + '/cache', XDG_DATA_HOME=private + '/data',
QT_QPA_PLATFORM=a.platform, DOCVIEW_IME_PLATFORM=a.platform,
QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1', QT_FONT_DPI='96',
QT_IM_MODULE='ibus', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', DOCVIEW_PRIVATE_IME='1',
DOCVIEW_IME_RUN_ID=str(uuid.uuid4()))
bus = Path(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="*"/></policy></busconfig>')
display = ['xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp'] if a.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 = a.output.resolve(); out.mkdir(parents=True, exist_ok=False)
binary, build = a.binary.resolve(), a.canonical_build.resolve()
paths = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker',
'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']]
paths += [ROOT / 'tests/fixtures/pdf/navigation.pdf', ROOT / 'resources.qrc']
paths += [p for folder in ['src', 'qml', 'resources', 'tests/ime'] for p in (ROOT / folder).rglob('*')
if p.is_file() and '__pycache__' not in p.parts]
before = {os.path.relpath(path, ROOT): sha(path) for path in sorted(set(paths))}
report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(),
'inputs': before, 'requestedPlatform': a.platform,
'scope': ('Actual IBus/Mozc through virtual XTest keys and US/JP XKB on private X11' if a.platform == 'xcb'
else 'Actual Qt Wayland and IBus/Mozc over D-Bus; private Sway and wtype virtual-keyboard keysyms, not physical US/JP mappings or compositor text-input protocol')
+ '; no physical keyboard, candidate-panel appearance or Windows acceptance'}
address = 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/ibus.sock'
env = os.environ.copy(); env.update(DOCVIEW_IME_CHILD_ID=str(uuid.uuid4()), IBUS_ADDRESS=address, DOCVIEW_IME_OUTPUT=str(out),
DOCVIEW_IME_PDF=str(ROOT / 'tests/fixtures/pdf/navigation.pdf'), DOCVIEW_IME_RAW_KEY=str(ROOT / 'tests/ime/raw_key.py'))
daemon = compositor = keyboard_keeper = None
tag = ('DOCVIEW_IME_CHILD_ID=' + env['DOCVIEW_IME_CHILD_ID']).encode()
def owned():
found = []
for directory in Path('/proc').glob('[0-9]*'):
try:
pid = int(directory.name)
if pid != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'):
found.append(pid)
except (OSError, ValueError):
pass
return found
try:
for name in ['docview-pdf-worker', 'docview-archive-worker']:
assert sha(binary.parent / name) == sha(build / name)
report['packages'] = subprocess.check_output(['dpkg-query', '-W', 'ibus', 'ibus-mozc', 'mozc-server', 'xdotool', 'libxtst6', 'xkb-data', 'sway', 'wtype'], text=True)
if a.platform == 'wayland':
runtime = Path(env['XDG_RUNTIME_DIR'])
config = runtime / 'sway.conf'
config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\n'
'output HEADLESS-1 mode 1920x1080\nfor_window [app_id=".*"] floating enable\n'
'default_border none\ndefault_floating_border none\nfocus_follows_mouse no\nmouse_warping none\n')
(out / 'sway.conf').write_bytes(config.read_bytes())
env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1',
XDG_CURRENT_DESKTOP='sway', XDG_SESSION_TYPE='wayland', QT_USE_PORTAL='0')
with (out / 'compositor.log').open('w') as log:
compositor = subprocess.Popen(['sway', '-c', str(config)], env=env, stdout=log, stderr=subprocess.STDOUT)
for _ in range(100):
sockets = [p for p in runtime.glob('wayland-*') if p.is_socket()]
ipc = list(runtime.glob('sway-ipc.*.sock'))
if sockets and ipc or compositor.poll() is not None:
break
time.sleep(.1)
if not sockets or not ipc or compositor.poll() is not None:
raise RuntimeError('Private Sway did not become ready')
env.update(WAYLAND_DISPLAY=sockets[0].name, SWAYSOCK=str(ipc[0]))
report['waylandOutputs'] = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_outputs'], env=env))
# Keep a virtual keyboard attached throughout the test. Otherwise
# each short wtype process disconnects the seat's only keyboard,
# clears Qt's active focus and cancels an in-progress composition.
# The deferred key is never reached: test timeout is 180 seconds,
# and cleanup terminates this uniquely tagged child first.
with (out / 'keyboard-keeper.log').open('w') as log:
keyboard_keeper = subprocess.Popen(['wtype', '-s', '300000', '-k', 'Shift_L'], env=env,
stdout=log, stderr=subprocess.STDOUT)
for _ in range(100):
inputs = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_inputs'], env=env))
if any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None:
break
time.sleep(.05)
if not any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None:
raise RuntimeError('Persistent virtual keyboard did not attach')
report['waylandInputsBeforeApplication'] = inputs
with (out / 'ibus.log').open('w') as log:
daemon = subprocess.Popen(['ibus-daemon', '--address', address, '--panel', 'disable', '--config', 'disable',
'--emoji-extension', 'disable', '--cache', 'none'], env=env, stdout=log, stderr=subprocess.STDOUT)
ready = False
for _ in range(50):
query = subprocess.run(['ibus', 'list-engine'], env=env, capture_output=True, text=True, timeout=5)
if query.returncode == 0 and 'mozc-jp' in query.stdout:
(out / 'engines.txt').write_text(query.stdout); ready = True; break
if daemon.poll() is not None:
break
time.sleep(.1)
if not ready:
raise RuntimeError('IBus/Mozc did not become ready')
# Launch the installed engine explicitly: this session deliberately
# has no ambient D-Bus service activation.
with (out / 'mozc.log').open('w') as mozc_log:
mozc = subprocess.Popen(['/usr/lib/ibus-mozc/ibus-engine-mozc', '--ibus'], env=env, stdout=mozc_log, stderr=subprocess.STDOUT)
time.sleep(.5)
if mozc.poll() is not None:
raise RuntimeError(f'Mozc engine exited: {mozc.returncode}')
probe = subprocess.run(['gdbus', 'call', '--address', address, '--dest', 'org.freedesktop.IBus', '--object-path', '/org/freedesktop/IBus', '--method', 'org.freedesktop.IBus.SetGlobalEngine', 'mozc-jp'], env=env, capture_output=True, text=True, timeout=20)
(out / 'engine-probe.txt').write_text(probe.stdout + probe.stderr)
with (out / 'test.log').open('w') as testlog:
test = subprocess.run([str(binary)], env=env, stdout=testlog, stderr=subprocess.STDOUT, timeout=180)
report['testExitCode'] = test.returncode
layouts = ['us', 'jp'] if a.platform == 'xcb' else ['virtual-keysyms']
report['cases'] = {layout: json.loads((out / f'{layout}.json').read_text()) for layout in layouts}
report['success'] = test.returncode == 0 and all(case['success'] and case['qtPlatform'] == a.platform for case in report['cases'].values())
except Exception as error:
report['error'] = str(error)
finally:
if daemon:
try:
subprocess.run(['ibus', 'exit'], env=env, capture_output=True, timeout=5)
except subprocess.TimeoutExpired:
pass
for sig in [signal.SIGTERM, signal.SIGKILL]:
for pid in owned():
try:
os.kill(pid, sig)
except ProcessLookupError:
pass
time.sleep(.2)
if daemon:
daemon.wait(timeout=5)
if compositor:
compositor.wait(timeout=5)
if keyboard_keeper:
keyboard_keeper.wait(timeout=5)
report['remainingOwnedProcesses'] = owned()
report['inputsUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items())
report['success'] &= report['inputsUnchanged'] and not report['remainingOwnedProcesses']
(out / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({k: report.get(k) for k in ['success', 'testExitCode', 'error', 'inputsUnchanged', 'remainingOwnedProcesses']}))
return 0 if report['success'] else 1
if __name__ == '__main__':
raise SystemExit(main())