mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip lets users choose an agent harness and account. > - The server resolves the runner for new agents. > - Claude still needs the same ordinary native-default journey as Codex. > - Existing agents must retain their runner, model and account. > - This PR defaults new Apple Silicon Claude agents to Paperclip Runner with Opus 5.5. > - Users can connect and work without a runner override. ## Linked Issues or Issue Description Refs #15683. This PR depends on #15994 and precedes #15997. Related scope: #15422, #13978 and #15224. ## What Changed - Resolve new Apple Silicon Claude agents to `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`. Keep explicit Legacy, models, provider routing and saved execution. - Use `claude-opus-5-5` for ordinary Claude defaults and retain explicit overrides. - Reuse existing API-key/subscription connections, credential storage and checks. Generalize the shared Advanced runner control with Claude branding. - Carry configured effort into the shared v9 contract. Preserve compatible permissions, instructions, skills, timeouts and session behavior. Require Legacy for unsupported settings. - Qualify a new immutable managed Claude target at explicit Connect through the existing permission-gated probe. Use fresh scoped discovery for test and hire. Discard stale results and require an explicit Connect after settings change. - Retain merged Codex onboarding recovery, auth preparation, import/effort corrections and Grok Bot gates. - Check the Claude first-task wizard's saved default directly. Update configuration/runtime documentation and exact Opus 5.5 accounting. - Align hire fixtures with the merged host-environment restriction. Native agent-authenticated hires cannot inject host env settings; both creation endpoints verify that no extra agent is saved. ## Verification - Updated onto master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb` through #15994. Native input v9 carries effort while preserving v7 home modes and v8 Muse. Codex defaults remain unchanged. - Focused local verification passed 285 server tests, 323 UI tests, 124 setup/registry tests, and two v9 permission-pin transport cases. Direct server/UI TypeScript checks passed. An initial 30-second hook timeout was resolved by serial verification with a longer hook budget: all 122 credential/permission cases and 232 snapshot/environment cases pass. Direct server TypeScript checking also passes. - Ordinary web creation with the experiment disabled saved native Claude, Opus 5.5 and High effort without runner/model overrides. The actual product task and follow-up both succeeded on integrated source `b2c9fe43f9cd3bc5dd9d22552da0148845ba71a4` with the cloud-built daemon from `04e0202ca81387b17cbe7fd094a8338648356fe9`. The final master reconciliation also changes shared runtime source. Historical proof retains its source identity; a final-source cloud qualification is running before refreshing the preview daemon. - Both actual browser downloads were checked: v1 has three sections/six checkboxes (451 bytes); v2 preserves the original body and adds two Rollback checks (612 bytes). The original attachment stayed byte-identical. The follow-up records session resume and the same provider session. Total estimated API usage: $0.518432800, with no pending/unpriced usage in the successful fixture. - Fixed the live-discovered setup incompatibility: native API-key validation no longer depends on the global Legacy Claude CLI. The existing provider endpoint verifies the key without a paid prompt; subscription/custom routing probes remain intact. - Fixed security review's native-hire credential redirection: agent-supplied URLs, proxy/launch settings and other host environment controls are rejected before inheritance. Native credential references remain supported and partial edits use the saved harness. Added create/hire/update regression coverage. - Current-head cloud CI is green. All three stack PRs have fresh Greptile 5/5 reviews and zero unresolved review threads. Required typecheck, test and build gates ran in cloud CI; Docker work stayed remote. Earlier failed checks remain in their original runs. - Docker and native release builds ran in cloud infrastructure. An inadvertent local Rust prerequisite was stopped; no local Docker build was run. - The final master merge preserves task-loading, permission, skill-retention and OpenRouter changes. Pi uses a fresh combined profile 27, preserving both historical profile-24 declarations; Copilot remains profile 22. Focused protocol checks passed 155 cases (one skipped), the updated Pi attestation check passed 18 (one skipped), and 91 evaluation/catalog checks passed. - Latest-application manual continuation passed in 37.942 seconds on `695e5c005a85bddc8711d3fcddcaf6b7e1fb64d0`, with High effort, native Opus 5.5, Anthropic API attribution, the same resumed Claude session, an independently checked 635-byte file and persisted Done status. It uses the earlier verified Mac daemon; the preceding-source package qualification passed at `dca62c7aeb4b45f0dd91a8e18d386a16a2658ef8`. That package run predates the small TypeScript startup fix and is not relabeled as qualification for it. - Upgrade limitation: changed compiled sidecar identity rejected old durable launch state and recovery hit session-open bounds before eventually resuming. That attempt was operator-cancelled and retained; the successful manual continuation does not qualify seamless upgrades. - Local latency profile, Opus 5.5/High unchanged: three alternating real-ACP loopback samples per variant reduce median provider-ready time from 5.081s to 4.034s. The matched real API task startup improves from 5.533s to 4.348s, but total reply time changes from 17.933s to 20.221s because agent work varies. This establishes a startup saving of about one second, not a consistent total-reply speedup. A real candidate follow-up passed in 20.146s and resumed the same saved session. The loopback fixture makes no external model requests; failed initial probes remain retained. - The startup change passes 107 focused runtime/effort/lease tests, 53 profile checks (one skipped), TypeScript checking and the TypeScript-only runtime build. Native hire fixture checks pass 70 tests; their first local run retained a socket-hangup failure and stale OpenCode expectation, both accounted for before the passing rerun. Broader required checks run in cloud CI. ## Risks - Missing packages, authentication rejection, unsupported settings and unavailable models must fail explicitly. Existing agents retain saved execution; explicit runner changes use existing revision/session invalidation. - Subscription follow-up remains unverified; remaining live acceptance uses API keys per human steering. Historical accounting holds and the separate $50 ceiling remain intact. - The subscription acceptance limit remains explicit for review. No Claude database migration is introduced. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution and browser testing. The exact serving model ID and context-window size are 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 - [ ] Focused local tests and final cloud verification 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>
1565 lines
74 KiB
TypeScript
1565 lines
74 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { webcrypto } from "node:crypto";
|
|
import type { ReactNode } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { AgentRunnerChoice, CompanyPortabilityImportResult, CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiError } from "../api/client";
|
|
import type { CompanyImportJobAccepted } from "../api/companies";
|
|
import { CompanyImport } from "./CompanyImport";
|
|
|
|
// jsdom's crypto has no SubtleCrypto; the chunked transfer path hashes parts
|
|
// with WebCrypto, so back the global with Node's implementation.
|
|
if (!globalThis.crypto?.subtle) {
|
|
vi.stubGlobal("crypto", webcrypto);
|
|
}
|
|
|
|
const mockCompaniesApi = vi.hoisted(() => ({
|
|
importPreview: vi.fn(),
|
|
importPreviewPackage: vi.fn(),
|
|
importBundle: vi.fn(),
|
|
importBundleAsync: vi.fn(),
|
|
importBundlePackageAsync: vi.fn(),
|
|
importTransferCreate: vi.fn(),
|
|
importTransferUploadPart: vi.fn(),
|
|
importTransferStatus: vi.fn(),
|
|
importTransferPreview: vi.fn(),
|
|
importTransferApply: vi.fn(),
|
|
getImportJob: vi.fn(),
|
|
get: vi.fn(),
|
|
}));
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
resume: vi.fn(),
|
|
}));
|
|
const mockAdaptersApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
}));
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
getGeneral: vi.fn(),
|
|
get: 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 mockOptionalCompany = vi.hoisted(() => vi.fn());
|
|
const mockReadZipArchive = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../api/companies", () => ({
|
|
companiesApi: mockCompaniesApi,
|
|
}));
|
|
|
|
// Keep the real sessionStorage bookkeeping so reload-resume is exercised, but
|
|
// make the poll delay instant so tests never wait the real 3s interval.
|
|
vi.mock("../lib/import-job-watch", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/import-job-watch")>();
|
|
return { ...actual, waitForNextImportJobPoll: () => Promise.resolve() };
|
|
});
|
|
|
|
vi.mock("../lib/zip", () => ({
|
|
readZipArchive: mockReadZipArchive,
|
|
}));
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
agentsApi: mockAgentsApi,
|
|
}));
|
|
|
|
vi.mock("../api/adapters", () => ({
|
|
adaptersApi: mockAdaptersApi,
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
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: mockOptionalCompany,
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToastActions: () => ({ pushToast: mockPushToast }),
|
|
useOptionalToastActions: () => ({ pushToast: mockPushToast }),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/tooltip", () => ({
|
|
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
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));
|
|
});
|
|
}
|
|
|
|
// The async import runs submit → poll → onSuccess, several awaits deep. Drain a
|
|
// handful of macrotask turns so the outcome/activation panels have committed.
|
|
async function settle(times = 6) {
|
|
for (let index = 0; index < times; index += 1) {
|
|
await flushReact();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Preview whose manifest mixes adapters: one claude_local agent and one codex_local agent. */
|
|
function buildMixedAdapterPreviewResult(): CompanyPortabilityPreviewResult {
|
|
return {
|
|
include: { company: true, agents: true, projects: true, issues: true },
|
|
targetCompanyId: null,
|
|
targetCompanyName: null,
|
|
collisionStrategy: "rename",
|
|
selectedAgentSlugs: ["coder", "researcher"],
|
|
plan: {
|
|
companyAction: "create",
|
|
agentPlans: [
|
|
{ slug: "coder", action: "create", plannedName: "Coder", existingAgentId: null, reason: null },
|
|
{ slug: "researcher", action: "create", plannedName: "Researcher", existingAgentId: null, reason: null },
|
|
],
|
|
projectPlans: [],
|
|
issuePlans: [],
|
|
},
|
|
manifest: {
|
|
agents: [
|
|
{ slug: "coder", name: "Coder", path: "agents/coder/AGENTS.md", adapterType: "claude_local" },
|
|
{ slug: "researcher", name: "Researcher", path: "agents/researcher/AGENTS.md", adapterType: "codex_local" },
|
|
],
|
|
projects: [],
|
|
issues: [],
|
|
skills: [],
|
|
company: null,
|
|
},
|
|
files: {
|
|
".paperclip.yaml": 'schema: "paperclip/v1"\n',
|
|
"agents/coder/AGENTS.md": "---\nname: Coder\n---\n\nYou write code.\n",
|
|
"agents/researcher/AGENTS.md": "---\nname: Researcher\n---\n\nYou research.\n",
|
|
},
|
|
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 }],
|
|
skills: [],
|
|
projects: [],
|
|
routines: [{ slug: "weekly-report", id: "routine-1", action: "created", title: "Weekly Report", status: "paused" }],
|
|
envInputs: [],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
function buildAccepted(id = "job-1"): CompanyImportJobAccepted {
|
|
return { job: { id, status: "running" }, statusUrl: `/companies/import/jobs/${id}` };
|
|
}
|
|
|
|
function buildTransferCreated(missingParts: number[], alreadyCompleted = false) {
|
|
return {
|
|
transferId: "transfer-1",
|
|
status: "running",
|
|
alreadyCompleted,
|
|
totalParts: 2,
|
|
missingParts,
|
|
};
|
|
}
|
|
|
|
const TRANSFER_PART_SIZE = 32 * 1024 * 1024;
|
|
|
|
/**
|
|
* A zip whose declared size crosses the 48 MB chunked-transfer threshold. Its
|
|
* real content is one full part plus a 1 KB tail so the manifest slices into
|
|
* two parts without allocating 48 MB in the test.
|
|
*/
|
|
function buildLargeZipFile(): File {
|
|
const bytes = new Uint8Array(TRANSFER_PART_SIZE + 1024);
|
|
const file = new File([bytes], "big-package.zip", { type: "application/zip" });
|
|
Object.defineProperty(file, "size", { value: 49 * 1024 * 1024 });
|
|
Object.defineProperty(file, "arrayBuffer", { value: async () => bytes.buffer });
|
|
return file;
|
|
}
|
|
|
|
function buildSucceededJob(id = "job-1") {
|
|
return { job: { id, status: "succeeded" as const, importResult: buildImportResult() } };
|
|
}
|
|
|
|
describe("CompanyImport", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
|
mockOptionalCompany.mockReturnValue(null);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: false });
|
|
mockInstanceSettingsApi.getGeneral.mockResolvedValue({});
|
|
mockInstanceSettingsApi.get.mockResolvedValue({});
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "codex_local", disabled: false },
|
|
]);
|
|
mockAgentsApi.resume.mockResolvedValue({ id: "agent-1", status: "idle" });
|
|
mockRoutinesApi.update.mockResolvedValue({ id: "routine-1", status: "active" });
|
|
mockCompaniesApi.importPreview.mockResolvedValue(buildPreviewResult());
|
|
mockCompaniesApi.importPreviewPackage.mockResolvedValue(buildPreviewResult());
|
|
// Default async flow: the submit is accepted (202) and the first poll finds
|
|
// the job already finished with the full result. Individual tests override.
|
|
mockCompaniesApi.importBundleAsync.mockResolvedValue(buildAccepted());
|
|
mockCompaniesApi.importBundlePackageAsync.mockResolvedValue(buildAccepted());
|
|
mockCompaniesApi.importTransferCreate.mockResolvedValue(buildTransferCreated([0, 1]));
|
|
mockCompaniesApi.importTransferUploadPart.mockResolvedValue({ ok: true, index: 0, alreadyCompleted: false });
|
|
mockCompaniesApi.importTransferPreview.mockResolvedValue(buildPreviewResult());
|
|
mockCompaniesApi.importTransferApply.mockResolvedValue(buildAccepted());
|
|
mockCompaniesApi.getImportJob.mockResolvedValue(buildSucceededJob());
|
|
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 = "";
|
|
sessionStorage.clear();
|
|
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 chooseLocalZip(file: File) {
|
|
await clickButton((text) => text.includes("Local zip"));
|
|
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
|
|
expect(fileInput).toBeTruthy();
|
|
Object.defineProperty(fileInput!, "files", { value: [file], configurable: true });
|
|
await act(async () => {
|
|
fileInput!.dispatchEvent(new Event("change", { 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"));
|
|
await settle();
|
|
}
|
|
|
|
it("submits the import as an async job, then activates selected agents and routines", async () => {
|
|
const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries");
|
|
try {
|
|
await renderPageAndImport();
|
|
|
|
expect(mockCompaniesApi.importBundleAsync).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pauseAutomations: true }),
|
|
);
|
|
// The page polls the job it was handed and runs the same activation path
|
|
// the synchronous response used to drive.
|
|
expect(mockCompaniesApi.getImportJob).toHaveBeenCalledWith("job-1");
|
|
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");
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ["companies"] }));
|
|
|
|
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();
|
|
} finally {
|
|
invalidateSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
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("uploads a local .zip as a multipart package and never blocks on inline size", async () => {
|
|
// Even a package whose inflated inline JSON would blow past the old browser
|
|
// limit uploads fine now: the raw compressed zip goes up as multipart and is
|
|
// unzipped server-side, so the inline-size ceiling no longer gates it.
|
|
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-zip-bytes"], "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();
|
|
|
|
// The inline preflight no longer blocks the zip path.
|
|
expect(container.textContent).not.toContain("Package too large for browser import");
|
|
expect(container.textContent).not.toContain("CLI folder import");
|
|
expect(findButton((text) => text === "Preview import")?.disabled).toBe(false);
|
|
|
|
await clickButton((text) => text === "Preview import");
|
|
// The preview goes up as a multipart package (the raw File), not inline JSON.
|
|
expect(mockCompaniesApi.importPreviewPackage).toHaveBeenCalledTimes(1);
|
|
expect(mockCompaniesApi.importPreview).not.toHaveBeenCalled();
|
|
expect(mockCompaniesApi.importPreviewPackage.mock.calls[0]![0]).toBe(file);
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
// The apply also uploads the raw File as a multipart async job — never the
|
|
// inflated inline body that truncated in transit.
|
|
expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled();
|
|
expect(mockCompaniesApi.importBundlePackageAsync).toHaveBeenCalledTimes(1);
|
|
const [sentFile, meta] = mockCompaniesApi.importBundlePackageAsync.mock.calls[0]! as [
|
|
File,
|
|
{ pauseAutomations: boolean; target: { mode: string } },
|
|
];
|
|
expect(sentFile).toBe(file);
|
|
expect(sentFile.name).toBe("big-package.zip");
|
|
expect(meta.pauseAutomations).toBe(true);
|
|
// The bundle itself is never expanded into the request; only the raw zip travels.
|
|
expect(meta).not.toHaveProperty("source");
|
|
// A zip under the chunked threshold never touches the transfer machinery.
|
|
expect(mockCompaniesApi.importTransferCreate).not.toHaveBeenCalled();
|
|
expect(mockCompaniesApi.importTransferUploadPart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uploads a large local .zip through the chunked transfer path for preview and import", async () => {
|
|
mockReadZipArchive.mockResolvedValue({ rootPath: "big-package", files: previewFiles });
|
|
mockCompaniesApi.importTransferCreate
|
|
.mockResolvedValueOnce(buildTransferCreated([0, 1]))
|
|
// The import's re-declaration resumes the transfer the preview uploaded.
|
|
.mockResolvedValueOnce(buildTransferCreated([]));
|
|
|
|
// Hold the last part so the visible upload progress can be observed.
|
|
let resolveLastPart!: (value: { ok: true; index: number; alreadyCompleted: boolean }) => void;
|
|
mockCompaniesApi.importTransferUploadPart.mockImplementation((_id: string, index: number) => {
|
|
if (index === 0) return Promise.resolve({ ok: true, index, alreadyCompleted: false });
|
|
return new Promise((resolve) => {
|
|
resolveLastPart = resolve;
|
|
});
|
|
});
|
|
|
|
await renderPage();
|
|
await chooseLocalZip(buildLargeZipFile());
|
|
await clickButton((text) => text === "Preview import");
|
|
await vi.waitFor(() => {
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledTimes(2);
|
|
});
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Uploading part 2 of 2");
|
|
expect(container.textContent).toContain("32 MB");
|
|
|
|
await act(async () => {
|
|
resolveLastPart({ ok: true, index: 1, alreadyCompleted: false });
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(mockCompaniesApi.importTransferPreview).toHaveBeenCalledTimes(1);
|
|
});
|
|
await settle();
|
|
|
|
// The declaration describes the sliced zip: whole-file hash plus one
|
|
// content hash per 32 MB byte range.
|
|
const manifest = mockCompaniesApi.importTransferCreate.mock.calls[0]![0] as {
|
|
totalBytes: number;
|
|
zipSha256: string;
|
|
partSizeBytes: number;
|
|
parts: Array<{ index: number; byteSize: number; sha256: string }>;
|
|
};
|
|
expect(manifest.totalBytes).toBe(TRANSFER_PART_SIZE + 1024);
|
|
expect(manifest.partSizeBytes).toBe(TRANSFER_PART_SIZE);
|
|
expect(manifest.zipSha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(manifest.parts.map((part) => ({ index: part.index, byteSize: part.byteSize }))).toEqual([
|
|
{ index: 0, byteSize: TRANSFER_PART_SIZE },
|
|
{ index: 1, byteSize: 1024 },
|
|
]);
|
|
expect(manifest.parts.every((part) => /^[0-9a-f]{64}$/.test(part.sha256))).toBe(true);
|
|
|
|
// Both parts traveled, then the preview ran against the assembled spool —
|
|
// never the single-shot multipart endpoints.
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenNthCalledWith(1, "transfer-1", 0, expect.anything());
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenNthCalledWith(2, "transfer-1", 1, expect.anything());
|
|
expect(mockCompaniesApi.importTransferPreview).toHaveBeenCalledWith(
|
|
"transfer-1",
|
|
expect.objectContaining({ collisionStrategy: "rename", target: { mode: "new_company", newCompanyName: null } }),
|
|
);
|
|
expect(mockCompaniesApi.importPreviewPackage).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain("Import preview");
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await vi.waitFor(() => {
|
|
expect(mockCompaniesApi.importTransferApply).toHaveBeenCalledTimes(1);
|
|
});
|
|
await settle();
|
|
|
|
// The resumed transfer had nothing left to upload, so the apply reused
|
|
// the spooled parts and ran as the usual async job.
|
|
expect(mockCompaniesApi.importTransferCreate).toHaveBeenCalledTimes(2);
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledTimes(2);
|
|
expect(mockCompaniesApi.importTransferApply).toHaveBeenCalledWith(
|
|
"transfer-1",
|
|
expect.objectContaining({ pauseAutomations: true }),
|
|
);
|
|
expect(mockCompaniesApi.importBundlePackageAsync).not.toHaveBeenCalled();
|
|
expect(mockCompaniesApi.getImportJob).toHaveBeenCalledWith("job-1");
|
|
expect(container.textContent).toContain("Import complete");
|
|
});
|
|
|
|
it("retries a failed part before surfacing the error panel, keeping the transfer resumable", async () => {
|
|
mockReadZipArchive.mockResolvedValue({ rootPath: "big-package", files: previewFiles });
|
|
mockCompaniesApi.importTransferCreate.mockResolvedValue(buildTransferCreated([0, 1]));
|
|
mockCompaniesApi.importTransferUploadPart.mockImplementation((_id: string, index: number) => {
|
|
if (index === 0) return Promise.resolve({ ok: true, index, alreadyCompleted: false });
|
|
return Promise.reject(new Error("socket hang up"));
|
|
});
|
|
|
|
await renderPage();
|
|
await chooseLocalZip(buildLargeZipFile());
|
|
await clickButton((text) => text === "Preview import");
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Preview failed: socket hang up");
|
|
});
|
|
|
|
// Part 1 was attempted three times before the failure surfaced; the
|
|
// preview never ran and no state was consumed, so retrying can resume.
|
|
const partOneAttempts = mockCompaniesApi.importTransferUploadPart.mock.calls.filter(
|
|
(call) => call[1] === 1,
|
|
);
|
|
expect(partOneAttempts).toHaveLength(3);
|
|
expect(mockCompaniesApi.importTransferPreview).not.toHaveBeenCalled();
|
|
expect(findButton((text) => text === "Preview import")?.disabled).toBe(false);
|
|
|
|
// Retry: the re-declared transfer reports only the lost part missing, so
|
|
// just that part travels again.
|
|
mockCompaniesApi.importTransferCreate.mockResolvedValue(buildTransferCreated([1]));
|
|
mockCompaniesApi.importTransferUploadPart.mockResolvedValue({ ok: true, index: 1, alreadyCompleted: false });
|
|
mockCompaniesApi.importTransferUploadPart.mockClear();
|
|
|
|
await clickButton((text) => text === "Preview import");
|
|
await vi.waitFor(() => {
|
|
expect(mockCompaniesApi.importTransferPreview).toHaveBeenCalledTimes(1);
|
|
});
|
|
await settle();
|
|
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledTimes(1);
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledWith("transfer-1", 1, expect.anything());
|
|
expect(container.textContent).toContain("Import preview");
|
|
});
|
|
|
|
it("re-uploads only the missing parts when resuming an interrupted transfer", async () => {
|
|
// A previous page load already uploaded part 0; re-declaring the same
|
|
// file resumes that transfer, so only part 1 travels.
|
|
mockReadZipArchive.mockResolvedValue({ rootPath: "big-package", files: previewFiles });
|
|
mockCompaniesApi.importTransferCreate.mockResolvedValue(buildTransferCreated([1]));
|
|
|
|
await renderPage();
|
|
await chooseLocalZip(buildLargeZipFile());
|
|
await clickButton((text) => text === "Preview import");
|
|
await vi.waitFor(() => {
|
|
expect(mockCompaniesApi.importTransferPreview).toHaveBeenCalledTimes(1);
|
|
});
|
|
await settle();
|
|
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledTimes(1);
|
|
expect(mockCompaniesApi.importTransferUploadPart).toHaveBeenCalledWith("transfer-1", 1, expect.anything());
|
|
expect(container.textContent).toContain("Import preview");
|
|
});
|
|
|
|
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 re-export the package without large attachments");
|
|
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 () => {
|
|
// The submit stays pending across the whole job, so the progress panel and
|
|
// structural locks cover it. Rejecting the submit surfaces the error panel.
|
|
let rejectImport!: (err: Error) => void;
|
|
mockCompaniesApi.importBundleAsync.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("Import running on the server");
|
|
expect(container.textContent).toContain("safe to keep waiting");
|
|
|
|
// 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 Organization"]',
|
|
);
|
|
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("Import running on the server");
|
|
|
|
await act(async () => {
|
|
rejectImport(new Error("connection reset"));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(container.textContent).not.toContain("Import running on the server");
|
|
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 Organization"]');
|
|
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:");
|
|
|
|
// The outcome reports the submitted pause option, not the live checkbox:
|
|
// a mid-flight toggle must not change what the completed import did. The
|
|
// submit stays pending until we resolve it to the accepted job.
|
|
let resolveAccepted!: (value: CompanyImportJobAccepted) => void;
|
|
mockCompaniesApi.importBundleAsync.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveAccepted = resolve;
|
|
}),
|
|
);
|
|
mockCompaniesApi.getImportJob.mockResolvedValue(buildSucceededJob("job-final"));
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
|
|
expect(mockCompaniesApi.importBundleAsync).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ pauseAutomations: false }),
|
|
);
|
|
|
|
const midFlightPauseInput = Array.from(container.querySelectorAll("label"))
|
|
.find((label) => label.textContent?.includes("Start imported agents and routines paused"))
|
|
?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
|
expect(midFlightPauseInput).toBeTruthy();
|
|
await act(async () => {
|
|
midFlightPauseInput!.click();
|
|
});
|
|
await flushReact();
|
|
|
|
await act(async () => {
|
|
resolveAccepted(buildAccepted("job-final"));
|
|
});
|
|
await settle();
|
|
|
|
expect(container.textContent).toContain("Import complete");
|
|
expect(container.textContent).not.toContain("Activate imported agents and routines");
|
|
});
|
|
|
|
it("adopts the already-running job when the server reports a 409", async () => {
|
|
mockCompaniesApi.importBundleAsync.mockRejectedValueOnce(
|
|
new ApiError("An import is already running for this account", 409, {
|
|
job: { id: "job-existing", status: "running" },
|
|
statusUrl: "/companies/import/jobs/job-existing",
|
|
}),
|
|
);
|
|
mockCompaniesApi.getImportJob.mockResolvedValue(buildSucceededJob("job-existing"));
|
|
|
|
await renderPageAndImport();
|
|
|
|
// The duplicate submit adopts the running job from the 409 body and polls
|
|
// it instead of firing a second import.
|
|
expect(mockCompaniesApi.getImportJob).toHaveBeenCalledWith("job-existing");
|
|
expect(container.textContent).toContain("Import complete");
|
|
expect(container.textContent).toContain("Activate imported agents and routines");
|
|
});
|
|
|
|
it("surfaces a failed import job in the durable error panel", async () => {
|
|
mockCompaniesApi.getImportJob.mockResolvedValue({
|
|
job: { id: "job-1", status: "failed", error: { message: "blob mismatch" } },
|
|
});
|
|
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(container.textContent).toContain("Import failed: blob mismatch");
|
|
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
|
|
});
|
|
|
|
it("terminates the import when polling hits a permanent auth error", async () => {
|
|
// A 403 (expired or revoked board session) will never recover by polling
|
|
// again, so the watch must stop and surface an error instead of leaving the
|
|
// import locked in its running state forever.
|
|
mockCompaniesApi.getImportJob.mockRejectedValue(
|
|
new ApiError("Board access required", 403, { error: "Board access required" }),
|
|
);
|
|
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(container.textContent).not.toContain("Import running on the server");
|
|
expect(container.textContent).toContain("your session may have expired");
|
|
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
|
|
});
|
|
|
|
it("keeps watching through a transient poll failure", async () => {
|
|
// A one-off 5xx is transient: the job is still running server-side, so the
|
|
// watch must keep polling and settle on the eventual success rather than
|
|
// treating the blip as a terminal failure.
|
|
mockCompaniesApi.getImportJob
|
|
.mockRejectedValueOnce(new ApiError("upstream unavailable", 503, null))
|
|
.mockResolvedValue(buildSucceededJob("job-1"));
|
|
|
|
await renderPageAndImport();
|
|
|
|
expect(container.textContent).not.toContain("Import failed:");
|
|
expect(container.textContent).toContain("Import complete");
|
|
});
|
|
|
|
it("treats a server-confirmed success without a full result as a soft success and refreshes the company list", async () => {
|
|
// The server reports the job `succeeded` but retains only the compact
|
|
// summary (a cloud tenant job, or a board job whose full in-memory result
|
|
// aged out): status is a confirmed success, `importResult` is gone, and the
|
|
// summary still carries the company id. That is a success we can no longer
|
|
// fully read — never a scary failure.
|
|
mockCompaniesApi.getImportJob.mockResolvedValue({
|
|
job: { id: "job-1", status: "succeeded", result: { companyId: "company-2" } },
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries");
|
|
try {
|
|
await renderPageAndImport();
|
|
|
|
// Success-leaning panel, not the failure panel.
|
|
expect(container.textContent).toContain("Import completed");
|
|
// The readable company gives the panel a name and a direct CTA into the
|
|
// new company's dashboard, plus the paused-agents pointer.
|
|
expect(container.textContent).toContain("Imported Test");
|
|
// The default import submits with pauseAutomations checked, so the
|
|
// paused pointer must show; it is gated off when the user unchecks it.
|
|
expect(container.textContent).toContain("Imported agents arrived paused");
|
|
const openCompany = container.querySelector('[data-testid="import-expired-open-company"]');
|
|
expect(openCompany).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Import failed");
|
|
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" }));
|
|
// The company list is refreshed so the new company appears in the switcher.
|
|
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ["companies"] }));
|
|
// The summary's company id still drives navigation into the import.
|
|
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
|
|
} finally {
|
|
invalidateSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("falls back to switcher guidance when the expired job's company is unreadable", async () => {
|
|
mockCompaniesApi.getImportJob.mockResolvedValue({
|
|
job: { id: "job-1", status: "succeeded", result: { companyId: "company-2" } },
|
|
});
|
|
mockCompaniesApi.get.mockRejectedValue(new Error("forbidden"));
|
|
|
|
await renderPageAndImport();
|
|
|
|
expect(container.textContent).toContain("Import completed");
|
|
expect(container.textContent).toContain("select it from the organization switcher");
|
|
// No readable company, so no dashboard CTA — the switcher guidance stands in.
|
|
expect(container.querySelector('[data-testid="import-expired-open-company"]')).toBeNull();
|
|
});
|
|
|
|
it("surfaces a first-poll 404 as an error because the job never existed", async () => {
|
|
// A 404 on the very first poll — before the client ever saw the job
|
|
// running — means the id never existed. That stays a hard error.
|
|
mockCompaniesApi.getImportJob.mockRejectedValue(
|
|
new ApiError("Import job not found", 404, { error: "Import job not found" }),
|
|
);
|
|
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(container.textContent).toContain("Import failed:");
|
|
expect(container.textContent).toContain("it may have restarted while the import ran");
|
|
expect(container.textContent).not.toContain("Import completed");
|
|
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
|
|
});
|
|
|
|
it("surfaces a running-then-gone job as an error because the server restarted mid-import", async () => {
|
|
// The first poll finds the job running; the next poll 404s. A running job is
|
|
// never dropped by the retention sweep (only settled jobs age out), so its
|
|
// disappearance means the server restarted mid-import — the import never
|
|
// reached a confirmed success and may not have finished. Report that
|
|
// honestly instead of masking a possibly-incomplete import as completed.
|
|
mockCompaniesApi.getImportJob
|
|
.mockResolvedValueOnce({ job: { id: "job-1", status: "running" } })
|
|
.mockRejectedValue(new ApiError("Import job not found", 404, { error: "Import job not found" }));
|
|
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(container.textContent).toContain("Import failed:");
|
|
expect(container.textContent).toContain("it may have restarted while the import ran");
|
|
expect(container.textContent).not.toContain("Import completed");
|
|
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
|
|
});
|
|
|
|
it("resumes watching a stored import job on mount", async () => {
|
|
// A previous page load persisted a running job; reloading must resume
|
|
// watching it rather than showing the stale form.
|
|
sessionStorage.setItem(
|
|
"paperclip:company-import-job:company-1:acme/starter",
|
|
JSON.stringify({ jobId: "job-resume", pauseAutomations: false }),
|
|
);
|
|
|
|
let resolveFirstPoll!: (value: ReturnType<typeof buildSucceededJob>) => void;
|
|
mockCompaniesApi.getImportJob.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveFirstPoll = resolve;
|
|
}),
|
|
);
|
|
|
|
await renderPage();
|
|
|
|
// The resume panel is shown while the first poll is in flight, and no new
|
|
// submit was fired — the job is only being watched.
|
|
expect(container.textContent).toContain("Resume watching import");
|
|
expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled();
|
|
expect(mockCompaniesApi.getImportJob).toHaveBeenCalledWith("job-resume");
|
|
|
|
await act(async () => {
|
|
resolveFirstPoll(buildSucceededJob("job-resume"));
|
|
});
|
|
await settle();
|
|
|
|
expect(container.textContent).not.toContain("Resume watching import");
|
|
expect(container.textContent).toContain("Import complete");
|
|
// The stored entry is cleared once the job settles.
|
|
expect(sessionStorage.getItem("paperclip:company-import-job:company-1:acme/starter")).toBeNull();
|
|
});
|
|
|
|
/** Adapter selects in the picker list, in manifest order (excludes the target/collision selects). */
|
|
function findAdapterSelects() {
|
|
return Array.from(container.querySelectorAll("select"))
|
|
.filter((select) => select.value !== "new" && select.value !== "existing" && select.value !== "rename");
|
|
}
|
|
|
|
async function previewMixedAdapterPackage() {
|
|
mockCompaniesApi.importPreview.mockResolvedValue(buildMixedAdapterPreviewResult());
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
}
|
|
|
|
function lastImportMeta() {
|
|
const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1);
|
|
expect(call).toBeTruthy();
|
|
return call![0] as { adapterOverrides?: Record<string, { adapterType: string; runner?: AgentRunnerChoice; adapterConfig?: Record<string, unknown> }> };
|
|
}
|
|
|
|
it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => {
|
|
await previewMixedAdapterPackage();
|
|
|
|
// The picker shows each agent's source adapter, not a coerced CEO default.
|
|
expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]);
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
// Untouched agents flow through with no override at all, so the server
|
|
// applies each agent's manifest adapterType.
|
|
expect(lastImportMeta().adapterOverrides).toBeUndefined();
|
|
});
|
|
|
|
it("sends an override only for the agent whose adapter the user changed", async () => {
|
|
await previewMixedAdapterPackage();
|
|
|
|
const coderSelect = findAdapterSelects()[0];
|
|
expect(coderSelect?.value).toBe("claude_local");
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!;
|
|
setter.call(coderSelect!, "codex_local");
|
|
coderSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(lastImportMeta().adapterOverrides).toEqual({
|
|
coder: { adapterType: "codex_local" },
|
|
});
|
|
});
|
|
|
|
it("hides Paperclip Runner import configuration while its experimental flag is off", async () => {
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "codex_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: true },
|
|
]);
|
|
await previewMixedAdapterPackage();
|
|
|
|
for (const select of findAdapterSelects()) {
|
|
expect(Array.from(select.options).map((option) => option.value))
|
|
.not.toContain("paperclip_runner");
|
|
}
|
|
});
|
|
|
|
it("offers Paperclip Runner import configuration after its experimental flag is enabled", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "codex_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
await previewMixedAdapterPackage();
|
|
|
|
for (const select of findAdapterSelects()) {
|
|
expect(Array.from(select.options).map((option) => option.value))
|
|
.toContain("paperclip_runner");
|
|
}
|
|
});
|
|
|
|
it("keeps imported Dot and native Codex agents under Dot-only opt-in", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildMixedAdapterPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.adapterConfig = { provider: "openai_dot", allowUnmeteredProvider: true };
|
|
preview.manifest.agents[1]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[1]!.adapterConfig = { provider: "codex" };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton((text) => text === "Preview import");
|
|
|
|
expect(findAdapterSelects().map((select) => select.value)).toEqual(["openai_dot", "codex_local"]);
|
|
for (const select of findAdapterSelects()) {
|
|
const choices = Array.from(select.options).map((option) => option.value);
|
|
expect(choices).toContain("openai_dot");
|
|
expect(choices).not.toContain("paperclip_runner");
|
|
}
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides).toBeUndefined();
|
|
});
|
|
|
|
it.each([undefined, "codex"])("preserves an exported native Codex package with provider %s when experiments are disabled", async provider => {
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "codex_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildMixedAdapterPreviewResult();
|
|
preview.manifest.agents[1]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[1]!.runner = "paperclip";
|
|
preview.manifest.agents[1]!.adapterConfig = { ...(provider ? { provider } : {}), model: "gpt-5.6-sol", reasoningEffort: "high" };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
expect(findAdapterSelects().map(select => select.value)).toEqual(["claude_local", "codex_local"]);
|
|
expect(container.textContent).not.toContain("will use Claude Code");
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{ provider: "opencode", model: "provider/model" },
|
|
{ provider: "acpx", acpxAgent: "codex" },
|
|
{ provider: "unknown-provider" },
|
|
])("keeps gated native $provider identity instead of falling back to another harness", async adapterConfig => {
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildMixedAdapterPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.adapterConfig = adapterConfig;
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
expect(findAdapterSelects()[0]!.value).toBe("paperclip_runner");
|
|
expect(container.textContent).toContain("requires its experimental feature to be enabled");
|
|
expect(container.textContent).not.toContain("source adapter paperclip_runner is not installed here");
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{ sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const, sourcePolicy: {}, expectedBypass: undefined },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false },
|
|
{ sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: { codexPermissionMode: "never" }, expectedBypass: true },
|
|
{ sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: {}, expectedBypass: true },
|
|
{ sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: { codexPermissionMode: "never", dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassSandbox: false }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: true }, expectedBypass: true },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["--sandbox", "read-only"] }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { args: ["-c", "approval_policy=on-request"] }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: [], args: ["--profile", "restricted"] }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["--color", "never"], args: ["--sandbox", "read-only"] }, expectedBypass: true },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["-c", "sandbox_workspace_write.network_access=false"] }, expectedBypass: false },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: true, extraArgs: ["--sandbox", "read-only"] }, expectedBypass: true },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassSandbox: true, args: ["--sandbox", "read-only"] }, expectedBypass: true },
|
|
{ sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true, extraArgs: ["--color", "never"] }, expectedBypass: false },
|
|
])("submits $runner from $sourceType with visible bypass=$expectedBypass while preserving saved policy", async ({ sourceType, sourceRunner, runner, sourcePolicy, expectedBypass }) => {
|
|
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "codex_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = sourceType;
|
|
preview.manifest.agents[0]!.runner = sourceRunner;
|
|
preview.manifest.agents[0]!.adapterConfig = {
|
|
...(sourceType === "paperclip_runner" ? { provider: "codex" } : {}),
|
|
model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { CODEX_HOME: "/saved/account" },
|
|
...sourcePolicy,
|
|
};
|
|
const savedSourceConfig = structuredClone(preview.manifest.agents[0]!.adapterConfig);
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
await clickButton(text => text.includes("Advanced"));
|
|
const select = container.querySelector<HTMLButtonElement>('[aria-label="Runner"]');
|
|
expect(select).toBeTruthy();
|
|
await act(async () => select!.click());
|
|
const option = document.querySelector<HTMLButtonElement>(`[role="option"][data-value="${runner}"]`);
|
|
expect(option).toBeTruthy();
|
|
expect(option!.disabled).toBe(false);
|
|
await act(async () => option!.click());
|
|
await flushReact();
|
|
if (expectedBypass !== undefined) {
|
|
expect(container.querySelector('[role="switch"][aria-label="Bypass sandbox"]')?.getAttribute("aria-checked"))
|
|
.toBe(String(expectedBypass));
|
|
}
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: sourceType === "paperclip_runner" ? "paperclip_runner" : "codex_local", runner } });
|
|
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig);
|
|
});
|
|
|
|
it.each(["deny-all", "approve-reads"].flatMap(permission => ["paperclip_runner", "claude_local"].map(sourceType => ({ permission, sourceType }))))("saves the selected Claude permission from $sourceType ($permission)", async ({ permission, sourceType }) => {
|
|
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildPreviewResult();
|
|
const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: permission, timeoutSec: 180, lifecycleMode: "per_turn" };
|
|
preview.manifest.agents[0]!.adapterType = sourceType;
|
|
preview.manifest.agents[0]!.runner = sourceType === "paperclip_runner" ? "paperclip" : "legacy";
|
|
preview.manifest.agents[0]!.adapterConfig = source;
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
if (sourceType === "claude_local") {
|
|
await clickButton(text => text.includes("Advanced"));
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value="paperclip"]')!.click());
|
|
await flushReact();
|
|
}
|
|
const select = container.querySelector<HTMLButtonElement>('[aria-label="Permission mode"]');
|
|
if (sourceType === "paperclip_runner") expect(select?.textContent).toContain(permission === "deny-all" ? "Deny all" : "Allow Paperclip reads");
|
|
await act(async () => select!.click());
|
|
const nextPermission = permission === "deny-all" ? "approve-reads" : "deny-all";
|
|
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(element => element.textContent === (nextPermission === "deny-all" ? "Deny all" : "Allow Paperclip reads"));
|
|
expect(option).toBeTruthy();
|
|
await act(async () => option!.click());
|
|
await flushReact();
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({
|
|
runner: "paperclip",
|
|
adapterConfig: sourceType === "paperclip_runner" ? { ...source, acpxPermissionMode: nextPermission } : { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: nextPermission },
|
|
});
|
|
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source);
|
|
});
|
|
|
|
it("keeps native Claude configuration at the server translation boundary when switching an import to Legacy", async () => {
|
|
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildPreviewResult();
|
|
const source = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high", acpxPermissionMode: "approve-all", timeoutSec: 180, lifecycleMode: "per_turn" };
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.runner = "paperclip";
|
|
preview.manifest.agents[0]!.adapterConfig = source;
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
await clickButton(text => text.includes("Advanced"));
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value="legacy"]')!.click());
|
|
await flushReact();
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({ adapterType: "paperclip_runner", runner: "legacy" });
|
|
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(source);
|
|
});
|
|
|
|
it.each([
|
|
{ dangerouslyBypassApprovalsAndSandbox: false },
|
|
{ dangerouslyBypassSandbox: false },
|
|
{ dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true },
|
|
])("keeps saved restrictive Legacy policy during an automatic import model edit (%j)", async policy => {
|
|
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "codex_local", disabled: false, supportedRunners: ["legacy"], defaultRunner: "legacy" },
|
|
]);
|
|
const preview = buildPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "codex_local";
|
|
preview.manifest.agents[0]!.adapterConfig = { model: "gpt-5.6-sol", ...policy };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
await clickButton(text => text.includes("Advanced"));
|
|
expect(container.querySelector('[aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")).toBe("false");
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Model"]')!.click());
|
|
const defaultModel = [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')].find(option => option.textContent?.trim() === "Default");
|
|
expect(defaultModel).toBeTruthy();
|
|
await act(async () => defaultModel!.click());
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toMatchObject({
|
|
adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false },
|
|
});
|
|
expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("model");
|
|
expect(lastImportMeta().adapterOverrides?.coder?.adapterConfig).not.toHaveProperty("codexLegacySandboxBypass");
|
|
});
|
|
|
|
it.each(["codex_local", "claude_local"].flatMap(harness => ["model-clear", "effort-clear", "env-remove", "legacy-model", "changed-harness"].map(edit => ({ harness, edit }))))("preserves native source configuration during an ordinary import edit ($harness, $edit)", async ({ harness, edit }) => {
|
|
mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "codex_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildPreviewResult();
|
|
const sourceConfig = {
|
|
...(harness === "codex_local" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all" }), modelReasoningEffort: "high", reasoningEffort: "low",
|
|
env: { CODEX_HOME: { type: "secret_ref", secretId: "saved-account", version: "latest" },
|
|
OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", version: "latest", required: true } },
|
|
timeoutSec: 120, graceSec: 30, cwd: "/saved/workspace", instructionsFilePath: "/saved/AGENTS.md",
|
|
workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "qa/{runId}", worktreeParentDir: "/saved/worktrees" },
|
|
workspaceRuntime: { services: [{ name: "app", command: "pnpm dev" }] },
|
|
lifecycleMode: "per_turn", idleTimeoutMs: 45000,
|
|
};
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.runner = "paperclip";
|
|
preview.manifest.agents[0]!.adapterConfig = sourceConfig;
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
if (edit === "changed-harness") {
|
|
const harness = findAdapterSelects()[0]!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(harness, sourceConfig.provider === "codex" ? "claude_local" : "codex_local");
|
|
harness.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
}
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
await clickButton(text => text.includes("Advanced"));
|
|
const expected: Record<string, unknown> = structuredClone(sourceConfig);
|
|
if (edit === "legacy-model") {
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Runner"]')!.click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value="legacy"]')!.click());
|
|
}
|
|
if (edit === "model-clear" || edit === "legacy-model" || edit === "changed-harness") {
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Model"]')!.click());
|
|
await act(async () => [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')].find(option => option.textContent?.trim().startsWith("Default"))!.click());
|
|
delete expected.model;
|
|
} else if (edit === "effort-clear") {
|
|
expect(container.querySelector('[aria-label="Thinking effort"]')?.textContent).toContain("High");
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Thinking effort"]')!.click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[role="option"][data-value=""]')!.click());
|
|
delete expected.modelReasoningEffort;
|
|
delete expected.reasoningEffort;
|
|
} else {
|
|
const remove = container.querySelector<HTMLButtonElement>('[aria-label="Remove CODEX_HOME"]');
|
|
expect(remove).toBeTruthy();
|
|
await act(async () => remove!.click());
|
|
await clickButton(text => text === "Save");
|
|
expected.env = { OPENAI_API_KEY: sourceConfig.env.OPENAI_API_KEY };
|
|
}
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
if (edit === "changed-harness") {
|
|
expect(lastImportMeta().adapterOverrides?.coder?.adapterType).toBe(harness === "codex_local" ? "claude_local" : "codex_local");
|
|
const config = lastImportMeta().adapterOverrides?.coder?.adapterConfig;
|
|
for (const field of ["provider", "env", "workspaceStrategy", "workspaceRuntime", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort"]) expect(config).not.toHaveProperty(field);
|
|
expect(config?.timeoutSec).toBe(0);
|
|
return;
|
|
}
|
|
expect(lastImportMeta().adapterOverrides?.coder).toEqual({
|
|
adapterType: "paperclip_runner", runner: edit === "legacy-model" ? "legacy" : "paperclip", adapterConfig: expected,
|
|
});
|
|
expect(preview.manifest.agents[0]!.adapterConfig).toEqual(sourceConfig);
|
|
});
|
|
|
|
it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => {
|
|
mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]);
|
|
const preview = buildPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.adapterConfig = { provider: "unknown-provider", customSetting: "keep" };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
await clickButton(text => text.includes("configure adapter"));
|
|
await clickButton(text => text.includes("Advanced"));
|
|
const input = container.querySelector<HTMLInputElement>('input[placeholder="e.g. --verbose, --foo=bar"]');
|
|
expect(input).toBeTruthy();
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
setter.call(input!, "--verbose");
|
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
await act(async () => {
|
|
input!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
});
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain("Unknown Paperclip Runner provider unknown-provider");
|
|
});
|
|
|
|
it("requires billing acknowledgement and serializes the standalone Dot import choice as Runner", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
await previewMixedAdapterPackage();
|
|
const select = findAdapterSelects()[0]!;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!;
|
|
setter.call(select, "openai_dot");
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await settle();
|
|
expect(container.textContent).toContain("Allow externally billed provider");
|
|
expect(container.textContent).not.toContain("Harness");
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain("OpenAI Dot requires acknowledgement of external billing");
|
|
|
|
const billingToggle = Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="switch"]'))
|
|
.find((button) => button.closest("label")?.textContent?.includes("Allow externally billed provider")
|
|
|| button.parentElement?.textContent?.includes("Allow externally billed provider"));
|
|
expect(billingToggle).toBeTruthy();
|
|
await act(async () => billingToggle!.click());
|
|
await flushReact();
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toEqual({
|
|
adapterType: "paperclip_runner",
|
|
adapterConfig: {
|
|
provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true,
|
|
dotAttachmentAccess: false, dotWorkspaceAccess: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not expose Dot when only the general Runner option is enabled", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
|
|
mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]);
|
|
await previewMixedAdapterPackage();
|
|
for (const select of findAdapterSelects()) {
|
|
const choices = Array.from(select.options).map((option) => option.value);
|
|
expect(choices).toContain("paperclip_runner");
|
|
expect(choices).not.toContain("openai_dot");
|
|
}
|
|
});
|
|
|
|
it("falls back to the CEO adapter with a visible warning when a manifest adapter is not installed", async () => {
|
|
// The destination has no codex_local adapter; the CEO fallback (an empty
|
|
// agent list defaults to claude_local) takes over — never silently.
|
|
mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", disabled: false }]);
|
|
await previewMixedAdapterPackage();
|
|
|
|
expect(container.textContent).toContain("source adapter codex_local is not installed here");
|
|
expect(container.textContent).toContain("will use Claude Code");
|
|
expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "claude_local"]);
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
// Only the unavailable agent is overridden; the claude_local agent still
|
|
// carries no override and keeps its manifest adapter.
|
|
expect(lastImportMeta().adapterOverrides).toEqual({
|
|
researcher: { adapterType: "claude_local" },
|
|
});
|
|
});
|
|
|
|
it("picks an installed adapter as the fallback when the CEO adapter is itself unavailable", async () => {
|
|
// Neither codex_local nor the CEO fallback (claude_local) is installed;
|
|
// the fallback must be an adapter that actually exists on the
|
|
// destination, never an unavailable type the server would reject.
|
|
mockAdaptersApi.list.mockResolvedValue([{ type: "gemini_local", disabled: false }]);
|
|
await previewMixedAdapterPackage();
|
|
|
|
expect(container.textContent).toContain("source adapter codex_local is not installed here");
|
|
expect(findAdapterSelects().map((select) => select.value)).toEqual(["gemini_local", "gemini_local"]);
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(lastImportMeta().adapterOverrides).toEqual({
|
|
researcher: { adapterType: "gemini_local" },
|
|
coder: { adapterType: "gemini_local" },
|
|
});
|
|
});
|
|
|
|
it("fails open to manifest adapters when the adapter list cannot be read", async () => {
|
|
// Unknown availability must never coerce: with no adapter list, every
|
|
// agent keeps its manifest adapter and no fallback warning renders.
|
|
mockAdaptersApi.list.mockRejectedValue(new ApiError("adapters unavailable", 500, null));
|
|
await previewMixedAdapterPackage();
|
|
|
|
expect(container.textContent).not.toContain("is not installed here");
|
|
expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]);
|
|
|
|
await clickButton((text) => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
|
|
expect(lastImportMeta().adapterOverrides).toBeUndefined();
|
|
});
|
|
});
|