// @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(); return { ...actual, useTranslation: () => ({ t: (key: string, opts?: Record) => 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 | null }; const order: string[] = []; const session = (over: Partial = {}): 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(); return { ...actual, api: { ...actual.api, startDebugSession, stopDebugSession, debugSessions: () => Promise.resolve(past), }, }; }); vi.mock("../lib/diagnostics", async (importOriginal) => { const actual = await importOriginal(); 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, 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( {extra} , ); 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 ( <> {open && ( setOpen(false)}>

inside

)} ); } 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(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() },

inside

, ); // 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() }, {}}>

inside

, ); 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() }, ); 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() }, ); 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(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( , ); } 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(); }); });