import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupStaleEmbeddedPostgresTestRegistrations, getAvailablePort, removeDataDirWithRetries, shutdownManagedEmbeddedPostgres, startManagedEmbeddedPostgres, } from "./embedded-postgres-manager.js"; import { getEmbeddedPostgresTestSupport } from "./test-embedded-postgres.js"; const registryRecordsDir = path.resolve(os.tmpdir(), "paperclip-embedded-postgres-test-registry", "records"); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres manager live tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } afterEach(async () => { await cleanupStaleEmbeddedPostgresTestRegistrations().catch(() => undefined); }); describe("removeDataDirWithRetries", () => { it("retries retryable directory removal failures and stays idempotent", async () => { const rm = vi.fn() .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EPERM" })) .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "ENOTEMPTY" })) .mockResolvedValue(undefined); const sleep = vi.fn(async () => undefined); await removeDataDirWithRetries("C:/tmp/paperclip-test-db", { rm, sleep }); await removeDataDirWithRetries("C:/tmp/paperclip-test-db", { rm: vi.fn().mockResolvedValue(undefined), sleep, }); expect(rm).toHaveBeenCalledTimes(3); expect(sleep).toHaveBeenCalledTimes(2); }); }); describe("cleanupStaleEmbeddedPostgresTestRegistrations", () => { it("only cleans stale registry entries whose owner process is gone", async () => { const staleRecord = { createdAt: "2026-04-05T10:00:00.000Z", dataDir: "C:/tmp/paperclip-stale-db", id: "stale-record", ownerPid: 111, pgCtlPath: null, port: 54321, postmasterPid: 333, postgresPath: "C:/tmp/postgres.exe", version: 1 as const, }; const liveRecord = { ...staleRecord, dataDir: "C:/tmp/paperclip-live-db", id: "live-record", ownerPid: 222, postmasterPid: 444, }; const removedFiles: string[] = []; const result = await cleanupStaleEmbeddedPostgresTestRegistrations({ execFile: async () => ({ exitCode: 0, stderr: "", stdout: "" }), forceKillProcessTree: async () => undefined, isPidAlive: (pid) => pid === liveRecord.ownerPid, listPostgresProcesses: async () => [], readDir: async () => ["stale-record.json", "live-record.json"], readJson: async (filePath: string) => { if (filePath.endsWith("stale-record.json")) return staleRecord as T; if (filePath.endsWith("live-record.json")) return liveRecord as T; return null; }, removeFile: async (filePath) => { removedFiles.push(path.basename(filePath)); }, removePostmasterPid: () => undefined, rm: async () => undefined, sleep: async () => undefined, writeJson: async () => undefined, }); expect(result.cleaned).toEqual(["stale-record"]); expect(result.skipped).toEqual(["live-record"]); expect(removedFiles).toEqual(["stale-record.json"]); }); }); describe("shutdownManagedEmbeddedPostgres", () => { it("falls back to process-tree cleanup when pg_ctl stop fails", async () => { const alivePids = new Set([401, 402]); const killedPids: number[] = []; const dataDir = "C:/tmp/paperclip-fallback-db"; const listPostgresProcesses = vi.fn(async () => Array.from(alivePids).map((pid) => ({ commandLine: `postgres.exe -D ${dataDir}`, parentPid: null, pid, })), ); await shutdownManagedEmbeddedPostgres( { dataDir, pgCtlPath: "C:/tmp/pg_ctl.exe", postmasterPid: 401, }, { execFile: async () => { throw new Error("pg_ctl failed"); }, forceKillProcessTree: async (pid) => { killedPids.push(pid); alivePids.delete(pid); }, isPidAlive: (pid) => alivePids.has(pid), listPostgresProcesses, removePostmasterPid: vi.fn(), }, ); expect(killedPids).toContain(401); expect(listPostgresProcesses).toHaveBeenCalled(); }); }); describeEmbeddedPostgres("startManagedEmbeddedPostgres", () => { it("starts and stops a managed test cluster while cleaning its registry entry", async () => { const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-live-")); const preferredPort = await getAvailablePort(); const handle = await startManagedEmbeddedPostgres({ dataDir, preferredPort, registerTestInstance: true, }); try { const entriesBefore = await fs.readdir(registryRecordsDir).catch(() => []); const matchingBefore = await Promise.all( entriesBefore .filter((entry) => entry.endsWith(".json")) .map(async (entry) => { const filePath = path.resolve(registryRecordsDir, entry); const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as { dataDir?: string }; return raw.dataDir === dataDir ? entry : null; }), ); expect(matchingBefore.filter(Boolean)).toHaveLength(1); await handle.stop(); await removeDataDirWithRetries(dataDir); const entriesAfter = await fs.readdir(registryRecordsDir).catch(() => []); const matchingAfter = await Promise.all( entriesAfter .filter((entry) => entry.endsWith(".json")) .map(async (entry) => { const filePath = path.resolve(registryRecordsDir, entry); const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as { dataDir?: string }; return raw.dataDir === dataDir ? entry : null; }), ); expect(matchingAfter.filter(Boolean)).toHaveLength(0); } finally { await handle.stop().catch(() => undefined); await removeDataDirWithRetries(dataDir).catch(() => undefined); } }, process.platform === "win32" ? 60_000 : 20_000); });