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:
@@ -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 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"];
|
||||
|
||||
@@ -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