aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--remote/config.example.toml1
-rwxr-xr-xremote/sbb-remote47
-rw-r--r--sb_builder/__init__.py2
-rw-r--r--sb_builder/dashboard.py22
-rw-r--r--sb_builder/mainwindow.py25
-rw-r--r--sb_builder/results.py195
-rw-r--r--tests/test_gui.py49
-rw-r--r--tests/test_remote.py44
9 files changed, 378 insertions, 12 deletions
diff --git a/README.md b/README.md
index 56cb829..cf84e0d 100644
--- a/README.md
+++ b/README.md
@@ -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())}">&#9632;</span> {html.escape(k)} {num(v)}'
+ f'<span style="color:{STATUS.get(k, gray.name())}">&#9632;</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()