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>
This commit is contained in:
105
tools/vm/qmp.py
Executable file
105
tools/vm/qmp.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user