mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Task documents keep versioned work beside each conversation. > - The default chat interface could read general documents but hid their editor. > - People also need unfinished edits to survive a tab switch or reload. > - This pull request restores the shared editor in Artifacts. > - A portable browser journey checks exact saved content and revision history. > - The benefit is a usable document flow with independent deployment checks. ## Linked Issues or Issue Description **What happened?** A person using the default task interface could read a general task document but could not create or edit one. The existing editor was available in Classic mode. A pending create could also leave newer text as a new form after its editor unmounted, causing a conflict against the person's own successful creation. **Expected behavior** A person can create and edit general documents through Artifacts. Titles, bodies and revision history persist after reopening and reloading the task. Unfinished text survives tab switches. A successful create makes retained newer text an edit of that confirmed revision. Later writes from another actor still cause a conflict. **Steps to reproduce** 1. Open an ordinary task in the default chat interface, then open Artifacts. 2. Try to create a general document. The previous interface has no authoring control. 3. With authoring restored, submit a new document, keep typing, and switch to Properties before the request finishes. 4. Reopen Artifacts. The previous draft path restores a new form despite the successful create. Related public work: #17598 adds document copy/download and #18157 adds image galleries. The selected actual merged parent is `f1d780838218f975064f38333946805e0ec103a5` from #18427. It includes the stable profile-entry ordering repair and the exact recovery-fixture prerequisite. The resulting PR diff contains 32 document and reporting files. ## What Changed - Reuse `IssueDocumentsSection` for general documents in default-chat Artifacts. Keep Classic authoring in its existing task body. Ordinary tasks, conversations and Coordinator mode share the same editor options and signed-in permission wrapper. - Preserve Plans, file-review documents, attachments, uploads and work products. Hide duplicate general-document cards. Use the same general-document key rules for visible rows, validation and refused writes. - Forward image uploads, mentions, references, author maps and feedback to both editor mounts. Keep empty mention/project values stable while queries load. - Back up unfinished forms within their signed-in account, task and editor scope. Preserve text entered before its key. Record a successful create on its matching retained newer buffer, including after unmount or an early reopen. Preserve newer title/body edits, real remote conflicts and matching cancellation cleanup. - Add the model-free `task-document-persistence` journey. Setup creates only an owned empty backlog task. The browser creates and edits the document; normal APIs independently check company, task, actor, exact content and both revision records. - Reopen the owned task through the explicit Backlog view, then reload it. Require exact UI readback and zero native runs. Add only the Doc registration, definition-hash inputs, report mapping and four closed checks to the shared harness. - Count explicit zero-run browser receipts after private journey metadata is removed for publication. Preserve missing billing evidence, positive usage and native/protocol accounting. - Update browser assertions for the restored inline editor while retaining exact download bytes, revision collisions, paused-state and no-run checks. Update the feature map, usage docs and shared editor stories. ## Verification - Latest focused checks use official Node 24.22.2: 568 journey, catalog, report, billing and attempt-accounting tests across 25 suites pass. All four admitted journeys remain registered. The seven relevant UI suites pass 529 tests, including the task-page container after the parent merge. Maps, token gates, the PR-scope diff check and the owned credential-pattern scan pass. The normal QA import preflight also passes with an empty environment and network access blocked. - Billing regression: before the repair, the explicit empty public receipt counted one run, and two positive-usage receipts were discarded by browser metadata. Those three cases fail before and pass after the repair. Two additional red regressions cover aggregate usage beside an empty run list. That ambiguous shape now keeps an unavailable receipt and a minimum count of one. Missing receipts retain unavailable billing, and native/protocol one-or-more run totals retain their measurements. - The previous fresh CI run failed its unchanged snapshot-only recovery fixture: one global sweep did not call its callback. The original failure remains recorded. An isolated PGlite probe with all canonical migrations exercises the real reconciler: it processes two earlier sources, then reaches the owned third source on the next sweep. It preserves source evidence, files and run IDs. This proves bounded pagination, not full native snapshot repair. - Local native recovery tests could not start PostgreSQL because the host semaphore limit was reached. That run executed zero cases. Canonical Linux CI later passed all 143 fixture cases, including the original snapshot-only case and the new competing-candidate control. The exact fixture repair is now inherited from #18427 and is absent from this PR diff. - The portable oracle rejects 19 stale, missing, foreign or incorrect outcomes. Result validation also rejects model/provider metadata and native runs for this case. - Five real Artifacts-to-Properties unmount regressions fail before the repair and pass after it. They cover both editor modes, reopening before and after completion, later typing, and a foreign revision that must still conflict. The edit-storage failure case also checks that Cancel clears the promoted fallback copy. - UI coverage includes default chat, Classic, conversations, Coordinator, Plan rendering, document-card deduplication, reserved keys, pending-query stability, permissions and rich editor options. - Earlier real browser checks passed five upload cases and two artifact-arrival cases. They retain exact CSV bytes, original/revised content, collision, paused-state and no-run assertions. Current-head normal CI runs the updated browser suites again. - The branch merges only the selected actual parent `f1d780838218f975064f38333946805e0ec103a5`. The merge has no conflicts or manual resolution. The SQL service and recovery fixture match that parent exactly. The own document editor, persistence flow, saved-state oracle and billing repair retain their previously qualified source blobs. Follow-up, clarification, question/resumption, screenshot helpers and the Steering map are byte-identical to the merged parent. Existing project/search/question/follow-up registrations remain present. - Required document source blobs remain exact: Doc UI `22e80663b053363e5f75e1432dd9043dc0e70243`, its unmount tests `16f3f4ed0dc9b11aaff129e06e6cf2bbf4522713`, readiness helper `800a747284db19b8484650b6759e8bd1f356839d`, and budget service `3a15d489f0aa55c22e8f7fe6a6bc6a0e14e5914e`. - Local typing is limited by borrowed declarations from another checkout. They omit current `skill_test` and sandbox creation-operation fields. No dependency declarations or repository config were changed. The authoritative clean CI Typecheck + Release Registry gate passes on exact head `2ffce29827ae170420d740940c1c069c7225a74c`. - Full monorepo typecheck, tests and build were not run locally. Borrowed build declarations from another checkout limit local typing. Authoritative [clean CI](https://github.com/paperclipai/paperclip/actions/runs/41171717008) passes on exact head `82d15946c5844d3ae41c6ade62fa224053f20a9a`: 55 successful checks, 3 skipped and 1 neutral, with zero pending or failed checks. Typecheck, build, Runner, server and browser lanes pass. Greptile gives that same head a fresh 5/5. All 15 review threads are resolved, with no further thread page. The PR is mergeable and clean. - Failure history remains recorded. The earlier recovery failure was repaired with a bounded, owned-source fixture. A later CI worker shut down after 2,375 Runner unit tests passed. Its one authorized narrow retry passed those tests and all five native integration suites. The separate AgentConfig browser failure was reproduced and fixed by the merged #18427 parent. These earlier passes do not replace current-head CI. - Final-source live qualification passed on exact harness and served application `82d15946c5844d3ae41c6ade62fa224053f20a9a`. QA run `41177696858` completed all four closed checks and all five phases with zero native runs. Cleanup passed; the durable manifest is complete with no pending evidence. The coordinator verified the original grade and manifest. - The [public qualification report](https://pages.paperclip.ing/evals-qa-smoke-41177696858-1/) records zero agent runs, empty run IDs and empty run usage. Pricing remains unavailable and billing remains incomplete; the report does not claim measured zero cost. - The earlier harness `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88` pass against application `2ffce29827ae170420d740940c1c069c7225a74c` remains historical evidence. It is separate from the final-source measurement above. - The selected parent brings profile-entry ordering, proactive chat, a database migration, Runner updates, and task-page chat/read behavior. Its new task-page container is included in the final application measurement. The coordinator retains the original evidence and owns the primary merge and daily-plan admission. ## Risks Artifacts now mounts the existing shared editor. Its autosave, conflict, revision, annotation and server access contracts remain in place. Tests cover both interface modes, shared scopes, later remote writes and unavailable edit storage. Plans and generated file-review documents keep their existing flows. The PR adds no database schema or API change. The shared billing repair requires explicit captured empty run IDs and an unambiguous usage object containing only an empty run list for public browser-only receipts. Other fields beside an empty list keep conservative unavailable billing. Missing measurements remain unavailable. Contradicting positive usage remains counted. The inherited recovery fixture keeps the production sweep limit and its source, readback and no-agent-work assertions. The live journey must fail when authoring controls or saved outcomes are absent. It must not create the expected document through setup APIs or switch to another interface to pass. Final deployment verification must retain the actual app and harness revisions separately. ## Model Used OpenAI Codex, based on GPT-6, with tool use and code execution. The exact runtime model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1787 lines
74 KiB
TypeScript
1787 lines
74 KiB
TypeScript
// @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<T>(callback: () => T | Promise<T>): Promise<T> {
|
|
if (typeof reactAct === "function") {
|
|
return await (reactAct(callback) as T | Promise<T>);
|
|
}
|
|
|
|
let result: T | Promise<T> | 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<string, unknown>;
|
|
}) => (
|
|
<div
|
|
className={className}
|
|
data-testid="markdown-body"
|
|
data-external-reference-keys={externalReferences ? Object.keys(externalReferences).join(",") : ""}
|
|
>
|
|
{children}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
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 (
|
|
<div className={contentClassName} data-testid="markdown-editor">
|
|
{value || placeholder || ""}
|
|
</div>
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
|
<button type={type} onClick={onClick} {...props}>{children}</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/input", () => ({
|
|
Input: (props: ComponentProps<"input">) => <input {...props} />,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dropdown-menu", async () => {
|
|
return {
|
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuItem: ({ children, onClick, onSelect, disabled }: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
onSelect?: () => void;
|
|
disabled?: boolean;
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
onSelect?.();
|
|
onClick?.();
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuRadioItem: ({ children, onSelect, disabled }: {
|
|
children: React.ReactNode;
|
|
onSelect?: () => void;
|
|
disabled?: boolean;
|
|
}) => (
|
|
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuSeparator: () => <hr />,
|
|
};
|
|
});
|
|
|
|
const localStorageEntries = new Map<string, string>();
|
|
|
|
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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
function createIssueDocument(overrides: Partial<IssueDocument> = {}): 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> = {}): 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 <>
|
|
<button onClick={() => setArtifacts(true)}>Artifacts</button>
|
|
<button onClick={() => setArtifacts(false)}>Properties</button>
|
|
{artifacts ? <IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : null}
|
|
</>;
|
|
}
|
|
await act(async () => root.render(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
|
|
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[placeholder="Document key"]')?.value).toBe("");
|
|
expect(container.querySelector<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection issue={task} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>);
|
|
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<IssueDocument>();
|
|
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 <>
|
|
<button onClick={() => setArtifacts(true)}>Artifacts</button>
|
|
<button onClick={() => setArtifacts(false)}>Properties</button>
|
|
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : 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<HTMLInputElement>(`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(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
|
|
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<HTMLInputElement>('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<HTMLInputElement>('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<IssueDocument>();
|
|
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 <>
|
|
<button onClick={() => setArtifacts(true)}>Artifacts</button>
|
|
<button onClick={() => setArtifacts(false)}>Properties</button>
|
|
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : 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<HTMLInputElement>(`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(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
|
|
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<HTMLInputElement>('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<IssueDocument>();
|
|
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(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /></QueryClientProvider>);
|
|
const typeKey = async (value: string) => act(async () => {
|
|
const input = container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<IssueDocument>();
|
|
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(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>));
|
|
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<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection
|
|
issue={{ ...createIssue(), legacyPlanDocument: { key: "plan", source: "issue_description", body: "Legacy Plan content" } }}
|
|
canDeleteDocuments={false} generalDocumentsOnly
|
|
/></QueryClientProvider>));
|
|
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<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection
|
|
issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly
|
|
/></QueryClientProvider>));
|
|
await flush();
|
|
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!.click());
|
|
const keyInput = container.querySelector<HTMLInputElement>('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<DocumentRevision[]>((resolve) => { loadHistory = resolve; }));
|
|
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
|
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
|
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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
|
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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
|
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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
|
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<DocumentRevision[]>((resolve, reject) => { load = resolve; fail = reject; }));
|
|
const show = () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>);
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} canManageDocumentLocks />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments canManageDocumentLocks />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection
|
|
issue={issue}
|
|
canDeleteDocuments={false}
|
|
agentMap={new Map([["agent-1", { id: "agent-1", name: "CodexCoder", icon: "code" }]])}
|
|
userProfileMap={new Map([["user-1", { label: "Dotta", image: "https://example.test/dotta.png" }]])}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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<IssueDocument[]>();
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>); });
|
|
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(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>);
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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<HTMLInputElement>('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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>));
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection
|
|
issue={issue}
|
|
canDeleteDocuments={false}
|
|
externalReferences={{
|
|
"https://github.com/example/repo/pull/99": {
|
|
providerKey: "github",
|
|
objectType: "pull_request",
|
|
statusCategory: "open",
|
|
liveness: "fresh",
|
|
statusLabel: "Open",
|
|
displayTitle: "PR #99",
|
|
},
|
|
}}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection
|
|
issue={issue}
|
|
canDeleteDocuments={false}
|
|
extraActions={(
|
|
<>
|
|
<button type="button">Upload</button>
|
|
<button type="button">Sub-issue</button>
|
|
</>
|
|
)}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<IssueDocumentsSection
|
|
subject={{
|
|
id: "case-1",
|
|
documentsQueryKey: ["cases", "documents", "case-1"],
|
|
idleDocumentRevisionsQueryKey: ["cases", "revisions", "case-1", "__idle__"],
|
|
documentRevisionsQueryKey: (key) => ["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
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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();
|
|
});
|
|
});
|