feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions) one dialog drafts the issue: title, description, the steps drafted from the session's markers and page changes (a marker without a label is named by its time), the environment (version, browser, system, window, language). Beside it, what goes with it: the browser and server logs — each reviewable line by line, an unticked line is not sent — and the screenshots, removable, with paste / drop / pick to add more. - it saves as it goes (debug_sessions.draft, migration 0090): the rendered markdown in the user's language, the excluded rows, and its own fields to reopen as it was; a sent session is read-only - the zip export (issue.md, client.log, server.log, screenshots) is built from the saved draft by app/issues/bundle.py — the one definition the Gitea delivery (S4) will share; .log, not .jsonl, which Gitea refuses - the log line's level tags and clock move to components/logLineParts so the review list draws a level the way both log viewers do
This commit is contained in:
@@ -127,10 +127,13 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user):
|
||||
("get", f"/api/debug-sessions/{sid}/screenshots"),
|
||||
("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
|
||||
("delete", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
|
||||
("get", f"/api/debug-sessions/{sid}/export"),
|
||||
):
|
||||
r = getattr(stranger, method)(path)
|
||||
assert r.status_code == 404, (method, path, r.status_code)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "debug_session"}
|
||||
r = stranger.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"})
|
||||
assert r.status_code == 404
|
||||
r = stranger.post(
|
||||
f"/api/debug-sessions/{sid}/screenshots",
|
||||
content=_PNG,
|
||||
@@ -446,3 +449,97 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
|
||||
|
||||
def test_the_upload_path_left_out_of_the_evidence_is_the_real_one():
|
||||
assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes}
|
||||
|
||||
|
||||
# --- the draft and the export ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_draft_is_kept_and_comes_back_with_the_session(recording):
|
||||
client, sid = recording
|
||||
draft = {
|
||||
"title": "Spinner stays",
|
||||
"body": "## Steps\n1. open",
|
||||
"excluded": [3],
|
||||
"form": {"a": 1},
|
||||
}
|
||||
r = client.put(f"/api/debug-sessions/{sid}/draft", json=draft)
|
||||
assert r.status_code == 200 and r.json()["session"]["draft"] == draft
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["draft"] == draft
|
||||
|
||||
|
||||
def test_a_draft_is_bounded(recording, monkeypatch):
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
client, sid = recording
|
||||
monkeypatch.setattr(routes, "FORM_MAX_BYTES", 16)
|
||||
r = client.put(
|
||||
f"/api/debug-sessions/{sid}/draft",
|
||||
json={"title": "t", "body": "b", "form": {"text": "x" * 32}},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
too_long = {"title": "t" * 201, "body": "b"}
|
||||
assert client.put(f"/api/debug-sessions/{sid}/draft", json=too_long).status_code == 422
|
||||
|
||||
|
||||
def test_a_sent_session_keeps_its_draft(db, recording):
|
||||
client, sid = recording
|
||||
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
|
||||
db.commit()
|
||||
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"})
|
||||
assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent"
|
||||
|
||||
|
||||
def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user):
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
user = make_user()
|
||||
client = as_user(user)
|
||||
sid = _start(client)["id"]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def row(ts, source, msg):
|
||||
return LogEvent(
|
||||
ts=ts,
|
||||
level="INFO",
|
||||
sev=9,
|
||||
source=source,
|
||||
logger=source,
|
||||
event="x.y",
|
||||
msg=msg,
|
||||
user_id=user.id,
|
||||
debug_session_id=sid,
|
||||
)
|
||||
|
||||
kept_web, dropped_web, kept_api = (
|
||||
row(now, "web", "kept click"),
|
||||
row(now + timedelta(seconds=1), "web", "private search"),
|
||||
row(now + timedelta(seconds=2), "api", "GET /api/x 200"),
|
||||
)
|
||||
db.add_all([kept_web, dropped_web, kept_api])
|
||||
db.commit()
|
||||
_upload(client, sid, _PNG)
|
||||
_upload(client, sid, _JPEG)
|
||||
client.put(
|
||||
f"/api/debug-sessions/{sid}/draft",
|
||||
json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]},
|
||||
)
|
||||
|
||||
r = client.get(f"/api/debug-sessions/{sid}/export")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "application/zip"
|
||||
assert "attachment;" in r.headers["content-disposition"]
|
||||
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||
assert z.namelist() == [
|
||||
"issue.md",
|
||||
"client.log",
|
||||
"server.log",
|
||||
"screenshot-1.png",
|
||||
"screenshot-2.jpg",
|
||||
]
|
||||
assert z.read("issue.md").decode() == "# Spinner stays\n\nIt spins.\n"
|
||||
web = [json.loads(line)["msg"] for line in z.read("client.log").decode().splitlines()]
|
||||
api = [json.loads(line)["msg"] for line in z.read("server.log").decode().splitlines()]
|
||||
assert "kept click" in web and "private search" not in web
|
||||
assert "GET /api/x 200" in api and all(m != "kept click" for m in api)
|
||||
assert z.read("screenshot-1.png") == _PNG
|
||||
|
||||
Reference in New Issue
Block a user