feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded, retention and the cap (now served as diagnostics.session_minutes) - the pill docks under the toasts: elapsed time, Step (an optional label, its own Escape layer), Screenshot (tab capture where the browser has it), Stop; compact on a phone - while recording, lib/log tags every event and every request path (req, beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics raises the level for the session's run and never lowers a raised one, and lapses at whichever ends first - Stop sends what is waiting before the server closes the session; the cap says so and opens the composer - Settings → Diagnosztika and the command palette start/step/stop it - the evidence leaves the log upload's own request lines out - the step shortcut is deliberately palette-only: the keymap's D1 rule bars a new modifier chord, and a bare letter collides with the players'
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
// @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 { 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 } = vi.hoisted(() => {
|
||||
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,
|
||||
screenshots: 0,
|
||||
...over,
|
||||
});
|
||||
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
|
||||
Promise.resolve({ ok: true as const, session: session({ level }) }),
|
||||
);
|
||||
const stopDebugSession = vi.fn((id: string) => {
|
||||
order.push("stop");
|
||||
return Promise.resolve({
|
||||
ok: true as const,
|
||||
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
|
||||
});
|
||||
});
|
||||
return { order, session, startDebugSession, stopDebugSession };
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, startDebugSession, stopDebugSession } };
|
||||
});
|
||||
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 from "./DebugRecorder";
|
||||
import Modal from "./ui/Modal";
|
||||
import Toaster from "./ui/Toaster";
|
||||
import { closeComposer, openRecorderStart, useComposerSession } from "../lib/debugRecorder";
|
||||
import { events } from "../lib/log";
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
sessionStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeComposer();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let composing: string | null = null;
|
||||
function ComposerProbe() {
|
||||
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" });
|
||||
|
||||
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 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("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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user