// @vitest-environment jsdom // // The issue composer. It drafts the steps from the session, saves as it goes (the rendered // text included — the export is built from what it SAVED), reopens as it was left, takes a log line // out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a // key-passthrough with its params; the API is stubbed. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DebugDraft, DebugSession, ServerLogRow } 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 api = vi.hoisted(() => ({ session: null as unknown as DebugSession, rows: [] as ServerLogRow[], 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 | null, // The server's half-failure: the issue is committed, then an upload dies and the call 422s. failSendAfterCreate: false, })); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, debugSession: () => api.failSession ? Promise.reject(new Error("down")) : Promise.resolve(api.session), debugSessionEvents: () => Promise.resolve({ items: api.rows, total: api.rows.length }), debugScreenshots: () => api.failShots ? Promise.reject(new Error("down")) : Promise.resolve({ items: [] }), saveDebugDraft: (_id: string, draft: DebugDraft) => { api.saved.push(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); return Promise.resolve({ ok: true, screenshot: { id: 1, mime: image.type, size: 4, created_at: "" }, }); }, debugExport: () => { api.order.push("export"); return Promise.resolve({ blob: new Blob(["zip"]), name: "siftlode-debug.zip" }); }, 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: api.failSendAfterCreate ? ["server.log"] : [], // What the server does on the way through: a send that got there shows the images too, // so the banner (and the Retry in it) goes. Leaving this true made the retry's own // banner outlive its success, which is not a state the server can produce. images_pending: false, }; if (api.failSendAfterCreate) throw new Error("issues_unreachable"); return { ok: true as const, session: api.session }; }, previewDebugIssue: (_id: string, text: string) => { api.previewed.push(text); return Promise.resolve({ ok: true, html: "

Steps

