aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_gui.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 19:12:05 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 19:12:05 +0200
commit6fdb31d538269136415ce71cbf58de6017f95463 (patch)
treea37e51bdaf4783bce22cf121061d71e69ea2a862 /tests/test_gui.py
parentfb64c88bb8672ee9bdb8172eec0252966d67404a (diff)
downloadsb-builder-6fdb31d538269136415ce71cbf58de6017f95463.tar.gz
sb-builder-6fdb31d538269136415ce71cbf58de6017f95463.zip
Add the PyQt6 GUI: Dashboard, Jobs, VM control, notifications
All VM access goes through remote.py (ssh with a shared ControlMaster, local virsh). The main window polls jobs, backs off while ssh is down, checks the libvirt domain and offers Start or Resume, and sends freedesktop notifications when a job ends or the VM comes up. The Jobs tab streams a job's log with job-follow and resumes at the last offset after a drop. Config is TOML, validated against built-in defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_gui.py')
-rw-r--r--tests/test_gui.py167
1 files changed, 167 insertions, 0 deletions
diff --git a/tests/test_gui.py b/tests/test_gui.py
new file mode 100644
index 0000000..95f9afb
--- /dev/null
+++ b/tests/test_gui.py
@@ -0,0 +1,167 @@
+# sb-builder - self-checks for the GUI
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Run: python3 -m unittest discover -s tests (headless, no VM needed)"""
+
+import os
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import importlib.machinery # noqa: E402
+import importlib.util # noqa: E402
+import sys # noqa: E402
+import tempfile # noqa: E402
+import unittest # noqa: E402
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+sys.path.insert(0, ROOT)
+
+from PyQt6.QtCore import QObject, pyqtSignal # noqa: E402
+from PyQt6.QtWidgets import QApplication # noqa: E402
+
+from sb_builder import VERSION, config # noqa: E402
+from sb_builder.mainwindow import MainWindow # noqa: E402
+from sb_builder.remote import DOWN, MISSING, parse # noqa: E402
+
+app = QApplication.instance() or QApplication([])
+
+
+class ConfigTest(unittest.TestCase):
+ def load(self, text):
+ with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
+ f.write(text)
+ self.addCleanup(os.unlink, f.name)
+ return config.load(f.name)
+
+ def test_missing_file(self):
+ self.assertEqual(config.load("/nonexistent/config.toml"), (config.DEFAULTS, None))
+
+ def test_example_matches_defaults(self):
+ self.assertEqual(config.load(os.path.join(ROOT, "config.example.toml")), (config.DEFAULTS, None))
+
+ def test_override(self):
+ cfg, err = self.load("[refresh]\njobs = 2\n")
+ self.assertIsNone(err)
+ self.assertEqual(cfg["refresh"], {"dashboard": 30, "jobs": 2})
+
+ def test_unknown_key(self):
+ cfg, err = self.load("[remote]\nbogus = 1\n")
+ self.assertIn("remote.bogus", err)
+ self.assertEqual(cfg, config.DEFAULTS)
+
+ def test_wrong_type(self):
+ cfg, err = self.load('[vm]\nautostart = "yes"\n')
+ self.assertIn("vm.autostart: expected bool", err)
+ self.assertEqual(cfg, config.DEFAULTS)
+
+
+class ParseTest(unittest.TestCase):
+ def test_cases(self):
+ self.assertEqual(parse(255, b"", b""), (None, DOWN))
+ self.assertEqual(parse(127, b"", b""), (None, MISSING))
+ self.assertEqual(parse(0, b'{"a": 1}', b""), ({"a": 1}, None))
+ self.assertEqual(parse(1, b'{"error": "boom"}', b""), (None, "boom"))
+ self.assertEqual(parse(1, b"", b"bash: x\n"), (None, "bash: x"))
+
+
+class VersionTest(unittest.TestCase):
+ def test_gui_matches_helper(self):
+ script = os.path.join(ROOT, "remote", "sbb-remote")
+ loader = importlib.machinery.SourceFileLoader("sbb_remote", script)
+ sbb = importlib.util.module_from_spec(importlib.util.spec_from_loader("sbb_remote", loader))
+ loader.exec_module(sbb)
+ self.assertEqual(sbb.VERSION, VERSION)
+
+
+class FakeRemote:
+ def __init__(self, answers):
+ self.answers = answers
+ self.calls = []
+ self.virsh_answer = (1, "")
+
+ def call(self, cb, *args):
+ self.calls.append(args)
+ cb(*self.answers.get(args[0], (None, DOWN)))
+
+ def virsh(self, cb, *args):
+ self.calls.append(("virsh", *args))
+ cb(*self.virsh_answer)
+
+
+class FakeNotifier(QObject):
+ clicked = pyqtSignal(str)
+
+ def __init__(self):
+ super().__init__()
+ self.sent = []
+
+ def send(self, title, body, jid=""):
+ self.sent.append((title, body, jid))
+
+
+STATS = {"load": [0.5, 0.4, 0.3], "uptime": 3600.0,
+ "disk_free": {"repo": 10 * 2**30, "state": None},
+ "packages": {"total": 10, "personal": 2, "pentesting": 1, "shadowed": 1},
+ "sync": {"personal": None, "pentesting": 1792351223}, "slackrepo": None}
+JOB = {"id": "20260101-000000-sbshadow", "kind": "sbshadow", "args": ["--no"], "argv": [],
+ "status": "running", "created": 0, "start": 0, "pid": 5}
+
+
+class SmokeTest(unittest.TestCase):
+ def window(self, **answers):
+ remote = FakeRemote({"version": ({"version": VERSION, "overlays": ["personal"]}, None),
+ "stats": (STATS, None), "jobs": ({"jobs": []}, None), **answers})
+ w = MainWindow(config.load("/nonexistent")[0], remote, FakeNotifier())
+ self.addCleanup(w.deleteLater)
+ return w, remote
+
+ def test_tabs_and_dashboard(self):
+ w, _ = self.window()
+ w.poll_jobs()
+ self.assertEqual(w.conn.text(), "● connected")
+ self.assertEqual(w.tabs.tabText(0), "Dashboard")
+ self.assertIn("total: 10", w.dashboard.labels["Repo"].text())
+ self.assertEqual(w.dashboard.labels["slackrepo"].text(), "n/a")
+ self.assertIn("10.0 GiB", w.dashboard.labels["VM"].text())
+ jobs = w.open_jobs()
+ self.assertIs(w.open_jobs(), jobs)
+ self.assertEqual(w.tabs.count(), 2)
+ self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)")
+
+ def test_end_notification_once(self):
+ w, remote = self.window(jobs=({"jobs": [JOB]}, None))
+ w.poll_jobs()
+ remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None)
+ w.poll_jobs()
+ w.poll_jobs()
+ self.assertEqual(w.notifier.sent, [("sb-builder: failed", "sbshadow --no", JOB["id"])])
+
+ def test_down_offers_vm_start(self):
+ w, remote = self.window(jobs=(None, DOWN))
+ remote.virsh_answer = (0, "shut off")
+ w.poll_jobs()
+ self.assertIn(("virsh", "domstate", "buildsystem"), remote.calls)
+ self.assertEqual(w.conn.text(), "● VM off")
+ self.assertFalse(w.vm_btn.isHidden())
+
+ def test_log_carriage_return(self):
+ w, _ = self.window()
+ jobs = w.open_jobs()
+ jobs.append_log("10%\r50%\r100%\ndone\r\n")
+ self.assertEqual(jobs.log.toPlainText(), "100%\ndone\n")
+
+
+if __name__ == "__main__":
+ unittest.main()