mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Users choose a harness during agent setup. > - Claude, Codex, and OpenCode already receive their qualified Runner default. > - Pi has a qualified native integration but new Pi agents still use the direct adapter. > - This change gives new Pi agents the native default on supported targets. > - Saved agents keep their runner and compatible settings. Legacy remains available in Advanced. ## Linked Issues or Issue Description Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic default work: #15422. This PR is the separate Pi follow-up to the merged OpenCode change. **Current behavior** Choosing Pi for a new agent saves the direct Pi adapter. The native Pi integration remains behind the generic experimental flag. **Proposed behavior** Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64 with supported glibc. Keep unsupported targets and explicit Legacy selections on the direct adapter. Require an explicit provider/model before test or hire. Permit model-free inactive setup drafts. **Reason and benefit** Users choose Pi once and receive its qualified runner. Saved agents retain their identity and settings. Conversions reject unsupported legacy thinking and launch settings instead of dropping them. ## What Changed - Add Pi to the shared platform/default contract and server creation selection. - Admit native Pi without the generic experimental flag. - Preserve provider models, credentials, workspace settings, exact thinking, and saved native policies. - Add Pi runner controls, branding, setup resume, onboarding, built-in configuration, and import handling. - Add managed Pi OpenRouter routing and matching native provider API-key accounts. Keep subscription and custom gateway exclusions. Clear alternate credentials and reject custom Pi provider overrides. - Keep Pi draft models empty instead of applying Claude's default model. - Verify setup against the pinned Pi runtime. For native Pi on the official OpenRouter route, reverify the API key live without requiring a global Legacy Pi CLI. - Match Pi’s bounded controller close window to its native snapshot cleanup and lifetime verification, preserving explicit caller deadlines and the mandatory drain/suspension proof. - Bind Pi sidecars to the package that owns the pinned native assets, including source and vendored server layouts. Other providers retain their existing package authority. - Keep protocol-owned finish/block tools out of the ACPX forwarded catalog. The trusted bridge installs these tools itself; the complete Rust authorization catalog and terminal receipt ledger remain intact. This fixes actual Pi session startup and applies to other ACPX consumers. - Advance Copilot’s shared-source attestation to v58 because it pins the shared files touched by Pi. Preserve immutable v57 bytes and historical decoding; Pi’s own release identity is unchanged. - Add regression coverage and update the paired maps and developer documentation. ## Verification - Passed: full repository typecheck and full production build on final head656d304783, including the generated attestation repair. - Passed: focused shared/server/UI suites covering default selection, platform fallback, conversion preservation, inactive drafts, managed account compatibility, credential isolation, OpenRouter routing/model IDs, exact thinking controls, and saved-account selection. Relevant batches include 375 server/conversion tests, 267 account/routing tests, and 110 UI setup/account tests. - Passed: 179 attestation, historical-profile, qualified-profile, and native-execution contract tests after CI identified stale Copilot source hashes. The identity generator check passes; the only delta after the passing Pi browser run is this Copilot attestation/history repair. - Passed: 145 managed-account tests, including actual service preparation and API-key adoption/rejection for native Pi without the Legacy CLI; 6 pinned-installation probe tests. - Red then green: an actual task failed session.open because its 54-operation catalog included the bridge-owned terminal tools. The regression with those tools failed before the filter; all 399 Rust library tests pass after it. The full product retry exposed an additional package-authority mismatch. Four authority tests failed before that repair; 11 authority checks and 14 installation/asset checks pass afterward. Direct real Pi session initialization with the corrected package root and catalog succeeds. No model prompt was sent by that diagnostic. - Red then green: real Pi posted Hello from Pi but its controller closed before native cleanup could finish. The cleanup-budget test failed with the old default and passes with the bounded Pi window. All 46 selected suspension, drain, and package-authority tests pass after building their native fixture. - Passed: token gates, paired map checks, Rust formatting, and git diff --check. - Passed real browser acceptance on macOS ARM64, generic native runner flag disabled, sourcea433748a12(Pi code unchanged afterward): the UI selects Paperclip Runner by default, retains Pi and the selected model/account, and verifies the pinned runtime plus live OpenRouter key. A fresh UI-created task, assigned to the UI-created native Pi agent, posted exactly `Hello from Pi` and reached Done without retry or restart. The native run and workspace finalization succeeded; the response and Done persisted after Reload. - Evidence: 8 preserved browser attempts, including the final passing 18-step journey with all screenshots. Prior failures remain recorded. The earlier failed checkpoint run recovered on server restart; that recovery is separate from the fresh final task’s clean success. - Preserved browser failures: missing account control/inherited Claude draft model; incompatible managed-account/model-prefix handling; generic npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and wrong provider-package authority at actual native session.open; a real Hello from Pi comment followed by premature checkpoint cleanup timeout. Each failure has retained screenshots/logs and a corresponding focused repair. The fresh final task proves these repairs end to end. - The exact Runner Vitest shard was also run locally after the attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10 skipped, and two Codex retained-control settlement scenarios failed with `native_cleanup_maintenance_unproven` during terminal ACK/suspension replay. Both scenarios passed in an isolated rerun (2/2), and the same 19-test file passed in the preceding Linux CI run. The precise full-shard timing cause is not established. These Codex scenarios are outside the changed Pi paths; the original local failures are retained; no complete local Runner-shard pass is claimed. The corresponding Runner shard now passes on the final head in hosted Linux CI. All 60 final-head check runs are now terminal and clean (55 successful, 1 neutral, 4 skipped), and the legacy Snyk status is successful. - Local full repository tests were attempted but not completed: provisioning briefly confused a workspace symlink, then a later run was stopped before browser-driven repairs. No full local-suite pass is claimed. Final-head hosted CI is green and fresh Greptile review is 5/5 with all review threads resolved; earlier jobs canceled by runner shutdown are not counted as a pass. ## Risks - Pi cleanup has a finite 45-second default window, accounting for the native 30-second snapshot cleanup and 5-second lifetime fence. Other providers and explicit caller deadlines retain their prior behavior. - Pi requires explicit operator setup with `paperclipai runtime setup pi` on the execution host. This PR does not add automatic downloads. - Linux ARM64, Windows, musl, and unqualified targets retain Legacy. - Legacy thinking levels minimal, medium, and xhigh cannot be translated exactly. Native max thinking, warm lifecycle, or restricted permissions cannot be dropped during a return to Legacy. - Copilot’s bound profile advances to v58 with exact current launch admission; v57 remains decodable for recovery. No historical identity is rewritten. - Existing agents and immutable run descriptors are not migrated. Cloud Pi availability is unchanged. ## Model Used OpenAI GPT-6 through Codex with reasoning, code editing, command execution, and browser use. 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 - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1604 lines
76 KiB
TypeScript
1604 lines
76 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: "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("keeps native OpenCode identity and saved policies with the experimental flag off", async () => {
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "opencode_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildMixedAdapterPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.adapterConfig = { provider: "opencode", model: "provider/model", opencodePermissionMode: "ask", lifecycleMode: "persistent" };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
expect(findAdapterSelects()[0]!.value).toBe("opencode_local");
|
|
expect(container.textContent).not.toContain("requires its experimental feature to be enabled");
|
|
await clickButton(text => text.startsWith("Import 3 file"));
|
|
await settle();
|
|
expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined();
|
|
});
|
|
|
|
it("keeps native Pi identity and saved policies with the experimental flag off", async () => {
|
|
mockAdaptersApi.list.mockResolvedValue([
|
|
{ type: "claude_local", disabled: false },
|
|
{ type: "pi_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" },
|
|
{ type: "paperclip_runner", disabled: false },
|
|
]);
|
|
const preview = buildMixedAdapterPreviewResult();
|
|
preview.manifest.agents[0]!.adapterType = "paperclip_runner";
|
|
preview.manifest.agents[0]!.adapterConfig = { provider: "acpx", acpxAgent: "pi", model: "provider/model", piThinkingLevel: "max", acpxPermissionMode: "approve-reads", lifecycleMode: "warm" };
|
|
mockCompaniesApi.importPreview.mockResolvedValue(preview);
|
|
await renderPage();
|
|
await enterGithubUrl();
|
|
await clickButton(text => text === "Preview import");
|
|
expect(findAdapterSelects()[0]!.value).toBe("pi_local");
|
|
expect(container.textContent).not.toContain("requires its experimental feature to be enabled");
|
|
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();
|
|
});
|
|
});
|