import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; const root = process.cwd(); const bash = process.platform === "win32" ? ((process.env.PATH ?? "") .split(delimiter) .flatMap((dir) => [ join(dir, "bash.exe"), join(dirname(dir), "bin", "bash.exe"), join(dirname(dirname(dir)), "bin", "bash.exe"), ]) .find((path) => existsSync(path)) ?? "bash") : "bash"; const sha = "a".repeat(40); function simulate(scenario: string, previous = "epicnext-cms-app") { const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-")); try { mkdirSync(join(dir, "production")); writeFileSync(join(dir, "production", ".env"), 'HOTEL_NAME="Test Hotel"\n'); if (previous) writeFileSync(join(dir, previous), "old\n"); const result = spawnSync(bash, [resolve(root, "scripts/ci-deploy.sh")], { cwd: dir, encoding: "utf8", timeout: 25000, env: { ...process.env, BASH_ENV: resolve(root, "src/test/ci-deploy-harness.sh"), TEST_DIR: dir.replaceAll("\\", "/"), CMS_DEPLOY_DIR: join(dir, "production").replaceAll("\\", "/"), TEST_SHA: sha, SCENARIO: scenario, DEPLOY_BRANCH: "main", }, }); if (result.error) throw result.error; return { status: result.status, output: result.stdout + result.stderr, calls: existsSync(join(dir, "calls")) ? readFileSync(join(dir, "calls"), "utf8") : "", app: existsSync(join(dir, "epicnext-cms-app")) ? readFileSync(join(dir, "epicnext-cms-app"), "utf8").trim() : null, previousRestored: previous ? existsSync(join(dir, previous)) : false, backup: existsSync(join(dir, "epicnext-cms-rollback")), }; } finally { rmSync(dir, { recursive: true, force: true }); } } describe("deployment transaction", () => { it("publishes the commit image only after migrations, health and smoke tests", () => { const result = simulate("success"); expect(result.status, result.output).toBe(0); expect(result.app).toBe("new"); expect(result.backup).toBe(false); expect(result.calls.indexOf("pnpm db:migrate")).toBeLessThan( result.calls.indexOf("docker stop"), ); expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan( result.calls.indexOf( `docker tag epicnext-cms:${sha} epicnext-cms:latest`, ), ); expect(result.calls).toContain( "docker tag sha256:old epicnext-cms:previous", ); }); it.each([ "run-failure", "health-failure", "smoke-failure", "release-failure", ])( "restores the exact previous container after %s", (scenario) => { const result = simulate(scenario); expect(result.status).not.toBe(0); expect(result.app).toBe("old"); expect(result.output).toContain("Rollback verified: sha256:old"); expect(result.calls).not.toContain( `docker tag epicnext-cms:${sha} epicnext-cms:latest`, ); }, 30000, ); it("restores the legacy compose container on failure", () => { const result = simulate("smoke-failure", "epicnext-cms"); expect(result.status).not.toBe(0); expect(result.app).toBeNull(); expect(result.previousRestored).toBe(true); expect(result.calls).toContain("docker start epicnext-cms"); }); it.each([ "lock-failure", "remote-failure", "build-failure", "migration-failure", ])("leaves the active container untouched after %s", (scenario) => { const result = simulate(scenario); expect(result.status).not.toBe(0); expect(result.app).toBe("old"); expect(result.calls).not.toContain("docker stop"); }); it.each(["stale", "superseded"])( "skips a %s commit without touching production", (scenario) => { const result = simulate(scenario); expect(result.status, result.output).toBe(0); expect(result.app).toBe("old"); expect(result.calls).not.toContain("pnpm db:migrate"); expect(result.calls).not.toContain("docker stop"); }, ); it("reports a failed first deployment without inventing a rollback", () => { const result = simulate("smoke-failure", ""); expect(result.status).not.toBe(0); expect(result.output).toContain("No previous container exists"); expect(result.app).toBeNull(); }); });