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.
383 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|