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:
+88
-17
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
+48
-21
@@ -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(
|
||||
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)},
|
||||
).json()
|
||||
),
|
||||
"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(
|
||||
asset = _json(
|
||||
_call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assets",
|
||||
params={"name": item.name},
|
||||
files={"attachment": (item.name, item.data, item.mime)},
|
||||
).json()
|
||||
),
|
||||
"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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "" 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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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"];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"), () => {});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user