Files
Nomarchy/tools/vm/qmp.py
Bernardo Magri a50e9793ea feat(tools): commit the VM test harness + one-command installer regression
Promotes the throwaway harness that verified the installer into tools/:
- tools/vm/qmp.py        QMP keystroke injection / typing / quit
- tools/vm/vncshot.py    GL-safe screenshots via VNC readback (QMP
                         screendump shows "no surface" with virtio-vga-gl)
- tools/vm/gap-analysis.py  drv-graph diff that converged the offline
                         pin set; run it when offline installs build
                         from source
- tools/test-install.sh  the full offline regression: build ISO, boot
                         offline, unattended LUKS+swap install via a
                         config disk (typed long commands drop keys),
                         wait for poweroff, boot the installed disk,
                         screenshot the first boot for visual verdict

docs/TESTING.md §4 now points at the script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:05:13 +01:00

106 lines
3.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Minimal QMP driver for the Nomarchy test VMs.
Drives a QEMU instance through its QMP socket: inject keystrokes, type
text, take screendumps, power off. Used by tools/test-install.sh; also
handy interactively while debugging a VM that has no SSH.
Usage:
qmp.py key <combo> # e.g. meta_l-ret, ctrl-alt-f2, ret
qmp.py type <text...> # types literal text (US layout)
qmp.py screenshot <out.png> # NOTE: with virtio-vga-gl this fails
# ("no surface") once the guest uses GL —
# use vncshot.py instead for desktops
qmp.py quit # hard power-off
The socket path comes from $NOMARCHY_QMP_SOCK
(default /tmp/nomarchy-vm/qmp.sock).
Keystroke pacing: the guest drops keys when busy (especially during
boot). $NOMARCHY_TYPE_DELAY (seconds between keys, default 0.08) trades
speed for reliability; verify long typed lines with a screenshot before
pressing enter.
"""
import json
import os
import socket
import sys
import time
SOCK = os.environ.get("NOMARCHY_QMP_SOCK", "/tmp/nomarchy-vm/qmp.sock")
TYPE_DELAY = float(os.environ.get("NOMARCHY_TYPE_DELAY", "0.08"))
CHAR_KEYS = {
" ": "spc", "-": "minus", "=": "equal", "[": "bracket_left",
"]": "bracket_right", ";": "semicolon", "'": "apostrophe",
"`": "grave_accent", "\\": "backslash", ",": "comma", ".": "dot",
"/": "slash", "\n": "ret", "\t": "tab",
}
SHIFT_CHARS = {
"!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
"&": "7", "*": "8", "(": "9", ")": "0", "_": "minus", "+": "equal",
"{": "bracket_left", "}": "bracket_right", ":": "semicolon",
'"': "apostrophe", "~": "grave_accent", "|": "backslash",
"<": "comma", ">": "dot", "?": "slash",
}
class QMP:
def __init__(self):
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.connect(SOCK)
self.f = self.s.makefile("rw")
self._read() # greeting
self.cmd("qmp_capabilities")
def _read(self):
while True:
msg = json.loads(self.f.readline())
if "event" in msg:
continue
return msg
def cmd(self, name, **args):
self.f.write(json.dumps({"execute": name, "arguments": args}) + "\n")
self.f.flush()
resp = self._read()
if "error" in resp:
raise RuntimeError(f"{name}: {resp['error']}")
return resp
def send_keys(self, names, hold=40):
keys = [{"type": "qcode", "data": n} for n in names]
self.cmd("send-key", keys=keys, **{"hold-time": hold})
def type_text(self, text):
for ch in text:
if ch.isalpha() and ch.lower() != ch:
self.send_keys(["shift", ch.lower()])
elif ch in SHIFT_CHARS:
self.send_keys(["shift", SHIFT_CHARS[ch]])
elif ch in CHAR_KEYS:
self.send_keys([CHAR_KEYS[ch]])
else:
self.send_keys([ch])
time.sleep(TYPE_DELAY)
def main():
q = QMP()
cmd = sys.argv[1]
if cmd == "screenshot":
q.cmd("screendump", filename=sys.argv[2], format="png")
elif cmd == "key":
q.send_keys(sys.argv[2].split("-"))
elif cmd == "type":
q.type_text(" ".join(sys.argv[2:]))
elif cmd == "quit":
q.f.write(json.dumps({"execute": "quit"}) + "\n")
q.f.flush()
else:
sys.exit(f"unknown command {cmd}")
if __name__ == "__main__":
main()