Add ability to send a bug report from within the app
Some checks failed
CI / test (push) Successful in 3m23s
Lint / test (push) Failing after 27s
Trivy / test (push) Successful in 21s

This commit is contained in:
Miguel Jacq 2025-11-17 16:06:33 +11:00
parent 6bc5b66d3f
commit eedf48dc6a
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 345 additions and 25 deletions

View file

@ -2,6 +2,7 @@
* Add weekday letters on left axis of Statistics page
* Add the ability to choose the database path at startup
* Add in-app bug report functionality
# 0.3.1

View file

@ -11,7 +11,8 @@ for SQLite3. This means that the underlying database for the notebook is encrypt
To increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk unless the user configures it to be in the settings.
There is deliberately no network connectivity or syncing intended.
There is deliberately no network connectivity or syncing intended, other than the option to send a bug
report from within the app.
## Screenshots

View file

@ -0,0 +1,131 @@
from __future__ import annotations
import importlib.metadata
from pathlib import Path
import requests
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QTextEdit,
QDialogButtonBox,
QMessageBox,
)
from . import strings
BUG_REPORT_HOST = "https://nr.mig5.net"
ROUTE = "forms/bouquin/bugs"
class BugReportDialog(QDialog):
"""
Dialog to collect a bug report
"""
MAX_CHARS = 5000
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("report_a_bug"))
self._attachment_path: Path | None = None
layout = QVBoxLayout(self)
header = QLabel(strings._("bug_report_explanation"))
header.setWordWrap(True)
layout.addWidget(header)
self.text_edit = QTextEdit()
self.text_edit.setPlaceholderText(strings._("bug_report_placeholder"))
layout.addWidget(self.text_edit)
self.text_edit.textChanged.connect(self._enforce_max_length)
# Buttons: Cancel / Send
button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
self.send_button = button_box.addButton(
strings._("send"), QDialogButtonBox.AcceptRole
)
button_box.accepted.connect(self._send)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.text_edit.setFocus()
# ------------Helpers ------------ #
def _enforce_max_length(self):
text = self.text_edit.toPlainText()
if len(text) <= self.MAX_CHARS:
return
# Remember cursor position
cursor = self.text_edit.textCursor()
pos = cursor.position()
# Trim and restore without re-entering this slot
self.text_edit.blockSignals(True)
self.text_edit.setPlainText(text[: self.MAX_CHARS])
self.text_edit.blockSignals(False)
# Clamp cursor position to end of text
if pos > self.MAX_CHARS:
pos = self.MAX_CHARS
cursor.setPosition(pos)
self.text_edit.setTextCursor(cursor)
def _send(self):
text = self.text_edit.toPlainText().strip()
if not text:
QMessageBox.warning(
self,
strings._("report_a_bug"),
strings._("bug_report_empty"),
)
return
# Get current app version
try:
version = importlib.metadata.version("bouquin")
except importlib.metadata.PackageNotFoundError:
version = "unknown"
payload: dict[str, str] = {
"message": text,
"version": version,
}
# POST as JSON
try:
resp = requests.post(
f"{BUG_REPORT_HOST}/{ROUTE}",
json=payload,
timeout=10,
)
except Exception as e:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f"\n{e}",
)
return
if resp.status_code == 201:
QMessageBox.information(
self,
strings._("report_a_bug"),
strings._("bug_report_sent_ok"),
)
self.accept()
else:
QMessageBox.critical(
self,
strings._("report_a_bug"),
strings._("bug_report_send_failed") + f" (HTTP {resp.status_code})",
)

View file

@ -148,6 +148,11 @@
"stats_metric_words": "Words",
"stats_metric_revisions": "Revisions",
"stats_no_data": "No statistics available yet.",
"select_notebook": "Select notebook"
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
"bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send"
}

View file

@ -56,6 +56,7 @@ from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
from .bug_report_dialog import BugReportDialog
from . import strings
from .tags_widget import PageTagsWidget
from .toolbar import ToolBar
@ -1292,14 +1293,8 @@ class MainWindow(QMainWindow):
)
def _open_bugs(self):
url_str = "https://nr.mig5.net/forms/mig5/contact"
url = QUrl.fromUserInput(url_str)
if not QDesktopServices.openUrl(url):
QMessageBox.warning(
self,
strings._("report_a_bug"),
strings._("couldnt_open") + url.toDisplayString(),
)
dlg = BugReportDialog(self)
dlg.exec()
def _open_version(self):
version = importlib.metadata.version("bouquin")

2
poetry.lock generated
View file

@ -756,4 +756,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<3.14"
content-hash = "8c65ccc55e84371f8695117dcd01ca9ad2d78b159327045eced824e5f425a7d0"
content-hash = "cdb45b4ed472b5fd77e4c5b6ccd88ad4402b4e5473279627177cccb960a29f27"

View file

@ -13,6 +13,7 @@ include = ["bouquin/locales/*.json"]
python = ">=3.9,<3.14"
pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0"
requests = "^2.32.5"
[tool.poetry.scripts]
bouquin = "bouquin.__main__:main"

