Files
siftlode/frontend/src/components/DebugRecorder.dom.test.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

383 lines
15 KiB
TypeScript

// @vitest-environment jsdom
//
// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
// 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";
import type { DebugSession, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
i18n: { language: "en" },
}),
};
});
const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.hoisted(() => {
const past: { items: unknown[] } = { items: [] };
// 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",
level: "DEBUG",
started_at: new Date().toISOString(),
until: new Date(Date.now() + 3_600_000).toISOString(),
ended_at: null,
ended_reason: null,
issue_url: null,
pending_files: [],
images_pending: false,
sending: false,
screenshots: 0,
draft: null,
...over,
});
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
Promise.resolve({ ok: true as const, session: session({ level }) }),
);
const stopDebugSession = vi.fn(async (id: string) => {
order.push("stop");
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, hold, past };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
startDebugSession,
stopDebugSession,
debugSessions: () => Promise.resolve(past),
},
};
});
vi.mock("../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
return {
...actual,
flushNow: () => {
order.push("flush");
return Promise.resolve();
},
};
});
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
import { events } from "../lib/log";
beforeEach(() => {
order.length = 0;
hold.stop = null;
sessionStorage.clear();
});
afterEach(() => {
cleanup();
closeComposer();
setStepPrompt(null);
vi.clearAllMocks();
vi.useRealTimers();
});
let composing: string | null = null;
function ComposerProbe() {
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
composing = useComposerSession();
return null;
}
function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
...me,
});
render(
<QueryClientProvider client={qc}>
<Toaster />
<DebugRecorder />
<ComposerProbe />
{extra}
</QueryClientProvider>,
);
return qc;
}
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 initialFocus="close" 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() });
const p = pill()!;
expect(p).not.toBeNull();
expect(p.closest("[data-toast-region]")).not.toBeNull();
expect(p.closest("[aria-live]")).toBeNull();
});
it("is not there for a session that ended or ran past its cap", () => {
mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) });
expect(pill()).toBeNull();
cleanup();
mount({ debug_session: session({ ended_at: new Date().toISOString() }) });
expect(pill()).toBeNull();
});
it("Stop sends what is waiting first, then stops, then opens the composer", async () => {
const qc = mount({ debug_session: session() });
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef"));
expect(order).toEqual(["flush", "stop"]);
await waitFor(() => expect(pill()).toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session).toBeNull();
expect(composing).toBe("0123456789abcdef");
});
it("a step is one tagged event, numbered, and the focus comes back to Step", () => {
mount({ debug_session: session() });
const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" });
fireEvent.click(step);
const input = screen.getByLabelText("recorder.pill.stepPrompt:1");
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: "opened the player" } });
fireEvent.keyDown(input, { key: "Enter" });
const marked = events().filter((e) => e.event === "debug.step");
expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(document.activeElement).toBe(step);
expect(step.textContent).toContain("· 1");
});
it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal initialFocus="close" title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
// The pill moved into the dialog's own toast host.
expect(pill()!.closest('[role="dialog"]')).not.toBeNull();
const marked = () => events().filter((e) => e.event === "debug.step").length;
const before = marked();
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(onClose).not.toHaveBeenCalled();
expect(marked()).toBe(before);
});
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal initialFocus="close" 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 quiets the page's keys and leaves the Tab to the dialog it sits in", () => {
mount({ debug_session: session() });
expect(anyBlockingLayer()).toBe(false);
// The pill docks inside whatever dialog is open; stand one in for it, under the prompt.
const dialog = pushLayer();
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
// The prompt's field has the keyboard, so `j` and the `g` chords stay off the page behind...
expect(anyBlockingLayer()).toBe(true);
// ...while the dialog around it keeps its focus trap. The prompt cannot be that trap: its two
// controls are two of the dialog's many, and taking it left the rest of them untrapped, with
// Tab walking out into the page behind the scrim.
expect(anyBlockingLayerAbove(dialog)).toBe(false);
// And the quiet is the prompt's own doing, not the stand-in's: it outlasts the dialog.
removeLayer(dialog);
expect(anyBlockingLayer()).toBe(true);
});
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.
expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull();
});
it("at its cap it says so and opens the composer", () => {
vi.useFakeTimers();
mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) });
act(() => void vi.advanceTimersByTime(5_000));
expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull();
expect(composing).toBe("0123456789abcdef");
});
});
describe("the start dialog", () => {
it("states the retention and the cap, and starts at the level chosen", async () => {
const qc = mount({});
act(() => openRecorderStart());
expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull();
expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" }));
expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ }));
await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE"));
await waitFor(() => expect(pill()).not.toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session?.level).toBe("TRACE");
expect(screen.queryByText("recorder.start.title")).toBeNull();
});
});
describe("the past sessions a user goes back to check", () => {
function settings() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
});
render(
<QueryClientProvider client={qc}>
<RecorderSettings />
</QueryClientProvider>,
);
}
it("does not call a half-delivered report sent", async () => {
// An issue that still owes its logs, or an edit that shows its screenshots, is not done. This
// list is where a user checks back, and a green "Sent" here is how one gets forgotten.
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
past.items = [
session({
...over,
id: "aaaaaaaaaaaaaaa1",
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["client.log"],
}),
session({
...over,
id: "aaaaaaaaaaaaaaa2",
issue_url: "https://git.example/o/r/issues/8",
images_pending: true,
}),
session({ ...over, id: "aaaaaaaaaaaaaaa3", issue_url: "https://git.example/o/r/issues/9" }),
];
settings();
expect(await screen.findAllByText("recorder.history.partial")).toHaveLength(2);
expect(screen.getAllByText("recorder.history.sent")).toHaveLength(1);
});
it("says a send is still running rather than naming what it has got to so far", async () => {
// This is the list a user opens TO CHECK on a send. Its two honest-looking words are both
// wrong for a running one: "draft" before it has an issue, "half sent" after.
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
past.items = [
session({
...over,
id: "bbbbbbbbbbbbbbb1",
sending: true,
draft: { title: "t", body: "b", excluded: [], form: {} },
}),
session({
...over,
id: "bbbbbbbbbbbbbbb2",
sending: true,
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["client.log"],
}),
];
settings();
expect(await screen.findAllByText("recorder.history.sending")).toHaveLength(2);
expect(screen.queryByText("recorder.history.draft")).toBeNull();
expect(screen.queryByText("recorder.history.partial")).toBeNull();
});
});