diff options
| -rw-r--r-- | sb_builder/hints_tab.py | 277 | ||||
| -rw-r--r-- | sb_builder/mainwindow.py | 45 | ||||
| -rw-r--r-- | sb_builder/packages.py | 53 | ||||
| -rw-r--r-- | tests/test_gui.py | 71 |
4 files changed, 438 insertions, 8 deletions
diff --git a/sb_builder/hints_tab.py b/sb_builder/hints_tab.py new file mode 100644 index 0000000..6be57fc --- /dev/null +++ b/sb_builder/hints_tab.py @@ -0,0 +1,277 @@ +# sb-builder - Hints 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. + +"""Hints tab: upstream check, hint jobs, and the diff/revert dialog after them.""" + +import difflib +import os + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFontDatabase +from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, + QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + +from . import upstream +from .dashboard import STATUS +from .packages import chip, load_rows + +STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") +STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], + "nvchecker error": STATUS["failed"]} +COLUMNS = ("", "package", "hint", ".info", "upstream", "state") +HINT_JOBS = ("hint-update", "hint-new") + + +class HintsTab(QWidget): + start_requested = pyqtSignal(list) # job-start arguments + + def __init__(self, remote, cfg, stanzas=upstream.STANZAS, parent=None): + super().__init__(parent) + self.remote, self.cfg, self.stanzas = remote, cfg, stanzas + self.rows = [] + self.names = upstream.stanzas(stanzas) + self.versions = None # {pkg: version} after Check upstream + self.errors, self.failure = {}, "" + self.checker = upstream.Checker(self) + self.checker.done.connect(self._checked) + + self.query = QLineEdit(placeholderText="search package") + self.query.textChanged.connect(self.refilter) + self.chips = {} + chips = QHBoxLayout() + for name in STATES: + self.chips[name] = chip(name) + self.chips[name].toggled.connect(self.refilter) + chips.addWidget(self.chips[name]) + chips.addStretch() + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.clicked.connect(self.reload) + chips.addWidget(self.refresh_btn) + + self.table = QTableWidget(0, len(COLUMNS)) + self.table.setHorizontalHeaderLabels(COLUMNS) + 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((28, 220, 110, 110, 110)): + self.table.setColumnWidth(col, width) + + self.status = QLabel() + self.check_btn = QPushButton("Check upstream") + self.check_btn.clicked.connect(self.check) + self.update_btn = QPushButton("Update selected") + self.update_btn.clicked.connect(self.update_selected) + self.no_dl = QCheckBox("no download") + self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache') + bar = QHBoxLayout() + bar.addWidget(self.status) + bar.addStretch() + for w in (self.check_btn, self.update_btn, self.no_dl): + bar.addWidget(w) + delete = QPushButton("Delete hint") + delete.clicked.connect(self.delete) + bar.addWidget(delete) + + layout = QVBoxLayout(self) + layout.addWidget(self.query) + layout.addLayout(chips) + layout.addWidget(self.table) + layout.addLayout(bar) + + def reload(self): + self.status.setText("loading…") + self.remote.call(self._loaded, "inventory") + + def _loaded(self, data, err): + if err: + self.status.setText(f"inventory failed: {err}") + return + self.names = upstream.stanzas(self.stanzas) # re-read: the file is edited by hand + self.rows = [r for r in load_rows(data) if r["hint_path"] or r["name"] in self.names] + self._fill() + + def _fill(self, check_outdated=False): + checked = self.checked() if not check_outdated else [] + current = self.current() + self.table.setSortingEnabled(False) + self.table.setRowCount(len(self.rows)) + for i, r in enumerate(self.rows): + r["state"] = upstream.state(r, self.names, self.versions, self.errors) + box = QTableWidgetItem() + box.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) + on = r["state"] == "outdated" if check_outdated else r["name"] in checked + box.setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked) + box.setData(Qt.ItemDataRole.UserRole, r["name"]) + self.table.setItem(i, 0, box) + up = (self.versions or {}).get(r["name"], "") + for col, text in enumerate((r["name"], r["hint_version"] or "", r["info_version"] or "", + up, r["state"]), 1): + self.table.setItem(i, col, QTableWidgetItem(text)) + state = self.table.item(i, 5) + if r["state"] in STATE_COLOR: + state.setForeground(QColor(STATE_COLOR[r["state"]])) + if r["state"] == "nvchecker error": + state.setToolTip(self.errors.get(r["name"]) or self.failure or "no result") + self.table.setSortingEnabled(True) + if current: + self.select(current) + self.refilter() + + def refilter(self): + on = {n for n, b in self.chips.items() if b.isChecked()} + text = self.query.text().lower() + shown = 0 + for i in range(self.table.rowCount()): + name, state = self.table.item(i, 1).text(), self.table.item(i, 5).text() + hide = (on and state not in on) or text not in name.lower() + self.table.setRowHidden(i, bool(hide)) + shown += not hide + n = len(self.checked()) + self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else "")) + + def checked(self): + return [self.table.item(i, 0).data(Qt.ItemDataRole.UserRole) + for i in range(self.table.rowCount()) + if self.table.item(i, 0).checkState() == Qt.CheckState.Checked] + + def current(self): + i = self.table.currentRow() + return self.table.item(i, 1).text() if i >= 0 and self.table.item(i, 1) else None + + def select(self, name): + for i in range(self.table.rowCount()): + if self.table.item(i, 1).text() == name: + self.table.setRowHidden(i, False) + self.table.selectRow(i) + return + + def check(self): + self.check_btn.setEnabled(False) + self.status.setText("checking upstream…") + self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas) + + def _checked(self, versions, errors, failure): + self.check_btn.setEnabled(True) + self.versions, self.errors, self.failure = versions, errors, failure + self._fill(check_outdated=True) + + def update_selected(self): + versions = self.versions or {} + pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions] + if pairs: + self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else [])) + + def delete(self): + name = self.current() + if name and QMessageBox.question(self, "Delete hint", f"Delete the hint of {name}? " + "(kept as .bak, Revert restores it)") \ + == QMessageBox.StandardButton.Yes: + self.remote.call(self._changed, "hint-delete", name) + + def _changed(self, _data, err): + if err: + QMessageBox.warning(self, "Hint", err) + self.reload() + + +class HintResultDialog(QDialog): + """After a hint job: each changed package's diff, Revert per package, Build these.""" + + build_requested = pyqtSignal(list) # package names + changed = pyqtSignal() + + def __init__(self, remote, result, parent=None): + super().__init__(parent) + self.remote = remote + self.setWindowTitle("Hint changes") + self.resize(900, 600) + self.list = QListWidget() + for pkg in result["ok"]: + item = QListWidgetItem(pkg) + item.setData(Qt.ItemDataRole.UserRole, pkg) + self.list.addItem(item) + self.list.currentItemChanged.connect(self._show) + self.diff = QPlainTextEdit(readOnly=True) + self.diff.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + split = QSplitter() + split.addWidget(self.list) + split.addWidget(self.diff) + split.setSizes([250, 650]) + failed = QLabel("\n".join(f"{p}: {why}" for p, why in result["failed"])) + failed.setStyleSheet(f"color: {STATUS['failed']}") + failed.setVisible(bool(result["failed"])) + self.revert_btn = QPushButton("Revert") + self.revert_btn.clicked.connect(self.revert) + build = QPushButton("Build these") + build.clicked.connect(self.build) + close = QPushButton("Close") + close.clicked.connect(self.accept) + bar = QHBoxLayout() + bar.addWidget(self.revert_btn) + bar.addStretch() + bar.addWidget(build) + bar.addWidget(close) + layout = QVBoxLayout(self) + layout.addWidget(split, 1) + layout.addWidget(failed) + layout.addLayout(bar) + if self.list.count(): + self.list.setCurrentRow(0) # the diff of each package is fetched when selected + + def _show(self, item, _prev): + self.diff.clear() + if item: + pkg = item.data(Qt.ItemDataRole.UserRole) + self.remote.call(lambda d, err: self._diff(pkg, d, err), "hint-diff", pkg) + + def _diff(self, pkg, data, err): + item = self.list.currentItem() + if not item or item.data(Qt.ItemDataRole.UserRole) != pkg: + return # another package was selected meanwhile + if err: + self.diff.setPlainText(err) + return + old = (data["bak"] or "").splitlines(keepends=True) + new = (data["current"] or "").splitlines(keepends=True) + self.diff.setPlainText("".join(difflib.unified_diff( + old, new, f"{pkg}.hint.bak" if data["bak"] is not None else "/dev/null", + f"{pkg}.hint" if data["current"] is not None else "/dev/null")) or "no change") + + def revert(self): + item = self.list.currentItem() + if not item or item.data(Qt.ItemDataRole.UserRole + 1): + return # a second revert would delete the restored hint + pkg = item.data(Qt.ItemDataRole.UserRole) + + def done(_data, err): + if err: + QMessageBox.warning(self, "Revert", err) + return + item.setText(f"{pkg} (reverted)") + item.setData(Qt.ItemDataRole.UserRole + 1, True) + self.diff.setPlainText("reverted") + self.changed.emit() + self.remote.call(done, "hint-revert", pkg) + + def build(self): + pkgs = [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count()) + if not self.list.item(i).data(Qt.ItemDataRole.UserRole + 1)] + if pkgs: + self.build_requested.emit(pkgs) + self.accept() diff --git a/sb_builder/mainwindow.py b/sb_builder/mainwindow.py index 247b0cf..0331eb9 100644 --- a/sb_builder/mainwindow.py +++ b/sb_builder/mainwindow.py @@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QSty from . import VERSION, config from .dashboard import Dashboard from .fmt import job_title +from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab from .jobs import JobsTab from .packages import PackagesTab from .results import ResultsTab @@ -37,6 +38,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), + ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView), ("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 @@ -87,6 +89,8 @@ class MainWindow(QMainWindow): self.jobs_tab = None self.packages_tab = None self.results_tab = None + self.hints_tab = None + self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown self.seen = None # job id -> status at the last poll; None before the first self.online = None # None until the first answer self.overlays = [] @@ -119,7 +123,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, "Results": lambda: self.open_results(), + "Packages": self.open_packages, "Hints": lambda: self.open_hints(), + "Results": lambda: self.open_results(), "Jobs": self.open_jobs} for label, name, fallback in RIBBON: icon = QIcon.fromTheme(name, self.style().standardIcon(fallback)) @@ -281,9 +286,27 @@ class MainWindow(QMainWindow): if event and prev.get(j["id"], "running") != j["status"]: self.notify(event, job_title(j), j["id"]) if j["kind"] in ("slackrepo", "publish"): # build state changed - for tab in (self.packages_tab, self.results_tab): - if tab: - tab.reload() + self._reload(self.packages_tab, self.results_tab) + if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"): + self._reload(self.packages_tab, self.hints_tab) + self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"]) + + @staticmethod + def _reload(*tabs): + for tab in tabs: + if tab: + tab.reload() + + def show_hint_result(self, result): + if not result["ok"] and not result["failed"]: + return # nothing changed, nothing failed: no news + d = HintResultDialog(self.remote, result, self) + d.build_requested.connect( + lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs)) + d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab)) + d.finished.connect(lambda _r: self.hint_dialogs.remove(d)) + self.hint_dialogs.append(d) + d.show() # ── tabs ──────────────────────────────────────────────────────────────── @@ -304,11 +327,23 @@ class MainWindow(QMainWindow): if self.packages_tab is None: self.packages_tab = PackagesTab(self.remote) self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) + self.packages_tab.hints_requested.connect(self.open_hints) self.tabs.addTab(self.packages_tab, "Packages") self.packages_tab.reload() self.tabs.setCurrentWidget(self.packages_tab) return self.packages_tab + def open_hints(self, name=None): + if self.hints_tab is None: + self.hints_tab = HintsTab(self.remote, self.cfg) + self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) + self.tabs.addTab(self.hints_tab, "Hints") + self.hints_tab.reload() + if name: + self.hints_tab.select(name) + self.tabs.setCurrentWidget(self.hints_tab) + return self.hints_tab + def open_results(self, result=None): if self.results_tab is None: self.results_tab = ResultsTab(self.remote) @@ -338,6 +373,8 @@ class MainWindow(QMainWindow): self.packages_tab = None if w is self.results_tab: self.results_tab = None + if w is self.hints_tab: + self.hints_tab = None w.deleteLater() def reload_config(self): diff --git a/sb_builder/packages.py b/sb_builder/packages.py index 34ce7f5..146db51 100644 --- a/sb_builder/packages.py +++ b/sb_builder/packages.py @@ -20,8 +20,9 @@ import html from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal from PyQt6.QtGui import QColor -from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, - QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter, + QTableView, QVBoxLayout, QWidget) from . import hints as hintlib from .dashboard import STATUS @@ -209,6 +210,7 @@ def table(rows): class PackagesTab(QWidget): start_requested = pyqtSignal(list) # job-start arguments + hints_requested = pyqtSignal(str) # package to show in the Hints tab def __init__(self, remote, parent=None): super().__init__(parent) @@ -245,8 +247,22 @@ class PackagesTab(QWidget): alignment=Qt.AlignmentFlag.AlignTop) self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.detail.setContentsMargins(12, 8, 12, 8) + self.new_hint_btn = QPushButton("New hint") + self.new_hint_btn.clicked.connect(self.new_hint) + self.open_hint_btn = QPushButton("Open in Hints") + self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"])) + detail_bar = QHBoxLayout() + detail_bar.addWidget(self.new_hint_btn) + detail_bar.addWidget(self.open_hint_btn) + detail_bar.addStretch() + pane = QWidget() + pane_layout = QVBoxLayout(pane) + pane_layout.addWidget(self.detail) + pane_layout.addLayout(detail_bar) + pane_layout.addStretch() scroll = QScrollArea(widgetResizable=True) - scroll.setWidget(self.detail) + scroll.setWidget(pane) + self.show_detail(QModelIndex(), None) split = QSplitter() split.addWidget(self.table) split.addWidget(scroll) @@ -327,6 +343,37 @@ class PackagesTab(QWidget): == QMessageBox.StandardButton.Yes: self.start_requested.emit(["publish"]) + def current(self): + index = self.table.currentIndex() + return index.data(Qt.ItemDataRole.UserRole) if index.isValid() else None + def show_detail(self, current, _previous): row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None self.detail.setText(detail_html(row) if row else "") + self.new_hint_btn.setVisible(bool(row) and not row["hint_path"]) + self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) + + def ask_new_hint(self, name): + """(version or "", no download) for a new hint, None on cancel.""" + d = QDialog(self, windowTitle=f"New hint: {name}") + version = QLineEdit(placeholderText="the .info version") + no_dl = QCheckBox('no download (DOWNLOAD="no")') + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(d.accept) + buttons.rejected.connect(d.reject) + form = QFormLayout(d) + form.addRow("version", version) + form.addRow(no_dl) + form.addRow(buttons) + if d.exec() != QDialog.DialogCode.Accepted: + return None + return version.text().strip(), no_dl.isChecked() + + def new_hint(self): + row = self.current() + answer = row and self.ask_new_hint(row["name"]) + if answer: + version, no_dl = answer + self.start_requested.emit(["hint-new", row["name"], *([version] if version else []), + *(["--no-dl"] if no_dl else [])]) diff --git a/tests/test_gui.py b/tests/test_gui.py index 0da4243..aa9c03b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -450,13 +450,82 @@ class SmokeTest(unittest.TestCase): w, _ = self.window() w.poll_jobs() self.assertEqual([a.text() for a in w.ribbon.actions()], - ["Dashboard", "Packages", "Results", "Jobs"]) + ["Dashboard", "Packages", "Hints", "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) w.ribbon.actions()[0].trigger() self.assertIs(w.tabs.currentWidget(), w.dashboard) + def test_hints_tab(self): + w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None), + "hint-delete": ({"ok": True}, None)}) + w.poll_jobs() + tab = w.open_hints() + with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f: + f.write('[nuclei]\nsource = "manual"\n[curl]\nsource = "manual"\n') + self.addCleanup(os.unlink, f.name) + tab.stanzas = f.name + tab.reload() + self.assertEqual(sorted(r["name"] for r in tab.rows), ["curl", "foo", "nuclei"]) # foo: hint only + states = {tab.table.item(i, 1).text(): tab.table.item(i, 5).text() for i in range(3)} + self.assertEqual(states, {"curl": "not checked", "foo": "no stanza", "nuclei": "not checked"}) + tab._checked({"nuclei": "3.12.0", "curl": "1.0"}, {}, "") + self.assertEqual(tab.checked(), ["nuclei"]) # outdated rows come checked + tab.chips["current"].setChecked(True) + visible = [tab.table.item(i, 1).text() for i in range(3) if not tab.table.isRowHidden(i)] + self.assertEqual(visible, ["curl"]) + tab.no_dl.setChecked(True) + tab.update_selected() + self.assertIn(("job-start", "hint-update", "nuclei=3.12.0", "--no-dl"), remote.calls) + tab._checked({}, {"curl": "HTTPError(404)"}, "") + row = next(i for i in range(3) if tab.table.item(i, 1).text() == "curl") + self.assertEqual(tab.table.item(row, 5).toolTip(), "HTTPError(404)") + self.assertIs(w.open_hints("foo"), tab) + self.assertEqual(tab.current(), "foo") + + def test_hint_job_end_opens_diff(self): + job = dict(JOB, id="20260101-000000-hint-update", kind="hint-update", args=["nuclei=3.12.0"]) + w, remote = self.window(jobs=({"jobs": [job]}, None), + **{"job-result": ({"ok": ["nuclei"], "failed": [["curl", "HTTP 404"]]}, None), + "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'}, + None), + "hint-revert": ({"ok": True}, None), + "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + w.poll_jobs() + remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None) + w.poll_jobs() + self.assertEqual(len(w.hint_dialogs), 1) + d = w.hint_dialogs[0] + self.assertIn('-VERSION="3.11.1"\n+VERSION="3.12.0"', d.diff.toPlainText()) + d.revert() + d.revert() # a second revert must not reach the helper: it would delete the hint + self.assertEqual([c for c in remote.calls if c[0] == "hint-revert"], [("hint-revert", "nuclei")]) + self.assertEqual(d.list.item(0).text(), "nuclei (reverted)") + d.build() # nothing left to build + self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) + self.assertEqual(w.hint_dialogs, []) + w.show_hint_result({"ok": ["nuclei"], "failed": []}) + w.hint_dialogs[0].build() + self.assertIn(("job-start", "slackrepo", "update", "--preview", "nuclei"), remote.calls) + + def test_packages_new_hint(self): + w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-new"}, None)}) + w.poll_jobs() + tab = w.open_packages() + tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint + self.assertFalse(tab.new_hint_btn.isVisibleTo(tab)) + tab.open_hint_btn.click() + self.assertIs(w.tabs.currentWidget(), w.hints_tab) + tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint + self.assertTrue(tab.new_hint_btn.isVisibleTo(tab)) + tab.ask_new_hint = lambda name: None + tab.new_hint() + self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) + tab.ask_new_hint = lambda name: ("8.0", True) + tab.new_hint() + self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls) + def test_tab_shortcuts_wrap(self): w, _ = self.window() w.poll_jobs() |
