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:
104
tools/vm/vncshot.py
Executable file
104
tools/vm/vncshot.py
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grab one full framebuffer from a no-auth VNC server and save as PNG.
|
||||
|
||||
Why VNC and not QMP screendump: with virtio-vga-gl the guest renders
|
||||
into a GL scanout and screendump fails with "no surface"; QEMU's VNC
|
||||
server reads the GL framebuffer back, so this always works. Pair the VM
|
||||
with `-display egl-headless -vnc 127.0.0.1:17`.
|
||||
|
||||
Stdlib only (manual RFB handshake + raw-encoding framebuffer + PNG
|
||||
writer) so it runs anywhere python3 exists.
|
||||
|
||||
Usage: vncshot.py <out.png> [host] [port] # default 127.0.0.1:5917
|
||||
"""
|
||||
import socket, struct, sys, zlib
|
||||
|
||||
def recv_exact(s, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = s.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise EOFError("server closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
def png_write(path, w, h, rgb):
|
||||
def chunk(tag, data):
|
||||
c = struct.pack(">I", len(data)) + tag + data
|
||||
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
out = (b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 6))
|
||||
+ chunk(b"IEND", b""))
|
||||
with open(path, "wb") as f:
|
||||
f.write(out)
|
||||
|
||||
def main():
|
||||
out = sys.argv[1]
|
||||
host = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1"
|
||||
port = int(sys.argv[3]) if len(sys.argv) > 3 else 5917
|
||||
s = socket.create_connection((host, port), timeout=30)
|
||||
s.settimeout(30)
|
||||
|
||||
ver = recv_exact(s, 12) # "RFB 003.008\n"
|
||||
s.sendall(b"RFB 003.008\n")
|
||||
ntypes = recv_exact(s, 1)[0]
|
||||
types = recv_exact(s, ntypes)
|
||||
if 1 not in types:
|
||||
sys.exit(f"no 'None' security type offered: {list(types)}")
|
||||
s.sendall(b"\x01")
|
||||
res = struct.unpack(">I", recv_exact(s, 4))[0]
|
||||
if res != 0:
|
||||
sys.exit("security handshake failed")
|
||||
s.sendall(b"\x01") # ClientInit: shared
|
||||
w, h = struct.unpack(">HH", recv_exact(s, 4))
|
||||
recv_exact(s, 16) # server pixel format (ignored)
|
||||
namelen = struct.unpack(">I", recv_exact(s, 4))[0]
|
||||
recv_exact(s, namelen)
|
||||
|
||||
# SetPixelFormat: 32bpp truecolor, shifts r=16 g=8 b=0 (little-endian BGRX)
|
||||
pf = struct.pack(">BBBBHHHBBBxxx", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0)
|
||||
s.sendall(b"\x00\x00\x00\x00" + pf)
|
||||
s.sendall(struct.pack(">BxH i", 2, 1, 0)) # SetEncodings: Raw only
|
||||
s.sendall(struct.pack(">BBHHHH", 3, 0, 0, 0, w, h)) # full update request
|
||||
|
||||
fb = bytearray(w * h * 4)
|
||||
got = False
|
||||
while not got:
|
||||
mtype = recv_exact(s, 1)[0]
|
||||
if mtype == 0: # FramebufferUpdate
|
||||
recv_exact(s, 1)
|
||||
nrects = struct.unpack(">H", recv_exact(s, 2))[0]
|
||||
for _ in range(nrects):
|
||||
x, y, rw, rh = struct.unpack(">HHHH", recv_exact(s, 8))
|
||||
enc = struct.unpack(">i", recv_exact(s, 4))[0]
|
||||
if enc == 0:
|
||||
data = recv_exact(s, rw * rh * 4)
|
||||
for row in range(rh):
|
||||
off = ((y + row) * w + x) * 4
|
||||
fb[off:off + rw * 4] = data[row * rw * 4:(row + 1) * rw * 4]
|
||||
elif enc == -224: # LastRect
|
||||
break
|
||||
else:
|
||||
sys.exit(f"unsupported encoding {enc}")
|
||||
got = True
|
||||
elif mtype == 2: # Bell
|
||||
pass
|
||||
elif mtype == 3: # ServerCutText
|
||||
recv_exact(s, 3)
|
||||
ln = struct.unpack(">I", recv_exact(s, 4))[0]
|
||||
recv_exact(s, ln)
|
||||
else:
|
||||
sys.exit(f"unexpected server message {mtype}")
|
||||
|
||||
rgb = bytearray(w * h * 3)
|
||||
for i in range(w * h):
|
||||
rgb[i * 3 + 0] = fb[i * 4 + 2]
|
||||
rgb[i * 3 + 1] = fb[i * 4 + 1]
|
||||
rgb[i * 3 + 2] = fb[i * 4 + 0]
|
||||
png_write(out, w, h, bytes(rgb))
|
||||
print(f"saved {out} ({w}x{h})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user