Add ability to send a bug report from within the app
This commit is contained in:
parent
6bc5b66d3f
commit
eedf48dc6a
9 changed files with 345 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
131
bouquin/bug_report_dialog.py
Normal file
131
bouquin/bug_report_dialog.py
Normal 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})",
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
poetry.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
195
tests/test_bug_report_dialog.py
Normal file
195
tests/test_bug_report_dialog.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue