diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts new file mode 100644 index 00000000..094f846b --- /dev/null +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -0,0 +1,501 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +const execFileAsync = promisify(execFile); +type ServerProcess = ReturnType; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db"); + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { connectionString, dataDir, instance }; +} + +function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { + const config = { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "doctor", + }, + database: { + mode: "postgres", + connectionString, + embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"), + embeddedPostgresPort: 54329, + backup: { + enabled: false, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(tempRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(tempRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port, + allowedHostnames: [], + serveUi: false, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(tempRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(tempRoot, "secrets", "master.key"), + }, + }, + }; + + mkdirSync(path.dirname(configPath), { recursive: true }); + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function createServerEnv(configPath: string, port: number, connectionString: string) { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("PAPERCLIP_")) { + delete env[key]; + } + } + delete env.DATABASE_URL; + delete env.PORT; + delete env.HOST; + delete env.SERVE_UI; + delete env.HEARTBEAT_SCHEDULER_ENABLED; + + env.PAPERCLIP_CONFIG = configPath; + env.DATABASE_URL = connectionString; + env.HOST = "127.0.0.1"; + env.PORT = String(port); + env.SERVE_UI = "false"; + env.PAPERCLIP_DB_BACKUP_ENABLED = "false"; + env.HEARTBEAT_SCHEDULER_ENABLED = "false"; + env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true"; + env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false"; + + return env; +} + +function createCliEnv() { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("PAPERCLIP_")) { + delete env[key]; + } + } + delete env.DATABASE_URL; + delete env.PORT; + delete env.HOST; + delete env.SERVE_UI; + delete env.PAPERCLIP_DB_BACKUP_ENABLED; + delete env.HEARTBEAT_SCHEDULER_ENABLED; + delete env.PAPERCLIP_MIGRATION_AUTO_APPLY; + delete env.PAPERCLIP_UI_DEV_MIDDLEWARE; + return env; +} + +async function stopServerProcess(child: ServerProcess | null) { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("exit", () => resolve()); + setTimeout(() => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); + }); +} + +async function api(baseUrl: string, pathname: string, init?: RequestInit): Promise { + const res = await fetch(`${baseUrl}${pathname}`, init); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Request failed ${res.status} ${pathname}: ${text}`); + } + return text ? JSON.parse(text) as T : (null as T); +} + +async function runCliJson(args: string[], opts: { apiBase: string; configPath: string }) { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const result = await execFileAsync( + "pnpm", + ["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"], + { + cwd: repoRoot, + env: createCliEnv(), + maxBuffer: 10 * 1024 * 1024, + }, + ); + const stdout = result.stdout.trim(); + const jsonStart = stdout.search(/[\[{]/); + if (jsonStart === -1) { + throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + return JSON.parse(stdout.slice(jsonStart)) as T; +} + +async function waitForServer( + apiBase: string, + child: ServerProcess, + output: { stdout: string[]; stderr: string[] }, +) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 30_000) { + if (child.exitCode !== null) { + throw new Error( + `paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`, + ); + } + + try { + const res = await fetch(`${apiBase}/api/health`); + if (res.ok) return; + } catch { + // Server is still starting. + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`, + ); +} + +describe("paperclipai company import/export e2e", () => { + let tempRoot = ""; + let configPath = ""; + let exportDir = ""; + let apiBase = ""; + let serverProcess: ServerProcess | null = null; + let dbDataDir = ""; + let dbInstance: EmbeddedPostgresInstance | null = null; + + beforeAll(async () => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); + configPath = path.join(tempRoot, "config", "config.json"); + exportDir = path.join(tempRoot, "exported-company"); + + const db = await startTempDatabase(); + dbDataDir = db.dataDir; + dbInstance = db.instance; + + const port = await getAvailablePort(); + writeTestConfig(configPath, tempRoot, port, db.connectionString); + apiBase = `http://127.0.0.1:${port}`; + + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + const output = { stdout: [] as string[], stderr: [] as string[] }; + const child = spawn( + "pnpm", + ["paperclipai", "run", "--config", configPath], + { + cwd: repoRoot, + env: createServerEnv(configPath, port, db.connectionString), + stdio: ["ignore", "pipe", "pipe"], + }, + ); + serverProcess = child; + child.stdout?.on("data", (chunk) => { + output.stdout.push(String(chunk)); + }); + child.stderr?.on("data", (chunk) => { + output.stderr.push(String(chunk)); + }); + + await waitForServer(apiBase, child, output); + }, 60_000); + + afterAll(async () => { + await stopServerProcess(serverProcess); + await dbInstance?.stop(); + if (dbDataDir) { + rmSync(dbDataDir, { recursive: true, force: true }); + } + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("exports a company package and imports it into new and existing companies", async () => { + expect(serverProcess).not.toBeNull(); + + const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }), + }); + + const sourceAgent = await api<{ id: string; name: string }>( + apiBase, + `/api/companies/${sourceCompany.id}/agents`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Export Engineer", + role: "engineer", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You verify company portability.", + }, + }), + }, + ); + + const sourceProject = await api<{ id: string; name: string }>( + apiBase, + `/api/companies/${sourceCompany.id}/projects`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Portability Verification", + status: "in_progress", + }), + }, + ); + + const sourceIssue = await api<{ id: string; title: string; identifier: string }>( + apiBase, + `/api/companies/${sourceCompany.id}/issues`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Validate company import/export", + description: "Round-trip the company package through the CLI.", + status: "todo", + projectId: sourceProject.id, + assigneeAgentId: sourceAgent.id, + }), + }, + ); + + const exportResult = await runCliJson<{ + ok: boolean; + out: string; + filesWritten: number; + }>( + [ + "company", + "export", + sourceCompany.id, + "--out", + exportDir, + "--include", + "company,agents,projects,issues", + ], + { apiBase, configPath }, + ); + + expect(exportResult.ok).toBe(true); + expect(exportResult.filesWritten).toBeGreaterThan(0); + expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name); + expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"'); + + const importedNew = await runCliJson<{ + company: { id: string; name: string; action: string }; + agents: Array<{ id: string | null; action: string; name: string }>; + }>( + [ + "company", + "import", + "--from", + exportDir, + "--target", + "new", + "--new-company-name", + `Imported ${sourceCompany.name}`, + "--include", + "company,agents,projects,issues", + ], + { apiBase, configPath }, + ); + + expect(importedNew.company.action).toBe("created"); + expect(importedNew.agents).toHaveLength(1); + expect(importedNew.agents[0]?.action).toBe("created"); + + const importedAgents = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/agents`, + ); + const importedProjects = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/projects`, + ); + const importedIssues = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/issues`, + ); + + expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name); + expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name); + expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title); + + const previewExisting = await runCliJson<{ + errors: string[]; + plan: { + companyAction: string; + agentPlans: Array<{ action: string }>; + projectPlans: Array<{ action: string }>; + issuePlans: Array<{ action: string }>; + }; + }>( + [ + "company", + "import", + "--from", + exportDir, + "--target", + "existing", + "--company-id", + importedNew.company.id, + "--include", + "company,agents,projects,issues", + "--collision", + "rename", + "--dry-run", + ], + { apiBase, configPath }, + ); + + expect(previewExisting.errors).toEqual([]); + expect(previewExisting.plan.companyAction).toBe("update"); + expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true); + expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true); + expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true); + + const importedExisting = await runCliJson<{ + company: { id: string; action: string }; + agents: Array<{ id: string | null; action: string; name: string }>; + }>( + [ + "company", + "import", + "--from", + exportDir, + "--target", + "existing", + "--company-id", + importedNew.company.id, + "--include", + "company,agents,projects,issues", + "--collision", + "rename", + ], + { apiBase, configPath }, + ); + + expect(importedExisting.company.action).toBe("updated"); + expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); + + const twiceImportedAgents = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/agents`, + ); + const twiceImportedProjects = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/projects`, + ); + const twiceImportedIssues = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/issues`, + ); + + expect(twiceImportedAgents).toHaveLength(2); + expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2); + expect(twiceImportedProjects).toHaveLength(2); + expect(twiceImportedIssues).toHaveLength(2); + }, 60_000); +});