// @vitest-environment jsdom import { act as reactAct, useState } from "react"; import type { ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueDocumentsSection } from "./IssueDocumentsSection"; import { queryKeys } from "../lib/queryKeys"; const mockIssuesApi = vi.hoisted(() => ({ listDocuments: vi.fn(), listDocumentRevisions: vi.fn(), restoreDocumentRevision: vi.fn(), upsertDocument: vi.fn(), lockDocument: vi.fn(), unlockDocument: vi.fn(), deleteDocument: vi.fn(), getDocument: vi.fn(), })); const markdownEditorMockState = vi.hoisted(() => ({ emitMountEmptyChange: false, /** Typed into the editor on mount, for forms the test cannot type into. */ emitMountValue: null as string | null, changeBody: null as null | ((body: string) => void), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => T | Promise): Promise { if (typeof reactAct === "function") { return await (reactAct(callback) as T | Promise); } let result: T | Promise | undefined; flushSync(() => { result = callback(); }); const resolved = await result; await new Promise((resolve) => setTimeout(resolve, 0)); flushSync(() => {}); return resolved as T; } vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, })); const mockSession = vi.hoisted(() => ({ userId: "user-1", unreachable: false })); vi.mock("../api/auth", () => ({ authApi: { getSession: async () => { if (mockSession.unreachable) throw new Error("Service unavailable"); return { user: { id: mockSession.userId } }; }, }, })); const mockLocation = vi.hoisted(() => ({ hash: "" })); vi.mock("@/lib/router", () => ({ useLocation: () => mockLocation })); vi.mock("./DocumentMarkdownBody", async () => ({ DocumentMarkdownBody: (await import("./MarkdownBody")).MarkdownBody, })); vi.mock("./MarkdownBody", () => ({ MarkdownBody: ({ children, className, externalReferences, }: { children: string; className?: string; externalReferences?: Record; }) => (
{children}
), })); vi.mock("./MarkdownEditor", async () => { const React = await import("react"); return { MarkdownEditor: ({ value, onChange, placeholder, contentClassName }: { value: string; onChange?: (value: string) => void; placeholder?: string; contentClassName?: string; }) => { React.useEffect(() => { if (markdownEditorMockState.emitMountValue !== null) { onChange?.(markdownEditorMockState.emitMountValue); return; } if (!markdownEditorMockState.emitMountEmptyChange) return; onChange?.(""); }, []); markdownEditorMockState.changeBody = onChange ?? null; return (
{value || placeholder || ""}
); }, }; }); vi.mock("@/components/ui/button", () => ({ Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => ( ), })); vi.mock("@/components/ui/input", () => ({ Input: (props: ComponentProps<"input">) => , })); vi.mock("@/components/ui/dropdown-menu", async () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuItem: ({ children, onClick, onSelect, disabled }: { children: React.ReactNode; onClick?: () => void; onSelect?: () => void; disabled?: boolean; }) => ( ), DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuRadioItem: ({ children, onSelect, disabled }: { children: React.ReactNode; onSelect?: () => void; disabled?: boolean; }) => ( ), DropdownMenuSeparator: () =>
, }; }); const localStorageEntries = new Map(); function ensureLocalStorageMock() { if ( typeof window.localStorage?.getItem === "function" && typeof window.localStorage?.setItem === "function" && typeof window.localStorage?.removeItem === "function" && typeof window.localStorage?.clear === "function" ) { return; } Object.defineProperty(window, "localStorage", { configurable: true, value: { getItem: (key: string) => localStorageEntries.get(key) ?? null, setItem: (key: string, value: string) => { localStorageEntries.set(key, value); }, removeItem: (key: string) => { localStorageEntries.delete(key); }, clear: () => { localStorageEntries.clear(); }, }, }); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } function createIssueDocument(overrides: Partial = {}): IssueDocument { return { id: "document-1", companyId: "company-1", issueId: "issue-1", key: "plan", title: "Plan", format: "markdown", body: "", latestRevisionId: "revision-4", latestRevisionNumber: 4, createdByAgentId: null, createdByUserId: "user-1", updatedByAgentId: null, updatedByUserId: "user-1", lockedAt: null, lockedByAgentId: null, lockedByUserId: null, createdAt: new Date("2026-03-31T12:00:00.000Z"), updatedAt: new Date("2026-03-31T12:05:00.000Z"), ...overrides, }; } function createRevision(overrides: Partial = {}): DocumentRevision { return { id: "revision-3", companyId: "company-1", documentId: "document-1", issueId: "issue-1", key: "plan", revisionNumber: 3, title: "Plan", format: "markdown", body: "Restored plan body", changeSummary: null, createdByAgentId: null, createdByUserId: "user-1", createdAt: new Date("2026-03-31T11:00:00.000Z"), ...overrides, }; } function createIssue(): Issue { return { id: "issue-1", identifier: "PAP-807", companyId: "company-1", projectId: null, projectWorkspaceId: null, goalId: null, parentId: null, title: "Plan rendering", description: null, status: "in_progress", workMode: "standard", priority: "medium", reviewPolicy: null, assigneeAgentId: null, assigneeUserId: null, responsibleUserId: null, createdByAgentId: null, createdByUserId: "user-1", issueNumber: 807, requestDepth: 0, billingCode: null, assigneeAdapterOverrides: null, executionWorkspaceId: null, executionWorkspacePreference: null, executionWorkspaceSettings: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, startedAt: null, completedAt: null, cancelledAt: null, hiddenAt: null, labels: [], labelIds: [], planDocument: createIssueDocument(), documentSummaries: [createIssueDocument()], legacyPlanDocument: null, createdAt: new Date("2026-03-31T12:00:00.000Z"), updatedAt: new Date("2026-03-31T12:05:00.000Z"), }; } describe("IssueDocumentsSection", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); ensureLocalStorageMock(); window.localStorage.clear(); vi.clearAllMocks(); markdownEditorMockState.emitMountEmptyChange = false; markdownEditorMockState.emitMountValue = null; markdownEditorMockState.changeBody = null; mockSession.unreachable = false; mockLocation.hash = ""; }); afterEach(() => { container.remove(); }); it.each([false, true])("restores an unfinished document after its Artifacts pane unmounts (general only: %s)", async generalDocumentsOnly => { mockIssuesApi.listDocuments.mockResolvedValue([]); const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); function TaskPane() { const [artifacts, setArtifacts] = useState(true); return <> {artifacts ? : null} ; } await act(async () => root.render()); await flush(); const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!; await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await act(async () => markdownEditorMockState.changeBody?.("Body typed before its key")); const title = container.querySelector('input[placeholder="Optional title"]')!; await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(title, "Unfinished note"); title.dispatchEvent(new Event("input", { bubbles: true })); }); const form = container.querySelector('input[placeholder="Document key"]')!.parentElement!; await act(async () => form.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: button("Properties") }))); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); await act(async () => button("Properties").click()); expect(container.textContent).not.toContain("Body typed before its key"); await act(async () => button("Artifacts").click()); await flush(); expect(container.textContent).toContain("Body typed before its key"); expect(container.querySelector('input[placeholder="Document key"]')?.value).toBe(""); expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe("Unfinished note"); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); const cancel = Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("Cancel"))!; await act(async () => cancel.click()); await act(async () => button("Properties").click()); await act(async () => button("Artifacts").click()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.textContent).not.toContain("Body typed before its key"); await act(async () => root.unmount()); client.clear(); }); it("keeps new document drafts within their task and signed-in account", async () => { mockIssuesApi.listDocuments.mockResolvedValue([]); const issue = createIssue(); const otherTask = { ...issue, id: "task-b", identifier: "DOC-2" }; const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); const show = (task: Issue) => root.render(); await act(async () => show(issue)); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await act(async () => markdownEditorMockState.changeBody?.("Private unfinished document")); await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } })); await flush(); expect(container.textContent).not.toContain("Private unfinished document"); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); await act(async () => { client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); show(otherTask); }); await flush(); expect(container.textContent).not.toContain("Private unfinished document"); await act(async () => show(issue)); await flush(); expect(container.textContent).toContain("Private unfinished document"); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); await act(async () => root.unmount()); client.clear(); }); it.each([ { generalDocumentsOnly: false, saveOutcome: "saved" }, { generalDocumentsOnly: false, saveOutcome: "failed" }, { generalDocumentsOnly: true, saveOutcome: "saved" }, { generalDocumentsOnly: true, saveOutcome: "failed" }, ])("reopens the latest edits after pending creation ($generalDocumentsOnly, $saveOutcome)", async ({ generalDocumentsOnly, saveOutcome }) => { const { ApiError } = await import("../api/client"); const issue = createIssue(); const initial = createIssueDocument({ key: "notes", title: "Initial title", body: "Initial body", latestRevisionId: "created-revision", latestRevisionNumber: 1 }); const revised = { ...initial, title: "Latest title", body: "Latest body", latestRevisionId: "edited-revision", latestRevisionNumber: 2 }; let savedDocuments: IssueDocument[] = []; const pendingCreate = deferred(); mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments); mockIssuesApi.upsertDocument.mockImplementationOnce(async () => { const saved = await pendingCreate.promise; savedDocuments = [saved]; return saved; }).mockImplementationOnce(async () => { if (saveOutcome === "failed") throw new ApiError("Save refused", 400, null); savedDocuments = [revised]; return revised; }); const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); function TaskPane() { const [artifacts, setArtifacts] = useState(true); return <> {artifacts ? : null} ; } const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!; const typeInput = async (placeholder: string, value: string) => act(async () => { const input = container.querySelector(`input[placeholder="${placeholder}"]`)!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); try { await act(async () => root.render()); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await typeInput("Document key", "notes"); await typeInput("Optional title", initial.title!); await act(async () => markdownEditorMockState.changeBody?.(initial.body)); await act(async () => button("Create document").click()); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce(); await typeInput("Optional title", "Title typed during creation"); await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation")); await act(async () => pendingCreate.resolve(initial)); await flush(); const titleAfterCreate = container.querySelector('input[placeholder="Optional title"]')?.value; expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Body typed during creation"); await typeInput("Optional title", revised.title); await act(async () => markdownEditorMockState.changeBody?.(revised.body)); await act(async () => new Promise(resolve => setTimeout(resolve, 1100))); await flush(); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2); expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({ title: revised.title, body: revised.body, baseRevisionId: initial.latestRevisionId, })); await act(async () => button("Properties").click()); await act(async () => button("Artifacts").click()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.textContent).toContain(revised.body); expect(titleAfterCreate).toBe("Title typed during creation"); expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:${generalDocumentsOnly ? "general" : "all"}`)).toBeNull(); const editBackup = localStorage.getItem(`paperclip:document-draft:v2:user-1:${issue.id}:notes`); if (saveOutcome === "failed") { expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(revised.body); expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe(revised.title); expect(JSON.parse(editBackup!)).toMatchObject({ body: revised.body, title: revised.title, baseRevisionId: initial.latestRevisionId, isNew: false }); } else expect(editBackup).toBeNull(); } finally { await act(async () => root.unmount()); client.clear(); } }); it.each([ { generalDocumentsOnly: false, reopenBeforeCreate: false, remoteChange: false }, { generalDocumentsOnly: false, reopenBeforeCreate: true, remoteChange: false }, { generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: false }, { generalDocumentsOnly: true, reopenBeforeCreate: true, remoteChange: false }, { generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: true }, ])("resumes edits when creation finishes after the Artifacts pane unmounts ($generalDocumentsOnly, $reopenBeforeCreate, $remoteChange)", async ({ generalDocumentsOnly, reopenBeforeCreate, remoteChange }) => { const { ApiError } = await import("../api/client"); const issue = createIssue(); const initial = createIssueDocument({ key: "notes", title: "Submitted title", body: "Submitted body", latestRevisionId: "created-revision", latestRevisionNumber: 1 }); const remote = { ...initial, title: "Remote title", body: "Another writer's text", latestRevisionId: "remote-revision", latestRevisionNumber: 2 }; let savedDocuments: IssueDocument[] = []; const pendingCreate = deferred(); mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments); mockIssuesApi.getDocument.mockImplementation(async () => savedDocuments[0]); mockIssuesApi.upsertDocument.mockImplementation(async (_issueId, _key, data) => { if (mockIssuesApi.upsertDocument.mock.calls.length === 1) { const saved = await pendingCreate.promise; savedDocuments = [saved]; return saved; } if (data.baseRevisionId !== savedDocuments[0].latestRevisionId) throw new ApiError("Document changed", 409, null); const revised = { ...initial, title: data.title, body: data.body, latestRevisionId: "edited-revision", latestRevisionNumber: 2 }; savedDocuments = [revised]; return revised; }); const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); function TaskPane() { const [artifacts, setArtifacts] = useState(true); return <> {artifacts ? : null} ; } const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!; const typeInput = async (placeholder: string, value: string) => act(async () => { const input = container.querySelector(`input[placeholder="${placeholder}"]`)!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); const title = reopenBeforeCreate ? "Newest reopened title" : "Title typed during creation"; const body = reopenBeforeCreate ? "Newest reopened body" : "Body typed during creation"; try { await act(async () => root.render()); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await typeInput("Document key", "notes"); await typeInput("Optional title", initial.title!); await act(async () => markdownEditorMockState.changeBody?.(initial.body)); await act(async () => button("Create document").click()); await typeInput("Optional title", "Title typed during creation"); await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation")); await act(async () => button("Properties").click()); expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull(); if (reopenBeforeCreate) { await act(async () => button("Artifacts").click()); await flush(); await typeInput("Optional title", title); await act(async () => markdownEditorMockState.changeBody?.(body)); } await act(async () => pendingCreate.resolve(initial)); await flush(); if (remoteChange) await act(async () => { savedDocuments = [remote]; client.setQueryData(queryKeys.issues.documents(issue.id), savedDocuments); }); if (!reopenBeforeCreate) await act(async () => button("Artifacts").click()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(body); expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe(title); await act(async () => new Promise(resolve => setTimeout(resolve, 1100))); await flush(); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2); expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({ title, body, baseRevisionId: initial.latestRevisionId })); if (remoteChange) { expect(savedDocuments).toEqual([remote]); expect(container.textContent).toContain("Out of date"); expect(container.textContent).toContain("Overwrite remote"); } else { expect(container.textContent).not.toContain("Out of date"); expect(savedDocuments[0]).toMatchObject({ title, body, latestRevisionId: "edited-revision" }); await act(async () => button("Properties").click()); await act(async () => button("Artifacts").click()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.textContent).toContain(body); } } finally { await act(async () => root.unmount()); client.clear(); } }); it.each([false, true])("keeps a newer new-document form when an earlier create completes (general only: %s)", async generalDocumentsOnly => { const issue = createIssue(); const saved = createIssueDocument({ key: "first", title: null, body: "Submitted first body" }); let savedDocuments: IssueDocument[] = []; const pendingCreate = deferred(); mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments); mockIssuesApi.upsertDocument.mockImplementation(async () => { const saved = await pendingCreate.promise; savedDocuments = [saved]; return saved; }); let root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); const show = () => root.render(); const typeKey = async (value: string) => act(async () => { const input = container.querySelector('input[placeholder="Document key"]')!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); try { await act(async () => show()); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await typeKey("first"); await act(async () => markdownEditorMockState.changeBody?.(saved.body)); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click()); await typeKey("second"); await act(async () => markdownEditorMockState.changeBody?.("Keep the newer document body")); await act(async () => pendingCreate.resolve(saved)); await flush(); await act(async () => root.unmount()); root = createRoot(container); await act(async () => show()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')?.value).toBe("second"); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the newer document body"); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce(); } finally { await act(async () => root.unmount()); client.clear(); } }); it("keeps the new-document backup if storing the pending-create edits fails", async () => { const issue = createIssue(); const saved = createIssueDocument({ key: "notes", title: null, body: "Submitted body" }); let savedDocuments: IssueDocument[] = []; const pendingCreate = deferred(); mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments); mockIssuesApi.upsertDocument.mockImplementation(async () => { const saved = await pendingCreate.promise; savedDocuments = [saved]; return saved; }); const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); const write = localStorage.setItem.bind(localStorage); const methodOwner: Storage = Object.hasOwn(localStorage, "setItem") ? localStorage : Object.getPrototypeOf(localStorage); const editKey = `paperclip:document-draft:v2:user-1:${issue.id}:notes`; const setItem = vi.spyOn(methodOwner, "setItem").mockImplementation((key, value) => { if (key === editKey) throw new Error("Storage full"); write(key, value); }); try { await act(async () => root.render()); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click()); await act(async () => { const input = container.querySelector('input[placeholder="Document key"]')!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "notes"); input.dispatchEvent(new Event("input", { bubbles: true })); markdownEditorMockState.changeBody?.(saved.body); }); await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click()); await act(async () => markdownEditorMockState.changeBody?.("Keep the unsaved pending-create edits")); await act(async () => pendingCreate.resolve(saved)); await flush(); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the unsaved pending-create edits"); expect(localStorage.getItem(editKey)).toBeNull(); const retained = localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`); expect(JSON.parse(retained!)).toMatchObject({ key: "notes", body: "Keep the unsaved pending-create edits", baseRevisionId: saved.latestRevisionId, isNew: false }); await act(async () => container.querySelector('[data-testid="markdown-editor"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`)).toBeNull(); } finally { setItem.mockRestore(); await act(async () => root.unmount()); client.clear(); } }); it("keeps native Plans out of the general-document authoring surface", async () => { mockIssuesApi.listDocuments.mockResolvedValue([ createIssueDocument({ body: "Native Plan content" }), createIssueDocument({ id: "note", key: "notes", title: "Task note", body: "General document content" }), createIssueDocument({ id: "file-review", key: "artifact-review-output", body: "Work product review content" }), ]); markdownEditorMockState.emitMountValue = "A new general note"; const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => root.render()); await flush(); expect(container.textContent).toContain("General document content"); expect(container.textContent).not.toContain("Native Plan content"); expect(container.textContent).not.toContain("Legacy Plan content"); expect(container.textContent).not.toContain("Work product review content"); const newDocument = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!; await act(async () => newDocument.click()); const keyInput = container.querySelector('input[placeholder="Document key"]')!; await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, "plan"); keyInput.dispatchEvent(new Event("input", { bubbles: true })); }); const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!; await act(async () => create.click()); await flush(); expect(container.textContent).toContain("Create and revise Plans in the task conversation."); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); await act(async () => root.unmount()); client.clear(); }); it.each([ ["continuation-summary", "This document key is reserved for task state."], ["pipeline-case-body", "This document key is reserved for task state."], ["artifact-review-output", "Review this document through its work product."], ])("refuses reserved general-document key %s and preserves the draft", async (key, message) => { mockIssuesApi.listDocuments.mockResolvedValue([]); mockIssuesApi.upsertDocument.mockResolvedValue(createIssueDocument({ key, body: "Keep my document draft" })); const root = createRoot(container); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => root.render()); await flush(); await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!.click()); const keyInput = container.querySelector('input[placeholder="Document key"]')!; await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, key); keyInput.dispatchEvent(new Event("input", { bubbles: true })); markdownEditorMockState.changeBody?.("Keep my document draft"); }); expect(container.textContent).toContain(message); const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!; await act(async () => create.click()); await flush(); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); expect(container.textContent).toContain(message); expect(keyInput.value).toBe(key); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep my document draft"); await act(async () => root.unmount()); client.clear(); }); it("unfolds a Classic confirmation link and loads its bound revision rather than the latest text", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-1"; localStorage.setItem("paperclip:issue-document-folds:issue-1", JSON.stringify(["plan"])); mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Newer unreviewed text" })]); let loadHistory!: (value: DocumentRevision[]) => void; mockIssuesApi.listDocumentRevisions.mockReturnValue(new Promise((resolve) => { loadHistory = resolve; })); const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); await act(async () => root.render()); await flush(); expect(mockIssuesApi.listDocumentRevisions).toHaveBeenCalledWith("issue-1", "plan"); expect(scroll).toHaveBeenCalled(); expect(container.textContent).toContain("Loading reviewed revision"); expect(container.textContent).not.toContain("Newer unreviewed text"); await act(async () => loadHistory([createRevision({ body: "Exact reviewed text" })])); await flush(); expect(container.textContent).toContain("Viewing revision 3"); expect(container.textContent).toContain("Exact reviewed text"); expect(container.textContent).not.toContain("Newer unreviewed text"); const latest = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Return to latest")); expect(latest).toBeTruthy(); await act(async () => latest!.click()); expect(container.textContent).toContain("Newer unreviewed text"); mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-2&image=2"; await act(async () => root.render()); expect(container.textContent).toContain("Exact reviewed text"); expect(container.textContent).not.toContain("Newer unreviewed text"); mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-2&image=3"; await act(async () => root.render()); expect(container.textContent).toContain("Exact reviewed text"); await act(async () => root.unmount()); scroll.mockRestore(); queryClient.clear(); }); it("does not substitute newer content when a Classic review revision is missing", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); mockLocation.hash = "#document-plan&revision=missing&interaction=review-1"; mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Newer unreviewed text" })]); mockIssuesApi.listDocumentRevisions.mockResolvedValue([]); await act(async () => root.render()); await flush(); await flush(); expect(container.textContent).toContain("The reviewed revision is unavailable"); expect(container.textContent).not.toContain("Newer unreviewed text"); await act(async () => root.unmount()); queryClient.clear(); }); it.each(["loaded", "failed"])("preserves an unsaved draft when linked revision loading %s", async (outcome) => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const storageKey = "paperclip:document-draft:v2:user-1:issue-1:plan"; localStorage.setItem(storageKey, JSON.stringify({ key: "plan", title: "Plan", body: "My unsaved draft", baseRevisionId: "revision-4", isNew: false })); mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Saved latest body" })]); let load!: (value: DocumentRevision[]) => void; let fail!: (error: Error) => void; mockIssuesApi.listDocumentRevisions.mockReturnValue(new Promise((resolve, reject) => { load = resolve; fail = reject; })); const show = () => root.render(); await act(async () => show()); await flush(); await flush(); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft"); mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-1"; await act(async () => show()); await flush(); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft"); expect(JSON.parse(localStorage.getItem(storageKey)!).body).toBe("My unsaved draft"); await act(async () => outcome === "loaded" ? load([createRevision()]) : fail(new Error("Disconnected"))); await flush(); await flush(); expect(container.textContent).toContain(outcome === "loaded" ? "Save or cancel your local changes" : "Could not load the reviewed revision"); expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft"); await act(async () => markdownEditorMockState.changeBody!("My unsaved draft, continued")); await flush(); expect(JSON.parse(localStorage.getItem(storageKey)!).body).toBe("My unsaved draft, continued"); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); await act(async () => root.unmount()); queryClient.clear(); }); it("keeps system handoff documents out of the normal document surface", async () => { const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([ createIssueDocument({ key: "plan", body: "# Plan" }), createIssueDocument({ id: "document-handoff", key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, title: "Continuation Summary", body: "# Handoff", }), ]); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("# Plan"); expect(container.textContent).not.toContain("# Handoff"); expect(container.querySelector(`#document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`)).toBeNull(); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("copies document bodies through the plain HTTP clipboard fallback", async () => { const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const writeText = vi.fn(async () => {}); const execCommand = vi.fn(() => true); const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext"); const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, }); Object.defineProperty(window, "isSecureContext", { configurable: true, value: false, }); Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand, }); mockIssuesApi.listDocuments.mockResolvedValue([ createIssueDocument({ body: "# Copy over HTTP" }), ]); try { await act(async () => { root.render( , ); }); await flush(); await flush(); const copyButton = container.querySelector('button[title="Copy document"]'); expect(copyButton).toBeTruthy(); await act(async () => { copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(writeText).not.toHaveBeenCalled(); expect(execCommand).toHaveBeenCalledWith("copy"); expect(container.querySelector('button[title="Copied"]')).toBeTruthy(); } finally { await act(async () => { root.unmount(); }); queryClient.clear(); if (originalClipboard) { Object.defineProperty(navigator, "clipboard", originalClipboard); } else { delete (navigator as { clipboard?: Clipboard }).clipboard; } if (originalSecureContext) { Object.defineProperty(window, "isSecureContext", originalSecureContext); } else { delete (window as { isSecureContext?: boolean }).isSecureContext; } if (originalExecCommand) { Object.defineProperty(document, "execCommand", originalExecCommand); } else { delete (document as { execCommand?: (command: string) => boolean }).execCommand; } } }); it("locks documents from the document header action", async () => { const unlockedDocument = createIssueDocument({ body: "Draftable plan body", lockedAt: null, }); const lockedDocument = createIssueDocument({ body: "Draftable plan body", lockedAt: new Date("2026-03-31T12:06:00.000Z"), lockedByUserId: "user-1", updatedAt: new Date("2026-03-31T12:06:00.000Z"), }); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments .mockResolvedValueOnce([unlockedDocument]) .mockResolvedValue([lockedDocument]); mockIssuesApi.lockDocument.mockResolvedValue(lockedDocument); await act(async () => { root.render( , ); }); await flush(); await flush(); const lockButton = container.querySelector('button[title="Lock document"]'); expect(lockButton).toBeTruthy(); await act(async () => { lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(mockIssuesApi.lockDocument).toHaveBeenCalledWith("issue-1", "plan"); expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("hides direct edit and delete actions for locked documents", async () => { const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([ createIssueDocument({ body: "Locked plan body", lockedAt: new Date("2026-03-31T12:06:00.000Z"), lockedByUserId: "user-1", }), ]); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Locked plan body"); expect(container.textContent).not.toContain("Edit document"); expect(container.textContent).not.toContain("Delete document"); expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("shows revision authors with names and avatars in the revision history menu", async () => { const currentDocument = createIssueDocument({ body: "Current plan body", latestRevisionId: "revision-agent", latestRevisionNumber: 4, updatedByAgentId: "agent-1", updatedByUserId: null, updatedAt: new Date("2026-03-31T12:05:00.000Z"), }); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); queryClient.setQueryData( queryKeys.issues.documentRevisions(issue.id, "plan"), [ createRevision({ id: "revision-agent", revisionNumber: 4, body: "Current plan body", createdByAgentId: "agent-1", createdByUserId: null, createdAt: new Date("2026-03-31T12:05:00.000Z"), }), createRevision({ id: "revision-user", revisionNumber: 3, body: "Board-written plan body", createdByAgentId: null, createdByUserId: "user-1", createdAt: new Date("2026-03-31T11:00:00.000Z"), }), ], ); await act(async () => { root.render( , ); }); await flush(); await flush(); const revisionButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("rev 4")); expect(revisionButton).toBeTruthy(); await act(async () => { revisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(document.body.textContent).toContain("CodexCoder"); expect(document.body.textContent).toContain("Dotta"); expect(document.body.textContent).not.toContain("• agent"); expect(document.body.querySelectorAll('[data-slot="avatar"], [data-slot="agent-avatar"]').length).toBeGreaterThanOrEqual(2); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("shows the restored document body immediately after a revision restore", async () => { const blankLatestDocument = createIssueDocument({ body: "", latestRevisionId: "revision-4", latestRevisionNumber: 4, }); const restoredDocument = createIssueDocument({ body: "Restored plan body", latestRevisionId: "revision-5", latestRevisionNumber: 5, updatedAt: new Date("2026-03-31T12:06:00.000Z"), }); const pendingDocuments = deferred(); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments .mockResolvedValueOnce([blankLatestDocument]) .mockImplementation(() => pendingDocuments.promise); mockIssuesApi.restoreDocumentRevision.mockResolvedValue(restoredDocument); queryClient.setQueryData( queryKeys.issues.documentRevisions(issue.id, "plan"), [ createRevision({ id: "revision-4", revisionNumber: 4, body: "", createdAt: new Date("2026-03-31T12:05:00.000Z") }), createRevision(), ], ); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).not.toContain("Restored plan body"); const revisionButtons = Array.from(container.querySelectorAll("button")); const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3")); expect(historicalRevisionButton).toBeTruthy(); await act(async () => { historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).toContain("Viewing revision 3"); expect(container.textContent).toContain("Restored plan body"); const restoreButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Restore this revision")); expect(restoreButton).toBeTruthy(); await act(async () => { restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3"); expect(container.textContent).toContain("Restored plan body"); expect(container.textContent).not.toContain("Viewing revision 3"); pendingDocuments.resolve([restoredDocument]); await flush(); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("returns from a historical preview when the current revision only exists in derived state", async () => { const currentDocument = createIssueDocument({ body: "Current plan body", latestRevisionId: "revision-4", latestRevisionNumber: 4, updatedAt: new Date("2026-03-31T12:05:00.000Z"), }); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); queryClient.setQueryData( queryKeys.issues.documentRevisions(issue.id, "plan"), [ createRevision({ id: "revision-3", revisionNumber: 3, body: "Historical plan body", createdAt: new Date("2026-03-31T11:00:00.000Z"), }), ], ); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Current plan body"); const revisionButtons = Array.from(container.querySelectorAll("button")); const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3")); expect(historicalRevisionButton).toBeTruthy(); await act(async () => { historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).toContain("Viewing revision 3"); expect(container.textContent).toContain("Historical plan body"); const currentRevisionButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("rev 4")); expect(currentRevisionButton).toBeTruthy(); await act(async () => { currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).not.toContain("Viewing revision 3"); expect(container.textContent).toContain("Current plan body"); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("returns from a historical preview when fetched history is newer than the document summary", async () => { const staleDocument = createIssueDocument({ body: "Original plan body", latestRevisionId: "revision-2", latestRevisionNumber: 2, updatedAt: new Date("2026-03-31T12:00:00.000Z"), }); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([staleDocument]); queryClient.setQueryData( queryKeys.issues.documentRevisions(issue.id, "plan"), [ createRevision({ id: "revision-3", revisionNumber: 3, body: "Current plan body", createdAt: new Date("2026-03-31T12:05:00.000Z"), }), createRevision({ id: "revision-2", revisionNumber: 2, body: "Original plan body", createdAt: new Date("2026-03-31T12:00:00.000Z"), }), ], ); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Current plan body"); const revisionButtons = Array.from(container.querySelectorAll("button")); const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 2")); expect(historicalRevisionButton).toBeTruthy(); await act(async () => { historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).toContain("Viewing revision 2"); expect(container.textContent).toContain("Original plan body"); const currentRevisionButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("rev 3")); expect(currentRevisionButton).toBeTruthy(); await act(async () => { currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).not.toContain("Viewing revision 2"); expect(container.textContent).toContain("Current plan body"); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("ignores mount-time editor change noise before a document is actively being edited", async () => { markdownEditorMockState.emitMountEmptyChange = true; const document = createIssueDocument({ body: "Loaded plan body", }); const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([document]); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Loaded plan body"); expect(container.textContent).not.toContain("Markdown body"); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("does not restore another account's unsaved edit", async () => { const document = createIssueDocument({ body: "Server body" }); const issue = createIssue(); window.localStorage.setItem( `paperclip:document-draft:v2:user-2:${issue.id}:plan`, JSON.stringify({ key: "plan", title: "Plan", body: "Someone else's draft", baseRevisionId: "revision-4", isNew: false }), ); mockIssuesApi.listDocuments.mockResolvedValue([document]); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Server body"); expect(container.textContent).not.toContain("Someone else's draft"); expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled(); await act(async () => { root.unmount(); }); queryClient.clear(); window.localStorage.clear(); }); it("drops a mounted editor's private draft when the signed-in account changes", async () => { const issue = createIssue(); const document = createIssueDocument({ body: "Server plan" }); mockIssuesApi.listDocuments.mockResolvedValue([document]); window.localStorage.setItem(`paperclip:document-draft:v2:user-1:${issue.id}:plan`, JSON.stringify({ key: "plan", title: document.title ?? "", body: "Private unpublished text", baseRevisionId: "revision-4", isNew: false, })); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); const root = createRoot(container); await act(async () => { root.render(); }); await flush(); await flush(); expect(container.textContent).toContain("Private unpublished text"); await act(async () => { queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); }); await flush(); expect(window.localStorage.getItem(`paperclip:document-draft:v2:user-2:${issue.id}:plan`)).toBeNull(); expect(container.textContent).not.toContain("Private unpublished text"); act(() => root.unmount()); queryClient.clear(); }); it("restores task B without copying task A's mounted document draft", async () => { const a = createIssue(); const b = { ...a, id: "task-b", identifier: "DOC-2" }; const document = createIssueDocument({ body: "Server plan" }); mockIssuesApi.listDocuments.mockResolvedValue([document]); const key = (id: string) => `paperclip:document-draft:v2:user-1:${id}:plan`; const stored = (body: string) => JSON.stringify({ key: "plan", title: "Plan", body, baseRevisionId: "revision-4", isNew: false }); localStorage.setItem(key(a.id), stored("Task A private edit")); const originalB = stored("Task B private edit"); localStorage.setItem(key(b.id), originalB); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); client.setQueryData(queryKeys.issues.documents(a.id), [document]); client.setQueryData(queryKeys.issues.documents(b.id), [document]); const root = createRoot(container); const show = (issue: Issue) => root.render(); try { await act(async () => show(a)); await flush(); expect(container.textContent).toContain("Task A private edit"); await act(async () => show(b)); await flush(); expect(container.textContent).not.toContain("Task A private edit"); expect(container.textContent).toContain("Task B private edit"); expect(localStorage.getItem(key(b.id))).toBe(originalB); } finally { act(() => root.unmount()); client.clear(); localStorage.clear(); } }); it("keeps the known account's drafts while a session refetch fails", async () => { const document = createIssueDocument({ body: "Server body" }); const issue = createIssue(); window.localStorage.setItem( `paperclip:document-draft:v2:user-1:${issue.id}:plan`, JSON.stringify({ key: "plan", title: "Plan", body: "Typed before the outage", baseRevisionId: "revision-4", isNew: false }), ); mockIssuesApi.listDocuments.mockResolvedValue([document]); mockSession.unreachable = true; const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); // Signed in earlier; the refetch on mount fails but the query keeps its data. queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }, { updatedAt: 0 }); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(queryClient.getQueryState(queryKeys.auth.session)?.status).toBe("error"); expect(container.textContent).toContain("Typed before the outage"); await act(async () => { root.unmount(); }); queryClient.clear(); window.localStorage.clear(); }); it("restores an unsaved edit after reload and treats a 409 with our own text as saved", async () => { const { ApiError } = await import("../api/client"); const document = createIssueDocument({ body: "Server body" }); const issue = createIssue(); // An edit typed before a reload; its save may have landed with a lost response. window.localStorage.setItem( `paperclip:document-draft:v2:user-1:${issue.id}:plan`, JSON.stringify({ key: "plan", title: "Plan", body: "Edited during the outage", baseRevisionId: "revision-4", isNew: false }), ); const saved = createIssueDocument({ body: "Edited during the outage", latestRevisionId: "revision-5" }); // The refetch after saving returns the server's current copy. mockIssuesApi.listDocuments.mockResolvedValueOnce([document]).mockResolvedValue([saved]); mockIssuesApi.upsertDocument.mockRejectedValue( new ApiError("Document was updated by someone else", 409, { error: "Document was updated by someone else" }), ); mockIssuesApi.getDocument.mockResolvedValue(saved); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); await act(async () => { root.render( , ); }); await flush(); await flush(); expect(container.textContent).toContain("Edited during the outage"); // Autosave debounce. await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1_100)); }); await flush(); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith(issue.id, "plan", expect.objectContaining({ body: "Edited during the outage", baseRevisionId: "revision-4", })); expect(container.textContent).not.toContain("Out of date"); expect(window.localStorage.getItem(`paperclip:document-draft:v2:user-1:${issue.id}:plan`)).toBeNull(); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("treats a 409 on a new document whose saved copy is ours as created", async () => { const { ApiError } = await import("../api/client"); const issue = createIssue(); // An earlier create landed but its response was lost; the key now exists. const saved = createIssueDocument({ key: "notes", title: null, body: "Notes body", latestRevisionId: "revision-1" }); mockIssuesApi.listDocuments.mockResolvedValueOnce([]).mockResolvedValue([saved]); mockIssuesApi.upsertDocument.mockRejectedValue( new ApiError("Document already exists", 409, { error: "Document already exists" }), ); mockIssuesApi.getDocument.mockResolvedValue(saved); markdownEditorMockState.emitMountValue = "Notes body"; const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); await act(async () => { root.render( , ); }); await flush(); const newDocument = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("New document"))!; await act(async () => { newDocument.click(); }); const keyInput = container.querySelector('input[placeholder="Document key"]')!; await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, "notes"); keyInput.dispatchEvent(new Event("input", { bubbles: true })); }); const create = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Create document"))!; await act(async () => { create.click(); }); await flush(); await flush(); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledWith(issue.id, "notes", expect.objectContaining({ body: "Notes body", baseRevisionId: null, })); expect(mockIssuesApi.getDocument).toHaveBeenCalledWith(issue.id, "notes"); // The form closes as for any created document; no conflict, no error. expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.textContent).not.toContain("Out of date"); expect(container.textContent).not.toContain("already exists"); await act(async () => { root.unmount(); }); const reopened = createRoot(container); await act(async () => reopened.render()); await flush(); expect(container.querySelector('input[placeholder="Document key"]')).toBeNull(); expect(container.textContent).toContain("Notes body"); expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce(); await act(async () => reopened.unmount()); queryClient.clear(); }); it("forwards externalReferences to the rendered document body so URL decoration applies", async () => { const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([ createIssueDocument({ body: "Linked work: https://github.com/example/repo/pull/99", }), ]); await act(async () => { root.render( , ); }); await flush(); await flush(); const markdownBodies = Array.from( container.querySelectorAll('[data-testid="markdown-body"]'), ) as HTMLElement[]; expect(markdownBodies.length).toBeGreaterThan(0); const rendered = markdownBodies.find((element) => (element.textContent ?? "").includes("Linked work"), ); expect(rendered?.getAttribute("data-external-reference-keys")) .toContain("https://github.com/example/repo/pull/99"); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("wraps the documents header actions so mobile layouts do not overflow", async () => { const issue = createIssue(); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, mutations: { retry: false, }, }, }); mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]); await act(async () => { root.render( )} /> , ); }); await flush(); await flush(); const heading = container.querySelector("h3"); expect(heading).toBeTruthy(); expect(heading?.parentElement?.className).toContain("flex-wrap"); expect(heading?.nextElementSibling?.className).toContain("flex-wrap"); await act(async () => { root.unmount(); }); queryClient.clear(); }); it("renders and locks documents for a non-issue document subject", async () => { const caseDocument = createIssueDocument({ id: "case-document-1", issueId: "case-1", key: "body", title: "Body", body: "Reusable case document body", latestRevisionId: "case-revision-2", latestRevisionNumber: 2, updatedByAgentId: "agent-1", updatedByUserId: null, }); const lockedCaseDocument = { ...caseDocument, lockedAt: new Date("2026-03-31T12:06:00.000Z"), lockedByUserId: "user-1", updatedAt: new Date("2026-03-31T12:06:00.000Z"), }; const listDocuments = vi.fn() .mockResolvedValueOnce([caseDocument]) .mockResolvedValue([lockedCaseDocument]); const setDocumentLock = vi.fn().mockResolvedValue(lockedCaseDocument); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); await act(async () => { root.render( ["cases", "revisions", "case-1", key], listDocuments, listDocumentRevisions: vi.fn().mockResolvedValue([]), getDocument: vi.fn().mockResolvedValue(caseDocument), upsertDocument: vi.fn().mockResolvedValue(caseDocument), deleteDocument: vi.fn().mockResolvedValue({ ok: true }), restoreDocumentRevision: vi.fn().mockResolvedValue(caseDocument), setDocumentLock, hideSystemDocuments: false, legacyPlanDocument: null, annotations: null, }} canDeleteDocuments canManageDocumentLocks /> , ); }); await flush(); await flush(); expect(listDocuments).toHaveBeenCalled(); expect(container.textContent).toContain("Reusable case document body"); expect(container.textContent).toContain("body"); const lockButton = container.querySelector('button[title="Lock document"]'); expect(lockButton).toBeTruthy(); await act(async () => { lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(setDocumentLock).toHaveBeenCalledWith("body", true); expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); await act(async () => { root.unmount(); }); queryClient.clear(); }); });