Files
PaperClipAI/ui/src/pages/CompanyExport.test.tsx
T
DottaandClaude Opus 5.5 b184e40108 perf(ui): serve every project list from one includeArchived read (#20395)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI reads the company project list in many places: the
sidebar, starred projects, the command palette, the new-task dialog, and
archive-aware pages such as the Mine inbox
> - Active-only consumers read `GET /projects`, and archive-aware ones
read `GET /projects?includeArchived=true`, under two React Query keys
> - A Mine load therefore fetched the same large payload twice (580–590
KB each on our dev instance)
> - The two responses differ only by archived rows; the server's active
list is exactly the rows where `archivedAt` is null
> - This pull request makes every consumer share the
`includeArchived=true` query, and active-only consumers filter it with
`select`
> - The benefit is one project read per page instead of two

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Duplicate project list reads on page load.

**Subsystem affected**
Board UI project queries (`ui/src/lib/company-projects-query.ts` and its
consumers).

**Current behavior**
A cold Mine load sends both `/companies/:id/projects` (sidebar) and
`/companies/:id/projects?includeArchived=true` (inbox).

**Proposed behavior**
One `includeArchived=true` read serves both. Active-only consumers apply
`selectActiveProjects`.

**Reason and benefit**
The project list is one of the largest shell payloads. Reading it once
saves a large response on every page that has both kinds of consumer.

## What Changed

- New `companyProjectsQuery(companyId, { includeArchived? })` in
`ui/src/lib/company-projects-query.ts`. It always uses the
`includeArchived: true` key and fetch. For active-only use it adds a
stable, module-level `select` that keeps rows without `archivedAt`.
- 22 files that read the active list now spread
`companyProjectsQuery(...)`, and so do the plugin SDK project picker and
16 archive-aware pages that wrote the shared key and fetcher inline.
Their `enabled` and other options are unchanged. `OnboardingWizard`'s
one imperative read is unchanged. `pages/Inbox.tsx` already reads the
same key and fetcher, so it shares the entry without an edit.
- Invalidation: project mutations and live events invalidate
`queryKeys.projects.all(companyId)`, which matches the shared key. The
onboarding wizard invalidated the old active-only key; it now
invalidates `projects.all`.
- Tests: new `company-projects-query.test.tsx` (one request for both
consumers, correct filtering, and one refetch after `projects.all`
invalidation). Three existing tests (`CommandPalette`, `CompanyExport`,
`GitHubBotManagement`) now seed or expect the shared key.
- New browser check
`tests/perf/collections/shared-project-list.spec.ts`: after the Mine
inbox read completes, the page has sent no active-only project request.

## Verification

- `pnpm verify` passes.
- `ui`: the consumer suites (`CoordinatorSidebar`, `SidebarProjects`,
`SidebarStarredProjects`, `CommandPalette`, `NewIssueDialog`,
`AgentWorkPanels`, `CoordinatorNeedsYou`, `IssueDetail`,
`ProjectDetail`, `Projects`, `CompanyExport`, `ChatExecutionDefaults`,
`GatewaysList`, `Search`, `IssueProperties`, `persisted-query-cache`)
pass.
- Browser check:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
shared-project-list.spec.ts`. It passes on this branch and fails on
`master`.

## Risks

- A page that needs only active projects now downloads archived rows
too. For most companies that is a small difference; the duplicate read
it removes is a full copy.
- The server does not order either list, and consumers sort for display,
so filtering on the client keeps behavior.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:42:47 -06:00

498 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExportFidelityReport } from "@paperclipai/shared/portability-fidelity";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyExport, resolveExportPreviewImageSrc } from "./CompanyExport";
const mockCompaniesApi = vi.hoisted(() => ({
exportPreview: vi.fn(),
exportBundle: vi.fn(),
exportFidelity: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../api/companies", () => ({
companiesApi: mockCompaniesApi,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
useOptionalCompany: () => null,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: vi.fn() }),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: "/PAP/company/export", search: "" }),
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function buildExportPreviewResult() {
return {
rootPath: "paperclip",
manifest: {
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
includes: { company: true, agents: true, projects: true, issues: true, skills: false },
company: { name: "Paperclip", description: null },
schemaVersion: 1,
generatedAt: "2026-01-01T00:00:00.000Z",
source: null,
},
files: { "README.md": "# Paperclip\n" },
fileInventory: [],
counts: { files: 1, agents: 0, skills: 0, projects: 0, issues: 0 },
warnings: [],
paperclipExtensionPath: ".paperclip.yaml",
};
}
function buildTaskAttachment(sha256: string) {
return {
sha256,
contentType: "application/octet-stream",
originalFilename: null,
byteSize: 1,
commentIndex: null,
};
}
function buildRichExportPreviewResult() {
const base = buildExportPreviewResult();
return {
...base,
files: {
"README.md": "# Paperclip\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"tasks/one-off/TASK.md": "# One-off\n",
"tasks/weekly-report/TASK.md": "# Weekly report\n",
"blobs/aaa111": "binary",
},
manifest: {
...base.manifest,
issues: [
{
slug: "one-off",
title: "One-off",
path: "tasks/one-off/TASK.md",
recurring: false,
attachments: [buildTaskAttachment("aaa111")],
},
{
slug: "weekly-report",
title: "Weekly report",
path: "tasks/weekly-report/TASK.md",
recurring: true,
attachments: [],
},
],
},
counts: { files: 6, agents: 1, skills: 0, projects: 0, issues: 2 },
};
}
function buildFidelityReport(warnings: ExportFidelityReport["warnings"]): ExportFidelityReport {
return {
schema: "paperclip-export-fidelity-v1",
companyId: "company-1",
counts: {
labelDefinitions: 0,
issueLabelReferences: 0,
issueBlockerRelations: 0,
issueDocuments: 0,
issueWorkProducts: 0,
issueAttachments: 0,
approvals: 0,
costEvents: 0,
activityLogEntries: 0,
issueMonitors: 0,
},
warnings,
generatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("CompanyExport", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockAgentsApi.list.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockCompaniesApi.exportPreview.mockResolvedValue(buildExportPreviewResult());
mockCompaniesApi.exportBundle.mockResolvedValue({ rootPath: "paperclip", files: {} });
mockCompaniesApi.exportFidelity.mockResolvedValue(buildFidelityReport([]));
Object.assign(URL, {
createObjectURL: vi.fn(() => "blob:mock"),
revokeObjectURL: vi.fn(),
});
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => {
currentRoot.unmount();
});
root = null;
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderPage() {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const currentRoot = root;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<CompanyExport />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await flushReact();
}
function categoryInput(key: string): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>(`input[data-export-category="${key}"]`);
if (!input) throw new Error(`No category toggle for ${key}`);
return input;
}
function exportButton(): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.trim().startsWith("Export "),
);
if (!button) throw new Error("Export button not found");
return button as HTMLButtonElement;
}
async function clickElement(element: HTMLElement) {
await act(async () => {
element.click();
});
await flushReact();
}
it("loads the no-task export automatically without an interstitial", async () => {
await renderPage();
expect(container.textContent).not.toContain("Prepare export preview");
expect(mockAuthApi.getSession).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.list).toHaveBeenCalledWith("company-1");
expect(mockProjectsApi.list).toHaveBeenCalledWith("company-1", { includeArchived: true });
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(1);
expect(mockCompaniesApi.exportPreview.mock.calls[0]?.[1]).toMatchObject({
include: { company: true, agents: true, projects: true, issues: false, skills: true },
});
expect(mockCompaniesApi.exportFidelity).toHaveBeenCalledWith("company-1");
expect(container.textContent).not.toContain("Not included in this export");
});
it("shows a retryable error instead of a false loading state", async () => {
mockCompaniesApi.exportPreview
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(buildExportPreviewResult());
await renderPage();
expect(container.textContent).toContain("Export preview failed");
// A network failure is explained in shared readable copy, never as the
// browser's raw "Failed to fetch".
expect(container.textContent).toContain("Paperclip is temporarily unavailable.");
expect(container.textContent).not.toContain("Failed to fetch");
expect(container.textContent).not.toContain("Loading export data");
const retry = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Retry preview"),
);
expect(retry).toBeDefined();
await clickElement(retry!);
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("Paperclip export");
});
it("starts the preview without waiting for sidebar-order dependencies", async () => {
let resolveSession!: (value: { user: { id: string } }) => void;
let resolveAgents!: (value: never[]) => void;
let resolveProjects!: (value: never[]) => void;
mockAuthApi.getSession.mockReturnValue(new Promise((resolve) => { resolveSession = resolve; }));
mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; }));
mockProjectsApi.list.mockReturnValue(new Promise((resolve) => { resolveProjects = resolve; }));
await renderPage();
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Paperclip export");
await act(async () => {
resolveSession({ user: { id: "user-1" } });
resolveAgents([]);
resolveProjects([]);
});
});
it("renders the generated org chart through the independent SVG endpoint", () => {
expect(resolveExportPreviewImageSrc({
src: "images/org-chart.png",
selectedFile: "README.md",
allFiles: {},
orgChartPreviewUrl: "/api/companies/company-1/org.svg",
})).toBe("/api/companies/company-1/org.svg");
});
it("keeps task history opt-in, then requests all selected files on download", async () => {
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
await renderPage();
expect(mockCompaniesApi.exportPreview.mock.calls[0]?.[1]).toMatchObject({
include: { issues: false, skills: true },
});
expect(container.textContent).toContain("Exporting 3 of 6 files");
const attachments = categoryInput("attachments");
expect(attachments.disabled).toBe(true);
expect(attachments.checked).toBe(false);
expect(container.querySelector('[data-file-tree-path="tasks"]')?.className).toContain("opacity-50");
await clickElement(categoryInput("tasks"));
await clickElement(categoryInput("routines"));
await clickElement(categoryInput("attachments"));
expect(mockCompaniesApi.exportPreview.mock.calls.some(([, request]) => request.include.issues === true)).toBe(true);
expect(container.textContent).toContain("Exporting 6 of 6 files");
// The tree is a pure browser now — no per-file checkboxes.
expect(container.querySelector('[role="tree"] input[type="checkbox"]')).toBeNull();
await clickElement(exportButton());
expect(mockCompaniesApi.exportBundle).toHaveBeenCalledTimes(1);
const request = mockCompaniesApi.exportBundle.mock.calls[0]![1];
expect(request.selectedFiles).toEqual([
".paperclip.yaml",
"README.md",
"agents/ceo/AGENT.md",
"blobs/aaa111",
"tasks/one-off/TASK.md",
"tasks/weekly-report/TASK.md",
]);
});
it("keeps controls mounted and aborts a slow task refetch when Tasks is unticked", async () => {
let requestCount = 0;
let slowRequestSignal: AbortSignal | undefined;
mockCompaniesApi.exportPreview.mockImplementation((_companyId, _request, options) => {
requestCount += 1;
if (requestCount !== 2) return Promise.resolve(buildRichExportPreviewResult());
slowRequestSignal = options?.signal;
return new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
});
});
await renderPage();
await clickElement(categoryInput("tasks"));
expect(container.textContent).toContain("Updating export preview");
expect(categoryInput("tasks").checked).toBe(true);
expect(container.querySelector('[role="tree"]')).not.toBeNull();
expect(exportButton()).toBeDefined();
await clickElement(categoryInput("tasks"));
expect(slowRequestSignal?.aborted).toBe(true);
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(3);
expect(mockCompaniesApi.exportPreview.mock.calls[2]?.[1]).toMatchObject({
include: { issues: false },
});
expect(categoryInput("tasks").checked).toBe(false);
expect(container.textContent).not.toContain("Updating export preview");
});
it("lets the user cancel a slow refetch without losing the export surface", async () => {
let requestCount = 0;
let slowRequestSignal: AbortSignal | undefined;
mockCompaniesApi.exportPreview.mockImplementation((_companyId, _request, options) => {
requestCount += 1;
if (requestCount === 1) return Promise.resolve(buildRichExportPreviewResult());
slowRequestSignal = options?.signal;
return new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
});
});
await renderPage();
await clickElement(categoryInput("tasks"));
const cancel = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Cancel update"),
);
expect(cancel).toBeDefined();
await clickElement(cancel!);
expect(slowRequestSignal?.aborted).toBe(true);
expect(container.textContent).toContain("Preview update cancelled");
expect(categoryInput("tasks")).toBeDefined();
expect(container.querySelector('[role="tree"]')).not.toBeNull();
expect(exportButton()).toBeDefined();
});
it("uses the Skills category for preview and bundle generation", async () => {
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
await renderPage();
await clickElement(categoryInput("skills"));
expect(mockCompaniesApi.exportPreview.mock.calls.at(-1)?.[1]).toMatchObject({
include: { skills: false },
});
await clickElement(exportButton());
expect(mockCompaniesApi.exportBundle.mock.calls[0]?.[1]).toMatchObject({
include: { skills: false },
});
});
it("toggling Tasks off drops one-off task files and their blobs but keeps routines", async () => {
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
await renderPage();
await clickElement(categoryInput("tasks"));
await clickElement(categoryInput("routines"));
await clickElement(categoryInput("attachments"));
await clickElement(categoryInput("tasks"));
expect(container.textContent).toContain("Exporting 4 of 6 files");
// Routines can carry attachments, so Attachments stays enabled while routines remain.
expect(categoryInput("attachments").disabled).toBe(false);
// Excluded files render dimmed in the tree browser.
expect(container.querySelector('[data-file-tree-path="blobs/aaa111"]')?.className).toContain("opacity-50");
expect(container.querySelector('[data-file-tree-path="blobs"]')?.className).toContain("opacity-50");
await clickElement(exportButton());
expect(mockCompaniesApi.exportBundle).toHaveBeenCalledTimes(1);
const request = mockCompaniesApi.exportBundle.mock.calls[0]![1];
expect(request.selectedFiles).toEqual([
".paperclip.yaml",
"README.md",
"agents/ceo/AGENT.md",
"tasks/weekly-report/TASK.md",
]);
});
it("shows the estimated download size and updates it when a category toggles off", async () => {
mockCompaniesApi.exportPreview.mockResolvedValue(buildRichExportPreviewResult());
await renderPage();
await clickElement(categoryInput("tasks"));
await clickElement(categoryInput("attachments"));
const sizeText = () =>
container.textContent?.match(/Exporting [\d,]+ of [\d,]+ files \(~([\d.]+ [KMGT]?B)\)/)?.[1] ?? null;
const initialSize = sizeText();
expect(initialSize).not.toBeNull();
await clickElement(categoryInput("tasks"));
expect(container.textContent).toContain("Exporting 3 of 6 files");
const toggledSize = sizeText();
expect(toggledSize).not.toBeNull();
// Dropping the one-off task and its blob shrinks the estimated zip.
expect(toggledSize).not.toBe(initialSize);
});
it("renders the export fidelity panel with blocker and warning messages", async () => {
mockCompaniesApi.exportFidelity.mockResolvedValue(buildFidelityReport([
{
code: "bundle_incompatible",
severity: "blocker",
message: "Importing this export will fail because the bundle references data this board cannot restore.",
},
{
code: "approvals_not_exported",
severity: "warning",
message: "3 approvals are not included in the export bundle.",
},
]));
await renderPage();
expect(mockCompaniesApi.exportFidelity).toHaveBeenCalledWith("company-1");
expect(container.textContent).toContain("Not included in this export");
expect(container.textContent).toContain(
"Importing this export will fail because the bundle references data this board cannot restore.",
);
expect(container.textContent).toContain("3 approvals are not included in the export bundle.");
});
});