Files
PaperClipAI/ui/src/pages/CompanyImport.test.tsx
T
Devin Foley 7608255e42 fix(ui): supersede settled import errors on every payload edit
File selection, conflict rename/confirm/skip, adapter overrides and
config, and continue-without-attachments all shape the next import
request but did not clear a settled failure, so the persistent panel
could keep describing a superseded request. Route them all through the
settled-state reset (pending requests remain untouched), and cover the
file-selection case in the component tests.
2026-07-30 13:37:52 -07:00

511 lines
20 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 { CompanyPortabilityImportResult, CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyImport } from "./CompanyImport";
const mockCompaniesApi = vi.hoisted(() => ({
importPreview: vi.fn(),
importBundle: vi.fn(),
get: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
resume: vi.fn(),
}));
const mockRoutinesApi = vi.hoisted(() => ({
update: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockSidebarPreferencesApi = vi.hoisted(() => ({
updateProjectOrder: vi.fn(),
}));
const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockReadZipArchive = vi.hoisted(() => vi.fn());
vi.mock("../api/companies", () => ({
companiesApi: mockCompaniesApi,
}));
vi.mock("../lib/zip", () => ({
readZipArchive: mockReadZipArchive,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/routines", () => ({
routinesApi: mockRoutinesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/sidebarPreferences", () => ({
sidebarPreferencesApi: mockSidebarPreferencesApi,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
useOptionalCompany: () => null,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// MarkdownEditor pulls in @mdxeditor/editor, whose sandpack dependency inserts
// CSS rules jsdom cannot parse; the editor is never exercised by these tests.
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: ({ value }: { value?: string }) => <textarea readOnly value={value ?? ""} />,
}));
// 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));
});
}
const previewFiles = {
".paperclip.yaml": 'schema: "paperclip/v1"\n',
"agents/coder/AGENTS.md": "---\nname: Coder\n---\n\nYou write code.\n",
"tasks/weekly-report/TASK.md": "---\nname: Weekly Report\nrecurring: true\n---\n\nSend the report.\n",
};
function buildPreviewResult(): CompanyPortabilityPreviewResult {
return {
include: { company: true, agents: true, projects: true, issues: true },
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["coder"],
plan: {
companyAction: "create",
agentPlans: [{ slug: "coder", action: "create", plannedName: "Coder", existingAgentId: null, reason: null }],
projectPlans: [],
issuePlans: [{ slug: "weekly-report", action: "create", plannedTitle: "Weekly Report", reason: "Recurring task will be imported as a routine." }],
},
manifest: {
agents: [{ slug: "coder", name: "Coder", path: "agents/coder/AGENTS.md", adapterType: "claude_local" }],
projects: [],
issues: [{ slug: "weekly-report", title: "Weekly Report", path: "tasks/weekly-report/TASK.md" }],
skills: [],
company: null,
},
files: previewFiles,
envInputs: [],
warnings: [],
errors: [],
} as unknown as CompanyPortabilityPreviewResult;
}
function buildImportResult(): CompanyPortabilityImportResult {
return {
company: { id: "company-2", name: "Imported Test", action: "created" },
agents: [{ slug: "coder", id: "agent-1", action: "created", name: "Coder", reason: null }],
projects: [],
routines: [{ slug: "weekly-report", id: "routine-1", action: "created", title: "Weekly Report", status: "paused" }],
envInputs: [],
warnings: [],
};
}
describe("CompanyImport", () => {
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([]);
mockAgentsApi.resume.mockResolvedValue({ id: "agent-1", status: "idle" });
mockRoutinesApi.update.mockResolvedValue({ id: "routine-1", status: "active" });
mockCompaniesApi.importPreview.mockResolvedValue(buildPreviewResult());
mockCompaniesApi.importBundle.mockResolvedValue(buildImportResult());
mockCompaniesApi.get.mockResolvedValue({ id: "company-2", name: "Imported Test", issuePrefix: "IMP" });
mockSidebarPreferencesApi.updateProjectOrder.mockResolvedValue(undefined);
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => {
currentRoot.unmount();
});
root = null;
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
function findButton(matches: (text: string) => boolean) {
return Array.from(container.querySelectorAll("button"))
.find((button) => matches(button.textContent?.trim() ?? "")) as HTMLButtonElement | undefined;
}
async function clickButton(matches: (text: string) => boolean) {
const button = findButton(matches);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
}
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}>
<CompanyImport />
</QueryClientProvider>,
);
});
await flushReact();
}
async function enterGithubUrl(url = "https://github.com/acme/starter/tree/main/company") {
const urlInput = container.querySelector<HTMLInputElement>(
'input[placeholder="https://github.com/owner/repo/tree/main/company"]',
);
expect(urlInput).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(urlInput!, url);
urlInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
}
async function renderPageAndImport() {
await renderPage();
await enterGithubUrl();
await clickButton((text) => text === "Preview import");
// Paused-import checkbox is present and checked by default.
const pauseLabel = Array.from(container.querySelectorAll("label"))
.find((label) => label.textContent?.includes("Start imported agents and routines paused"));
expect(pauseLabel).toBeTruthy();
expect(pauseLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
await clickButton((text) => text.startsWith("Import 3 file"));
}
it("shows the activation panel after import and activates selected agents and routines", async () => {
await renderPageAndImport();
expect(mockCompaniesApi.importBundle).toHaveBeenCalledWith(
expect.objectContaining({ pauseAutomations: true }),
);
expect(container.textContent).toContain("Import complete");
expect(container.textContent).toContain("Activate imported agents and routines");
expect(container.textContent).toContain("Coder");
expect(container.textContent).toContain("Weekly Report");
await clickButton((text) => text.startsWith("Activate selected"));
expect(mockAgentsApi.resume).toHaveBeenCalledWith("agent-1");
expect(mockRoutinesApi.update).toHaveBeenCalledWith("routine-1", { status: "active" });
expect(container.textContent).toContain("activated");
expect(container.textContent).not.toContain("failed:");
expect(findButton((text) => text === "Go to dashboard")).toBeTruthy();
});
it("keeps activating remaining items and surfaces per-item failures", async () => {
mockAgentsApi.resume.mockRejectedValue(new Error("resume exploded"));
await renderPageAndImport();
await clickButton((text) => text.startsWith("Activate selected"));
expect(mockAgentsApi.resume).toHaveBeenCalledWith("agent-1");
expect(mockRoutinesApi.update).toHaveBeenCalledWith("routine-1", { status: "active" });
expect(container.textContent).toContain("failed: resume exploded");
expect(container.textContent).toContain("activated");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
});
it("blocks oversized local packages until attachments are dropped", async () => {
// A synthetic parsed package: the base64 blob payload alone exceeds the
// inline import limit, so no real 60MB zip needs to be built.
mockReadZipArchive.mockResolvedValue({
rootPath: "big-package",
files: {
"COMPANY.md": "---\nname: Big\n---\n",
".paperclip.yaml": 'schema: "paperclip/v1"\n',
"blobs/4f2d1c9a": {
encoding: "base64",
data: "A".repeat(57 * 1024 * 1024),
contentType: "application/octet-stream",
},
},
});
await renderPage();
await clickButton((text) => text.includes("Local zip"));
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).toBeTruthy();
const file = new File(["stub"], "big-package.zip", { type: "application/zip" });
Object.defineProperty(file, "arrayBuffer", { value: async () => new ArrayBuffer(0) });
Object.defineProperty(fileInput!, "files", { value: [file] });
await act(async () => {
fileInput!.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("CLI folder import");
expect(container.textContent).toContain("Package too large for browser import");
expect(findButton((text) => text === "Preview import")?.disabled).toBe(true);
await clickButton((text) => text === "Continue without attachments");
expect(container.textContent).not.toContain("CLI folder import");
expect(container.textContent).not.toContain("Package too large for browser import");
expect(findButton((text) => text === "Preview import")?.disabled).toBe(false);
await clickButton((text) => text === "Preview import");
await clickButton((text) => text.startsWith("Import 3 file"));
expect(mockCompaniesApi.importBundle).toHaveBeenCalledTimes(1);
const request = mockCompaniesApi.importBundle.mock.calls[0]![0] as {
source: { type: string; files: Record<string, unknown> };
};
expect(request.source.type).toBe("inline");
expect(Object.keys(request.source.files).sort()).toEqual([".paperclip.yaml", "COMPANY.md"]);
});
it("explains the disabled preview button until a package is chosen", async () => {
await renderPage();
expect(findButton((text) => text === "Preview import")?.disabled).toBe(true);
expect(container.textContent).toContain("Choose a package above to enable the preview.");
await enterGithubUrl();
expect(findButton((text) => text === "Preview import")?.disabled).toBe(false);
expect(container.textContent).not.toContain("Choose a package above to enable the preview.");
});
it("shows a progress panel while the preview runs and a durable error panel when it fails", async () => {
let rejectPreview!: (err: Error) => void;
mockCompaniesApi.importPreview.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectPreview = reject;
}),
);
await renderPage();
await enterGithubUrl();
await clickButton((text) => text === "Preview import");
expect(container.textContent).toContain("Uploading and analyzing your package");
expect(container.textContent).toContain("Keep this page open.");
// A mid-flight config edit keeps the progress panel visible (the request
// really is still running) but supersedes the request, so its failure
// settles silently instead of describing a package no longer selected.
await enterGithubUrl("https://github.com/acme/starter-b/tree/main/company");
expect(container.textContent).toContain("Uploading and analyzing your package");
await act(async () => {
rejectPreview(new Error("stream disconnected"));
});
await flushReact();
expect(container.textContent).not.toContain("Uploading and analyzing your package");
expect(container.textContent).not.toContain("Preview failed:");
expect(mockPushToast).not.toHaveBeenCalled();
// A failure of the currently configured request renders a durable panel.
await clickButton((text) => text === "Preview import");
await act(async () => {
rejectPreview(new Error("stream disconnected"));
});
await flushReact();
expect(container.textContent).toContain("Preview failed: stream disconnected");
expect(container.textContent).toContain("Retry, or use the CLI folder import");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
// Changing the package supersedes the failed request: the error panel resets.
await enterGithubUrl("https://github.com/acme/other-starter/tree/main/company");
expect(container.textContent).not.toContain("Preview failed:");
});
it("shows a progress panel while the import runs and a durable error panel when it fails", async () => {
let rejectImport!: (err: Error) => void;
mockCompaniesApi.importBundle.mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectImport = reject;
}),
);
await renderPage();
await enterGithubUrl();
await clickButton((text) => text === "Preview import");
await clickButton((text) => text.startsWith("Import 3 file"));
expect(container.textContent).toContain("Uploading and importing");
expect(container.textContent).toContain("Keep this page open.");
// While the import runs, previewing and the structural package/settings
// controls are locked (and explained), so nothing can replace or unmount
// the plan the import started from.
expect(findButton((text) => text === "Preview import")?.disabled).toBe(true);
expect(container.textContent).toContain(
"Import in progress — the package and settings unlock when it finishes.",
);
const lockedUrlInput = container.querySelector<HTMLInputElement>(
'input[placeholder="https://github.com/owner/repo/tree/main/company"]',
);
expect(lockedUrlInput?.disabled).toBe(true);
const lockedSelects = Array.from(container.querySelectorAll("select")).filter(
(select) => select.value === "new" || select.value === "rename",
);
expect(lockedSelects).toHaveLength(2);
expect(lockedSelects.every((select) => select.disabled)).toBe(true);
// Config edits while the import is in flight must not detach it from
// the UI: the progress panel keeps reporting it until it settles.
const midFlightNameInput = container.querySelector<HTMLInputElement>(
'input[placeholder="Imported Company"]',
);
expect(midFlightNameInput).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(midFlightNameInput!, "Mid Flight");
midFlightNameInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Uploading and importing");
await act(async () => {
rejectImport(new Error("connection reset"));
});
await flushReact();
expect(container.textContent).not.toContain("Uploading and importing");
expect(container.textContent).toContain("Import failed: connection reset");
expect(container.textContent).toContain("check the target company before retrying.");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
// The new-company name feeds the request payload too: editing it clears
// the stale error panel without discarding the rendered preview.
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Imported Company"]');
expect(nameInput).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(nameInput!, "Renamed Import");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
expect(container.textContent).not.toContain("Import failed:");
expect(findButton((text) => text.startsWith("Import 3 file"))).toBeTruthy();
expect(findButton((text) => text === "Preview import")?.disabled).toBe(false);
expect(
container.querySelector<HTMLInputElement>(
'input[placeholder="https://github.com/owner/repo/tree/main/company"]',
)?.disabled,
).toBe(false);
// The pause toggle feeds the request payload too: after another failed
// attempt, toggling it also supersedes the request and clears the panel.
await clickButton((text) => text.startsWith("Import 3 file"));
await act(async () => {
rejectImport(new Error("second failure"));
});
await flushReact();
expect(container.textContent).toContain("Import failed: second failure");
const pauseInput = Array.from(container.querySelectorAll("label"))
.find((label) => label.textContent?.includes("Start imported agents and routines paused"))
?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(pauseInput).toBeTruthy();
await act(async () => {
pauseInput!.click();
});
await flushReact();
expect(container.textContent).not.toContain("Import failed:");
// File-selection edits feed the payload too and supersede a failure.
await clickButton((text) => text.startsWith("Import 3 file"));
await act(async () => {
rejectImport(new Error("third failure"));
});
await flushReact();
expect(container.textContent).toContain("Import failed: third failure");
const fileCheckbox = Array.from(
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
).find((input) => !input.closest("label")?.textContent?.includes("Start imported agents"));
expect(fileCheckbox).toBeTruthy();
await act(async () => {
fileCheckbox!.click();
});
await flushReact();
expect(container.textContent).not.toContain("Import failed:");
// Changing the package supersedes the failed import: previewing a fresh
// package must not resurface the stale import error panel.
await enterGithubUrl("https://github.com/acme/other-starter/tree/main/company");
await clickButton((text) => text === "Preview import");
expect(findButton((text) => text.startsWith("Import 3 file"))).toBeTruthy();
expect(container.textContent).not.toContain("Import failed:");
});
});