#!/usr/bin/env python3 """Offline tests for qmp.py's drive(): the QMP socket and the control channel are faked, so this needs no VM and runs in a few seconds -- most of that is test_mismatches_are_fails_not_stops deliberately waiting out two real one-second timeouts to prove wait_json/wait_hit honour them. What is worth pinning is the contract the docstring makes: every step gets a results.jsonl row of ok / fail / fatal and the run CONTINUES, so one run reports every broken expectation. The region verbs are where that was once false (flare-edge issue #147): a reference whose box did not fit the screendump raised out of drive() as a traceback, and a tolerance on the line was never compared with the one the reference was captured under. python3 test_qmp_drive.py """ import json import os import socket import subprocess import sys import tempfile import threading import time import unittest from types import SimpleNamespace HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import qmp # noqa: E402 SIZE = 64 def ppm_bytes(fill=0): return b"P6\n%d %d\n255\n" % (SIZE, SIZE) + bytes([fill]) * (SIZE * SIZE * 3) class FakeCtl: """Canned replies in the shapes warden_debug.c actually produces.""" def __init__(self, path, timeout=15.0): self.path = path self.stats_calls = 0 def send(self, cmd): if cmd == "page": return "Demo/Rows" if cmd == "stats": self.stats_calls += 1 return ("page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\n" f"rga: 0%\nidle: 0\npresses: {self.stats_calls}\ntermbusy: 1\n" "termintr: 2\ntermfg: -1\ntermsig: 3") if cmd == "wake": return "wake: ok" if cmd == "home": return "home: ok" if cmd.startswith("scroll "): arg = cmd[len("scroll "):] if not cmd.endswith(" 0"): return "scroll: ok y=300 of 900" return "scroll: expected X Y DY (DY non-zero pixels), got '%s'" % arg if cmd.startswith("@cat "): return json.dumps({"a": {"b": 1}, "list": [1, 2], "name": "warden"}) if cmd.startswith("hit "): return 'hit 47,676: obj text="" box=12,640,72x72' if cmd.startswith("nav "): return cmd + ": ok" return "" class DyingCtl: """A control channel that answers once -- a page still building, not yet a match, the exact case wait_json/wait_hit poll for -- and then dies on every later call, the way a real Ctl.send() does on EOF (RuntimeError) or a socket timeout past its own 15s (socket.timeout, an OSError). Regression fixture: that used to raise straight out of drive() and take every later step with it.""" def __init__(self, path, timeout=15.0): self.calls = 0 def send(self, cmd): self.calls += 1 if self.calls == 1: if cmd.startswith("@cat "): return json.dumps({"a": {"b": 0}}) if cmd.startswith("hit "): return "hit 47,676: nothing" return "" if self.calls == 2: raise RuntimeError("control channel closed") raise socket.timeout("timed out") class ImmediateDyingCtl: """A control channel that is already dead before the first command -- models a crash that happened between two script steps, not mid-poll. Regression fixture for drive()'s own top-level guard around handler(): before that guard existed, any non-wait_* verb (nav, wake, scroll/home, page/hit/stats/ctl, assert_page, assert_hit, assert_json, assert_stat) raised this straight out of drive() as an unhandled traceback, ending the run instead of recording one fatal row and continuing (flare-edge round-4 review).""" def __init__(self, path, timeout=15.0): pass def send(self, cmd): raise RuntimeError("control channel closed") class FullscreenCtl: """Models the dashboard's REAL fullscreen state plus warden_debug.c's `fullscreen`/`home` verbs closely enough to drive qemu/tests/scripts/fullscreen-toggle-tracks-real-state.txt offline: `home` always clears real_fs directly (warden_screen_overview_set_fullscreen(false) in the `home` handler), and `hit 47 676` answers with the gear's box when real_fs is false or the fullscreen canvas's box when it is true, exactly like the live dashboard's tap-catcher target (overview.targets.yaml). Subclasses disagree only about what `fullscreen toggle` reads to decide its next state -- the one line of behaviour the underlying bug is about.""" def __init__(self, path, timeout=15.0): self.real_fs = False def send(self, cmd): if cmd == "wake": return "wake: ok" if cmd == "page": return "Dashboard/Dashboard" if cmd == "home": self.real_fs = False return "home: ok" if cmd.startswith("hit "): if self.real_fs: return 'hit 47,676: widget text="" box=0,0,720x720' return 'hit 47,676: obj text="" box=12,640,72x72' if cmd in ("fullscreen on", "fullscreen off", "fullscreen toggle"): want = self._decide(cmd) self.real_fs = want self._remember(want) return f"fullscreen {'on' if want else 'off'}: ok" return "" def _decide(self, cmd): if cmd == "fullscreen on": return True if cmd == "fullscreen off": return False return self._toggle_target() def _remember(self, want): pass def _toggle_target(self): raise NotImplementedError class FixedFullscreenCtl(FullscreenCtl): """The fix: `toggle` reads the SAME ground truth `home` just changed (warden_screen_overview_get_fullscreen()), so it can never disagree with what `home` (or a real tap) already did.""" def _toggle_target(self): return not self.real_fs class StaleBeliefFullscreenCtl(FullscreenCtl): """Pre-fix: `toggle` reads a belief of its own that `home` never touches (warden_debug.c used to keep a private `static bool on`), so it can drift from the real state and then toggle AWAY from that stale belief instead of away from reality.""" def __init__(self, path, timeout=15.0): super().__init__(path, timeout) self.belief = False def _remember(self, want): self.belief = want def _toggle_target(self): return not self.belief def fake_rpc(sock, sock_file, obj): if obj.get("execute") == "screendump": with open(obj["arguments"]["filename"], "wb") as fh: fh.write(ppm_bytes()) return {} def run_script(text, refs=None, rs485_control=None, ctl_cls=None, rpc_fn=None, console_path=None): """-> (exit code or None, {cmd: row}, rows) for one drive() over TEXT.""" outdir = tempfile.mkdtemp(prefix="qmpdrive.") script = os.path.join(outdir, "s.txt") with open(script, "w") as fh: fh.write(text) refs_path = os.path.join(outdir, "refs.json") if refs is not None: with open(refs_path, "w") as fh: json.dump(refs, fh) saved = qmp.rpc, qmp.Ctl qmp.rpc, qmp.Ctl = (rpc_fn or fake_rpc), (ctl_cls or FakeCtl) rc = None try: try: qmp.drive(None, None, script, outdir, SIZE, ctl_path="fake", console_path=console_path, refs_path=refs_path, rs485_control=rs485_control) except SystemExit as e: rc = e.code finally: qmp.rpc, qmp.Ctl = saved with open(os.path.join(outdir, "results.jsonl")) as fh: rows = [json.loads(line) for line in fh if line.strip()] return rc, {r["cmd"]: r for r in rows}, rows class PureHelpers(unittest.TestCase): def test_tap_holds_press_across_loaded_guest_polls(self): calls = [] saved_send, saved_sleep = qmp.send_events, qmp.time.sleep qmp.send_events = lambda _s, _f, events: calls.append(events) qmp.time.sleep = lambda seconds: calls.append(seconds) try: qmp.do_tap(None, None, 123, 456) finally: qmp.send_events, qmp.time.sleep = saved_send, saved_sleep self.assertEqual(calls[0], [qmp.abs_ev("x", 123), qmp.abs_ev("y", 456), qmp.btn_ev(True)]) self.assertGreaterEqual(calls[1], 0.25) self.assertLess(calls[1], 0.4) self.assertEqual(calls[2], [qmp.btn_ev(False)]) def test_observed_tap_releases_after_guest_consumes_press(self): calls = [] class ObservingCtl: def __init__(self): self.calls = 0 def send(self, cmd): self.calls += 1 return f"presses: {0 if self.calls < 3 else 1}" saved_send, saved_sleep = qmp.send_events, qmp.time.sleep qmp.send_events = lambda _s, _f, events: calls.append(events) qmp.time.sleep = lambda _seconds: None try: observed = qmp.do_observed_tap( SimpleNamespace(s=None, f=None, ctl=ObservingCtl()), 123, 456) finally: qmp.send_events, qmp.time.sleep = saved_send, saved_sleep self.assertTrue(observed) self.assertEqual(calls, [ [qmp.abs_ev("x", 123), qmp.abs_ev("y", 456), qmp.btn_ev(True)], [qmp.btn_ev(False)], ]) def test_observed_tap_timeout_still_releases_press(self): calls = [] clock = iter((0.0, 0.0, qmp.TAP_OBSERVE_TIMEOUT_S + 0.1)) class UnobservingCtl: def send(self, cmd): return "presses: 0" saved_send, saved_sleep, saved_monotonic = ( qmp.send_events, qmp.time.sleep, qmp.time.monotonic) qmp.send_events = lambda _s, _f, events: calls.append(events) qmp.time.sleep = lambda _seconds: None qmp.time.monotonic = lambda: next(clock) try: observed = qmp.do_observed_tap( SimpleNamespace(s=None, f=None, ctl=UnobservingCtl()), 123, 456) finally: qmp.send_events = saved_send qmp.time.sleep = saved_sleep qmp.time.monotonic = saved_monotonic self.assertFalse(observed) self.assertEqual(calls[-1], [qmp.btn_ev(False)]) def test_every_pixel_round_trips_through_lvgl_calibration(self): # lv_evdev.c _evdev_calibrate: px = axis * (width - 1) / AXIS_MAX, # integer division. A tap requested at px must land at px, for every # px, or a hit-confirmed target can be missed by one pixel. for size in (720, 480, 1024): for px in range(size): axis = qmp.to_axis(px, size) self.assertTrue(0 <= axis <= qmp.AXIS_MAX) back = axis * (size - 1) // qmp.AXIS_MAX self.assertEqual(back, px, f"size {size}: px {px} -> axis {axis} -> {back}") def test_resolve_path_and_ops(self): doc = {"a": {"b": [5, 6]}, "s": "connected"} self.assertEqual(qmp.resolve_path(doc, "a.b[1]"), (6, None)) self.assertIsNotNone(qmp.resolve_path(doc, "a.c")[1]) self.assertTrue(qmp.apply_op("eq", 1, "1")) self.assertTrue(qmp.apply_op("ne", 1, "2")) self.assertTrue(qmp.apply_op("contains", "connected", "nect")) self.assertTrue(qmp.apply_op("len_ge", [1, 2], "2")) self.assertTrue(qmp.apply_op("len_eq", [1, 2], "2")) self.assertTrue(qmp.apply_op("gt", 3.0, "2")) self.assertTrue(qmp.apply_op("lt", 1, "2")) self.assertTrue(qmp.apply_op("eq", "connected", "connected")) def test_apply_op_rejects_bad_combinations(self): # eval_json and verb_assert_stat both catch (TypeError, ValueError) # specifically so a malformed OP in a hand-written or generated # script reads as a `fail` row with a reason, not a driver crash -- # that contract depends on apply_op actually raising these, which # nothing exercised directly before. with self.assertRaises(ValueError): qmp.apply_op("bogus", 1, "1") with self.assertRaises(TypeError): qmp.apply_op("contains", 5, "1") def test_flag_reads_an_optional_argv_pair_or_the_default(self): argv = ["qmp.py", "sock", "drive", "s.txt", "out", "--size", "480"] self.assertEqual(qmp.flag(argv, "--size"), "480") self.assertEqual(qmp.flag(argv, "--ctl"), None) self.assertEqual(qmp.flag(argv, "--ctl", "default"), "default") def test_parse_stats(self): got = qmp.parse_stats(FakeCtl("x").send("stats")) self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2, "idle": 0.0, "presses": 1.0, "termbusy": 1.0, "termintr": 2.0, "termfg": -1.0, "termsig": 3.0}) def test_poll_until_turns_a_channel_fault_into_fatal_not_a_raise(self): # A CHECK that raises RuntimeError or OSError (Ctl.send on EOF or a # socket timeout) must stop poll_until() and come back with the # exception text and fatal=True instead of propagating -- the wait_* # verbs are the only thing standing between that and drive() dying # with an unhandled traceback mid-script. ok, detail, waited, fatal = qmp.poll_until( lambda: (_ for _ in ()).throw(RuntimeError("control channel closed")), 5) self.assertFalse(ok) self.assertEqual(detail, "control channel closed") self.assertTrue(fatal) self.assertLess(waited, 1, "a raise must stop the poll immediately, not wait out TIMEOUT_S") ok, detail, waited, fatal = qmp.poll_until( lambda: (_ for _ in ()).throw(socket.timeout("timed out")), 5) self.assertFalse(ok) self.assertTrue(fatal, "socket.timeout is an OSError and must be caught the same way") # The ordinary paths still return the same 4-tuple shape. ok, detail, waited, fatal = qmp.poll_until(lambda: (True, ""), 5) self.assertTrue(ok) self.assertFalse(fatal) ok, detail, waited, fatal = qmp.poll_until(lambda: (False, "not yet"), 0.1, period=0.05) self.assertFalse(ok) self.assertFalse(fatal) class DriveVerbs(unittest.TestCase): def test_every_channel_passes_on_a_healthy_ui(self): rc, by, rows = run_script( "wake\n" "assert_page Demo/Rows\n" "assert_hit 47 676 obj box=12,640,72x72\n" "assert_stat idle eq 0\n" "wait_json a.b eq 1 2\n" "wait_json list len_ge 2 2\n" "assert_json name eq warden\n" "assert_stat fps gt 0\n" "nav Demo/Rows\n" "capture_region r1 0 0 8 8 exact\n" "assert_region r1 exact\n" "assert_region r1\n" "wait_hit 47 676 obj 2 box=12,640,72x72\n" "wait_region r1 exact 2\n" "scroll 360 400 300\n" "home\n" ) self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"]) self.assertEqual(len(rows), 16) self.assertIn("waited", by["wait_hit 47 676 obj 2 box=12,640,72x72"]["detail"]) self.assertTrue(all(r["status"] == "ok" for r in rows)) def test_mismatches_are_fails_not_stops(self): t0 = time.monotonic() rc, by, rows = run_script( "assert_page Demo/Other\n" "assert_hit 47 676 obj box=0,0,1x1\n" "wait_json a.b eq 2 1\n" "assert_json a.zz eq 1\n" "assert_stat fps lt 0\n" "scroll 360 400 0\n" "wait_hit 47 676 obj 1 box=0,0,1x1\n" "assert_page Demo/Rows\n" ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fail", "fail", "fail", "fail", "fail", "fail", "fail", "ok"]) self.assertIn("moved", by["assert_hit 47 676 obj box=0,0,1x1"]["detail"]) self.assertIn("moved", by["wait_hit 47 676 obj 1 box=0,0,1x1"]["detail"]) self.assertGreaterEqual(time.monotonic() - t0, 2.0, "wait_json and wait_hit must each honour their timeout") def test_dying_channel_is_fatal_for_the_step_not_a_crash(self): # A page still building when the control channel itself drops mid- # poll -- Ctl.send() raising RuntimeError on EOF or socket.timeout # past its own 15s -- used to escape drive() as an unhandled # traceback, losing the row for the step that was polling and every # step after it. wait_json and wait_hit (both poll_until) must # instead read as one `fatal` row, with the run continuing past it. rc, by, rows = run_script( "wait_json a.b eq 1 2\n" "sleep 0\n", ctl_cls=DyingCtl, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "ok"]) self.assertIn("control channel closed", by["wait_json a.b eq 1 2"]["detail"]) rc, by, rows = run_script( "wait_hit 47 676 obj 2\n" "sleep 0\n", ctl_cls=DyingCtl, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "ok"]) self.assertIn("control channel closed", by["wait_hit 47 676 obj 2"]["detail"]) def test_dying_channel_is_fatal_for_every_verb_not_just_wait(self): # wait_hit/wait_json route the channel through poll_until, which has # always caught this. Every other verb that reaches ctl.send() # directly did not, and used to crash the whole run instead of # recording one fatal row and moving on. One representative of each # family, back to back: every one must read as its own `fatal` row # and the script must still reach the last line. rc, by, rows = run_script( "nav Demo/Rows\n" "wake\n" "home\n" "page\n" "assert_page Demo/Rows\n" "assert_hit 47 676 obj\n" "assert_json a.b eq 1\n" "assert_stat idle eq 0\n" "sleep 0\n", ctl_cls=ImmediateDyingCtl, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal"] * 8 + ["ok"], "a dead channel must not crash the run: every ctl " "verb gets its own fatal row and sleep still runs") for row in rows[:-1]: self.assertIn("control channel closed", row["detail"]) def test_dying_qmp_socket_is_fatal_not_a_crash(self): # tap/swipe/fling/shot (and the region verbs) reach the QMP socket # through rpc(), a separate channel from ctl.send(), guarded by the # same top-level try/except in drive(). A RuntimeError from a QMP # error reply, or an OSError from the socket itself going away, must # read as a fatal row per step, not crash the run. def dying_rpc(sock, sock_file, obj): raise OSError("QMP socket closed") rc, by, rows = run_script( "shot before\n" "tap 10 10\n" "sleep 0\n", rpc_fn=dying_rpc, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "ok"]) for row in rows[:-1]: self.assertIn("QMP socket closed", row["detail"]) def test_malformed_numeric_argument_is_fatal_for_the_step_not_a_crash(self): # Several verbs parse their own arguments with bare # int()/float()/positional indexing before any handler-local guard # (tap, swipe, fling, sleep, wait_hit, wait_json, capture_region, # wait_region). A typo'd coordinate or a missing argument -- exactly # what a hand-edited *.txt script or a flowc.py bug can produce -- # used to raise ValueError/IndexError straight out of drive(), # losing every row from that line onward instead of reading as its # own fatal row (flare-edge #244). rc, by, rows = run_script( "tap 10 abc\n" "sleep 0\n", ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "ok"]) self.assertIn("invalid literal", by["tap 10 abc"]["detail"]) rc, by, rows = run_script( "tap 10\n" "sleep 0\n", ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "ok"]) self.assertIn("list index out of range", by["tap 10"]["detail"]) rc, by, rows = run_script( "capture_region r1 0 0 8 notanumber exact\n" "sleep 0\n", ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "ok"]) self.assertIn("invalid literal", by["capture_region r1 0 0 8 notanumber exact"]["detail"]) def test_bad_op_reads_as_a_fail_row_not_a_crash(self): # apply_op's error paths (unknown OP -> ValueError, 'contains' # against the wrong type -> TypeError) are caught by both callers # (eval_json, verb_assert_stat) and must read as an ordinary `fail` # row through the real verb handlers, not an uncaught exception or a # SystemExit out of drive() itself. rc, by, rows = run_script( "assert_json a.b bogus 1\n" "assert_stat fps bogus 1\n" ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fail", "fail"]) self.assertIn("bogus", by["assert_json a.b bogus 1"]["detail"]) self.assertIn("bogus", by["assert_stat fps bogus 1"]["detail"]) def test_assert_ocr_reports_no_tesseract_ocr_failure_and_match_or_not(self): # assert_ocr's own surface -- the tesseract-not-installed fatal, the # subprocess call, its exception net, and the final regex decision # -- had no coverage at all: a regression here would only be caught # by a live rig run against real tesseract. shutil.which and # subprocess.run are swapped the same way rs485_send is above, since # both are stdlib calls qmp.py makes directly, not seams of its own. refs = {"r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact", "phash": "0" * 16, "structural": "00" * 32}} saved_which, saved_run = qmp.shutil.which, qmp.subprocess.run def restore(): qmp.shutil.which, qmp.subprocess.run = saved_which, saved_run try: qmp.shutil.which = lambda name: None rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs) self.assertEqual(rows[0]["status"], "fatal") self.assertIn("tesseract not installed", rows[0]["detail"]) qmp.shutil.which = lambda name: "/usr/bin/tesseract" def crashing_run(*a, **k): raise OSError("tesseract crashed") qmp.subprocess.run = crashing_run rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs) self.assertEqual(rows[0]["status"], "fatal") self.assertIn("ocr failed", rows[0]["detail"]) def matching_run(cmd, **k): return type("R", (), {"stdout": "hello world\n"})() qmp.subprocess.run = matching_run rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs) self.assertEqual(rows[0]["status"], "ok") def nonmatching_run(cmd, **k): return type("R", (), {"stdout": "goodbye\n"})() qmp.subprocess.run = nonmatching_run rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs) self.assertEqual(rows[0]["status"], "fail") self.assertIn("goodbye", rows[0]["detail"]) finally: restore() def test_region_faults_are_per_step_fatal(self): # A pre-seeded reference whose box does not fit a 64x64 screendump, # a name with no reference, a tolerance other than the captured one, # and a capture box off the screen: each is FATAL for its own step # and the assert_page after them still runs. refs = {"big": {"x": 10, "y": 10, "w": 1000, "h": 1000, "tolerance": "exact", "phash": "0" * 16, "structural": "00" * 32}} rc, by, rows = run_script( "assert_region big exact\n" "assert_region nope exact\n" "capture_region r1 0 0 8 8 exact\n" "assert_region r1 loose\n" "capture_region huge 0 0 999 999 exact\n" "capture_region r2 0 0 8 8 fuzzy\n" "assert_page Demo/Rows\n", refs=refs, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "ok", "fatal", "fatal", "fail", "ok"]) self.assertIn("unusable", by["assert_region big exact"]["detail"]) self.assertIn("no reference", by["assert_region nope exact"]["detail"]) self.assertIn("captured as exact, script expects loose", by["assert_region r1 loose"]["detail"]) self.assertIn("cannot capture", by["capture_region huge 0 0 999 999 exact"]["detail"]) self.assertIn("unknown tolerance", by["capture_region r2 0 0 8 8 fuzzy"]["detail"]) def test_rs485_verb_talks_to_the_simulator_or_is_fatal(self): sent = [] def fake_send(path, line): sent.append((path, line)) return "ok 5 silent" if line.startswith("silence") else "error no simulated device at 9" saved = qmp.rs485_send qmp.rs485_send = fake_send try: rc, by, rows = run_script("rs485 silence 5\nrs485 restore 9\nrs485 bounce 5\n", rs485_control="fake.ctl") finally: qmp.rs485_send = saved self.assertEqual([r["status"] for r in rows], ["ok", "fail", "fatal"]) self.assertEqual(sent, [("fake.ctl", "silence 5"), ("fake.ctl", "restore 9")]) rc, by, rows = run_script("rs485 silence 5\n") self.assertEqual(rows[0]["status"], "fatal", "no bus in the run must not look like a pass") self.assertIn("no simulated RS485 bus", rows[0]["detail"]) def test_shot_and_region_names_cannot_escape_outdir(self): # flowc.py does not restrict shot/region names beyond forbidding # whitespace (see its own `shot` and `one_token` comments), so an # absolute or '../'-relative name must fail the step closed instead # of ever reaching the screendump RPC -- an absolute name used to # make os.path.join() discard outdir entirely and write there # verbatim (flare-edge security review). calls = [] def counting_rpc(sock, sock_file, obj): calls.append(obj) return fake_rpc(sock, sock_file, obj) rc, by, rows = run_script( "shot /etc/cron.d/evil\n" "shot ../../../tmp/evil\n" "capture_region ../evil 0 0 8 8 exact\n", rpc_fn=counting_rpc, ) self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fail", "fail", "fail"]) for row in rows: self.assertIn("must not contain '/'", row["detail"]) self.assertEqual(calls, [], "a malformed name must never reach a screendump RPC") # The ordinary case -- a plain name -- must still work. rc, by, rows = run_script("shot 01-overview\n", rpc_fn=counting_rpc) self.assertIsNone(rc) self.assertEqual(rows[0]["status"], "ok") def test_assert_region_refuses_a_refs_entry_whose_name_could_escape_outdir(self): # capture_region can no longer write such a name (previous test), # but refs.json is a hand-editable file on disk; assert_region (and # wait_region, same fresh_region() call) must refuse a bad name from # there too, not just at capture time. refs = {"../evil": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact", "phash": "0" * 16, "structural": "00" * 32}} rc, by, rows = run_script("assert_region ../evil exact\n", refs=refs) self.assertEqual(rc, 1) self.assertEqual(rows[0]["status"], "fatal") self.assertIn("must not contain '/'", rows[0]["detail"]) def test_wait_region_hits_the_same_fatal_paths_as_assert_region(self): # wait_region drives the same fresh_region() call as assert_region # (comment on verb_wait_region), so a missing reference, a foreign # tolerance, and a refs.json name that could escape outdir must all # be fatal here too -- and, since none of them can ever start # passing, each must stop on its first check instead of waiting out # TIMEOUT_S (poll_until's check_region() signals this by returning # ok=True, caught via the `fault` list). refs = { "r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact", "phash": "0" * 16, "structural": "00" * 32}, "../evil": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact", "phash": "0" * 16, "structural": "00" * 32}, } t0 = time.monotonic() rc, by, rows = run_script( "wait_region nope exact 2\n" "wait_region r1 loose 2\n" "wait_region ../evil exact 2\n", refs=refs, ) waited = time.monotonic() - t0 self.assertEqual(rc, 1) self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "fatal"]) self.assertIn("no reference", by["wait_region nope exact 2"]["detail"]) self.assertIn("captured as exact, script expects loose", by["wait_region r1 loose 2"]["detail"]) self.assertIn("must not contain '/'", by["wait_region ../evil exact 2"]["detail"]) self.assertLess(waited, 2.0, "none of these three can ever pass, so none may wait " "out its TIMEOUT_S of 2s each") def test_unknown_verb_is_fatal_for_the_run(self): # A silently-ignored line is a test that proves nothing, so this one # is the documented exception to "the run continues": drive() exits # with the message rather than recording a row. rc, by, rows = run_script("assert_page Demo/Rows\nfrobnicate 1 2\n") self.assertIsInstance(rc, str) self.assertIn("unknown command 'frobnicate'", rc) self.assertEqual([r["status"] for r in rows], ["ok"]) class ConsoleWatchTests(unittest.TestCase): """ConsoleWatch replaced a ui_exited() that reread and rescanned the WHOLE console log on every one of drive()'s per-step checks -- O(steps * final_size) for a file that only grows over a run. These pin the two properties a byte-offset-tracking rewrite must not lose: a marker split across two reads is still caught, and a match is cached (never rereads again).""" def test_finds_a_marker_split_across_two_reads_and_caches_it(self): d = tempfile.mkdtemp(prefix="console.") path = os.path.join(d, "console.log") with open(path, "wb") as fh: fh.write(b"boot log line one\nboot log line two\nwarden-ui EXI") watch = qmp.ConsoleWatch(path) self.assertIsNone(watch.check(), "no full marker written yet") with open(path, "ab") as fh: fh.write(b"TED at pc=0x1234\n") got = watch.check() self.assertIsNotNone(got, "the marker's second half arrived in this read") self.assertTrue(got.startswith("warden-ui EXITED"), got) # Cached: removing the file out from under a later check must not # un-find the marker or raise. os.remove(path) self.assertEqual(watch.check(), got) def test_no_console_path_is_always_none(self): watch = qmp.ConsoleWatch(None) self.assertIsNone(watch.check()) self.assertIsNone(watch.check()) def test_missing_file_returns_none_without_raising(self): watch = qmp.ConsoleWatch(os.path.join(tempfile.mkdtemp(), "does-not-exist.log")) self.assertIsNone(watch.check()) class DriveConsoleCheck(unittest.TestCase): def test_a_console_crash_is_a_fatal_row_and_stops_the_run(self): # End-to-end through drive(), not just ConsoleWatch on its own: the # stage-2 init's marker on the console must still stop the script at # the step that caused it (module docstring), the same contract # ui_exited() made before this became an incremental read. d = tempfile.mkdtemp(prefix="console.") console_path = os.path.join(d, "console.log") with open(console_path, "wb") as fh: fh.write(b"warden-ui EXITED signal=11\n") rc, by, rows = run_script("wake\nsleep 0\n", console_path=console_path) self.assertEqual(rc, 1) # drive() records the step's own result (wake's "ok") and then a # second row for the crash it finds right after -- both pinned to # line 1 -- and never reaches the `sleep 0` on line 2. self.assertEqual(len(rows), 2, "drive() must stop at the crashing step") self.assertEqual(rows[-1]["status"], "fatal") self.assertIn("warden-ui EXITED", rows[-1]["detail"]) self.assertTrue(all(r["line"] == 1 for r in rows)) class FullscreenToggleTracksRealState(unittest.TestCase): """qemu/tests/scripts/fullscreen-toggle-tracks-real-state.txt, offline: `home` clears the dashboard's real fullscreen state directly, and a `fullscreen toggle` right after it must read THAT, not a belief `home` bypassed (warden_debug.c's `fullscreen` verb). Run twice against the same script to pin both sides: the fix passes it, the pre-fix shape fails on exactly the step the bug breaks.""" SCRIPT = ( "wake\n" "assert_page Dashboard/Dashboard\n" "assert_hit 47 676 obj box=12,640,72x72\n" "ctl fullscreen on\n" "assert_hit 47 676 widget box=0,0,720x720\n" "home\n" "assert_hit 47 676 obj box=12,640,72x72\n" "ctl fullscreen toggle\n" "assert_hit 47 676 widget box=0,0,720x720\n" ) def test_fixed_channel_passes_every_step(self): rc, by, rows = run_script(self.SCRIPT, ctl_cls=FixedFullscreenCtl) self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"]) self.assertEqual(len(rows), 9) self.assertTrue(all(r["status"] == "ok" for r in rows)) def test_stale_belief_channel_fails_the_toggle_after_home(self): rc, by, rows = run_script(self.SCRIPT, ctl_cls=StaleBeliefFullscreenCtl) self.assertEqual(rc, 1) # Every step up to and including the post-home reset still passes -- # the drift is invisible until the toggle right after it. self.assertEqual([r["status"] for r in rows[:7]], ["ok"] * 7) self.assertEqual(rows[7]["cmd"], "ctl fullscreen toggle") self.assertEqual(rows[8]["status"], "fail", "toggle read the stale belief (still 'on' from the earlier " "explicit call) and flipped away from it, landing back on " "the already-off real state instead of turning it on") class LazyImports(unittest.TestCase): def test_module_import_does_not_pull_in_imgtools_or_pillow(self): # imgtools.py imports Pillow at its own module scope; qmp.py used to # `import imgtools` at ITS module scope too, so every subprocess # invocation of qmp.py paid that cost even for screendump/tap/quit, # which never touch a pixel -- defeating the Pillow-free boot-wait # loop ui-drive.sh's own comment documents. A subprocess (not just # checking qmp.imgtools in-process) is what actually pins this: the # other tests in this file exercise region verbs and so leave the # lazy slot filled in for the rest of THIS process. script = ( "import sys\n" f"sys.path.insert(0, {HERE!r})\n" "import qmp\n" "assert 'imgtools' not in sys.modules, 'imgtools imported eagerly'\n" "assert 'PIL' not in sys.modules, 'Pillow imported eagerly'\n" ) result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stderr) def _bare_ctl(sock): """A Ctl instance around an already-connected socket, bypassing __init__'s own socket()+connect() (there is no path on disk to connect to -- these tests drive a socketpair() end directly).""" ctl = qmp.Ctl.__new__(qmp.Ctl) ctl.sock = sock ctl.buf = b"" return ctl class CtlSocketProtocol(unittest.TestCase): """Ctl.send() itself -- the line-buffering loop that reassembles a reply across possibly many recv() calls, skips the cooked-mode echo of the command it just sent, and stops on the SENTINEL line -- has zero coverage anywhere else in this file: every FakeCtl/DyingCtl/etc. above replaces the whole class, never exercising the real one. This drives the real qmp.Ctl over a live AF_UNIX socketpair standing in for the FIFO bridge in rootfs/sbin/init, so the actual wire protocol gets checked without a VM or rootfs changes.""" def setUp(self): self.client_sock, self.server_sock = socket.socketpair( socket.AF_UNIX, socket.SOCK_STREAM) self.client_sock.settimeout(5.0) self.ctl = _bare_ctl(self.client_sock) def tearDown(self): self.client_sock.close() self.server_sock.close() def test_reassembles_a_reply_split_across_two_recv_calls(self): # The reply plus SENTINEL arrive in two separate writes, forcing # Ctl.send() through at least two recv() calls for one line: the # exact shape a reply straddling a 4096-byte read boundary takes on # real hardware. def server(): self.server_sock.recv(4096) # the command line self.server_sock.sendall(b"first line\nsecond ") time.sleep(0.05) self.server_sock.sendall(b"line\n<>\n") th = threading.Thread(target=server, daemon=True) th.start() try: got = self.ctl.send("stats") finally: th.join(timeout=2) self.assertEqual(got, "first line\nsecond line") def test_strips_the_cooked_mode_echo_of_the_command(self): # The tty is in cooked mode, so the command comes back echoed before # the real reply; Ctl.send() must drop that line, not treat it as # part of the answer. def server(): cmd_line = self.server_sock.recv(4096) self.server_sock.sendall(cmd_line) # cooked-mode echo self.server_sock.sendall(b"the actual reply\n<>\n") th = threading.Thread(target=server, daemon=True) th.start() try: got = self.ctl.send("page") finally: th.join(timeout=2) self.assertEqual(got, "the actual reply") def test_leftover_bytes_after_sentinel_carry_over_to_the_next_send(self): # One write carries this reply's SENTINEL immediately followed by # bytes belonging to the NEXT command's reply -- proving self.buf # correctly holds the leftover across two separate send() calls # instead of dropping or re-reading it. def server(): self.server_sock.recv(4096) self.server_sock.sendall(b"reply one\n<>\nreply two\n<>\n") th = threading.Thread(target=server, daemon=True) th.start() try: got1 = self.ctl.send("cmd1") finally: th.join(timeout=2) self.assertEqual(got1, "reply one") # cmd2's own reply is already sitting in self.ctl.buf from the single # write above; send() must serve it without another recv(). got2 = self.ctl.send("cmd2") self.assertEqual(got2, "reply two") class CtlBufferCap(unittest.TestCase): """Regression for Ctl.send() growing self.buf without bound: a peer that keeps streaming bytes fast enough to beat the per-recv() socket timeout, but never emits a newline or SENTINEL, used to grow self.buf forever instead of failing closed.""" def test_raises_instead_of_growing_self_buf_without_bound(self): client_sock, server_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) client_sock.settimeout(3.0) ctl = _bare_ctl(client_sock) def server(): server_sock.recv(4096) target = qmp.Ctl.MAX_BUF + 8192 sent = 0 try: while sent < target: server_sock.sendall(b"x" * 4096) # no newline, ever sent += 4096 except OSError: pass # the client closed once the cap tripped; nothing left to send to th = threading.Thread(target=server, daemon=True) th.start() try: with self.assertRaises(RuntimeError) as cm: ctl.send("stats") self.assertIn("too large", str(cm.exception)) self.assertLessEqual( len(ctl.buf), qmp.Ctl.MAX_BUF + 4096, "must fail as soon as the cap is crossed, not keep draining " "an unbounded peer first") finally: client_sock.close() server_sock.close() th.join(timeout=2) class QmpSocketTimeout(unittest.TestCase): """Regression for the QMP unix socket having no timeout: a peer that accepts the connection but never answers (a wedged VM -- a TCG stall or a kernel panic loop) used to block main()'s greeting readline() forever. ui-drive.sh's own cleanup() calls `quit` on this exact socket before it reaches reap("$QEMU_PID"), so an unbounded hang here defeats the one thing meant to guarantee a wedged qemu-system-arm cannot outlive the script.""" def test_main_bounds_a_wedged_qmp_peer_instead_of_hanging_forever(self): d = tempfile.mkdtemp(prefix="qmpsock.") sock_path = os.path.join(d, "qmp.sock") srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) srv.bind(sock_path) srv.listen(1) def accept_and_hang(): conn, _ = srv.accept() time.sleep(5) # never answer the greeting/qmp_capabilities handshake conn.close() th = threading.Thread(target=accept_and_hang, daemon=True) th.start() saved_timeout, saved_argv = qmp.QMP_TIMEOUT_S, sys.argv qmp.QMP_TIMEOUT_S = 0.3 sys.argv = ["qmp.py", sock_path, "quit"] try: t0 = time.monotonic() with self.assertRaises(OSError): qmp.main() elapsed = time.monotonic() - t0 self.assertLess( elapsed, 2.0, "a wedged QMP peer must be bounded by QMP_TIMEOUT_S, not hang " "indefinitely (main()'s socket needs its own settimeout(), the " "same way Ctl's already has one)") finally: sys.argv = saved_argv qmp.QMP_TIMEOUT_S = saved_timeout srv.close() th.join(timeout=6) class JsonStatusFetch(unittest.TestCase): """fetch_status_json()'s two failure branches -- the guest's snapshot not existing yet (the bridge answers 'bridge: no such file: PATH' for the first couple of seconds after boot, before webstatus.c's first 2s timer tick) and a torn/invalid JSON snapshot -- have no coverage anywhere else in this file: every FakeCtl-style '@cat' handler above always returns valid JSON.""" class StubCtl: def __init__(self, reply): self.reply = reply def send(self, cmd): assert cmd.startswith("@cat "), cmd return self.reply def test_missing_snapshot_file_is_a_detail_not_a_crash(self): ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json") doc, err = qmp.fetch_status_json(ctl) self.assertIsNone(doc) self.assertEqual(err, "bridge: no such file: /tmp/warden-web-status.json") def test_torn_json_is_a_detail_not_a_crash(self): ctl = self.StubCtl('{"a": 1, "b":') doc, err = qmp.fetch_status_json(ctl) self.assertIsNone(doc) self.assertIn("status json unparsable", err) def test_eval_json_turns_a_missing_snapshot_into_an_ordinary_fail(self): ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json") ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1") self.assertFalse(ok) self.assertEqual(detail, "bridge: no such file: /tmp/warden-web-status.json") def test_eval_json_turns_torn_json_into_an_ordinary_fail(self): ctl = self.StubCtl('{"a": 1, "b":') ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1") self.assertFalse(ok) self.assertIn("status json unparsable", detail) def test_wait_json_retries_a_missing_snapshot_instead_of_treating_it_fatal(self): # A missing snapshot is an ordinary not-yet-true check, so wait_json # must poll it out to TIMEOUT_S like any other fail -- not read the # bridge's plain-text error as a channel fault the way a dead # RuntimeError/OSError from ctl.send() itself already is. ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json") t0 = time.monotonic() ok, detail, waited, fatal = qmp.poll_until( lambda: qmp.eval_json(ctl, "a.b", "eq", "1"), 0.6, period=0.2) self.assertFalse(ok) self.assertFalse(fatal, "a missing snapshot is a fail to retry, not a channel fault") self.assertGreaterEqual(time.monotonic() - t0, 0.6) if __name__ == "__main__": unittest.main(verbosity=1)