" }); }, }, }; }); import IssueComposer from "./IssueComposer"; const SID = "0123456789abcdef"; let n = 1; const row = (over: Partial): ServerLogRow => ({ id: n++, ts: "2026-09-19T14:05:31.000Z", level: "INFO", source: "web", logger: "web", event: "x.y", msg: "m", attrs: null, exc: null, request_id: null, user_id: 1, job_id: null, run_id: null, debug_session_id: SID, version: null, ...over, }); const baseSession = (over: Partial = {}): DebugSession => ({ id: SID, level: "DEBUG", started_at: "2026-09-19T14:02:00.000Z", until: "2026-09-19T15:02:00.000Z", ended_at: "2026-09-19T14:16:00.000Z", ended_reason: "stopped", issue_url: null, pending_files: [], images_pending: false, sending: false, screenshots: 0, draft: null, ...over, }); beforeEach(() => { api.session = baseSession(); api.rows = [ row({ event: "debug.step", msg: "resume", attrs: { n: 1, label: "Resume" } }), row({ event: "ui.click", msg: "private click" }), row({ source: "api", event: "http.request", msg: "GET /api/x 200" }), ]; api.saved = []; api.uploaded = []; api.failSession = false; api.failShots = false; api.order = []; api.holdSend = null; api.failSendAfterCreate = false; api.sent = 0; api.previewed = []; tracker = null; }); afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); }); let tracker: { repo: string } | null = null; function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, issues: tracker }); render( , ); } /** jsdom lays nothing out, so every element reads as hidden to the dialog's focus rules. */ const layOut = () => vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body); const lastSaved = () => api.saved[api.saved.length - 1]!; const title = () => screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement; /** A button once it can be pressed: `loading` holds it while the draft's first save settles. */ async function ready(name: string): Promise { 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 () => { mount(); const first = await screen.findByLabelText("recorder.composer.stepLabel:1"); expect((first as HTMLInputElement).value).toBe("Resume"); fireEvent.change(screen.getByLabelText("recorder.composer.fieldTitle"), { target: { value: "Spinner stays" }, }); await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 }); expect(lastSaved().title).toBe("Spinner stays"); expect(lastSaved().body).toContain("1. Resume"); expect(lastSaved().body).toContain("`client.log` — recorder.issue.clientLog:2"); }); it("reopens a saved draft as it was left", async () => { api.session = baseSession({ draft: { title: "Saved title", body: "", excluded: [], form: { title: "Saved title", description: "kept", steps: [{ id: "m1", kind: "manual", text: "typed" }], excluded: [], }, }, }); mount(); expect( ((await screen.findByLabelText("recorder.composer.fieldTitle")) as HTMLInputElement).value, ).toBe("Saved title"); expect((screen.getByLabelText("recorder.composer.stepLabel:1") as HTMLInputElement).value).toBe( "typed", ); }); it("takes an unticked line out of the evidence", async () => { mount(); fireEvent.click( (await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!, ); fireEvent.click( screen.getByRole("checkbox", { name: "recorder.composer.keepRow:ui.click,private click" }), ); fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" })); expect(screen.getByText(/recorder.composer.linesKept:1,2/)).not.toBeNull(); await waitFor(() => expect(api.saved.length).toBeGreaterThan(0), { timeout: 2000 }); const clickId = api.rows[1]!.id; expect(lastSaved().excluded).toEqual([clickId]); expect(lastSaved().body).toContain("recorder.issue.clientLog:1"); }); it("adds a pasted image, and nothing else that was pasted", async () => { mount(); const zone = (await screen.findByText("recorder.composer.attachments")).parentElement!; const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" }); const text = new File(["hi"], "note.txt", { type: "text/plain" }); fireEvent.paste(zone, { clipboardData: { files: [png, text] } }); await waitFor(() => expect(api.uploaded).toHaveLength(1)); expect(api.uploaded[0]!.type).toBe("image/png"); }); it("takes a dropped or picked image, and nothing else", async () => { mount(); const zone = (await screen.findByText(/recorder.composer.drop/)).closest("div")!; const png = new File([new Uint8Array([137, 80, 78, 71])], "a.png", { type: "image/png" }); const pdf = new File(["%PDF"], "a.pdf", { type: "application/pdf" }); fireEvent.drop(zone, { dataTransfer: { files: [pdf, png] } }); await waitFor(() => expect(api.uploaded).toHaveLength(1)); 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 still counts them from the session", async () => { api.failShots = true; api.session = baseSession({ screenshots: 2 }); mount(); expect(await screen.findByText("recorder.composer.shotsFailed")).not.toBeNull(); // Everything still works: the images are the one thing that could not be listed. expect(screen.queryByText("recorder.composer.attachments")).not.toBeNull(); expect( (screen.getByRole("button", { name: "recorder.composer.export" }) as HTMLButtonElement) .disabled, ).toBe(false); // Including the control standing right beside the failed list: more can still be added. expect(screen.queryByText("recorder.composer.pick")).not.toBeNull(); // The draft is still saved — and says two screenshots, the server's own count, not none. fireEvent.change(title(), { target: { value: "x" } }); await waitFor(() => expect(api.saved).toHaveLength(1), { timeout: 2000 }); expect(lastSaved().body).toContain("recorder.issue.screenshots:2"); }); it("says a send is under way instead of calling it half-failed, and holds the draft", async () => { // Every step of a send commits its progress, so a reload or a second tab reads exactly the // shape of a send that STOPPED. Only the server can tell the two apart — and it does. api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7", pending_files: ["server.log", "screenshot-1.png"], sending: true, }); mount(); expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull(); expect(screen.queryByText(/recorder.composer.partial/)).toBeNull(); // No retry to press: it would only be refused while the send it invites is still running. expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull(); fireEvent.change(title(), { target: { value: "typed while it went out" } }); await new Promise((r) => setTimeout(r, 1000)); expect(api.saved).toHaveLength(0); }); it("holds the draft through a first send, before there is an issue to point at", async () => { tracker = { repo: "o/r" }; // A send with no issue_url yet is what the read-only lock is for: an edit made mid-flight // would be saved over the very body the send is at that moment uploading, and the issue that // arrives would be neither draft. The half-sent case above is locked by its link instead. api.session = baseSession({ sending: true }); mount(); expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull(); fireEvent.change(title(), { target: { value: "typed mid-flight" } }); await new Promise((r) => setTimeout(r, 1000)); expect(api.saved).toHaveLength(0); // Nor can a screenshot join what is already on its way up. expect(screen.queryByText("recorder.composer.pick")).toBeNull(); // And the button that would start a SECOND send is out — the server would only refuse it. expect( (screen.getByRole("button", { name: "recorder.composer.send" }) as HTMLButtonElement) .disabled, ).toBe(true); }); it("announces the half-sent banner instead of only drawing it", async () => { // A screen-reader user hears the failure toast — "couldn't send the report" — and nothing else. // WHICH attachments are missing, and that a Retry now stands beside them, live only in this // banner, which is inserted already carrying its first message. A live region created together // with its content is unreliably announced (the rule ui/Toaster.tsx states and follows), so the // announcement has to come from a ROLE, which announces on insertion. api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7", pending_files: ["server.log"], }); mount(); const said = await screen.findByText(/recorder\.composer\.partial/); const announced = said.closest('[role="status"]'); expect(announced).not.toBeNull(); // ...and the Retry is inside what gets read out, not left behind silently beside it. expect(announced!.querySelector("button")).not.toBeNull(); }); it("hands the focus on when a send half-fails, not only when it works", async () => { tracker = { repo: "o/r" }; // The issue is created and an upload then dies, so the call FAILS with the link already // committed. The refetch brings the link back, `sent` flips, and "Create issue" — the control // holding the focus — unmounts under it. Without a hand-off the focus lands on in the // middle of an open dialog: a screen reader loses its place and the Retry that just appeared // is several Tabs away, past the dialog's own ✕. api.failSendAfterCreate = true; mount(); const send = (await ready("recorder.composer.send")) as HTMLButtonElement; send.focus(); expect(document.activeElement).toBe(send); fireEvent.click(send); await waitFor(() => expect(api.sent).toBe(1)); const retry = await screen.findByRole("button", { name: "recorder.composer.retry" }); await waitFor(() => expect(document.activeElement).toBe(retry)); expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull(); }); it("still builds the zip while a send holds the draft, instead of saving into a refusal", async () => { tracker = { repo: "o/r" }; // The zip is the only way out of a send that is stuck — a claim left by a dead request stands // for ten minutes. Its pre-save is pointless here (the draft is frozen, and the server would // 409 `issue_sending` and take the export down with it), and unnecessary: `GET /export` has no // send guard and builds from the saved draft, which is exactly what the send is uploading. api.session = baseSession({ sending: true }); const click = vi.spyOn(HTMLAnchorElement.prototype, "click"); click.mockImplementation(() => void api.order.push("click")); const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL }; URL.createObjectURL = () => "blob:zip"; URL.revokeObjectURL = () => {}; try { mount(); expect(await screen.findByText("recorder.composer.sendingNow")).not.toBeNull(); fireEvent.click(await ready("recorder.composer.export")); await waitFor(() => expect(click).toHaveBeenCalled()); expect(api.order).toEqual(["export", "click"]); expect(api.saved).toHaveLength(0); } finally { click.mockRestore(); URL.createObjectURL = real.make; URL.revokeObjectURL = real.revoke; } }); it("asks to show the images, not to send what is already there", async () => { tracker = { repo: "o/r" }; api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7", images_pending: true, }); mount(); await screen.findByText("recorder.composer.partialImages"); // Nothing is missing from the issue here — only its text has still to show what it carries. expect(screen.queryByRole("button", { name: "recorder.composer.retry" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retryImages" })); await waitFor(() => expect(api.sent).toBe(1)); // The button just pressed is gone with its banner: the focus goes to what takes its place, // not to in the middle of an open dialog. await waitFor(() => expect(document.activeElement?.getAttribute("href")).toBe("https://git.example/o/r/issues/7"), ); }); it("leaves an image pasted into a dialog opened over it alone", async () => { mount(); await screen.findByText("recorder.composer.attachments"); const above = document.createElement("div"); above.setAttribute("aria-modal", "true"); const field = document.createElement("input"); above.appendChild(field); document.body.appendChild(above); const png = new File([new Uint8Array([137, 80, 78, 71])], "shot.png", { type: "image/png" }); fireEvent.paste(field, { clipboardData: { files: [png] } }); await new Promise((r) => setTimeout(r, 50)); expect(api.uploaded).toHaveLength(0); above.remove(); }); 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!; const pasted = fireEvent.paste(zone, { clipboardData: { files: [] } }); expect(pasted).toBe(true); // not prevented expect(api.uploaded).toHaveLength(0); }); it("saves before it asks for the zip, and hands it to the browser", async () => { const click = vi.spyOn(HTMLAnchorElement.prototype, "click"); click.mockImplementation(() => void api.order.push("click")); const urls = { made: 0, revoked: 0 }; // jsdom implements neither, so there is nothing to spy on: they are DEFINED here — and put // back afterwards, or every later test in this file would keep running against them. const real = { make: URL.createObjectURL, revoke: URL.revokeObjectURL }; URL.createObjectURL = () => { urls.made += 1; return "blob:zip"; }; URL.revokeObjectURL = () => void (urls.revoked += 1); mount(); fireEvent.click(await ready("recorder.composer.export")); await waitFor(() => expect(click).toHaveBeenCalled()); // A request, not a bare navigation: a refusal (the export's own pace) is shown like any other. expect(api.order).toEqual(["save", "saved", "export", "click"]); expect([urls.made, urls.revoked]).toEqual([1, 1]); click.mockRestore(); URL.createObjectURL = real.make; URL.revokeObjectURL = real.revoke; }); it("a sent session is read-only and links its issue", async () => { api.session = baseSession({ issue_url: "https://git.example/o/r/issues/7" }); mount(); const link = await screen.findByRole("link", { name: /recorder.composer.openIssue/ }); expect(link.getAttribute("href")).toBe("https://git.example/o/r/issues/7"); const title = screen.getByLabelText("recorder.composer.fieldTitle") as HTMLInputElement; expect(title.readOnly).toBe(true); expect(screen.queryByRole("button", { name: /recorder.composer.addStep/ })).toBeNull(); }); it("opens on the title, not on the Edit / Preview switch above it", async () => { // The form arrives after the dialog opens; its first control is the switch, and the focus // landed there — typing went nowhere. layOut(); mount(); await waitFor(() => expect(document.activeElement).toBe(title())); }); it("moves to the title once a retried load brings the form", async () => { layOut(); api.failSession = true; mount(); const message = await screen.findByText("recorder.composer.loadFailed"); api.failSession = false; fireEvent.click(within(message.closest('[role="alert"]')!).getByRole("button")); await waitFor(() => expect(document.activeElement).toBe(title())); }); it("says so when the session cannot be loaded, with a retry", async () => { api.failSession = true; mount(); const message = await screen.findByText("recorder.composer.loadFailed"); expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull(); }); it("offers the tracker only where one is set up, and sends the saved draft", async () => { mount(); await screen.findByRole("button", { name: "recorder.composer.export" }); expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull(); cleanup(); tracker = { repo: "o/r" }; mount(); expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull(); fireEvent.click(await ready("recorder.composer.send")); await waitFor(() => expect(api.sent).toBe(1)); // 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({ issue_url: "https://git.example/o/r/issues/7", pending_files: ["server.log"], }); mount(); expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull(); fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" })); await waitFor(() => expect(api.sent).toBe(1)); expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send }); it("returns the focus to a reviewed log once, not on every way back from the preview", async () => { mount(); fireEvent.click( (await screen.findAllByRole("button", { name: "recorder.composer.review" }))[0]!, ); fireEvent.click(screen.getByRole("button", { name: "recorder.composer.reviewDone" })); const review = screen.getAllByRole("button", { name: "recorder.composer.review" })[0]; expect(document.activeElement).toBe(review); fireEvent.click(screen.getByRole("button", { name: "recorder.composer.preview" })); const edit = screen.getByRole("button", { name: "recorder.composer.edit" }); edit.focus(); fireEvent.click(edit); await screen.findAllByRole("button", { name: "recorder.composer.review" }); expect(document.activeElement).toBe(edit); // the toggle keeps it; the log's button does not take it }); it("previews through the tracker in a frame where no script runs", async () => { tracker = { repo: "o/r" }; mount(); fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" })); const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement; expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts"); expect(frame.getAttribute("srcdoc")).toContain("

Steps

"); expect(api.previewed[0]).toContain("1. Resume"); }); it("previews the zip's own text where there is no tracker", async () => { mount(); fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" })); expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE"); expect(api.previewed).toHaveLength(0); }); });