mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## 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>
498 lines
17 KiB
TypeScript
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.");
|
|
});
|
|
});
|