diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 21:28:54 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 21:28:54 +0200 |
| commit | 993950b7733ba9300915955ec6e51e1f21dad806 (patch) | |
| tree | d8800305748d96581f3bea06eeb9b1a1da979a49 | |
| parent | 5609a744ef5e89c4d57a5239199a6afe5497590e (diff) | |
| download | sb-builder-993950b7733ba9300915955ec6e51e1f21dad806.tar.gz sb-builder-993950b7733ba9300915955ec6e51e1f21dad806.zip | |
Results tab with build logs; count only real packages
sbb-remote 0.4.0: results lists slackrepo's last attempt per package
(newest first, with built version and whether a log dir exists), and
build-log returns the end of a package's build.log (512 KB) plus its
resource.log, accepting only category/pkg paths. Result counts skip
the legacy buildresults rows keyed by package file name: the 213
"bad" on the Dashboard matched no package.
GUI: a Results tab (chips per result, table sorted by attempt time,
detail pane, log viewer window that keeps the failure at the end),
opened from the ribbon or by clicking a result in the Dashboard bar or
legend, filtered to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | remote/config.example.toml | 1 | ||||
| -rwxr-xr-x | remote/sbb-remote | 47 | ||||
| -rw-r--r-- | sb_builder/__init__.py | 2 | ||||
| -rw-r--r-- | sb_builder/dashboard.py | 22 | ||||
| -rw-r--r-- | sb_builder/mainwindow.py | 25 | ||||
| -rw-r--r-- | sb_builder/results.py | 195 | ||||
| -rw-r--r-- | tests/test_gui.py | 49 | ||||
| -rw-r--r-- | tests/test_remote.py | 44 |
9 files changed, 378 insertions, 12 deletions
@@ -52,6 +52,11 @@ Every key is optional. `File > Reload config` rereads the GUI config. `--preview` job. When the preview is fine, **Run it** in the Jobs tab starts the real run; untick **publish** to keep the packages local and press **Publish** later to push them. +- **Results** (ribbon, or click a result in the Dashboard's slackrepo bar or + legend): slackrepo's last build attempt per package, newest first, filterable + by result. **Show build log** (or double-click) opens the end of the + package's `build.log`, where slackrepo reports the failure, with its + `resource.log` (load, memory, tmp over the build). - Real runs sign the repository. When the package signing key is locked (status bar π), the GUI asks for its passphrase first and hands it to the VM's gpg-agent; it is never stored. diff --git a/remote/config.example.toml b/remote/config.example.toml index e9b76d0..d275401 100644 --- a/remote/config.example.toml +++ b/remote/config.example.toml @@ -11,3 +11,4 @@ slackrepo_repo = "SBo-danix" hintdir = "/etc/slackrepo/SBo-danix/hintfiles" default_hintdir = "" # searched after hintdir when set genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key +slackrepo_logdir = "/var/log/slackrepo/SBo-danix" diff --git a/remote/sbb-remote b/remote/sbb-remote index d82120a..a3b83a6 100755 --- a/remote/sbb-remote +++ b/remote/sbb-remote @@ -38,7 +38,7 @@ import tempfile import time import tomllib -VERSION = "0.3.0" +VERSION = "0.4.0" DEFAULTS = { "state_dir": "/var/lib/sb-builder", "repo_dir": "/var/lib/sbopkg/SBo-danix", @@ -50,6 +50,7 @@ DEFAULTS = { "hintdir": "/etc/slackrepo/SBo-danix/hintfiles", "default_hintdir": "", "genreprc": "/root/.genreprc", + "slackrepo_logdir": "/var/log/slackrepo/SBo-danix", } FLAGS = ("--yes", "--no") JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$") @@ -57,6 +58,8 @@ PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$") PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$") ACTIONS = ("build", "rebuild", "update", "remove") PRESET = "/usr/libexec/gpg-preset-passphrase" +ITEM = re.compile(r"^[A-Za-z0-9._+-]+/[A-Za-z0-9._+-]+$") # category/pkg +LOG_TAIL = 512 * 1024 # bytes of build.log returned; the failure is at the end ACTIVE = ("starting", "running") START_GRACE = 10 # seconds a job may sit in "starting" before it counts as lost @@ -346,7 +349,9 @@ def _packages(cfg): def _slackrepo(cfg): db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True) try: - results = dict(db.execute("select result, count(*) from buildresults group by result")) + # legacy rows keyed by package file name, not category/pkg, match nothing: skip + results = dict(db.execute("select result, count(*) from buildresults" + " where itemid like '%/%' group by result")) built = db.execute("select count(distinct itemid) from revisions where dep = '/'").fetchone()[0] finally: db.close() @@ -478,6 +483,42 @@ def _builds(cfg): return built, results +def cmd_results(cfg): + """Last build attempt per package, newest first.""" + db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True) + try: + rows = db.execute(""" + select b.itemid, b.result, cast(b.time as integer), r.version + from buildresults b left join revisions r on r.itemid = b.itemid and r.dep = '/' + where b.itemid like '%/%' order by cast(b.time as integer) desc""").fetchall() + finally: + db.close() + logdir = cfg["slackrepo_logdir"] + return {"rows": [[i, res, t, v, os.path.isdir(os.path.join(logdir, i))] + for i, res, t, v in rows]} + + +def _tail(path, limit): + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - limit)) + return f.read().decode(errors="replace"), size > limit + + +def cmd_build_log(cfg, item=None): + if not ITEM.match(item or "") or ".." in item: + raise Error(f"bad package path: {item}") + d = os.path.join(cfg["slackrepo_logdir"], item) + build = os.path.join(d, "build.log") + if not os.path.isfile(build): + raise Error(f"no build log for {item}") + text, truncated = _tail(build, LOG_TAIL) + resource = os.path.join(d, "resource.log") + return {"build": text, "truncated": truncated, "mtime": int(os.path.getmtime(build)), + "resource": _tail(resource, LOG_TAIL)[0] if os.path.isfile(resource) else None} + + COLUMNS = ["path", "version", "info_version", "hint_version", "built_version", "built_time", "result", "maintainer", "requires", "summary", "hint_path", "hints"] @@ -577,6 +618,8 @@ COMMANDS = { "job-follow": cmd_job_follow, "job-kill": cmd_job_kill, "inventory": cmd_inventory, + "results": cmd_results, + "build-log": cmd_build_log, "gpg-status": cmd_gpg_status, "gpg-unlock": cmd_gpg_unlock, "_run": cmd_run, diff --git a/sb_builder/__init__.py b/sb_builder/__init__.py index 563a7ce..539b874 100644 --- a/sb_builder/__init__.py +++ b/sb_builder/__init__.py @@ -16,4 +16,4 @@ """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" -VERSION = "0.3.0" # must match remote/sbb-remote VERSION +VERSION = "0.4.0" # must match remote/sbb-remote VERSION diff --git a/sb_builder/dashboard.py b/sb_builder/dashboard.py index 8c98040..a8de823 100644 --- a/sb_builder/dashboard.py +++ b/sb_builder/dashboard.py @@ -104,7 +104,9 @@ class Tile(Card): class StackedBar(QWidget): - """Horizontal part-to-whole bar; hover a segment for its label and count.""" + """Horizontal part-to-whole bar; hover a segment for its count, click to open it.""" + + clicked = pyqtSignal(str) def __init__(self): super().__init__() @@ -135,6 +137,14 @@ class StackedBar(QWidget): p.setBrush(color) p.drawRoundedRect(rect, 4, 4) + def label_at(self, pos): + return next((label for rect, label, _c, _col in self.rects() if rect.contains(pos)), None) + + def mouseReleaseEvent(self, e): + label = self.label_at(e.position()) + if label and e.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(label) + def event(self, e): if e.type() == QEvent.Type.ToolTip: hit = next((f"{label}: {count:,}" for rect, label, count, _ in self.rects() @@ -239,6 +249,8 @@ class Meter(QWidget): class Dashboard(QWidget): + result_clicked = pyqtSignal(str) # a slackrepo result kind, from the bar or legend + def __init__(self, parent=None): super().__init__(parent) self.setStyleSheet("QFrame#card { background: palette(base);" @@ -258,6 +270,9 @@ class Dashboard(QWidget): self.legend = QLabel(NA) self.legend.setTextFormat(Qt.TextFormat.RichText) self.legend.setWordWrap(True) + self.legend.linkActivated.connect(self.result_clicked) + self.bar.clicked.connect(self.result_clicked) + self.bar.setCursor(Qt.CursorShape.PointingHandCursor) self.results.body.addWidget(self.bar) self.results.body.addWidget(self.legend) self.results.body.addStretch() @@ -341,11 +356,14 @@ class Dashboard(QWidget): self.legend.setText(NA) return gray = self.palette().color(QPalette.ColorRole.PlaceholderText) # Mid can match the background + ink = self.palette().color(QPalette.ColorRole.WindowText).name() # links in text color order = sorted(sr["results"].items(), key=lambda kv: (list(STATUS).index(kv[0]) if kv[0] in STATUS else 99, kv[0])) self.bar.set_parts([(k, v, QColor(STATUS.get(k)) if k in STATUS else gray) for k, v in order]) self.legend.setText(" ".join( - f'<span style="color:{STATUS.get(k, gray.name())}">■</span> {html.escape(k)} {num(v)}' + f'<span style="color:{STATUS.get(k, gray.name())}">■</span> ' + f'<a href="{html.escape(k)}" style="color:{ink}; text-decoration:none">' + f'{html.escape(k)} {num(v)}</a>' for k, v in order)) def show_jobs(self, jobs): diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py index bd2245d..247b0cf 100644 --- a/sb_builder/mainwindow.py +++ b/sb_builder/mainwindow.py @@ -29,6 +29,7 @@ from .dashboard import Dashboard from .fmt import job_title from .jobs import JobsTab from .packages import PackagesTab +from .results import ResultsTab from .remote import DOWN, MISSING NOTIFY = "org.freedesktop.Notifications" @@ -36,6 +37,7 @@ NOTIFY_PATH = "/org/freedesktop/Notifications" # ribbon entries: label, freedesktop icon name, Qt fallback icon RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon), ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView), + ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning), ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay)) # tabs as a distinct, centered row; colors from the palette so the theme applies TAB_STYLE = """ @@ -84,6 +86,7 @@ class MainWindow(QMainWindow): self.notifier.clicked.connect(self.open_job) self.jobs_tab = None self.packages_tab = None + self.results_tab = None self.seen = None # job id -> status at the last poll; None before the first self.online = None # None until the first answer self.overlays = [] @@ -100,6 +103,7 @@ class MainWindow(QMainWindow): self.dashboard.refresh_btn.clicked.connect(self.poll_stats) self.dashboard.tiles["Packages"].link(self.open_packages) self.dashboard.jobs.link(self.open_jobs) + self.dashboard.result_clicked.connect(self.open_results) self.tabs.addTab(self.dashboard, "Dashboard") self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None) self.setCentralWidget(self.tabs) @@ -115,7 +119,8 @@ class MainWindow(QMainWindow): self.ribbon.setIconSize(QSize(32, 32)) self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon) slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard), - "Packages": self.open_packages, "Jobs": self.open_jobs} + "Packages": self.open_packages, "Results": lambda: self.open_results(), + "Jobs": self.open_jobs} for label, name, fallback in RIBBON: icon = QIcon.fromTheme(name, self.style().standardIcon(fallback)) self.ribbon.addAction(icon, label, slots[label]) @@ -275,8 +280,10 @@ class MainWindow(QMainWindow): event = END_EVENT.get(j["status"]) if event and prev.get(j["id"], "running") != j["status"]: self.notify(event, job_title(j), j["id"]) - if j["kind"] in ("slackrepo", "publish") and self.packages_tab: - self.packages_tab.reload() # build state changed + if j["kind"] in ("slackrepo", "publish"): # build state changed + for tab in (self.packages_tab, self.results_tab): + if tab: + tab.reload() # ββ tabs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ @@ -302,6 +309,16 @@ class MainWindow(QMainWindow): self.tabs.setCurrentWidget(self.packages_tab) return self.packages_tab + def open_results(self, result=None): + if self.results_tab is None: + self.results_tab = ResultsTab(self.remote) + self.tabs.addTab(self.results_tab, "Results") + self.results_tab.reload() + if result: + self.results_tab.filter_to(result) + self.tabs.setCurrentWidget(self.results_tab) + return self.results_tab + def open_job(self, jid): self.show() self.raise_() @@ -319,6 +336,8 @@ class MainWindow(QMainWindow): w.stop() if w is self.packages_tab: self.packages_tab = None + if w is self.results_tab: + self.results_tab = None w.deleteLater() def reload_config(self): diff --git a/sb_builder/results.py b/sb_builder/results.py new file mode 100644 index 0000000..30a3adc --- /dev/null +++ b/sb_builder/results.py @@ -0,0 +1,195 @@ +# sb-builder - Results tab +# 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. + +"""Results tab: slackrepo's last build attempt per package, and its build log.""" + +import html +import re + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor +from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, + QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + +from .dashboard import STATUS +from .fmt import NA, fmt_time +from .packages import chip + +KINDS = ("ok", "failed", "bad", "aborted", "removed") +ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") + + +class TimeItem(QTableWidgetItem): + """Sorts by the timestamp it carries, not by its MM-DD HH:MM text.""" + + def __lt__(self, other): + return self.data(Qt.ItemDataRole.UserRole) < other.data(Qt.ItemDataRole.UserRole) + + +class LogViewer(QDialog): + """Non-modal window with one package's build log, scrolled to the end.""" + + def __init__(self, path, log, parent=None): + super().__init__(parent) + self.setWindowTitle(f"{path}: build log") + self.resize(1000, 700) + self.text = QPlainTextEdit(readOnly=True) + self.text.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + parts = [] # resource.log first: the end of build.log, where it failed, stays last + if log["resource"]: + parts.append("ββββ resource.log ββββ\n" + ANSI.sub("", log["resource"]) + "\n") + parts.append("ββββ build.log ββββ\n") + if log["truncated"]: + parts.append("[... start of build.log truncated, showing its end ...]\n") + parts.append(ANSI.sub("", log["build"])) + self.text.setPlainText("".join(parts)) + self.text.moveCursor(QTextCursor.MoveOperation.End) + QVBoxLayout(self).addWidget(self.text) + + +class ResultsTab(QWidget): + def __init__(self, remote, parent=None): + super().__init__(parent) + self.remote = remote + self.rows = [] + self.viewers = [] + self.chips = {k: chip(k) for k in KINDS} + bar = QHBoxLayout() + for c in self.chips.values(): + c.toggled.connect(self.refilter) + bar.addWidget(c) + bar.addStretch() + self.count = QLabel() + bar.addWidget(self.count) + refresh = QPushButton("Refresh") + refresh.clicked.connect(self.reload) + bar.addWidget(refresh) + + self.table = QTableWidget(0, 4, sortingEnabled=True) + self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"]) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.verticalHeader().hide() + self.table.horizontalHeader().setStretchLastSection(True) + for col, width in enumerate((90, 320, 140)): + self.table.setColumnWidth(col, width) + self.table.itemSelectionChanged.connect(self.show_detail) + self.table.doubleClicked.connect(self.show_log) + + self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True, + alignment=Qt.AlignmentFlag.AlignTop) + self.detail.setContentsMargins(12, 8, 12, 8) + self.log_btn = QPushButton("Show build log") + self.log_btn.clicked.connect(self.show_log) + self.log_btn.setEnabled(False) + side = QWidget() + col = QVBoxLayout(side) + col.addWidget(self.detail) + col.addWidget(self.log_btn) + col.addStretch() + split = QSplitter() + split.addWidget(self.table) + split.addWidget(side) + split.setSizes([750, 350]) + + layout = QVBoxLayout(self) + layout.addLayout(bar) + layout.addWidget(split) + + def reload(self): + self.count.setText("loadingβ¦") + self.remote.call(self._loaded, "results") + + def _loaded(self, data, err): + if err: + self.count.setText(f"results failed: {err}") + return + self.rows = data["rows"] + self.table.setSortingEnabled(False) + self.table.setRowCount(len(self.rows)) + for i, (path, result, when, version, has_log) in enumerate(self.rows): + items = [QTableWidgetItem(result), QTableWidgetItem(path), + QTableWidgetItem(version or ""), TimeItem(fmt_time(when))] + if result in STATUS: + items[0].setForeground(QColor(STATUS[result])) + items[3].setData(Qt.ItemDataRole.UserRole, when) + items[1].setData(Qt.ItemDataRole.UserRole, i) + for c, item in enumerate(items): + self.table.setItem(i, c, item) + self.table.setSortingEnabled(True) + self.table.sortItems(3, Qt.SortOrder.DescendingOrder) # newest attempt first + self.refilter() + + def filter_to(self, result): + """Show only one result kind (from a Dashboard click).""" + for k, c in self.chips.items(): + c.blockSignals(True) + c.setChecked(k == result) + c.blockSignals(False) + self.refilter() + + def refilter(self): + on = {k for k, c in self.chips.items() if c.isChecked()} + shown = 0 + for r in range(self.table.rowCount()): + hide = bool(on) and self.table.item(r, 0).text() not in on + self.table.setRowHidden(r, hide) + shown += not hide + if hide and self.table.item(r, 0).isSelected(): + self.table.clearSelection() # no detail for a row the filter hides + self.count.setText(f"{shown} of {self.table.rowCount()}") + + def visible_paths(self): + return [self.table.item(r, 1).text() for r in range(self.table.rowCount()) + if not self.table.isRowHidden(r)] + + def current(self): + rows = self.table.selectionModel().selectedRows() + if not rows: + return None + return self.rows[self.table.item(rows[0].row(), 1).data(Qt.ItemDataRole.UserRole)] + + def show_detail(self): + row = self.current() + self.log_btn.setEnabled(bool(row and row[4])) + if not row: + self.detail.setText("") + return + path, result, when, version, has_log = row + color = STATUS.get(result) + res = f'<span style="color:{color}">{result}</span>' if color else html.escape(result) + self.detail.setText( + f"<h3>{html.escape(path.split('/', 1)[1])}</h3><p>{html.escape(path)}</p>" + f"<p>last attempt: {res}, {fmt_time(when)}<br>built version: " + f"{html.escape(version or NA)}</p>" + + ("" if has_log else "<p><i>no build log kept for this package</i></p>")) + + def show_log(self, *_): + row = self.current() + if not row or not row[4]: + return + path = row[0] + + def got(log, err): + if err: + self.count.setText(f"build log: {err}") + return + viewer = LogViewer(path, log, self) + viewer.finished.connect(lambda *_: self.viewers.remove(viewer)) + self.viewers.append(viewer) + viewer.show() + self.remote.call(got, "build-log", path) diff --git a/tests/test_gui.py b/tests/test_gui.py index fb0044c..d77df20 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -29,7 +29,7 @@ import unittest # noqa: E402 ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") sys.path.insert(0, ROOT) -from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QProcess, pyqtSignal # noqa: E402 +from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QPointF, QProcess, pyqtSignal # noqa: E402 from PyQt6.QtGui import QColor # noqa: E402 from PyQt6.QtWidgets import QApplication # noqa: E402 @@ -146,6 +146,9 @@ INVENTORY = {"columns": COLS, "rows": [ inv_row("pentesting/nmap", built="7.9", result="bad"), inv_row("network/curl"), ]} +RESULTS = {"rows": [["games/pcsx2", "failed", 1789992366, None, True], # newest first, as the helper + ["development/whisper.cpp", "aborted", 1789753147, None, False], + ["misc/nuclei", "ok", 1788978222, "3.11.0", True]]} PREVIEW = {"id": "20260101-000000-slackrepo", "kind": "slackrepo", "status": "ok", "args": ["update", "--preview", "nuclei"], "argv": [], "created": 0, "start": 0, "end": 1} @@ -287,7 +290,11 @@ class SmokeTest(unittest.TestCase): remote = FakeRemote({"version": ({"version": VERSION, "overlays": ["personal"]}, None), "stats": (STATS, None), "jobs": ({"jobs": []}, None), "gpg-status": ({"unlocked": True, "key": "K"}, None), - "inventory": (INVENTORY, None), **answers}) + "inventory": (INVENTORY, None), "results": (RESULTS, None), + "build-log": ({"build": "\x1b[1mERROR\x1b[0m: make failed\n", + "truncated": True, "mtime": 0, + "resource": "peak memory 3308M\n"}, None), + **answers}) w = MainWindow(config.load("/nonexistent")[0], remote, FakeNotifier()) self.addCleanup(w.deleteLater) self.addCleanup(lambda: w.jobs_tab and w.jobs_tab.stop()) @@ -434,7 +441,8 @@ class SmokeTest(unittest.TestCase): def test_ribbon_opens_tabs(self): w, _ = self.window() w.poll_jobs() - self.assertEqual([a.text() for a in w.ribbon.actions()], ["Dashboard", "Packages", "Jobs"]) + self.assertEqual([a.text() for a in w.ribbon.actions()], + ["Dashboard", "Packages", "Results", "Jobs"]) self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"]) w.ribbon.actions()[1].trigger() self.assertIs(w.tabs.currentWidget(), w.packages_tab) @@ -453,6 +461,41 @@ class SmokeTest(unittest.TestCase): prev.activated.emit() self.assertEqual(w.tabs.currentIndex(), 2) + def test_results_tab(self): + w, remote = self.window() + w.poll_jobs() + tab = w.open_results() + tab.table.selectRow(2) # nuclei, ok + tab.filter_to("failed") + self.assertEqual(tab.detail.text(), "") # hidden by the filter: no stale detail + self.assertIs(w.tabs.currentWidget(), tab) + self.assertEqual(tab.visible_paths(), ["games/pcsx2"]) + tab.chips["failed"].setChecked(False) + self.assertEqual(tab.visible_paths(), ["games/pcsx2", "development/whisper.cpp", "misc/nuclei"]) + tab.table.selectRow(1) # whisper.cpp: no log dir + self.assertFalse(tab.log_btn.isEnabled()) + tab.table.selectRow(0) + self.assertTrue(tab.log_btn.isEnabled()) + tab.show_log() + self.assertIn(("build-log", "games/pcsx2"), remote.calls) + viewer = tab.viewers[-1] + self.assertIn("ERROR: make failed", viewer.text.toPlainText()) # ANSI stripped + self.assertIn("peak memory", viewer.text.toPlainText()) + self.assertIn("truncated", viewer.text.toPlainText()) + self.assertTrue(viewer.text.toPlainText().endswith("make failed\n")) # failure stays last + viewer.close() + + def test_dashboard_results_clicks(self): + w, _ = self.window(stats=(dict(STATS, slackrepo={"results": {"ok": 3, "failed": 1}, "built": 3}), None)) + w.poll_jobs() + w.dashboard.legend.linkActivated.emit("failed") + self.assertIs(w.tabs.currentWidget(), w.results_tab) + self.assertEqual(w.results_tab.visible_paths(), ["games/pcsx2"]) + bar = w.dashboard.bar + bar.resize(400, 14) + hit = bar.label_at(QPointF(5, 5)) + self.assertEqual(hit, "ok") + def test_log_carriage_return(self): w, _ = self.window() jobs = w.open_jobs() diff --git a/tests/test_remote.py b/tests/test_remote.py index 2f273fb..85e642b 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -446,14 +446,56 @@ class StatsTest(Base): create table buildresults (itemid text primary key, time text, result text); create table revisions (itemid text, dep text, deplist text, version text, built text, rev text, os text, hintcksum text, primary key (itemid, dep)); - insert into buildresults values ('a', '1', 'ok'), ('b', '1', 'ok'), ('c', '1', 'failed'); + insert into buildresults values ('cat/a', '1', 'ok'), ('cat/b', '1', 'ok'), + ('cat/c', '1', 'failed'), ('ghost', '1', 'bad'), ('c-1.0-x86_64-1_x', '1', 'bad'); insert into revisions values ('cat/a', '/', '/', '1', '1', 'r', 'o', '/'), ('cat/a', 'dep', '/', '1', '1', 'r', 'o', '/'); """) db.close() + # legacy keys without a category/ match no package and are not counted self.assertEqual(sbb.cmd_stats(self.cfg)["slackrepo"], {"results": {"ok": 2, "failed": 1}, "built": 1}) +class ResultsTest(Base): + def setUp(self): + super().setUp() + logdir = os.path.join(self.tmp, "logs") + self.cfg["slackrepo_logdir"] = logdir + db = sqlite3.connect(self.cfg["slackrepo_db"]) + db.executescript(""" + create table buildresults (itemid text primary key, time text, result text); + create table revisions (itemid text, dep text, deplist text, version text, + built text, rev text, os text, hintcksum text, primary key (itemid, dep)); + insert into buildresults values ('cat/a', '100', 'ok'), ('cat/c', '300', 'failed'), + ('ghost', '200', 'bad'); + insert into revisions values ('cat/a', '/', '/', '1.0', '100', 'r', 'o', '/'); + """) + db.close() + os.makedirs(os.path.join(logdir, "cat", "c")) + with open(os.path.join(logdir, "cat", "c", "build.log"), "w") as f: + f.write("x" * 100 + "\nERROR: make failed\n") + with open(os.path.join(logdir, "cat", "c", "resource.log"), "w") as f: + f.write("peak memory 3308M\n") + + def test_rows(self): + self.assertEqual(sbb.cmd_results(self.cfg), {"rows": [ + ["cat/c", "failed", 300, None, True], + ["cat/a", "ok", 100, "1.0", False]]}) + + def test_build_log_tail(self): + with mock.patch.object(sbb, "LOG_TAIL", 20): + log = sbb.cmd_build_log(self.cfg, "cat/c") + self.assertEqual(log["build"], "\nERROR: make failed\n"[-20:]) + self.assertTrue(log["truncated"]) + self.assertEqual(log["resource"], "peak memory 3308M\n") + self.assertIsInstance(log["mtime"], int) + + def test_build_log_rejects(self): + for bad in ("../etc/passwd", "cat/../../x", "cat", "cat/a", "/etc/x"): + with self.subTest(bad=bad), self.assertRaises(sbb.Error): + sbb.cmd_build_log(self.cfg, bad) + + if __name__ == "__main__": unittest.main() |
