"""Textual pilot tests for cv4pve_tui.app; spec: docs/plans/guest-tui-mvp.md ("UI contract"). All cli functions are monkeypatched: cv4pve-cli is never executed. """ import subprocess import threading import pytest from textual.screen import ModalScreen from textual.widgets import Button, DataTable, Input, Select, Static from cv4pve_tui import cli from cv4pve_tui.app import ConfirmScreen, EditScreen, GuestApp VM = {"vmid": 108, "name": "docker", "node": "mainframe", "type": "qemu", "status": "running", "maxcpu": 4, "maxmem": 8192 * 2**20, "maxdisk": 10 * 2**30} # fmt: skip CT = {"vmid": 100, "name": "backrest", "node": "mainframe", "type": "lxc", "status": "stopped", "maxcpu": 1, "maxmem": 512 * 2**20, "maxdisk": 8 * 2**30} # fmt: skip class Fake: """Recorder standing in for the cli module functions the app calls.""" def __init__(self): self.guests = [VM, CT] self.pending = [] self.disk_map = {"scsi0": "10G"} self.list_calls = 0 self.actions, self.sets, self.resizes, self.pending_calls = [], [], [], [] self.action_error = None self.action_hook = None self.live_status, self.status_calls = {}, [] def list_guests(self): self.list_calls += 1 return list(self.guests) def guest_config(self, guest): return {"cores": guest["maxcpu"]} def disks(self, config, kind): return dict(self.disk_map) def action(self, name, guest): self.actions.append((name, guest["vmid"])) if self.action_hook: self.action_hook() if self.action_error: raise self.action_error def set_resources(self, guest, cores=None, memory_mb=None): given = {"cores": cores, "memory_mb": memory_mb} self.sets.append({k: v for k, v in given.items() if v is not None}) def resize_disk(self, guest, disk, grow_gb): self.resizes.append((guest["vmid"], disk, grow_gb)) def pending_keys(self, guest): self.pending_calls.append(guest["vmid"]) return list(self.pending) def status(self, guest): self.status_calls.append(guest["vmid"]) return self.live_status.get(guest["vmid"], guest["status"]) @pytest.fixture def fake(monkeypatch): f = Fake() for name in ("list_guests", "guest_config", "disks", "action", "set_resources", "resize_disk", "pending_keys", "status"): # fmt: skip monkeypatch.setattr(cli, name, getattr(f, name)) return f def make_app(): app = GuestApp() app.seen = [] # (message, severity) of every notify original = app.notify def spy(message, **kwargs): app.seen.append((str(message), kwargs.get("severity", "information"))) return original(message, **kwargs) app.notify = spy return app async def settle(app, pilot): await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() def text_of(screen): return " ".join(str(w.render()) for w in screen.query(Static)) def errors(app): return [m for m, sev in app.seen if sev == "error"] async def press_button(app, pilot, selector): app.screen.query_one(selector, Button).press() await settle(app, pilot) # --- table ------------------------------------------------------------------ async def test_table_loads_one_row_per_guest_in_list_order(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) table = app.query_one("#guests", DataTable) assert table.row_count == 2 assert [k.value for k in table.rows] == ["108", "100"] cells = [str(c) for c in table.get_row("108")] assert "docker" in cells and "mainframe" in cells and "running" in cells assert "8192" in cells # memory in MiB async def test_refresh_key_reloads_guests(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) before = fake.list_calls fake.guests = [CT] await pilot.press("g") await settle(app, pilot) assert fake.list_calls == before + 1 assert app.query_one("#guests", DataTable).row_count == 1 # --- lifecycle actions ------------------------------------------------------ async def test_start_runs_without_confirm_and_refreshes_table(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) before = fake.list_calls await pilot.press("s") await settle(app, pilot) assert fake.actions == [("start", 108)] assert not isinstance(app.screen, ModalScreen) assert fake.list_calls > before async def test_action_targets_the_cursor_row(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("down", "s") await settle(app, pilot) assert fake.actions == [("start", 100)] @pytest.mark.parametrize( "key,name", [("S", "shutdown"), ("x", "stop"), ("r", "reboot")] ) async def test_confirmed_action_calls_cli_only_after_yes(fake, key, name): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await pilot.pause() assert isinstance(app.screen, ConfirmScreen) assert "docker" in text_of(app.screen) and "108" in text_of(app.screen) assert fake.actions == [] await press_button(app, pilot, "#yes") assert fake.actions == [(name, 108)] @pytest.mark.parametrize("key", ["S", "x", "r"]) async def test_declined_action_does_not_call_cli(fake, key): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await pilot.pause() await press_button(app, pilot, "#no") assert fake.actions == [] assert not isinstance(app.screen, ModalScreen) async def test_stop_confirm_says_hard_power_off(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("x") await pilot.pause() assert "hard power-off" in text_of(app.screen).lower() @pytest.mark.parametrize("key", ["s", "S", "x", "r", "e"]) async def test_keys_on_empty_table_do_nothing(fake, key): fake.guests = [] app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await settle(app, pilot) assert fake.actions == [] and fake.sets == [] and fake.resizes == [] assert not isinstance(app.screen, ModalScreen) async def test_cli_error_is_notified_with_summary(fake, monkeypatch): with monkeypatch.context() as m: # build a real CliError through cli.run m.setattr(cli.subprocess, "run", lambda argv, **kw: subprocess.CompletedProcess( argv, 5, "UPID:mainframe:0:0:0:qmstart:108:root@pam:\nstart failed: no quorum\n", "")) # fmt: skip with pytest.raises(cli.CliError) as exc: cli.run("do", "start") fake.action_error = exc.value app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("s") await settle(app, pilot) assert any(exc.value.summary in m for m in errors(app)) assert not any("UPID" in m for m in errors(app)) async def test_busy_guest_refuses_second_action(fake): started, release = threading.Event(), threading.Event() fake.action_hook = lambda: (started.set(), release.wait(10)) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) try: await pilot.press("s") for _ in range(200): if started.is_set(): break await pilot.pause(0.01) assert started.is_set() assert app.seen, "a working notice is expected while the action runs" await pilot.press("s") await pilot.pause() assert fake.actions == [("start", 108)] finally: release.set() await settle(app, pilot) assert fake.actions == [("start", 108)] # --- edit flow -------------------------------------------------------------- async def open_edit(app, pilot): await settle(app, pilot) await pilot.press("e") await settle(app, pilot) assert isinstance(app.screen, EditScreen) def fill(app, **values): for key, value in values.items(): w = app.screen.query_one(f"#{key}", Select if key == "disk" else Input) w.value = value async def test_edit_applies_only_given_fields_as_ints(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8") await press_button(app, pilot, "#apply") assert fake.sets == [{"cores": 8}] await pilot.press("e") await settle(app, pilot) fill(app, cores="2", memory="4096") await press_button(app, pilot, "#apply") assert fake.sets[-1] == {"cores": 2, "memory_mb": 4096} assert all(isinstance(v, int) for v in fake.sets[-1].values()) @pytest.mark.parametrize("field", ["cores", "memory"]) @pytest.mark.parametrize("bad", ["abc", "0", "-4", "2.5"]) async def test_edit_invalid_resource_input_errors_without_cli_call(fake, field, bad): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, **{field: bad}) await press_button(app, pilot, "#apply") assert errors(app) assert fake.sets == [] and fake.resizes == [] @pytest.mark.parametrize("bad", ["abc", "0", "-1"]) async def test_edit_invalid_grow_errors_without_cli_call(fake, bad): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow=bad) await press_button(app, pilot, "#apply") assert errors(app) assert fake.resizes == [] async def test_edit_with_empty_fields_changes_nothing(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) await press_button(app, pilot, "#apply") assert fake.sets == [] and fake.resizes == [] async def test_edit_cancel_changes_nothing(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8") await press_button(app, pilot, "#cancel") assert fake.sets == [] and fake.resizes == [] assert not isinstance(app.screen, ModalScreen) async def test_grow_needs_confirm_then_resizes(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow="5") await press_button(app, pilot, "#apply") assert isinstance(app.screen, ConfirmScreen) assert fake.resizes == [] await press_button(app, pilot, "#yes") assert fake.resizes == [(108, "scsi0", 5)] async def test_grow_declined_does_not_resize(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#no") assert fake.resizes == [] @pytest.mark.parametrize( "pending,expect_notice", [(["memory"], True), (["cores"], True), ([], False)] ) async def test_pending_cores_or_memory_notifies_reboot(fake, pending, expect_notice): fake.pending = pending app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, memory="4096") await press_button(app, pilot, "#apply") assert fake.pending_calls == [108] assert any("reboot" in m.lower() for m, _ in app.seen) is expect_notice # --- review findings: robustness -------------------------------------------- async def test_guest_without_size_fields_still_listed(fake): # e.g. a guest on an unreachable node: the API omits maxcpu/maxmem/maxdisk fake.guests = [ { "vmid": 999, "name": "ghost", "node": "down", "type": "qemu", "status": "unknown", } ] app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert app.query_one("#guests", DataTable).row_count == 1 async def test_unexpected_error_in_action_is_notified_not_fatal(fake): fake.action_error = KeyError("key") app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press("s") await settle(app, pilot) assert errors(app) assert app.is_running and app.busy == set() async def test_unexpected_error_in_listing_is_notified_not_fatal(fake, monkeypatch): def broken(): raise ValueError("bad json") monkeypatch.setattr(cli, "list_guests", broken) app = make_app() async with app.run_test() as pilot: await settle(app, pilot) assert errors(app) and app.is_running async def test_pending_check_failure_does_not_skip_confirmed_grow(fake, monkeypatch): def broken(guest): raise cli.CliError("Error: boom") monkeypatch.setattr(cli, "pending_keys", broken) app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, memory="4096", disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#yes") assert fake.sets == [{"memory_mb": 4096}] assert fake.resizes == [(108, "scsi0", 5)] async def test_declining_grow_cancels_whole_edit(fake): app = make_app() async with app.run_test() as pilot: await open_edit(app, pilot) fill(app, cores="8", disk="scsi0", grow="5") await press_button(app, pilot, "#apply") await press_button(app, pilot, "#no") assert fake.sets == [] and fake.resizes == [] @pytest.mark.parametrize("key", ["S", "e"]) async def test_escape_closes_modal_without_cli_call(fake, key): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) await pilot.press(key) await settle(app, pilot) assert isinstance(app.screen, ModalScreen) await pilot.press("escape") await settle(app, pilot) assert not isinstance(app.screen, ModalScreen) assert fake.actions == [] and fake.sets == [] async def test_table_shows_live_status_after_action_not_lagging_list(fake): # /cluster/resources lags a few seconds behind; the acted-on guest must show live status app = make_app() async with app.run_test() as pilot: await settle(app, pilot) fake.live_status = {108: "stopped"} # list_guests still says "running" await pilot.press("S") await settle(app, pilot) await press_button(app, pilot, "#yes") assert fake.status_calls == [108] assert "stopped" in [ str(c) for c in app.query_one("#guests", DataTable).get_row("108") ]