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

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

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

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

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
@@ -36,7 +36,8 @@ export default function AppCommandPalette() {
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
const recording = useDebugRecording();
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
const stopMutate = stopRecording.mutate;
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
@@ -155,19 +156,19 @@ export default function AppCommandPalette() {
// player or a dialog where the pill's buttons are a long Tab away.
...(me?.is_demo
? []
: recording.session
: recordingSession
? [
{
id: "action:recorder:step",
label: t("recorder.palette.step"),
icon: <Flag className="w-4 h-4" />,
run: () => recording.markStep(""),
run: () => markStep(""),
},
{
id: "action:recorder:stop",
label: t("recorder.palette.stop"),
icon: <Square className="w-4 h-4" />,
run: () => recording.stop.mutate(recording.session!.id),
run: () => stopMutate(recordingSession.id),
},
]
: [
@@ -197,7 +198,9 @@ export default function AppCommandPalette() {
savedViews,
theme,
setTheme,
recording,
recordingSession,
markStep,
stopMutate,
setPage,
openChannel,
setSelectedId,
@@ -6,6 +6,7 @@
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
// lives in a real query cache, as in the app.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -24,7 +25,9 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => {
const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted(() => {
// A Stop held open until released, when a test needs it in flight.
const hold = { stop: null as Promise<void> | null };
const order: string[] = [];
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
id: "0123456789abcdef",
@@ -42,14 +45,15 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
Promise.resolve({ ok: true as const, session: session({ level }) }),
);
const stopDebugSession = vi.fn((id: string) => {
const stopDebugSession = vi.fn(async (id: string) => {
order.push("stop");
return Promise.resolve({
if (hold.stop) await hold.stop;
return {
ok: true as const,
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
});
};
});
return { order, session, startDebugSession, stopDebugSession };
return { order, session, startDebugSession, stopDebugSession, hold };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
@@ -69,16 +73,23 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
import DebugRecorder from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import { closeComposer, openRecorderStart, useComposerSession } from "../lib/debugRecorder";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { events } from "../lib/log";
beforeEach(() => {
order.length = 0;
hold.stop = null;
sessionStorage.clear();
});
afterEach(() => {
cleanup();
closeComposer();
setStepPrompt(null);
vi.clearAllMocks();
vi.useRealTimers();
});
@@ -112,6 +123,21 @@ function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
/** A dialog the test opens and closes while the pill is up: the pill moves into its toast host. */
function DialogToggle() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
</>
);
}
describe("the recorder pill", () => {
it("shows while a session records, under the toasts and outside their live region", () => {
mount({ debug_session: session() });
@@ -173,6 +199,57 @@ describe("the recorder pill", () => {
expect(marked()).toBe(before);
});
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
mark.focus();
// The dialog's trap takes the Tab (jsdom has no layout, so it can only be seen taking it — the
// browser would otherwise walk out of the dialog into the page behind its scrim).
const unhandled = fireEvent.keyDown(mark, { key: "Tab" });
expect(unhandled).toBe(false);
});
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.change(screen.getByLabelText(/recorder.pill.stepPrompt/), {
target: { value: "opened the player" },
});
fireEvent.click(screen.getByText("toggle dialog"));
expect(pill()!.closest('[role="dialog"]')).not.toBeNull(); // moved: a new pill
expect((screen.getByLabelText(/recorder.pill.stepPrompt/) as HTMLInputElement).value).toBe(
"opened the player",
);
});
it("a Stop still running keeps its button busy after the pill moves", async () => {
let release = () => {};
hold.stop = new Promise((r) => (release = r));
mount({ debug_session: session() }, <DialogToggle />);
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(order).toContain("stop"));
fireEvent.click(screen.getByText("toggle dialog"));
const stop = within(pill()!).getByRole("button", { name: "recorder.pill.stop" });
expect(stop.getAttribute("aria-busy")).toBe("true");
release();
await waitFor(() => expect(pill()).toBeNull());
});
it("a Start or a Stop in another tab makes this one ask the server again", async () => {
const qc = mount({ debug_session: session() });
const invalidate = vi.spyOn(qc, "invalidateQueries");
const otherTab = new BroadcastChannel("siftlode.debugSession");
otherTab.postMessage("changed");
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.me() }));
otherTab.close();
});
it("offers a screenshot only where the browser can capture a tab", () => {
mount({ debug_session: session() });
// jsdom has no Screen Capture API — like iOS Safari.
+25 -14
View File
@@ -7,12 +7,16 @@ import { api, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
openComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
@@ -33,15 +37,14 @@ import { useDebugRecording } from "./useDebugRecording";
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo.
/** `m:ss` since `since`, re-rendered every second. */
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
const s = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
export default function DebugRecorder() {
@@ -54,6 +57,11 @@ export default function DebugRecorder() {
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
// Started or stopped in another tab: this one asks the server what is in force now.
useEffect(
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
[qc],
);
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
@@ -133,9 +141,9 @@ function StartDialog() {
/** The pill: how long it has been recording, and the three things to do while it does. */
function RecorderPill() {
const { t } = useTranslation();
const { session, stop, shoot, markStep, screenshot } = useDebugRecording();
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
const [hidden, setHidden] = useState(false);
const [stepOpen, setStepOpen] = useState(false);
const stepOpen = useStepPrompt() !== null;
const stepButton = useRef<HTMLButtonElement>(null);
const steps = useStepCount(session?.id ?? "");
if (!session) return null;
@@ -164,7 +172,7 @@ function RecorderPill() {
icon={<Flag className="w-4 h-4" />}
label={t("recorder.pill.step")}
aria-expanded={stepOpen}
onClick={() => setStepOpen((v) => !v)}
onClick={() => setStepPrompt(stepOpen ? null : "")}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.step")}
@@ -178,7 +186,7 @@ function RecorderPill() {
className="rounded-full"
icon={<Camera className="w-4 h-4" />}
label={t("recorder.pill.screenshot")}
loading={shoot.isPending}
loading={shooting}
onClick={() => void screenshot(setHidden)}
>
<span className="hidden sm:inline tabular-nums">
@@ -193,7 +201,7 @@ function RecorderPill() {
className="rounded-full"
icon={<Square className="w-3.5 h-3.5 fill-current" />}
label={t("recorder.pill.stop")}
loading={stop.isPending}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
@@ -203,7 +211,7 @@ function RecorderPill() {
id={session.id}
onDone={(label) => {
if (label !== null) markStep(label);
setStepOpen(false);
setStepPrompt(null);
stepButton.current?.focus();
}}
/>
@@ -237,7 +245,7 @@ export function RecorderSettings() {
function RecorderControl() {
const { t } = useTranslation();
const { session, stop } = useDebugRecording();
const { session, stop, stopping } = useDebugRecording();
if (!session)
return (
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -260,7 +268,7 @@ function RecorderControl() {
<Button
variant="danger-soft"
size="sm"
loading={stop.isPending}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
{t("recorder.settings.stop")}
@@ -279,10 +287,13 @@ function SettingsElapsed({ since }: { since: string }) {
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
const { t } = useTranslation();
const n = useStepCount(id) + 1;
const [label, setLabel] = useState("");
const label = useStepPrompt() ?? "";
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last.
useLayer(true, () => onDone(null));
// Passive: it takes Escape, not the Tab — the dialog under it keeps its focus trap, which spans
// its toast host and so this prompt too; a blocking layer would switch that trap off and let Tab
// walk out of the dialog into the page behind its scrim.
useLayer(true, () => onDone(null), { passive: true });
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -298,7 +309,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
@@ -29,8 +29,12 @@ const api = vi.hoisted(() => ({
saved: [] as DebugDraft[],
uploaded: [] as Blob[],
failSession: false,
failShots: false,
sent: 0,
previewed: [] as string[],
// What happened, in order: a save is "save" when asked and "saved" when it answered.
order: [] as string[],
holdSend: null as Promise<void> | null,
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
@@ -41,10 +45,18 @@ vi.mock("../lib/api", async (importOriginal) => {
debugSession: () =>
api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session),
debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }),
debugScreenshots: () => Promise.resolve({ items: [] }),
debugScreenshots: () =>
api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }),
saveDebugDraft: (_id: string, draft: DebugDraft) => {
api.saved.push(draft);
return Promise.resolve({ ok: true, session: { ...api.session, draft } });
api.order.push("save");
// Answers a task later, so a caller that does not wait for it is seen not waiting.
return new Promise((resolve) =>
setTimeout(() => {
api.order.push("saved");
resolve({ ok: true, session: { ...api.session, draft } });
}, 0),
);
},
addDebugScreenshot: (_id: string, image: Blob) => {
api.uploaded.push(image);
@@ -54,14 +66,16 @@ vi.mock("../lib/api", async (importOriginal) => {
});
},
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
sendDebugIssue: () => {
sendDebugIssue: async () => {
api.order.push("send");
if (api.holdSend) await api.holdSend;
api.sent += 1;
api.session = {
...api.session,
issue_url: "https://git.example/o/r/issues/7",
pending_files: [],
};
return Promise.resolve({ ok: true, session: api.session });
return { ok: true as const, session: api.session };
},
previewDebugIssue: (_id: string, text: string) => {
api.previewed.push(text);
@@ -116,6 +130,9 @@ beforeEach(() => {
api.saved = [];
api.uploaded = [];
api.failSession = false;
api.failShots = false;
api.order = [];
api.holdSend = null;
api.sent = 0;
api.previewed = [];
tracker = null;
@@ -138,6 +155,13 @@ function mount() {
}
const lastSaved = () => api.saved[api.saved.length - 1]!;
const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement;
/** A button once it can be pressed: the export and the send wait for the screenshots. */
async function ready(name: string): Promise<HTMLButtonElement> {
const button = (await screen.findByRole("button", { name })) as HTMLButtonElement;
await waitFor(() => expect(button.disabled).toBe(false));
return button;
}
describe("the issue composer", () => {
it("drafts the steps from the session and saves the rendered text after a pause", async () => {
@@ -212,6 +236,64 @@ describe("the issue composer", () => {
expect(api.uploaded[0]!.type).toBe("image/png");
});
it("takes an image pasted while typing, anywhere in the composer", async () => {
mount();
await screen.findByText("recorder.composer.attachments");
const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" });
fireEvent.paste(title(), { clipboardData: { files: [png] } });
await waitFor(() => expect(api.uploaded).toHaveLength(1));
});
it("saves an edit still waiting when it is closed", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "Typed and closed" } });
cleanup(); // closed well inside the pause
await waitFor(() => expect(api.saved).toHaveLength(1));
expect(lastSaved().title).toBe("Typed and closed");
});
it("does not call a newer edit saved", async () => {
mount();
await ready("recorder.composer.export");
fireEvent.change(title(), { target: { value: "one" } });
expect(
await screen.findByText("recorder.composer.saved", {}, { timeout: 2000 }),
).not.toBeNull();
fireEvent.change(title(), { target: { value: "two" } });
expect(screen.queryByText("recorder.composer.saved")).toBeNull();
});
it("says so when the screenshots cannot be read, and saves nothing that would miscount them", async () => {
api.failShots = true;
mount();
expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull();
expect(
(screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.change(title(), { target: { value: "x" } });
await new Promise((r) => setTimeout(r, 1000));
expect(api.saved).toHaveLength(0);
});
it("moves the focus to what stays when a control goes", async () => {
mount();
fireEvent.click(
(await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!,
);
const done = screen.getByRole("button", { name: "recorder.composer.reviewDone" });
expect(document.activeElement).toBe(done);
fireEvent.click(done);
expect(document.activeElement).toBe(
screen.getAllByRole("button", { name: "recorder.composer.review" })[0],
);
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.removeStep:1" }));
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "recorder.composer.addStep" }),
);
});
it("leaves a paste without an image to the field it was typed into", async () => {
mount();
const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!;
@@ -222,10 +304,11 @@ describe("the issue composer", () => {
it("saves before it downloads the zip", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
click.mockImplementation(() => void api.order.push("click"));
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.export" }));
fireEvent.click(await ready("recorder.composer.export"));
await waitFor(() => expect(click).toHaveBeenCalled());
expect(api.saved).toHaveLength(1);
expect(api.order).toEqual(["save", "saved", "click"]);
click.mockRestore();
});
@@ -255,12 +338,27 @@ describe("the issue composer", () => {
tracker = { repo: "o/r" };
mount();
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.send" }));
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.sent).toBe(1));
expect(api.saved).toHaveLength(1); // saved first: the issue is built from what was saved
// Saved, and answered, first: the issue is built from what was saved.
expect(api.order).toEqual(["save", "saved", "send"]);
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
});
it("holds the draft while it is being sent", async () => {
tracker = { repo: "o/r" };
let release = () => {};
api.holdSend = new Promise((r) => (release = r));
mount();
fireEvent.click(await ready("recorder.composer.send"));
await waitFor(() => expect(api.order).toContain("send"));
// What is typed now would not reach the issue: the fields do not take it.
expect(title().readOnly).toBe(true);
expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull();
release();
await waitFor(() => expect(api.sent).toBe(1));
});
it("finishes a send that stopped half-way, and says what is missing", async () => {
tracker = { repo: "o/r" };
api.session = baseSession({
+184 -69
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
@@ -28,8 +29,9 @@ import { inputCls } from "./ui/form";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
// to add more). It saves as it goes, so closing it loses nothing: Settings → Diagnosztika reopens
// any past session. The export and (S4) the issue are built from what it SAVED.
// to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still
// waiting to be saved when it closes is saved then, so closing it loses nothing: Settings →
// Diagnosztika reopens any past session. The export and (S4) the issue are built from what it SAVED.
const SAVE_DELAY_MS = 800;
type Source = "web" | "server";
@@ -43,6 +45,18 @@ export default function IssueComposer({ id }: { id: string }) {
queryFn: () => api.debugSessionEvents(id),
});
const failed = sessionQ.isError || eventsQ.isError;
// Closed after a Stop, the focus has nowhere to go back to — the Stop button went with the pill —
// and would be left on <body>. The page the user is on is the next best place. After the dialog's
// own restore (a timeout: its cleanup runs after this one), and only when that had nothing.
useEffect(
() => () => {
window.setTimeout(() => {
if (document.activeElement === document.body)
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
}, 0);
},
[],
);
return (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
{failed && !(sessionQ.data && eventsQ.data) ? (
@@ -106,12 +120,17 @@ function ComposerBody({
},
);
const [reviewing, setReviewing] = useState<Source | null>(null);
// The log whose review just closed: its Review button takes the focus back.
const reviewed = useRef<Source | null>(null);
const sent = session.issue_url !== null;
const shotsQ = useQuery({
queryKey: qk.debugScreenshots(session.id),
queryFn: () => api.debugScreenshots(session.id),
});
// Until the screenshots are known, the body cannot say how many there are: nothing is saved or
// sent from it before then (a failed read must not become "no screenshots" in the issue).
const shotsReady = shotsQ.data !== undefined;
const shots = shotsQ.data?.items ?? [];
const excluded = useMemo(() => new Set(form.excluded), [form.excluded]);
@@ -140,36 +159,37 @@ function ComposerBody({
text,
);
// Saves as it goes — after the typing pauses, not on every key.
// Saves as it goes — after the typing pauses, not on every key. `edits` counts the edits, so a
// save that finishes after a newer edit does not call that one saved.
const edits = useRef(0);
const [unsaved, setUnsaved] = useState(false);
const save = useMutation({
mutationFn: () =>
api.saveDebugDraft(session.id, {
mutationFn: async () => {
const upTo = edits.current;
const r = await api.saveDebugDraft(session.id, {
title: form.title.trim(),
body,
excluded: form.excluded,
form: form as unknown as Record<string, unknown>,
}),
});
return { ...r, upTo };
},
meta: { failTitle: "recorder.composer.saveFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
if (r.upTo === edits.current) setUnsaved(false);
},
});
const dirty = useRef(false);
const saveRef = useRef(save.mutate);
saveRef.current = save.mutate;
useEffect(() => {
if (sent || !dirty.current) return;
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, sent]);
const edit = (next: (f: DraftForm) => DraftForm) => {
dirty.current = true;
edits.current += 1;
setUnsaved(true);
setForm(next);
};
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
// A send that stopped half-way is finished by the same call, which sends only what is missing.
// A send that stopped half-way is finished by the same call, which does only what is missing.
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
const target = useMe()?.issues ?? null;
const pending = session.pending_files;
const send = useMutation({
@@ -183,13 +203,59 @@ function ComposerBody({
notify({ level: "success", message: t("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: () => void qc.invalidateQueries({ queryKey: qk.debugSessions() }),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
},
});
const locked = sent || send.isPending;
const saveRef = useRef(save.mutate);
saveRef.current = save.mutate;
useEffect(() => {
if (locked || !unsaved || !shotsReady) return;
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, locked, unsaved, shotsReady]);
// Closed before the typing paused: save what is still waiting, now.
const flushRef = useRef(() => {});
flushRef.current = () => {
if (unsaved && !locked && shotsReady) save.mutate();
};
useEffect(() => () => flushRef.current(), []);
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
// composer is taken — while typing the description is exactly when one is pasted.
const addShot = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const addRef = useRef(addShot.mutate);
addRef.current = addShot.mutate;
useEffect(() => {
if (locked) return;
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
if (!images.length) return;
e.preventDefault();
for (const image of images) addRef.current(image);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [locked]);
const [mode, setMode] = useState<"edit" | "preview">("edit");
// The export is built from the SAVED draft: save what is on screen first.
const exportZip = async () => {
if (!sent) await save.mutateAsync();
if (!sent) {
try {
await save.mutateAsync();
} catch {
return; // the save's own message says why
}
}
const a = document.createElement("a");
a.href = api.debugExportUrl(session.id);
a.download = "";
@@ -266,7 +332,7 @@ function ComposerBody({
reviewing === "web" ? "recorder.composer.clientLog" : "recorder.composer.serverLog",
)}
excluded={excluded}
readOnly={sent}
readOnly={locked}
onToggle={(rowId) =>
edit((f) => ({
...f,
@@ -275,20 +341,28 @@ function ComposerBody({
: [...f.excluded, rowId],
}))
}
onDone={() => setReviewing(null)}
onDone={() => {
reviewed.current = reviewing;
setReviewing(null);
}}
/>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<DraftFields form={form} edit={edit} env={env} readOnly={sent} />
<DraftFields form={form} edit={edit} env={env} readOnly={locked} />
<Attachments
session={session}
shots={shots}
shotsQ={shotsQ}
counts={{
web: [kept("web"), rows.filter((r) => sourceOf(r) === "web").length],
server: [kept("server"), rows.filter((r) => sourceOf(r) === "server").length],
}}
readOnly={sent}
readOnly={locked}
adding={addShot.isPending}
onAdd={(files) => {
for (const file of files) if (isImage(file)) addShot.mutate(file);
}}
onReview={setReviewing}
reviewed={reviewed}
/>
</div>
)}
@@ -300,7 +374,7 @@ function ComposerBody({
? t("recorder.composer.sending")
: save.isPending
? t("recorder.composer.saving")
: save.isSuccess
: save.isSuccess && !unsaved
? t("recorder.composer.saved")
: ""}
</span>
@@ -308,11 +382,21 @@ function ComposerBody({
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
</Button>
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
<Button
variant="outline"
loading={save.isPending}
disabled={!shotsReady || send.isPending}
onClick={() => void exportZip()}
>
{t("recorder.composer.export")}
</Button>
{target && !sent && (
<Button variant="primary" loading={send.isPending} onClick={() => send.mutate()}>
<Button
variant="primary"
loading={send.isPending}
disabled={!shotsReady}
onClick={() => send.mutate()}
>
{t("recorder.composer.send")}
</Button>
)}
@@ -333,6 +417,8 @@ function DraftFields({
readOnly: boolean;
}) {
const { t } = useTranslation();
// A removed step's ✕ goes with it: the focus moves to "add a step", which stays.
const addStep = useRef<HTMLButtonElement>(null);
const setStep = (i: number, patch: Partial<DraftStep>) =>
edit((f) => ({ ...f, steps: f.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
return (
@@ -395,7 +481,10 @@ function DraftFields({
iconOnly
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeStep", { n: i + 1 })}
onClick={() => edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }))}
onClick={() => {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
/>
)}
</li>
@@ -403,6 +492,7 @@ function DraftFields({
</ol>
{!readOnly && (
<Button
ref={addStep}
variant="link"
className="self-start"
icon={<Plus className="w-4 h-4" />}
@@ -432,50 +522,57 @@ function DraftFields({
);
}
// The types the server keeps (backend debugsession.IMAGE_TYPES) — offered by the picker, and the
// only ones a paste or a drop hands over.
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
const isImage = (file: File) => IMAGE_TYPES.includes(file.type);
/** What changes with a screenshot: its list, and the session's count — not the session's events,
* which share the session's key prefix and are thousands of rows. */
function refreshShots(qc: ReturnType<typeof useQueryClient>, id: string) {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(id), exact: true });
}
function Attachments({
session,
shots,
shotsQ,
counts,
readOnly,
adding,
onAdd,
onReview,
reviewed,
}: {
session: DebugSession;
shots: DebugScreenshot[];
shotsQ: { data?: { items: DebugScreenshot[] }; isError: boolean; refetch: () => unknown };
counts: Record<Source, [kept: number, total: number]>;
readOnly: boolean;
adding: boolean;
onAdd: (files: Iterable<File>) => void;
onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: qk.debugScreenshots(session.id) });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id) });
};
const add = useMutation({
mutationFn: (image: Blob) => api.addDebugScreenshot(session.id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
});
const shots = shotsQ.data?.items ?? [];
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
useEffect(() => {
const source = reviewed.current;
if (!source) return;
reviewed.current = null;
reviewButtons.current[source]?.focus();
}, [reviewed]);
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: refresh,
meta: { failTitle: "recorder.composer.removeShotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
const addFiles = (files: Iterable<File>) => {
for (const file of files) if (IMAGE_TYPES.includes(file.type)) add.mutate(file);
};
// Only an image paste is taken over: text pasted into the title or a step goes where it was typed.
const onPaste = (e: ClipboardEvent) => {
const images = [...e.clipboardData.files].filter((f) => IMAGE_TYPES.includes(f.type));
if (!images.length) return;
e.preventDefault();
for (const image of images) add.mutate(image);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
onAdd(e.dataTransfer.files);
};
const logRow = (source: Source, icon: React.ReactNode) => {
@@ -495,7 +592,14 @@ function Attachments({
</span>
</div>
{total > 0 && (
<Button variant="outline" size="sm" onClick={() => onReview(source)}>
<Button
ref={(el) => {
reviewButtons.current[source] = el;
}}
variant="outline"
size="sm"
onClick={() => onReview(source)}
>
{t("recorder.composer.review")}
</Button>
)}
@@ -504,13 +608,15 @@ function Attachments({
};
return (
<div
className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"
onPaste={readOnly ? undefined : onPaste}
>
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4">
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{shotsQ.isError && !shotsQ.data && (
<StateMessage tone="error" compact onRetry={() => void shotsQ.refetch()}>
{t("recorder.composer.shotsFailed")}
</StateMessage>
)}
{shots.map((shot, i) => (
<div
key={shot.id}
@@ -523,9 +629,7 @@ function Attachments({
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
<span className="text-xs text-muted tabular-nums">
{Math.round(shot.size / 1024)} KB
</span>
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
</div>
{!readOnly && (
<Button
@@ -535,27 +639,32 @@ function Attachments({
icon={<X className="w-4 h-4" />}
label={t("recorder.composer.removeShot", { n: i + 1 })}
loading={remove.isPending && remove.variables === shot.id}
onClick={() => remove.mutate(shot.id)}
onClick={() => {
remove.mutate(shot.id);
pick.current?.focus();
}}
/>
)}
</div>
))}
{!readOnly && (
{!readOnly && shotsQ.data && (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
aria-busy={adding}
className="rounded-xl border border-dashed border-border px-3 py-4 text-center text-sm text-muted"
>
{t("recorder.composer.drop")}{" "}
<label className="cursor-pointer font-semibold text-accent hover:underline">
<label className="cursor-pointer rounded font-semibold text-accent hover:underline focus-within:underline focus-within:ring-2 focus-within:ring-accent/60">
{t("recorder.composer.pick")}
<input
ref={pick}
type="file"
accept={IMAGE_TYPES.join(",")}
multiple
className="sr-only"
onChange={(e) => {
addFiles(e.target.files ?? []);
onAdd(e.target.files ?? []);
e.target.value = "";
}}
/>
@@ -583,6 +692,9 @@ function ReviewList({
onDone: () => void;
}) {
const { t } = useTranslation();
// Opened from its Review button, which is gone now: the focus comes here.
const done = useRef<HTMLButtonElement>(null);
useEffect(() => done.current?.focus(), []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -590,7 +702,7 @@ function ReviewList({
<div className="font-semibold">{t("recorder.composer.reviewTitle", { name })}</div>
<div className="text-xs text-muted">{t("recorder.composer.reviewHint")}</div>
</div>
<Button variant="primary" size="sm" onClick={onDone}>
<Button ref={done} variant="primary" size="sm" onClick={onDone}>
{t("recorder.composer.reviewDone")}
</Button>
</div>
@@ -642,7 +754,7 @@ function IssuePreview({
}) {
const { t } = useTranslation();
const q = useQuery({
queryKey: [...qk.debugSession(sessionId), "preview", text],
queryKey: qk.debugSessionPreview(sessionId, text),
queryFn: () => api.previewDebugIssue(sessionId, text),
enabled: viaTracker,
});
@@ -666,10 +778,13 @@ function IssuePreview({
if (!q.data) return <LoadingState />;
const css = getComputedStyle(document.documentElement);
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
// The frame is a document of its own: the app's text size (the root font, which the text-size
// setting drives) is handed to it, and everything in it is sized off that.
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{margin:0;padding:12px 14px;font:14px/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:4px 8px}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px}h1,h2,h3{line-height:1.25}
html{font-size:${css.fontSize}}
body{margin:0;padding:.75rem .875rem;font:.875rem/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:.25rem .5rem}
code{font-family:ui-monospace,Consolas,monospace;font-size:.75rem}h1,h2,h3{line-height:1.25}
</style></head><body>${q.data.html}</body></html>`;
return (
<iframe
@@ -50,6 +50,14 @@ describe("Toaster — one host shows the toasts", () => {
app.unmount();
});
it("lets a click through wherever there is no toast and nothing docked, in every host", () => {
render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's
// clicks; the cards and the dock opt back in.
expect(region.className).toContain("pointer-events-none");
});
it("does not let a click on a toast reach the overlay's backdrop", () => {
const backdrop = vi.fn();
render(
+5 -2
View File
@@ -122,8 +122,11 @@ export default function Toaster({
// lib/listNav: focus on a toast's Undo — or on the docked control — is not the reader leaving
// the list the toast is about.
data-toast-region=""
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] ${
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
// Click-through in every host: the region is as wide as its (always present) list and as tall
// as the docked pill, and without this the empty part beside and above the pill swallowed the
// page's clicks. The cards and the dock opt back in.
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] pointer-events-none ${
overlay ? "bottom-24" : "bottom-4"
}`}
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined}
+34 -11
View File
@@ -1,10 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
@@ -21,7 +28,9 @@ export function useDebugRecording() {
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) => {
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
void qc.invalidateQueries({ queryKey: qk.debugSessions() });
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
announceSessionChange();
setStepPrompt(null); // a prompt left open belongs to the session that ended
};
const start = useMutation({
@@ -34,6 +43,7 @@ export function useDebugRecording() {
});
const stop = useMutation({
mutationKey: STOP_KEY,
mutationFn: async (id: string) => {
log.info("debug.stop", "recording stopped by the user");
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
@@ -50,6 +60,7 @@ export function useDebugRecording() {
});
const shoot = useMutation({
mutationKey: SHOOT_KEY,
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
api.addDebugScreenshot(id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
@@ -71,14 +82,23 @@ export function useDebugRecording() {
},
});
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
// to another toast host, and a Stop still running must keep its button busy there.
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
/** Mark the next step; `label` is what the user says they just did (may be empty). */
const markStep = (label: string) => {
if (!session) return;
const n = nextStep(session.id);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
};
const sessionId = session?.id;
const markStep = useCallback(
(label: string) => {
if (!sessionId) return;
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
},
[sessionId, t],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
* frame. Nothing happens when the user cancels the browser's picker. */
@@ -98,5 +118,8 @@ export function useDebugRecording() {
if (image) shoot.mutate({ id: session.id, image });
};
return { session, start, stop, shoot, markStep, screenshot };
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
}
const STOP_KEY = ["debug-session", "stop"];
const SHOOT_KEY = ["debug-session", "screenshot"];
+4 -1
View File
@@ -222,6 +222,9 @@
"issues_public_repo": "The issue repository is public. Because of the log, reports go only to a private one.",
"issues_disabled": "The issue repository has its issue tracker switched off.",
"issues_rejected": "The issue tracker refused the request.",
"issues_rate_limited": "You can send at most 5 reports an hour. Try again later."
"issues_rate_limited": "You can send at most {{limit}} reports an hour. Try again later.",
"debug_session_rate_limited": "You can start at most {{limit}} new recordings an hour. Try again later.",
"export_rate_limited": "Too many downloads in a row. Wait a minute.",
"screenshot_storage_full": "You've reached the {{limit}} MB screenshot allowance. Remove images from older sessions, or wait until they expire."
}
}
+3 -1
View File
@@ -105,7 +105,9 @@
"preview": "Preview",
"previewFailed": "Couldn't load the preview",
"previewTitle": "Preview of the report",
"previewLocal": "Without an issue tracker, this is the zip's issue.md."
"previewLocal": "Without an issue tracker, this is the zip's issue.md.",
"shotsFailed": "Couldn't load the screenshots",
"removeShotFailed": "Couldn't remove the screenshot"
},
"issue": {
"steps": "Steps",
+4 -1
View File
@@ -222,6 +222,9 @@
"issues_public_repo": "A hibajegy-tárhely nyilvános. A napló miatt csak privát tárhelyre küldünk.",
"issues_disabled": "A hibajegy-tárhelyen ki van kapcsolva a hibajegy-kezelés.",
"issues_rejected": "A hibajegy-kezelő visszautasította a kérést.",
"issues_rate_limited": "Óránként legfeljebb 5 hibajegyet küldhetsz. Próbáld újra később."
"issues_rate_limited": "Óránként legfeljebb {{limit}} hibajegyet küldhetsz. Próbáld újra később.",
"debug_session_rate_limited": "Óránként legfeljebb {{limit}} új rögzítést indíthatsz. Próbáld újra később.",
"export_rate_limited": "Túl sok letöltés egymás után. Várj egy percet.",
"screenshot_storage_full": "Elérted a képernyőképek {{limit}} MB-os keretét. Törölj régebbi munkamenetekből képeket, vagy várd meg, amíg lejárnak."
}
}
+3 -1
View File
@@ -105,7 +105,9 @@
"preview": "Előnézet",
"previewFailed": "Nem sikerült betölteni az előnézetet",
"previewTitle": "A hibajegy előnézete",
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja."
"previewLocal": "Hibajegy-kezelő nélkül ez a zip issue.md fájlja.",
"shotsFailed": "Nem sikerült betölteni a képernyőképeket",
"removeShotFailed": "Nem sikerült törölni a képernyőképet"
},
"issue": {
"steps": "Lépések",
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
//
// E16: one screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
// a cancelled or refused picker is the user saying no (null, no error); anything else is an error;
// a tab that was shared but gave no frame is an error too, not a silent nothing; and the capture is
// stopped whatever happened — nothing keeps sharing the tab after one frame.
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureTab } from "./captureTab";
function stubPicker(result: () => Promise<MediaStream>) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: vi.fn(result) },
});
}
function fakeStream() {
const track = { stop: vi.fn() };
return { stream: { getTracks: () => [track] } as unknown as MediaStream, track };
}
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(navigator, "mediaDevices");
});
describe("captureTab", () => {
it.each(["NotAllowedError", "AbortError"])(
"a %s from the picker is the user saying no",
async (name) => {
stubPicker(() => Promise.reject(new DOMException("no", name)));
await expect(captureTab()).resolves.toBeNull();
},
);
it("any other failure of the picker is an error", async () => {
stubPicker(() => Promise.reject(new DOMException("broken", "NotReadableError")));
await expect(captureTab()).rejects.toMatchObject({ name: "NotReadableError" });
});
it("a shared tab that gives no frame is an error, and the capture is stopped", async () => {
vi.useFakeTimers();
const { stream, track } = fakeStream();
stubPicker(() => Promise.resolve(stream));
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
// jsdom's video never has a size: no frame arrives.
const result = captureTab();
const settled = expect(result).rejects.toMatchObject({ name: "NoFrameError" });
await vi.advanceTimersByTimeAsync(2_000);
await settled;
expect(track.stop).toHaveBeenCalled();
vi.useRealTimers();
});
});
+16 -5
View File
@@ -6,7 +6,8 @@
//
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
// name Chrome gives a cancelled picker.
// name Chrome gives a cancelled picker. A tab that WAS shared but gave no frame is an error, though:
// the user said yes, and a silent nothing would read as a screenshot taken.
//
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
// Firefox and Safari lack.
@@ -16,7 +17,8 @@ export function canCaptureTab(): boolean {
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
}
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. */
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. Throws
* `NoFrameError` when the shared tab gave no frame. */
export async function captureTab(): Promise<Blob | null> {
let stream: MediaStream;
try {
@@ -44,7 +46,14 @@ export async function captureTab(): Promise<Blob | null> {
}
}
async function firstFrame(stream: MediaStream): Promise<Blob | null> {
class NoFrameError extends Error {
constructor() {
super("the shared tab gave no frame");
this.name = "NoFrameError";
}
}
async function firstFrame(stream: MediaStream): Promise<Blob> {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
@@ -52,11 +61,13 @@ async function firstFrame(stream: MediaStream): Promise<Blob | null> {
await video.play();
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
if (!video.videoWidth) return null;
if (!video.videoWidth) throw new NoFrameError();
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
video.srcObject = null;
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new NoFrameError();
return blob;
}
+28
View File
@@ -10,6 +10,34 @@ export const openRecorderStart = (): void => startOpen.set(true);
export const closeRecorderStart = (): void => startOpen.set(false);
export const useRecorderStartOpen = (): boolean => startOpen.use();
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
// one (a remount). The prompt must come along with what was typed.
const stepPrompt = createStore<string | null>(null);
/** The step prompt's text while it is open, else null. */
export const useStepPrompt = (): string | null => stepPrompt.use();
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
// it only at their next `me` poll — and meanwhile record into a session that has ended.
const CHANNEL = "siftlode.debugSession";
let channel: BroadcastChannel | null | undefined;
function tabs(): BroadcastChannel | null {
if (channel === undefined)
channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
return channel;
}
/** Tell the other tabs the recording started or stopped. */
export const announceSessionChange = (): void => tabs()?.postMessage("changed");
/** Run `fn` when another tab started or stopped a recording. */
export function onSessionChangeElsewhere(fn: () => void): () => void {
const c = tabs();
if (!c) return () => {};
const handler = () => fn();
c.addEventListener("message", handler);
return () => c.removeEventListener("message", handler);
}
const composing = createStore<string | null>(null);
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
export const openComposer = (id: string): void => composing.set(id);
@@ -131,6 +131,16 @@ describe("a debug session applied to the tab", () => {
expect(codes).toEqual(expect.arrayContaining(["ui.before", "ui.after"]));
});
it("its last seconds are sent before its cap, while the server still tags them", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG", 1), () => {});
await vi.advanceTimersByTimeAsync(57_000); // the regular flushes ran; the next is past the cap
d.log.info("debug.step", "the last step");
await vi.advanceTimersByTimeAsync(2_000); // 59 s: the cap is still a second away
const step = flat().find((e) => e.event === "debug.step");
expect(step?.debug_session_id).toBe("0123456789abcdef");
});
it("flushNow sends what is waiting at once — before Stop closes the session", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
+10
View File
@@ -52,6 +52,10 @@ const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
let applied: string | null = null;
let lapseTimer: number | null = null;
// A recording's last seconds are sent BEFORE its cap: at the cap the server stops tagging (or, at the
// base level, taking) them, and the regular flush could be up to FLUSH_MS behind.
const CAP_FLUSH_LEAD_MS = 1_500;
let capFlushTimer: number | null = null;
let onLapsed: (() => void) | null = null;
let lastState: DiagnosticsState | null | undefined = null;
let lastSession: DebugSession | null | undefined = null;
@@ -79,6 +83,8 @@ export function applyDiagnostics(
applied = key;
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
lapseTimer = null;
if (capFlushTimer !== null) window.clearTimeout(capFlushTimer);
capFlushTimer = null;
if (level === "INFO") {
stopStreaming();
setLevel("INFO");
@@ -99,6 +105,10 @@ export function applyDiagnostics(
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
if (live) {
const lead = Date.parse(live.until) - CAP_FLUSH_LEAD_MS - Date.now();
if (lead > 0 && lead < wait) capFlushTimer = window.setTimeout(() => void flush(), lead);
}
}
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */
+3
View File
@@ -100,6 +100,9 @@ export const qk = {
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
// The tracker's rendering of a body: the text is the key (typing pauses, then it renders again).
debugSessionPreview: (id: string, text: string) =>
["debug-sessions", id, "preview", text] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const,