View file

@ -0,0 +1,195 @@
import bouquin.bug_report_dialog as bugmod
from bouquin.bug_report_dialog import BugReportDialog
def test_bug_report_truncates_text_to_max_chars(qtbot):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
# Make a string longer than the allowed maximum
long_text = "x" * (max_chars + 50)
# Setting the text should trigger textChanged -> _enforce_max_length
dlg.text_edit.setPlainText(long_text)
# Let Qt process the signal/slot if needed
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
assert len(current) == max_chars
assert current == long_text[:max_chars]
def test_bug_report_allows_up_to_max_chars_unchanged(qtbot):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
max_chars = getattr(dlg, "MAX_CHARS", 5000)
exact_text = "y" * max_chars
dlg.text_edit.setPlainText(exact_text)
qtbot.wait(10)
current = dlg.text_edit.toPlainText()
# Should not be trimmed if it's exactly the limit
assert len(current) == max_chars
assert current == exact_text
def test_bug_report_send_success_201_shows_info_and_accepts(qtbot, monkeypatch):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
# Non-empty message so we don't hit the "empty" warning branch
dlg.text_edit.setPlainText("Hello, something broke.")
qtbot.wait(10)
# Make version() deterministic
def fake_version(pkg_name):
assert pkg_name == "bouquin"
return "1.2.3"
monkeypatch.setattr(
bugmod.importlib.metadata, "version", fake_version, raising=True
)
# Capture the POST call and fake a 201 Created response
calls = {}
class DummyResp:
status_code = 201
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
# Capture information / critical message boxes
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
# Don't actually close the dialog in the test; just record that accept() was called
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
# Call the send logic directly
dlg._send()
# --- Assertions ---------------------------------------------------------
# POST was called with the expected URL and JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Hello, something broke."
assert calls["json"]["version"] == "1.2.3"
# No attachment fields expected any more
# Success path: information dialog shown, critical not shown
assert "title" in info_called
assert "text" in info_called
assert crit_called == {}
# Dialog accepted
assert accepted.get("called") is True
def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted(
qtbot, monkeypatch
):
dlg = BugReportDialog()
qtbot.addWidget(dlg)
dlg.show()
dlg.text_edit.setPlainText("Broken again.")
qtbot.wait(10)
# Stub version() again
monkeypatch.setattr(
bugmod.importlib.metadata,
"version",
lambda name: "9.9.9",
raising=True,
)
# Fake a non-201 response (e.g. 500)
calls = {}
class DummyResp:
status_code = 500
def fake_post(url, json=None, timeout=None):
calls["url"] = url
calls["json"] = json
calls["timeout"] = timeout
return DummyResp()
monkeypatch.setattr(bugmod.requests, "post", fake_post, raising=True)
info_called = {}
crit_called = {}
def fake_info(parent, title, text, *a, **k):
info_called["title"] = title
info_called["text"] = str(text)
return 0
def fake_critical(parent, title, text, *a, **k):
crit_called["title"] = title
crit_called["text"] = str(text)
return 0
monkeypatch.setattr(
bugmod.QMessageBox, "information", staticmethod(fake_info), raising=True
)
monkeypatch.setattr(
bugmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
)
accepted = {}
def fake_accept():
accepted["called"] = True
dlg.accept = fake_accept
dlg._send()
# POST still called with JSON payload
assert calls["url"] == f"{bugmod.BUG_REPORT_HOST}/{bugmod.ROUTE}"
assert calls["json"]["message"] == "Broken again."
assert calls["json"]["version"] == "9.9.9"
# Failure path: critical dialog shown, information not shown
assert crit_called # non-empty
assert info_called == {}
# Dialog should NOT be accepted on failure
assert accepted.get("called") is not True

View file

@ -80,7 +80,7 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
assert "carry me" not in y_txt or "- [ ]" not in y_txt
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
@ -101,16 +101,12 @@ def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch)
t = str(text)
if "wiki" in t:
called["docs"] = True
if "forms/mig5/contact" in t or "contact" in t:
called["bugs"] = True
return 0
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
# Trigger both actions
w._open_docs()
w._open_bugs()
assert called["docs"] and called["bugs"]
assert called["docs"]
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
@ -900,9 +896,7 @@ def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path)
# ---- Help openers (1152-1169) ----
def test_open_docs_and_bugs_show_warning_on_failure(
qtbot, tmp_db_cfg, app, monkeypatch
):
def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch):
w = _make_main_window(tmp_db_cfg, app, monkeypatch)
qtbot.addWidget(w)
@ -914,17 +908,14 @@ def test_open_docs_and_bugs_show_warning_on_failure(
def warn(parent, title, text, *a, **k):
if "documentation" in title.lower():
seen["docs"] = True
if "bug" in title.lower():
seen["bugs"] = True
return 0
monkeypatch.setattr(
mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)})
)
w._open_docs()
w._open_bugs()
assert seen["docs"] and seen["bugs"]
assert seen["docs"]
def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):