fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself

The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
+88 -17
View File
@@ -32,6 +32,12 @@ HEADER = "x-debug-session"
MAX_MINUTES = diagnostics.SESSION_MINUTES
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
SCREENSHOTS_MAX = 8
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
# per-session cap alone would let Start → 8 images → Stop repeat without end.
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
# accepted type always has a name.
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
# bundle says how many it left out rather than growing without bound.
BUNDLE_MAX = 5_000
@@ -48,15 +54,16 @@ class EndReason(StrEnum):
EXPIRED = "expired"
def _is_live(session: DebugSession, now: datetime) -> bool:
return session.ended_at is None and session.until > now
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
def open_session(db: Session, user: User) -> DebugSession | None:
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
`start` and the hourly sweep are what close it."""
session = _unclosed(db, user)
if session is None or not _is_live(session, datetime.now(timezone.utc)):
if session is None or not is_live(session, datetime.now(timezone.utc)):
return None
return session
@@ -71,11 +78,11 @@ def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
"""Open a session, or return the live one — a second tab pressing Start joins the recording
rather than ending it. One past its cap is closed first: the one-open-per-user index would
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
get here: the index refuses the second, and the caller retries once."""
get here: the index refuses the second, and the caller joins the one that won."""
now = datetime.now(timezone.utc)
current = _unclosed(db, user)
if current is not None:
if _is_live(current, now):
if is_live(current, now):
return current
end(current, EndReason.EXPIRED)
db.flush()
@@ -91,6 +98,12 @@ def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
return session
def stop(session: DebugSession) -> None:
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
not rename that."""
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
def end(session: DebugSession, reason: EndReason) -> None:
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
if session.ended_at is not None:
@@ -111,7 +124,7 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
if (
session is None
or session.user_id != user.id
or not _is_live(session, datetime.now(timezone.utc))
or not is_live(session, datetime.now(timezone.utc))
):
return None
return session
@@ -127,11 +140,37 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime |
return session.level, session.until
def lock(db: Session, session: DebugSession) -> None:
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
in flight sees none of them change under it, and each of them sees a send that committed."""
db.refresh(session, with_for_update=True)
def save_draft(session: DebugSession, draft: dict) -> None:
"""Keep the composer's draft (the caller validated and commits). Shape: `title`, `body` (the
rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was)."""
session.draft = draft
"""Keep the composer's draft (the caller validated, locked and commits). Shape: `title`, `body`
(the rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was). A delivery's progress
is not the composer's to replace, and is kept."""
progress = (session.draft or {}).get("delivery")
session.draft = {**draft, "delivery": progress} if progress else draft
# --- a delivery's progress -----------------------------------------------------------------
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
# composer shows are one reading of it.
def delivery(session: DebugSession) -> dict:
return dict((session.draft or {}).get("delivery") or {})
def unfinished(session: DebugSession) -> list[str]:
"""What a send that stopped half-way still owes its issue: the files not yet uploaded, and the
screenshots uploaded but not yet shown in its body (the edit that shows them failed)."""
state = delivery(session)
missing = list(state.get("pending") or [])
if not state.get("images_shown"):
missing += [n for n in state.get("links") or {} if n.startswith("screenshot-")]
return missing
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
@@ -145,13 +184,18 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
# A send that stopped half-way (app/issues/gitea.py): the files still to go.
"pending_files": ((session.draft or {}).get("delivery") or {}).get("pending") or [],
# A send that stopped half-way: what it still owes.
"pending_files": unfinished(session),
"screenshots": screenshots,
"draft": session.draft,
}
def view(db: Session, session: DebugSession) -> dict:
"""The session as the API serves it — one shape wherever it is served."""
return serialize(session, screenshot_count(db, session)) or {}
def screenshot_count(db: Session, session: DebugSession) -> int:
return (
db.scalar(
@@ -172,7 +216,7 @@ _SIGNATURES = (
def sniff(data: bytes) -> str | None:
"""The image type `data` really is — PNG, JPEG or WebP — or None."""
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
for magic, mime in _SIGNATURES:
if data.startswith(magic):
return mime
@@ -181,6 +225,17 @@ def sniff(data: bytes) -> str | None:
return None
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
return (
db.scalar(
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
.where(DebugSession.user_id == user_id)
)
or 0
)
class ScreenshotRefused(Exception):
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
@@ -190,17 +245,23 @@ class ScreenshotRefused(Exception):
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
"""Keep one screenshot. Flushes; the caller commits. Refused once the issue was sent (it can no
longer carry it), past the per-session count, or when it is not an image we can show."""
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
"""Keep one screenshot. Locks the session and flushes; the caller commits. Refused once the
issue was sent (it can no longer carry it), past the per-session count or the user's storage,
or when it is not an image we can show."""
if not data:
raise ScreenshotRefused("empty")
mime = sniff(data)
if mime is None:
raise ScreenshotRefused("not_an_image")
# Under the lock: a send in flight is waited for (and then refuses this), and two uploads at
# once cannot both pass the count.
lock(db, session)
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
raise ScreenshotRefused("too_many")
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
raise ScreenshotRefused("storage_full")
shot = DebugScreenshot(
session_id=session.id,
created_at=datetime.now(timezone.utc),
@@ -213,6 +274,16 @@ def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScre
return shot
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
"""Delete one screenshot, under the lock. Refused once the issue was sent: it carries this one
already, and a delivery names the screenshots by their order. The caller commits."""
lock(db, session)
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
db.delete(shot)
db.flush()
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
return list(
+5 -4
View File
@@ -23,8 +23,6 @@ from sqlalchemy.orm import Session
from app import debugsession, logsink
from app.models import DebugScreenshot, DebugSession
_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
@dataclass(frozen=True)
class IssueFile:
@@ -65,7 +63,8 @@ def files(db: Session, session: DebugSession) -> list[IssueFile]:
.order_by(DebugScreenshot.id)
)
for n, shot in enumerate(shots, start=1):
out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime))
ext = debugsession.IMAGE_TYPES[shot.mime]
out.append(IssueFile(f"screenshot-{n}.{ext}", shot.data, shot.mime))
return out
@@ -73,5 +72,7 @@ def zipped(items: list[IssueFile]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for item in items:
z.writestr(item.name, item.data)
# The images are compressed already: deflating them again costs time and wins nothing.
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
z.writestr(item.name, item.data, compress_type=method)
return buf.getvalue()
+57 -30
View File
@@ -17,19 +17,20 @@ A private repository's attachment answers 404 to a browser not signed in to Gite
composer's own preview shows our copies, never these links.
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
upload, the links of those uploaded), so a failure half-way keeps the issue that exists and a retry
sends only what is missing — never a second issue. Every step flushes; the caller commits, also
when a step fails, so what did happen is kept.
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
Every step flushes; the caller holds the session's lock throughout (`debugsession.lock`) and commits,
also when a step fails, so what did happen is kept. `debugsession.unfinished` reads that progress.
"""
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Literal
from typing import Any, Literal
import httpx
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app import debugsession, logs, sysconfig
from app.issues import bundle
from app.models import DebugSession
@@ -44,7 +45,8 @@ Reason = Literal[
"repo_not_found", # 404 on the repository
"public_repo", # the repository is public — an issue would publish the reporter's log
"issues_disabled", # the repository has its issue tracker switched off
"rejected", # any other 4xx (a file type the instance does not allow, …)
"rejected", # any other 4xx (a file type the instance does not allow, …), or an answer that is
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
]
@@ -91,7 +93,7 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
)
raise GiteaError("unreachable") from None
if r.status_code < 400:
if r.status_code < 300:
return r
# The status only: Gitea's message can quote back what was sent — the user's own title.
log.warning(
@@ -107,8 +109,25 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
raise GiteaError("rejected")
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash: a
crash after the issue was created would lose the record that it was."""
try:
data = r.json()
# silent-ok: not JSON is one of the answers refused (and logged) just below.
except ValueError:
data = None
if not isinstance(data, dict) or any(k not in data for k in keys):
log.warning(
"the issue tracker answered something that is not its API",
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
)
raise GiteaError("rejected")
return data
def _private_repo(client: httpx.Client, repo: str) -> dict:
data = _call(client, "GET", f"/repos/{repo}").json()
data = _json(_call(client, "GET", f"/repos/{repo}"))
if not data.get("private"):
raise GiteaError("public_repo")
if not data.get("has_issues"):
@@ -135,20 +154,30 @@ def _save(session: DebugSession, state: dict) -> None:
session.draft = {**(session.draft or {}), "delivery": state}
def deliver(db: Session, session: DebugSession) -> str:
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step."""
state = dict((session.draft or {}).get("delivery") or {})
def deliver(
db: Session, session: DebugSession, *, before_create: Callable[[], None] | None = None
) -> str:
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step.
`before_create` runs right before a NEW issue is opened — after every check that could refuse
it — and may raise to stop it (the caller's pace on new issues)."""
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
with _client(db) as (client, repo):
_private_repo(client, repo)
if "number" not in state:
created = _call(
client,
"POST",
f"/repos/{repo}/issues",
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
).json()
if before_create is not None:
before_create()
created = _json(
_call(
client,
"POST",
f"/repos/{repo}/issues",
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
),
"number",
"html_url",
)
state = {
"number": created["number"],
"pending": [f.name for f in attachments],
@@ -165,13 +194,16 @@ def deliver(db: Session, session: DebugSession) -> str:
for item in attachments:
if item.name not in state["pending"]:
continue
asset = _call(
client,
"POST",
f"/repos/{repo}/issues/{number}/assets",
params={"name": item.name},
files={"attachment": (item.name, item.data, item.mime)},
).json()
asset = _json(
_call(
client,
"POST",
f"/repos/{repo}/issues/{number}/assets",
params={"name": item.name},
files={"attachment": (item.name, item.data, item.mime)},
),
"browser_download_url",
)
state = {
**state,
"pending": [n for n in state["pending"] if n != item.name],
@@ -187,8 +219,3 @@ def deliver(db: Session, session: DebugSession) -> str:
_save(session, {**state, "images_shown": True})
db.flush()
return session.issue_url or ""
def pending(session: DebugSession) -> list[str]:
"""The files a half-finished delivery still has to send."""
return list(((session.draft or {}).get("delivery") or {}).get("pending") or [])
+29 -10
View File
@@ -127,7 +127,11 @@ def set_user(
from the request middleware, which runs OUTSIDE the session middleware.
`debug_session` tags every line with the user's open debug session (E16). The caller has
already checked it is theirs and open; a session's level arrives here as `level`/`until`."""
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
The root logger's floor is tracked per elevation: a request without the session's header (an
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
must not lower the floor a concurrent request of the session still needs."""
_user_id.set(user_id)
scope = _scope.get()
if scope is None: # outside log_requests (a script, a unit test): this context only
@@ -138,7 +142,8 @@ def set_user(
scope.debug_session_id = debug_session
scope.level = resolve_level(level) if elevated else None
if user_id is not None:
_track_elevation(user_id, scope.level, until)
key = user_id if debug_session is None else (user_id, debug_session)
_track_elevation(key, scope.level, until)
def set_job(job_id: int | None) -> None:
@@ -562,18 +567,21 @@ def current_level() -> str:
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
# test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
_elevations: dict[int, tuple[int, datetime]] = {}
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
# its level, so it is tracked apart from the user's own requests.
_ElevationKey = int | tuple[int, str]
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
_elevations_lock = threading.Lock()
_root_applied: int | None = None
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
with _elevations_lock:
if level is None or until is None:
_elevations.pop(user_id, None)
_elevations.pop(key, None)
else:
_elevations[user_id] = (level, until)
_elevations[key] = (level, until)
active = bool(_elevations)
if active or _root_applied != _base_level:
_apply_root_level()
@@ -590,8 +598,8 @@ def _apply_root_level(force: bool = False) -> None:
# different answer would otherwise apply them in either order, and the older one could win —
# the root back at the base while an elevation is still active.
with _elevations_lock:
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
del _elevations[user_id]
for key in [k for k, (_, until) in _elevations.items() if until <= now]:
del _elevations[key]
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
if force or lowest != _root_applied:
logging.getLogger().setLevel(lowest)
@@ -628,13 +636,24 @@ def forget_user(user_id: int) -> None:
del _erased[stale]
_erased[user_id] = now
with _elevations_lock:
_elevations.pop(user_id, None)
for key in [
k for k in _elevations if k == user_id or (isinstance(k, tuple) and k[0] == user_id)
]:
del _elevations[key]
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
# and every unexpired one. Recomputed here rather than left to whichever later request happens
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
_apply_root_level()
def end_session_elevation(session_id: str) -> None:
"""A debug session was stopped: its elevation ends now, not at its cap."""
with _elevations_lock:
for key in [k for k in _elevations if isinstance(k, tuple) and k[1] == session_id]:
del _elevations[key]
_apply_root_level()
def is_erased(user_id: int | None) -> bool:
if user_id is None:
return False
+64 -19
View File
@@ -11,7 +11,6 @@ those types, whatever the Content-Type claimed.
"""
import json
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -42,8 +41,11 @@ class StartIn(BaseModel):
level: debugsession.SessionLevel
def _view(db: Session, session: DebugSession) -> dict:
return debugsession.serialize(session, debugsession.screenshot_count(db, session)) or {}
_view = debugsession.view
# New sessions a user may open per hour (joining the running one is not counted): a session is a
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
@router.post("")
@@ -53,6 +55,9 @@ def start_session(
db: Session = Depends(get_db),
) -> dict:
"""Start recording, or join the recording already running (another tab started it)."""
joining = debugsession.open_session(db, user) is not None
if not joining and not _new_sessions.allow(str(user.id)):
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
try:
session = debugsession.start(db, user, body.level)
db.commit()
@@ -81,11 +86,9 @@ def stop_session(
# Logged BEFORE the session closes, while this request still carries its tag: the last line
# of the evidence is the Stop itself.
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
# Past its cap it had already ended on its own; a late Stop must not rename that.
live = session.until > datetime.now(timezone.utc)
reason = debugsession.EndReason.STOPPED if live else debugsession.EndReason.EXPIRED
debugsession.end(session, reason)
debugsession.stop(session)
db.commit()
logs.end_session_elevation(session.id)
return {"ok": True, "session": _view(db, session)}
@@ -121,6 +124,8 @@ def session_events(
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
# was — bounded here so a draft cannot become a place to store anything else.
FORM_MAX_BYTES = 64 * 1024
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
_exports = RateLimiter(max_events=6, window_seconds=60)
class DraftIn(BaseModel):
@@ -140,11 +145,14 @@ def save_draft(
db: Session = Depends(get_db),
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
# Under the lock: a save that arrives while the issue is being sent waits for the send, and then
# sees it — the draft of a sent issue cannot change after it.
debugsession.lock(db, session)
if session.issue_url is not None:
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
raise refused(409, "issue_already_sent")
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
debugsession.save_draft(session, body.model_dump())
db.commit()
return {"ok": True, "session": _view(db, session)}
@@ -158,6 +166,8 @@ def export_session(
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
what it saved."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if not _exports.allow(str(user.id)):
raise refused(429, "export_rate_limited")
data = bundle.zipped(bundle.files(db, session))
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
return Response(
@@ -171,11 +181,16 @@ def export_session(
# --- the issue tracker (S4) -----------------------------------------------------------------
# Issues a user may OPEN per hour — finishing a half-sent one is not counted. The composer sends one
# at a time; this is room for a bad afternoon, not a pace anything legitimate keeps up.
# Issues a user may OPEN per hour — finishing a half-sent one, or an attempt refused before the issue
# was opened, is not counted. The composer sends one at a time; this is room for a bad afternoon, not
# a pace anything legitimate keeps up.
_new_issues = RateLimiter(max_events=5, window_seconds=3600)
def _issues_rate_limited() -> HTTPException:
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
@router.post("/{sid}/issue")
def send_issue(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
@@ -183,22 +198,40 @@ def send_issue(
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
# Held to the end: a second send (another tab, a retry after a timeout) waits for this one and
# then finds the issue it opened, instead of opening another; an autosave or a screenshot waits
# too, and then finds the issue sent.
debugsession.lock(db, session)
new = session.issue_url is None
if not new and not gitea.pending(session):
if not new and not debugsession.unfinished(session):
return {"ok": True, "session": _view(db, session)}
key = str(user.id)
if new:
if not ((session.draft or {}).get("title") or "").strip():
raise refused(422, "issue_title_required")
if not _new_issues.allow(str(user.id)):
raise refused(429, "issues_rate_limited")
if _new_issues.exhausted(key):
raise _issues_rate_limited()
def count_new_issue() -> None:
if not _new_issues.allow(key):
raise _issues_rate_limited()
try:
gitea.deliver(db, session)
gitea.deliver(db, session, before_create=count_new_issue)
except gitea.GiteaError as exc:
# Keep what did happen: an issue created before an upload failed stays linked, and the
# retry sends only the files still missing.
# retry does only what is still missing.
_record_created(db, user, session, new)
db.commit()
raise issue_refused(exc) from None
except HTTPException:
raise # the pace, refused before anything was opened
except Exception:
# Anything else after the issue was opened must not lose the record that it was: a retry
# would open a second one.
_record_created(db, user, session, new)
db.commit()
raise
_record_created(db, user, session, new)
db.commit()
return {"ok": True, "session": _view(db, session)}
@@ -282,13 +315,21 @@ def _screenshot_refused(code: str) -> HTTPException:
return refused(409, "screenshot_already_sent")
if code == "too_many":
return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX)
if code == "storage_full":
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
return refused(422, "screenshot_storage_full", limit=mb)
if code == "empty":
return refused(422, "screenshot_empty")
return refused(422, "screenshot_not_an_image")
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
return _screenshot_of(
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
)
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
shot = db.get(DebugScreenshot, shot_id)
if shot is None or shot.session_id != session.id:
raise not_found("screenshot")
@@ -312,7 +353,11 @@ def get_screenshot(
def delete_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
shot = _session_screenshot(db, sid, shot_id, user)
db.delete(shot)
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
shot = _screenshot_of(db, session, shot_id)
try:
debugsession.remove_screenshot(db, session, shot)
except debugsession.ScreenshotRefused as exc:
raise _screenshot_refused(exc.code) from None
db.commit()
return {"ok": True, "deleted": shot_id}
+1 -3
View File
@@ -168,9 +168,7 @@ def get_me(
def _live_session(db: Session, user: User) -> dict | None:
session = debugsession.open_session(db, user)
if session is None:
return None
return debugsession.serialize(session, debugsession.screenshot_count(db, session))
return None if session is None else debugsession.view(db, session)
@router.delete("/account")
+174 -1
View File
@@ -10,6 +10,7 @@ Four parts:
* screenshots: judged by their bytes, capped in size and count, served back only to their owner.
"""
import contextvars
import json
import logging
from datetime import datetime, timedelta, timezone
@@ -274,6 +275,31 @@ def test_a_header_naming_anything_else_is_ignored_not_refused(gated, db, client,
assert getattr(line, "elevated", False) is False, value
def test_a_header_naming_the_own_session_past_its_cap_is_ignored(gated, db, client, make_user):
_signed_in(client, make_user)
sid = _start(client)["id"]
_expire(db, sid) # past its cap, not yet swept: still open in the table
gated.records.clear()
client.get("/api/debug-sessions", headers={"x-debug-session": sid})
(line,) = _request_lines(gated)
assert getattr(line, "debug_session_id", None) is None
assert getattr(line, "elevated", False) is False
def test_a_request_without_the_header_keeps_the_sessions_floor(gated):
"""The browser's own requests (an <img>, a <video>) carry no header: they must not lower the
root level a concurrent request of the session still needs."""
root = logging.getLogger()
until = datetime.now(timezone.utc) + timedelta(minutes=30)
request = contextvars.copy_context() # set_user writes the context: keep it this test's own
request.run(logs.set_user, 5, "TRACE", until, "a" * 16)
assert root.level == logs.resolve_level("TRACE")
request.run(logs.set_user, 5, "INFO", None, None) # the same user, a request without the header
assert root.level == logs.resolve_level("TRACE")
logs.end_session_elevation("a" * 16) # Stop
assert root.level == logging.INFO
def test_a_session_never_lowers_the_users_own_level():
now = datetime.now(timezone.utc)
user = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(hours=3))
@@ -454,6 +480,75 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
assert r.status_code == 404 and r.json()["detail"]["reason"] == "screenshot"
def test_a_sent_session_keeps_its_screenshots(db, recording):
client, sid = recording
shot = _upload(client, sid, _PNG).json()["screenshot"]["id"]
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
db.commit()
# The issue carries it already, and a half-sent one names its screenshots by their order.
r = client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent"
def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeypatch):
client, sid = recording
monkeypatch.setattr(debugsession, "SCREENSHOT_USER_MAX_BYTES", len(_PNG) * 2)
assert _upload(client, sid, _PNG).status_code == 200
client.post(f"/api/debug-sessions/{sid}/stop")
other = _start(client)["id"]
assert _upload(client, other, _PNG).status_code == 200
r = _upload(client, other, _PNG)
assert r.status_code == 422 and r.json()["detail"]["reason"] == "screenshot_storage_full"
def test_new_sessions_are_paced_but_joining_one_is_not(db, make_user, as_user, monkeypatch):
from app.ratelimit import RateLimiter
from app.routes import debug_sessions as routes
monkeypatch.setattr(routes, "_new_sessions", RateLimiter(max_events=1, window_seconds=3600))
client = as_user(make_user())
sid = _start(client)["id"]
assert _start(client)["id"] == sid # another tab joining
client.post(f"/api/debug-sessions/{sid}/stop")
r = client.post("/api/debug-sessions", json={"level": "DEBUG"})
assert r.status_code == 429 and r.json()["detail"]["reason"] == "debug_session_rate_limited"
def test_two_starts_at_once_join_the_one_that_won(db, make_user, as_user, monkeypatch):
user = make_user()
client = as_user(user)
won = _start(client)["id"]
def raced(db_, user_, level):
# The loser of the race: past the "is one open?" read, its insert meets the index.
db_.add(
DebugSession(
id="0" * 16,
user_id=user_.id,
level=level,
started_at=datetime.now(timezone.utc),
until=datetime.now(timezone.utc) + timedelta(minutes=5),
)
)
db_.flush()
monkeypatch.setattr(debugsession, "start", raced)
r = client.post("/api/debug-sessions", json={"level": "DEBUG"})
assert r.status_code == 200, r.text
assert r.json()["session"]["id"] == won
def test_the_export_is_paced(recording, monkeypatch):
from app.ratelimit import RateLimiter
from app.routes import debug_sessions as routes
client, sid = recording
monkeypatch.setattr(routes, "_exports", RateLimiter(max_events=1, window_seconds=60))
assert client.get(f"/api/debug-sessions/{sid}/export").status_code == 200
r = client.get(f"/api/debug-sessions/{sid}/export")
assert r.status_code == 429 and r.json()["detail"]["reason"] == "export_rate_limited"
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}
@@ -565,6 +660,9 @@ class FakeGitea:
self.has_issues = True
self.token_ok = True
self.fail_asset: str | None = None
self.fail_patch = False
self.redirect = False # an http:// address behind a proxy that sends it to https
self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page)
self.patched_body: str | None = None
def handler(self, request: httpx.Request) -> httpx.Response:
@@ -572,6 +670,8 @@ class FakeGitea:
self.calls.append((request.method, path))
if request.headers.get("authorization") != "token t0ken" or not self.token_ok:
return httpx.Response(401, json={"message": "invalid username, password or token"})
if self.redirect:
return httpx.Response(301, headers={"location": "https://git.example/"}, text="moved")
if request.method == "GET" and path == "/repos/o/r":
return httpx.Response(
200,
@@ -591,10 +691,16 @@ class FakeGitea:
if name == self.fail_asset:
self.fail_asset = None
return httpx.Response(500, json={"message": "boom"})
if self.garbage_asset:
self.garbage_asset = False
return httpx.Response(200, text="<html>sign in</html>")
return httpx.Response(
201, json={"name": name, "browser_download_url": f"https://git.example/a/{name}"}
)
if request.method == "PATCH" and path == "/repos/o/r/issues/7":
if self.fail_patch:
self.fail_patch = False
return httpx.Response(502, json={"message": "bad gateway"})
self.patched_body = json.loads(request.content)["body"]
return httpx.Response(201, json={"number": 7})
if request.method == "POST" and path == "/markdown":
@@ -698,6 +804,53 @@ def test_a_send_that_stops_half_way_keeps_the_issue_and_the_retry_finishes_it(
assert len(gitea_fake.uploads()) == 3 # client.log, the failed server.log, then server.log
def test_a_failed_edit_that_shows_the_screenshots_is_finished_by_the_retry(
gitea_fake, db, client, make_user
):
gitea_fake.fail_patch = True
_signed_in(client, make_user)
sid = _start(client)["id"]
_upload(client, sid, _PNG)
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable"
# Every file is up, but the body does not show the screenshot yet: the send is not finished.
assert client.get(f"/api/debug-sessions/{sid}").json()["pending_files"] == ["screenshot-1.png"]
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 200 and r.json()["session"]["pending_files"] == []
assert "![screenshot-1.png](https://git.example/a/screenshot-1.png)" in gitea_fake.patched_body
assert len(gitea_fake.uploads()) == 3 # nothing uploaded twice
def test_an_answer_that_is_not_the_api_is_refused_and_the_issue_stays_linked(
gitea_fake, db, client, make_user
):
gitea_fake.garbage_asset = True
_signed_in(client, make_user)
sid = _start(client)["id"]
_ready(client, sid)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_rejected"
assert client.get(f"/api/debug-sessions/{sid}").json()["issue_url"] is not None
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
# Still the one issue.
assert [m for m, p in gitea_fake.calls if p == "/repos/o/r/issues"] == ["POST"]
def test_a_redirecting_tracker_is_refused_not_a_crash(gitea_fake, db, make_user, as_user):
gitea_fake.redirect = True
admin = as_user(make_user(role="admin"))
r = admin.post("/api/admin/config/test-issues")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_rejected"
def test_the_delivery_survives_a_save_of_the_draft():
session = DebugSession(draft={"title": "t", "delivery": {"number": 7, "pending": ["a.log"]}})
debugsession.save_draft(session, {"title": "t2", "body": "b", "excluded": [], "form": {}})
assert session.draft["title"] == "t2"
assert debugsession.unfinished(session) == ["a.log"]
def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user):
_signed_in(client, make_user)
sid = _start(client)["id"]
@@ -723,7 +876,27 @@ def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monke
second = _start(client)["id"]
_ready(client, second)
r = client.post(f"/api/debug-sessions/{second}/issue")
assert r.status_code == 429 and r.json()["detail"]["reason"] == "issues_rate_limited"
assert r.status_code == 429 and r.json()["detail"] == {
"code": "refused",
"reason": "issues_rate_limited",
"limit": 1,
}
def test_an_attempt_refused_before_the_issue_opened_is_not_counted(
gitea_fake, db, make_user, as_user, monkeypatch
):
from app.ratelimit import RateLimiter
from app.routes import debug_sessions as routes
monkeypatch.setattr(routes, "_new_issues", RateLimiter(max_events=1, window_seconds=3600))
client = as_user(make_user())
sid = _start(client)["id"]
_ready(client, sid)
gitea_fake.token_ok = False
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 422
gitea_fake.token_ok = True
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as_user):
@@ -36,7 +36,8 @@ export default function AppCommandPalette() {
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
const recording = useDebugRecording();
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
const stopMutate = stopRecording.mutate;
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
@@ -155,19 +156,19 @@ export default function AppCommandPalette() {
// player or a dialog where the pill's buttons are a long Tab away.
...(me?.is_demo
? []
: recording.session
: recordingSession
? [
{
id: "action:recorder:step",
label: t("recorder.palette.step"),
icon: <Flag className="w-4 h-4" />,
run: () => recording.markStep(""),
run: () => markStep(""),
},
{
id: "action:recorder:stop",
label: t("recorder.palette.stop"),
icon: <Square className="w-4 h-4" />,
run: () => recording.stop.mutate(recording.session!.id),
run: () => stopMutate(recordingSession.id),
},
]
: [
@@ -197,7 +198,9 @@ export default function AppCommandPalette() {
savedViews,
theme,
setTheme,
recording,
recordingSession,
markStep,
stopMutate,
setPage,
openChannel,
setSelectedId,
@@ -6,6 +6,7 @@
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
// lives in a real query cache, as in the app.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -24,7 +25,9 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => {
const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted(() => {
// A Stop held open until released, when a test needs it in flight.
const hold = { stop: null as Promise<void> | null };
const order: string[] = [];
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
id: "0123456789abcdef",
@@ -42,14 +45,15 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
Promise.resolve({ ok: true as const, session: session({ level }) }),
);
const stopDebugSession = vi.fn((id: string) => {
const stopDebugSession = vi.fn(async (id: string) => {
order.push("stop");
return Promise.resolve({
if (hold.stop) await hold.stop;
return {
ok: true as const,
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
});
};
});
return { order, session, startDebugSession, stopDebugSession };
return { order, session, startDebugSession, stopDebugSession, hold };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
@@ -69,16 +73,23 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
import DebugRecorder from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import { closeComposer, openRecorderStart, useComposerSession } from "../lib/debugRecorder";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { events } from "../lib/log";
beforeEach(() => {
order.length = 0;
hold.stop = null;
sessionStorage.clear();
});
afterEach(() => {
cleanup();
closeComposer();
setStepPrompt(null);
vi.clearAllMocks();
vi.useRealTimers();
});
@@ -112,6 +123,21 @@ function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
/** A dialog the test opens and closes while the pill is up: the pill moves into its toast host. */
function DialogToggle() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
</>
);
}
describe("the recorder pill", () => {
it("shows while a session records, under the toasts and outside their live region", () => {
mount({ debug_session: session() });
@@ -173,6 +199,57 @@ describe("the recorder pill", () => {
expect(marked()).toBe(before);
});
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
mark.focus();
// The dialog's trap takes the Tab (jsdom has no layout, so it can only be seen taking it — the
// browser would otherwise walk out of the dialog into the page behind its scrim).
const unhandled = fireEvent.keyDown(mark, { key: "Tab" });
expect(unhandled).toBe(false);
});
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.change(screen.getByLabelText(/recorder.pill.stepPrompt/), {
target: { value: "opened the player" },
});
fireEvent.click(screen.getByText("toggle dialog"));
expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); // moved: a new pill
expect((screen.getByLabelText(/recorder.pill.stepPrompt/) as HTMLInputElement).value).toBe(
"opened the player",
);
});
it("a Stop still running keeps its button busy after the pill moves", async () => {
let release = () => {};
hold.stop = new Promise((r) => (release = r));
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(order).toContain("stop"));
fireEvent.click(screen.getByText("toggle dialog"));
const stop = within(pill()!).getByRole("button", { name: "recorder.pill.stop" });
expect(stop.getAttribute("aria-busy")).toBe("true");
release();
await waitFor(() => expect(pill()).toBeNull());
});
it("a Start or a Stop in another tab makes this one ask the server again", async () => {
const qc = mount({ debug_session: session() });
const invalidate = vi.spyOn(qc, "invalidateQueries");
const otherTab = new BroadcastChannel("siftlode.debugSession");
otherTab.postMessage("changed");
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.me() }));
otherTab.close();
});
it("offers a screenshot only where the browser can capture a tab", () => {
mount({ debug_session: session() });
// jsdom has no Screen Capture API — like iOS Safari.
+25 -14
View File
@@ -7,12 +7,16 @@ import { api, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
openComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
@@ -33,15 +37,14 @@ import { useDebugRecording } from "./useDebugRecording";
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo.
/** `m:ss` since `since`, re-rendered every second. */
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
const s = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
export default function DebugRecorder() {
@@ -54,6 +57,11 @@ export default function DebugRecorder() {
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
// Started or stopped in another tab: this one asks the server what is in force now.
useEffect(
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
[qc],
);
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
@@ -133,9 +141,9 @@ function StartDialog() {
/** The pill: how long it has been recording, and the three things to do while it does. */
function RecorderPill() {
const { t } = useTranslation();
const { session, stop, shoot, markStep, screenshot } = useDebugRecording();
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
const [hidden, setHidden] = useState(false);
const [stepOpen, setStepOpen] = useState(false);
const stepOpen = useStepPrompt() !== null;
const stepButton = useRef<HTMLButtonElement>(null);
const steps = useStepCount(session?.id ?? "");
if (!session) return null;
@@ -164,7 +172,7 @@ function RecorderPill() {
icon={<Flag className="w-4 h-4" />}
label={t("recorder.pill.step")}
aria-expanded={stepOpen}
onClick={() => setStepOpen((v) => !v)}
onClick={() => setStepPrompt(stepOpen ? null : "")}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.step")}
@@ -178,7 +186,7 @@ function RecorderPill() {
className="rounded-full"
icon={<Camera className="w-4 h-4" />}
label={t("recorder.pill.screenshot")}
loading={shoot.isPending}
loading={shooting}
onClick={() => void screenshot(setHidden)}
>
<span className="hidden sm:inline tabular-nums">
@@ -193,7 +201,7 @@ function RecorderPill() {
className="rounded-full"
icon={<Square className="w-3.5 h-3.5 fill-current" />}
label={t("recorder.pill.stop")}
loading={stop.isPending}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
@@ -203,7 +211,7 @@ function RecorderPill() {
id={session.id}
onDone={(label) => {
if (label !== null) markStep(label);
setStepOpen(false);
setStepPrompt(null);
stepButton.current?.focus();
}}
/>
@@ -237,7 +245,7 @@ export function RecorderSettings() {
function RecorderControl() {
const { t } = useTranslation();
const { session, stop } = useDebugRecording();
const { session, stop, stopping } = useDebugRecording();
if (!session)
return (
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -260,7 +268,7 @@ function RecorderControl() {
<Button
variant="danger-soft"
size="sm"
loading={stop.isPending}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
{t("recorder.settings.stop")}
@@ -279,10 +287,13 @@ function SettingsElapsed({ since }: { since: string }) {
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
const { t } = useTranslation();
const n = useStepCount(id) + 1;
const [label, setLabel] = useState("");
const label = useStepPrompt() ?? "";
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last.
useLayer(true, () => onDone(null));
// Passive: it takes Escape, not the Tab — the dialog under it keeps its focus trap, which spans
// its toast host and so this prompt too; a blocking layer would switch that trap off and let Tab
// walk out of the dialog into the page behind its scrim.
useLayer(true, () => onDone(null), { passive: true });
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -298,7 +309,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
@@ -29,8 +29,12 @@ const api = vi.hoisted(() => ({
saved: [] as DebugDraft[],
uploaded: [] as Blob[],
failSession: false,
failShots: false,
sent: 0,
previewed: [] as string[],
// What happened, in order: a save is "save" when asked and "saved" when it answered.
order: [] as string[],
holdSend: null as Promise<void> | null,
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
@@ -41,10 +45,18 @@ vi.mock("../lib/api", async (importOriginal) => {
debugSession: () =>
api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session),
debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }),
debugScreenshots: () => Promise.resolve({ items: [] }),
debugScreenshots: () =>
api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }),
saveDebugDraft: (_id: string, draft: DebugDraft) => {
api.saved.push(draft);
return Promise.resolve({ ok: true, session: { ...api.session, draft } });
api.order.push("save");
// Answers a task later, so a caller that does not wait for it is seen not waiting.
return new Promise((resolve) =>
setTimeout(() => {
api.order.push("saved");
resolve({ ok: true, session: { ...api.session, draft } });
}, 0),
);
},
addDebugScreenshot: (_id: string, image: Blob) => {
api.uploaded.push(image);
@@ -54,14 +66,16 @@ vi.mock("../lib/api", async (importOriginal) => {
});
},
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
sendDebugIssue: () => {
sendDebugIssue: async () => {
api.order.push("send");
if (api.holdSend) await api.holdSend;
api.sent += 1;
api.session = {
...api.session,
issue_url: "https://git.example/o/r/issues/7",
pending_files: [],
};
return Promise.resolve({ ok: true, session: api.session });
return { ok: true as const, session: api.session };
},
previewDebugIssue: (_id: string, text: string) => {
api.previewed.push(text);
@@ -116,6 +130,9 @@ beforeEach(() => {
api.saved = [];
api.uploaded = [];
api.failSession = false;
api.failShots = false;
api.order = [];
api.holdSend = null;
api.sent = 0;
api.previewed = [];
tracker = null;
@@ -138,6 +155,13 @@ function mount() {
}
const lastSaved = () => api.saved[api.saved.length - 1]!;
const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
/** A button once it can be pressed: the export and the send wait for the screenshots. */
async function ready(name: string): Promise<HTMLButtonElement> {
const button = (await screen.findByRole("button", { name })) as HTMLButtonElement;
await waitFor(() => expect(button.disabled).toBe(false));
return button;
}
describe("the issue composer", () => {
it("drafts the steps from the session and saves the rendered text after a pause", async () => {
@@ -212,6 +236,64 @@ describe("the issue composer", () => {
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("takes an image pasted while typing, anywhere in the composer", async () => {
mount();
await screen.findByText("recorder.composer.attachments");
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
fireEvent.paste(title(), { clipboardData: { files: [png] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
});
it("saves an edit still waiting when it is closed", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "Typed and closed" } });
cleanup(); // closed well inside the pause
await waitFor(() => expect(api.saved).toHaveLength(1));
expect(lastSaved().title).toBe("Typed and closed");
});
it("does not call a newer edit saved", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "one" } });
expect(
await screen.findByText("recorder.composer.saved", {}, { timeout: 2000 }),
).not.toBeNull();
fireEvent.change(title(), { target: { value: "two" } });
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
});
it("says so when the screenshots cannot be read, and saves nothing that would miscount them", async () => {
api.failShots = true;
mount();
expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull();
expect(
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.change(title(), { target: { value: "x" } });
await new Promise((r) => setTimeout(r, 1000));
expect(api.saved).toHaveLength(0);
});
it("moves the focus to what stays when a control goes", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
const done = screen.getByRole("button", { name: "recorder.composer.reviewDone" });
expect(document.activeElement).toBe(done);
fireEvent.click(done);
expect(document.activeElement).toBe(
screen.getAllByRole("button", { name: "recorder.composer.review" })[0],
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.removeStep:1" }));
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "recorder.composer.addStep" }),
);
});
it("leaves a paste without an image to the field it was typed into", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
@@ -222,10 +304,11 @@ describe("the issue composer", () => {
it("saves before it downloads the zip", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
click.mockImplementation(() => void api.order.push("click"));
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.export" }));
fireEvent.click(await ready("recorder.composer.export"));
await waitFor(() => expect(click).toHaveBeenCalled());
expect(api.saved).toHaveLength(1);
expect(api.order).toEqual(["save", "saved", "click"]);
click.mockRestore();
});
@@ -255,12 +338,27 @@ describe("the issue composer", () => {
tracker = { repo: "o/r" };
mount();
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.send" }));
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.sent).toBe(1));
expect(api.saved).toHaveLength(1); // saved first: the issue is built from what was saved
// Saved, and answered, first: the issue is built from what was saved.
expect(api.order).toEqual(["save", "saved", "send"]);
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
});
it("holds the draft while it is being sent", async () => {
tracker = { repo: "o/r" };
let release = () => {};
api.holdSend = new Promise((r) => (release = r));
mount();
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.order).toContain("send"));
// What is typed now would not reach the issue: the fields do not take it.
expect(title().readOnly).toBe(true);
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
release();
await waitFor(() => expect(api.sent).toBe(1));
});
it("finishes a send that stopped half-way, and says what is missing", async () => {
tracker = { repo: "o/r" };
api.session = baseSession({
+184 -69
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
@@ -28,8 +29,9 @@ import { inputCls } from "./ui/form";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
// to add more). It saves as it goes, so closing it loses nothing: Settings → Diagnosztika reopens
// any past session. The export and (S4) the issue are built from what it SAVED.
// to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still
// waiting to be saved when it closes is saved then, so closing it loses nothing: Settings →
// Diagnosztika reopens any past session. The export and (S4) the issue are built from what it SAVED.
const SAVE_DELAY_MS = 800;
type Source = "web" | "server";
@@ -43,6 +45,18 @@ export default function IssueComposer({ id }: { id: string }) {
queryFn: () => api.debugSessionEvents(id),
});
const failed = sessionQ.isError || eventsQ.isError;
// Closed after a Stop, the focus has nowhere to go back to — the Stop button went with the pill —
// and would be left on <body>. The page the user is on is the next best place. After the dialog's
// own restore (a timeout: its cleanup runs after this one), and only when that had nothing.
useEffect(
() => () => {
window.setTimeout(() => {
if (document.activeElement === document.body)
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
}, 0);
},
[],
);
return (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
{failed && !(sessionQ.data && eventsQ.data) ? (
@@ -106,12 +120,17 @@ function ComposerBody({
},
);
const [reviewing, setReviewing] = useState<Source | null>(null);
// The log whose review just closed: its Review button takes the focus back.
const reviewed = useRef<Source | null>(null);
const sent = session.issue_url !== null;
const shotsQ = useQuery({
queryKey: qk.debugScreenshots(session.id),
queryFn: () => api.debugScreenshots(session.id),
});
// Until the screenshots are known, the body cannot say how many there are: nothing is saved or
// sent from it before then (a failed read must not become "no screenshots" in the issue).
const shotsReady = shotsQ.data !== undefined;
const shots = shotsQ.data?.items ?? [];
const excluded = useMemo(() => new Set(form.excluded), [form.excluded]);
@@ -140,36 +159,37 @@ function ComposerBody({
text,
);
// Saves as it goes — after the typing pauses, not on every key.
// Saves as it goes — after the typing pauses, not on every key. `edits` counts the edits, so a
// save that finishes after a newer edit does not call that one saved.
const edits = useRef(0);
const [unsaved, setUnsaved] = useState(false);
const save = useMutation({
mutationFn: () =>
api.saveDebugDraft(session.id, {
mutationFn: async () => {
const upTo = edits.current;
const r = await api.saveDebugDraft(session.id, {
title: form.title.trim(),
body,
excluded: form.excluded,
form: form as unknown as Record<string, unknown>,
}),
});
return { ...r, upTo };
},
meta: { failTitle: "recorder.composer.saveFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
if (r.upTo === edits.current) setUnsaved(false);
},
});
const dirty = useRef(false);
const saveRef = useRef(save.mutate);
saveRef.current = save.mutate;
useEffect(() => {
if (sent || !dirty.current) return;
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, sent]);
const edit = (next: (f: DraftForm) => DraftForm) => {
dirty.current = true;
edits.current += 1;
setUnsaved(true);
setForm(next);
};
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
// A send that stopped half-way is finished by the same call, which sends only what is missing.
// A send that stopped half-way is finished by the same call, which does only what is missing.
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
const target = useMe()?.issues ?? null;
const pending = session.pending_files;
const send = useMutation({
@@ -183,13 +203,59 @@ function ComposerBody({
notify({ level: "success", message: t("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: () => void qc.invalidateQueries({ queryKey: qk.debugSessions() }),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
},
});
const locked = sent || send.isPending;
const saveRef = useRef(save.mutate);
saveRef.current = save.mutate;
useEffect(() => {
if (locked || !unsaved || !shotsReady) return;
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, locked, unsaved, shotsReady]);
// Closed before the typing paused: save what is still waiting, now.
const flushRef = useRef(() => {});
flushRef.current = () => {
if (unsaved && !locked && shotsReady) save.mutate();
};
useEffect(() => () => flushRef.current(), []);
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
// composer is taken — while typing the description is exactly when one is pasted.
const addShot = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const addRef = useRef(addShot.mutate);
addRef.current = addShot.mutate;
useEffect(() => {
if (locked) return;
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
if (!images.length) return;
e.preventDefault();
for (const image of images) addRef.current(image);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [locked]);
const [mode, setMode] = useState<"edit" | "preview">("edit");
// The export is built from the SAVED draft: save what is on screen first.
const exportZip = async () => {
if (!sent) await save.mutateAsync();
if (!sent) {
try {
await save.mutateAsync();
} catch {
return; // the save's own message says why
}
}
const a = document.createElement("a");
a.href = api.debugExportUrl(session.id);
a.download = "";
@@ -266,7 +332,7 @@ function ComposerBody({
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
)}
excluded={excluded}
readOnly={sent}
readOnly={locked}
onToggle={(rowId) =>
edit((f) => ({
...f,
@@ -275,20 +341,28 @@ function ComposerBody({
: [...f.excluded, rowId],
}))
}
onDone={() => setReviewing(null)}
onDone={() => {
reviewed.current = reviewing;
setReviewing(null);
}}
/>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<DraftFields form={form} edit={edit} env={env} readOnly={sent} />
<DraftFields form={form} edit={edit} env={env} readOnly={locked} />
<Attachments
session={session}
shots={shots}
shotsQ={shotsQ}
counts={{
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
}}
readOnly={sent}
readOnly={locked}
adding={addShot.isPending}
onAdd={(files) => {
for (const file of files) if (isImage(file)) addShot.mutate(file);
}}
onReview={setReviewing}
reviewed={reviewed}
/>
</div>
)}
@@ -300,7 +374,7 @@ function ComposerBody({
? t("recorder.composer.sending")
: save.isPending
? t("recorder.composer.saving")
: save.isSuccess
: save.isSuccess && !unsaved
? t("recorder.composer.saved")
: ""}
</span>
@@ -308,11 +382,21 @@ function ComposerBody({
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
</Button>
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
<Button
variant="outline"
loading={save.isPending}
disabled={!shotsReady || send.isPending}
onClick={() => void exportZip()}
>
{t("recorder.composer.export")}
</Button>
{target && !sent && (
<Button variant="primary" loading={send.isPending} onClick={() => send.mutate()}>
<Button
variant="primary"
loading={send.isPending}
disabled={!shotsReady}
onClick={() => send.mutate()}
>
{t("recorder.composer.send")}
</Button>
)}
@@ -333,6 +417,8 @@ function DraftFields({
readOnly: boolean;
}) {
const { t } = useTranslation();
// A removed step's ✕ goes with it: the focus moves to "add a step", which stays.
const addStep = useRef<HTMLButtonElement>(null);
const setStep = (i: number, patch: Partial<DraftStep>) =>
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
return (
@@ -395,7 +481,10 @@ function DraftFields({
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeStep", { n: i + 1 })}
onClick={() => edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }))}
onClick={() => {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
/>
)}
</li>
@@ -403,6 +492,7 @@ function DraftFields({
</ol>
{!readOnly && (
<Button
ref={addStep}
variant="link"
className="self-start"
icon={<Plus className="w-4 h-4" />}
@@ -432,50 +522,57 @@ function DraftFields({
);
}
// The types the server keeps (backend debugsession.IMAGE_TYPES) — offered by the picker, and the
// only ones a paste or a drop hands over.
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
const isImage = (file: File) => IMAGE_TYPES.includes(file.type);
/** What changes with a screenshot: its list, and the session's count — not the session's events,
* which share the session's key prefix and are thousands of rows. */
function refreshShots(qc: ReturnType<typeof useQueryClient>, id: string) {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(id), exact: true });
}
function Attachments({
session,
shots,
shotsQ,
counts,
readOnly,
adding,
onAdd,
onReview,
reviewed,
}: {
session: DebugSession;
shots: DebugScreenshot[];
shotsQ: { data?: { items: DebugScreenshot[] }; isError: boolean; refetch: () => unknown };
counts: Record<Source, [kept: number, total: number]>;
readOnly: boolean;
adding: boolean;
onAdd: (files: Iterable<File>) => void;
onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(session.id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id) });
};
const add = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const shots = shotsQ.data?.items ?? [];
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
useEffect(() => {
const source = reviewed.current;
if (!source) return;
reviewed.current = null;
reviewButtons.current[source]?.focus();
}, [reviewed]);
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
meta: { failTitle: "recorder.composer.removeShotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const addFiles = (files: Iterable<File>) => {
for (const file of files) if (IMAGE_TYPES.includes(file.type)) add.mutate(file);
};
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...e.clipboardData.files].filter((f) => IMAGE_TYPES.includes(f.type));
if (!images.length) return;
e.preventDefault();
for (const image of images) add.mutate(image);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
onAdd(e.dataTransfer.files);
};
const logRow = (source: Source, icon: React.ReactNode) => {
@@ -495,7 +592,14 @@ function Attachments({
</span>
</div>
{total > 0 && (
<Button variant="outline" size="sm" onClick={() => onReview(source)}>
<Button
ref={(el) => {
reviewButtons.current[source] = el;
}}
variant="outline"
size="sm"
onClick={() => onReview(source)}
>
{t("recorder.composer.review")}
</Button>
)}
@@ -504,13 +608,15 @@ function Attachments({
};
return (
<div
className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"
onPaste={readOnly ? undefined : onPaste}
>
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4">
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{shotsQ.isError && !shotsQ.data && (
<StateMessage tone="error" compact onRetry={() => void shotsQ.refetch()}>
{t("recorder.composer.shotsFailed")}
</StateMessage>
)}
{shots.map((shot, i) => (
<div
key={shot.id}
@@ -523,9 +629,7 @@ function Attachments({
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">
{Math.round(shot.size / 1024)} KB
</span>
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
</div>
{!readOnly && (
<Button
@@ -535,27 +639,32 @@ function Attachments({
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeShot", { n: i + 1 })}
loading={remove.isPending && remove.variables === shot.id}
onClick={() => remove.mutate(shot.id)}
onClick={() => {
remove.mutate(shot.id);
pick.current?.focus();
}}
/>
)}
</div>
))}
{!readOnly && (
{!readOnly && shotsQ.data && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
aria-busy={adding}
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
>
{t("recorder.composer.drop")}{" "}
<label className="cursor-pointer font-semibold text-accent hover:underline">
<label className="cursor-pointer rounded font-semibold text-accent hover:underline focus-within:underline focus-within:ring-2 focus-within:ring-accent/60">
{t("recorder.composer.pick")}
<input
ref={pick}
type="file"
accept={IMAGE_TYPES.join(",")}
multiple
className="sr-only"
onChange={(e) => {
addFiles(e.target.files ?? []);
onAdd(e.target.files ?? []);
e.target.value = "";
}}
/>
@@ -583,6 +692,9 @@ function ReviewList({
onDone: () => void;
}) {
const { t } = useTranslation();
// Opened from its Review button, which is gone now: the focus comes here.
const done = useRef<HTMLButtonElement>(null);
useEffect(() => done.current?.focus(), []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -590,7 +702,7 @@ function ReviewList({
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
</div>
<Button variant="primary" size="sm" onClick={onDone}>
<Button ref={done} variant="primary" size="sm" onClick={onDone}>
{t("recorder.composer.reviewDone")}
</Button>
</div>
@@ -642,7 +754,7 @@ function IssuePreview({
}) {
const { t } = useTranslation();
const q = useQuery({
queryKey: [...qk.debugSession(sessionId), "preview", text],
queryKey: qk.debugSessionPreview(sessionId, text),
queryFn: () => api.previewDebugIssue(sessionId, text),
enabled: viaTracker,
});
@@ -666,10 +778,13 @@ function IssuePreview({
if (!q.data) return <LoadingState />;
const css = getComputedStyle(document.documentElement);
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
// The frame is a document of its own: the app's text size (the root font, which the text-size
// setting drives) is handed to it, and everything in it is sized off that.
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{margin:0;padding:12px 14px;font:14px/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:4px 8px}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px}h1,h2,h3{line-height:1.25}
html{font-size:${css.fontSize}}
body{margin:0;padding:.75rem .875rem;font:.875rem/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:.25rem .5rem}
code{font-family:ui-monospace,Consolas,monospace;font-size:.75rem}h1,h2,h3{line-height:1.25}
</style></head><body>${q.data.html}</body></html>`;
return (
<iframe
@@ -50,6 +50,14 @@ describe("Toaster — one host shows the toasts", () => {
app.unmount();
});
it("lets a click through wherever there is no toast and nothing docked, in every host", () => {
render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's
// clicks; the cards and the dock opt back in.
expect(region.className).toContain("pointer-events-none");
});
it("does not let a click on a toast reach the overlay's backdrop", () => {
const backdrop = vi.fn();
render(
+5 -2
View File
@@ -122,8 +122,11 @@ export default function Toaster({
// lib/listNav: focus on a toast's Undo — or on the docked control — is not the reader leaving
// the list the toast is about.
data-toast-region=""
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] ${
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
// Click-through in every host: the region is as wide as its (always present) list and as tall
// as the docked pill, and without this the empty part beside and above the pill swallowed the
// page's clicks. The cards and the dock opt back in.
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] pointer-events-none ${
overlay ? "bottom-24" : "bottom-4"
}`}
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined}
+34 -11
View File
@@ -1,10 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
@@ -21,7 +28,9 @@ export function useDebugRecording() {
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions() });
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
announceSessionChange();
setStepPrompt(null); // a prompt left open belongs to the session that ended
};
const start = useMutation({
@@ -34,6 +43,7 @@ export function useDebugRecording() {
});
const stop = useMutation({
mutationKey: STOP_KEY,
mutationFn: async (id: string) => {
log.info("debug.stop", "recording stopped by the user");
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
@@ -50,6 +60,7 @@ export function useDebugRecording() {
});
const shoot = useMutation({
mutationKey: SHOOT_KEY,
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
api.addDebugScreenshot(id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
@@ -71,14 +82,23 @@ export function useDebugRecording() {
},
});
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
// to another toast host, and a Stop still running must keep its button busy there.
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
/** Mark the next step; `label` is what the user says they just did (may be empty). */
const markStep = (label: string) => {
if (!session) return;
const n = nextStep(session.id);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
};
const sessionId = session?.id;
const markStep = useCallback(
(label: string) => {
if (!sessionId) return;
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
},
[sessionId, t],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
* frame. Nothing happens when the user cancels the browser's picker. */
@@ -98,5 +118,8 @@ export function useDebugRecording() {
if (image) shoot.mutate({ id: session.id, image });
};
return { session, start, stop, shoot, markStep, screenshot };
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
}
const STOP_KEY = ["debug-session", "stop"];
const SHOOT_KEY = ["debug-session", "screenshot"];
+4 -1
View File
@@ -222,6 +222,9 @@
"issues_public_repo": "The issue repository is public. Because of the log, reports go only to a private one.",
"issues_disabled": "The issue repository has its issue tracker switched off.",
"issues_rejected": "The issue tracker refused the request.",
"issues_rate_limited": "You can send at most 5 reports an hour. Try again later."
"issues_rate_limited": "You can send at most {{limit}} reports an hour. Try again later.",
"debug_session_rate_limited": "You can start at most {{limit}} new recordings an hour. Try again later.",
"export_rate_limited": "Too many downloads in a row. Wait a minute.",
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire."
}
}
+3 -1
View File
@@ -105,7 +105,9 @@
"preview": "Preview",
"previewFailed": "Couldn't load the preview",
"previewTitle": "Preview of the report",
"previewLocal": "Without an issue tracker, this is the zip's issue.md."
"previewLocal": "Without an issue tracker, this is the zip's issue.md.",
"shotsFailed": "Couldn't load the screenshots",
"removeShotFailed": "Couldn't remove the screenshot"
},
"issue": {
"steps": "Steps",
+4 -1
View File
@@ -222,6 +222,9 @@
"issues_public_repo": "A hibajegy-tárhely nyilvános. A napló miatt csak privát tárhelyre küldünk.",
"issues_disabled": "A hibajegy-tárhelyen ki van kapcsolva a hibajegy-kezelés.",
"issues_rejected": "A hibajegy-kezelő visszautasította a kérést.",
"issues_rate_limited": "Óránként legfeljebb 5 hibajegyet küldhetsz. Próbáld újra később."
"issues_rate_limited": "Óránként legfeljebb {{limit}} hibajegyet küldhetsz. Próbáld újra később.",
"debug_session_rate_limited": "Óránként legfeljebb {{limit}} új rögzítést indíthatsz. Próbáld újra később.",
"export_rate_limited": "Túl sok letöltés egymás után. Várj egy percet.",
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak."
}
}
+3 -1
View File
@@ -105,7 +105,9 @@
"preview": "Előnézet",
"previewFailed": "Nem sikerült betölteni az előnézetet",
"previewTitle": "A hibajegy előnézete",
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja."
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja.",
"shotsFailed": "Nem sikerült betölteni a képernyőképeket",
"removeShotFailed": "Nem sikerült törölni a képernyőképet"
},
"issue": {
"steps": "Lépések",
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
//
// E16: one screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
// a cancelled or refused picker is the user saying no (null, no error); anything else is an error;
// a tab that was shared but gave no frame is an error too, not a silent nothing; and the capture is
// stopped whatever happened — nothing keeps sharing the tab after one frame.
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureTab } from "./captureTab";
function stubPicker(result: () => Promise<MediaStream>) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: vi.fn(result) },
});
}
function fakeStream() {
const track = { stop: vi.fn() };
return { stream: { getTracks: () => [track] } as unknown as MediaStream, track };
}
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(navigator, "mediaDevices");
});
describe("captureTab", () => {
it.each(["NotAllowedError", "AbortError"])(
"a %s from the picker is the user saying no",
async (name) => {
stubPicker(() => Promise.reject(new DOMException("no", name)));
await expect(captureTab()).resolves.toBeNull();
},
);
it("any other failure of the picker is an error", async () => {
stubPicker(() => Promise.reject(new DOMException("broken", "NotReadableError")));
await expect(captureTab()).rejects.toMatchObject({ name: "NotReadableError" });
});
it("a shared tab that gives no frame is an error, and the capture is stopped", async () => {
vi.useFakeTimers();
const { stream, track } = fakeStream();
stubPicker(() => Promise.resolve(stream));
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
// jsdom's video never has a size: no frame arrives.
const result = captureTab();
const settled = expect(result).rejects.toMatchObject({ name: "NoFrameError" });
await vi.advanceTimersByTimeAsync(2_000);
await settled;
expect(track.stop).toHaveBeenCalled();
vi.useRealTimers();
});
});
+16 -5
View File
@@ -6,7 +6,8 @@
//
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
// name Chrome gives a cancelled picker.
// name Chrome gives a cancelled picker. A tab that WAS shared but gave no frame is an error, though:
// the user said yes, and a silent nothing would read as a screenshot taken.
//
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
// Firefox and Safari lack.
@@ -16,7 +17,8 @@ export function canCaptureTab(): boolean {
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
}
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. */
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. Throws
* `NoFrameError` when the shared tab gave no frame. */
export async function captureTab(): Promise<Blob | null> {
let stream: MediaStream;
try {
@@ -44,7 +46,14 @@ export async function captureTab(): Promise<Blob | null> {
}
}
async function firstFrame(stream: MediaStream): Promise<Blob | null> {
class NoFrameError extends Error {
constructor() {
super("the shared tab gave no frame");
this.name = "NoFrameError";
}
}
async function firstFrame(stream: MediaStream): Promise<Blob> {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
@@ -52,11 +61,13 @@ async function firstFrame(stream: MediaStream): Promise<Blob | null> {
await video.play();
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
if (!video.videoWidth) return null;
if (!video.videoWidth) throw new NoFrameError();
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
video.srcObject = null;
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new NoFrameError();
return blob;
}
+28
View File
@@ -10,6 +10,34 @@ export const openRecorderStart = (): void => startOpen.set(true);
export const closeRecorderStart = (): void => startOpen.set(false);
export const useRecorderStartOpen = (): boolean => startOpen.use();
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
// one (a remount). The prompt must come along with what was typed.
const stepPrompt = createStore<string | null>(null);
/** The step prompt's text while it is open, else null. */
export const useStepPrompt = (): string | null => stepPrompt.use();
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
// it only at their next `me` poll — and meanwhile record into a session that has ended.
const CHANNEL = "siftlode.debugSession";
let channel: BroadcastChannel | null | undefined;
function tabs(): BroadcastChannel | null {
if (channel === undefined)
channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
return channel;
}
/** Tell the other tabs the recording started or stopped. */
export const announceSessionChange = (): void => tabs()?.postMessage("changed");
/** Run `fn` when another tab started or stopped a recording. */
export function onSessionChangeElsewhere(fn: () => void): () => void {
const c = tabs();
if (!c) return () => {};
const handler = () => fn();
c.addEventListener("message", handler);
return () => c.removeEventListener("message", handler);
}
const composing = createStore<string | null>(null);
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
export const openComposer = (id: string): void => composing.set(id);
@@ -131,6 +131,16 @@ describe("a debug session applied to the tab", () => {
expect(codes).toEqual(expect.arrayContaining(["ui.before", "ui.after"]));
});
it("its last seconds are sent before its cap, while the server still tags them", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG", 1), () => {});
await vi.advanceTimersByTimeAsync(57_000); // the regular flushes ran; the next is past the cap
d.log.info("debug.step", "the last step");
await vi.advanceTimersByTimeAsync(2_000); // 59 s: the cap is still a second away
const step = flat().find((e) => e.event === "debug.step");
expect(step?.debug_session_id).toBe("0123456789abcdef");
});
it("flushNow sends what is waiting at once — before Stop closes the session", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
+10
View File
@@ -52,6 +52,10 @@ const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
let applied: string | null = null;
let lapseTimer: number | null = null;
// A recording's last seconds are sent BEFORE its cap: at the cap the server stops tagging (or, at the
// base level, taking) them, and the regular flush could be up to FLUSH_MS behind.
const CAP_FLUSH_LEAD_MS = 1_500;
let capFlushTimer: number | null = null;
let onLapsed: (() => void) | null = null;
let lastState: DiagnosticsState | null | undefined = null;
let lastSession: DebugSession | null | undefined = null;
@@ -79,6 +83,8 @@ export function applyDiagnostics(
applied = key;
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
lapseTimer = null;
if (capFlushTimer !== null) window.clearTimeout(capFlushTimer);
capFlushTimer = null;
if (level === "INFO") {
stopStreaming();
setLevel("INFO");
@@ -99,6 +105,10 @@ export function applyDiagnostics(
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
if (live) {
const lead = Date.parse(live.until) - CAP_FLUSH_LEAD_MS - Date.now();
if (lead > 0 && lead < wait) capFlushTimer = window.setTimeout(() => void flush(), lead);
}
}
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */
+3
View File
@@ -100,6 +100,9 @@ export const qk = {
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
// The tracker's rendering of a body: the text is the key (typing pauses, then it renders again).
debugSessionPreview: (id: string, text: string) =>
["debug-sessions", id, "preview", text] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const,