diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 27334105..c543249e 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -6,33 +6,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { createStoredZipArchive } from "./helpers/zip.js"; -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(); @@ -53,30 +35,13 @@ async function getAvailablePort(): Promise { }); } -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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - 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 }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { @@ -265,26 +230,23 @@ async function waitForServer( ); } -describe("paperclipai company import/export e2e", () => { +describeEmbeddedPostgres("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; + let tempDb: Awaited> | 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; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); const port = await getAvailablePort(); - writeTestConfig(configPath, tempRoot, port, db.connectionString); + writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => { ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, - env: createServerEnv(configPath, port, db.connectionString), + env: createServerEnv(configPath, port, tempDb.connectionString), stdio: ["ignore", "pipe", "pipe"], }, ); @@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => { afterAll(async () => { await stopServerProcess(serverProcess); - await dbInstance?.stop(); - if (dbDataDir) { - rmSync(dbDataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/cli/src/__tests__/helpers/embedded-postgres.ts b/cli/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..8249d98b --- /dev/null +++ b/cli/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "@paperclipai/db"; + +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; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +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); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + 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, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 752fce15..622130ac 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1,83 +1,24 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import postgres from "postgres"; import { applyPendingMigrations, - ensurePostgresDatabase, inspectMigrations, } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; -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 tempPaths: string[] = []; -const runningInstances: EmbeddedPostgresInstance[] = []; - -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); - }); - }); - }); -} +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; async function createTempDatabase(): Promise { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-")); - tempPaths.push(dataDir); - 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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - runningInstances.push(instance); - - const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminUrl, "paperclip"); - return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-"); + cleanups.push(db.cleanup); + return db.connectionString; } async function migrationHash(migrationFile: string): Promise { @@ -89,19 +30,19 @@ async function migrationHash(migrationFile: string): Promise { } afterEach(async () => { - while (runningInstances.length > 0) { - const instance = runningInstances.pop(); - if (!instance) continue; - await instance.stop(); - } - while (tempPaths.length > 0) { - const tempPath = tempPaths.pop(); - if (!tempPath) continue; - fs.rmSync(tempPath, { recursive: true, force: true }); + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); } }); -describe("applyPendingMigrations", () => { +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("applyPendingMigrations", () => { it( "applies an inserted earlier migration without replaying later legacy migrations", async () => { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5c32ab13..b5ccb5d4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,12 @@ export { type MigrationBootstrapResult, type Db, } from "./client.js"; +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "./test-embedded-postgres.js"; export { runDatabaseBackup, runDatabaseRestore, diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts new file mode 100644 index 00000000..1a959ed5 --- /dev/null +++ b/packages/db/src/test-embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js"; + +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; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +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); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + 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, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d0e3cc31..6b18d162 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,89 +1,29 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { - applyPendingMigrations, - createDb, - ensurePostgresDatabase, agents, agentWakeupRequests, companies, + createDb, heartbeatRunEvents, heartbeatRuns, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { runningProcesses } from "../adapters/index.ts"; import { heartbeatService } from "../services/heartbeat.ts"; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -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; - -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 = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-")); - 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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - 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, instance, dataDir }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function spawnAliveProcess() { @@ -92,17 +32,14 @@ function spawnAliveProcess() { }); } -describe("heartbeat orphaned process recovery", () => { +describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; const childProcesses = new Set(); beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => { } childProcesses.clear(); runningProcesses.clear(); - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedRunFixture(input?: { diff --git a/server/src/__tests__/helpers/embedded-postgres.ts b/server/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..8249d98b --- /dev/null +++ b/server/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "@paperclipai/db"; + +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; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +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); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + 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", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + 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, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ba27866f..1d20293b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,99 +1,37 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, createDb, - ensurePostgresDatabase, issueComments, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -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; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -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 = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); - 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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - 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 }; -} - -describe("issueService.list participantAgentId", () => { +describeEmbeddedPostgres("issueService.list participantAgentId", () => { let db!: ReturnType; let svc!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-"); + db = createDb(tempDb.connectionString); svc = issueService(db); - instance = started.instance; - dataDir = started.dataDir; }, 20_000); afterEach(async () => { @@ -105,10 +43,7 @@ describe("issueService.list participantAgentId", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); it("returns issues an agent participated in across the supported signals", async () => { diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 83689724..ab5fd778 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -1,8 +1,4 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; @@ -11,11 +7,9 @@ import { activityLog, agentWakeupRequests, agents, - applyPendingMigrations, companies, companyMemberships, createDb, - ensurePostgresDatabase, heartbeatRunEvents, heartbeatRuns, instanceSettings, @@ -26,6 +20,10 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; @@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => { }; }); -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -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; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -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 = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-")); - 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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - 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 }; -} - -describe("routine routes end-to-end", () => { +describeEmbeddedPostgres("routine routes end-to-end", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function createApp(actor: Record) { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d5954246..d6aad0f2 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1,19 +1,13 @@ import { createHmac, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, companySecrets, companySecretVersions, createDb, - ensurePostgresDatabase, heartbeatRuns, issues, projects, @@ -21,85 +15,29 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; import { routineService } from "../services/routines.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -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; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -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 = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-")); - 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", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - 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 }; -} - -describe("routine service live-execution coalescing", () => { +describeEmbeddedPostgres("routine service live-execution coalescing", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedFixture(opts?: {