// @ts-nocheck 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", both = false, ) { const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-")); try { mkdirSync(join(dir, "production")); // Een werkende deploymap heeft een DATABASE_URL; die gebruikt // scripts/ci-deploy.sh voor de migraties op de host. writeFileSync( join(dir, "production", ".env"), 'HOTEL_NAME="Test Hotel"\nDATABASE_URL="mysql://test:test@127.0.0.1:3306/test"\n', ); if (previous) writeFileSync(join(dir, previous), "old\n"); if (both) writeFileSync(join(dir, "epicnext-cms-app"), "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")), secondaryBackup: existsSync(join(dir, "epicnext-cms-rollback-secondary")), }; } 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.calls).toContain("node --import tsx e2e/news-real/run.ts"); expect(result.calls.indexOf("e2e/news-real/run.ts")).toBeLessThan( result.calls.indexOf("pnpm db:migrate"), ); expect(result.backup).toBe(false); expect(result.calls.indexOf("pnpm db:migrate")).toBeLessThan( result.calls.indexOf("docker stop"), ); expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan( result.calls.indexOf("docker tag sha256:new epicnext-cms:latest"), ); expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan( result.calls.indexOf( `docker tag sha256:new epicnext-cms:verified-${sha}`, ), ); expect(result.calls).toContain( `docker image rm epicnext-cms:verified-${"c".repeat(40)}`, ); expect(result.calls).not.toContain( `docker image rm epicnext-cms:verified-${"b".repeat(40)}`, ); expect(result.calls).not.toContain( `docker image rm epicnext-cms:verified-${sha}`, ); 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.calls).not.toContain( `docker tag sha256:new epicnext-cms:verified-${sha}`, ); expect(result.output).toContain("Rollback verified: sha256:old"); expect(result.calls).not.toContain( "docker tag sha256:new 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", "news-e2e-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(); }); }); describe("legacy and CI container coexistence", () => { it("stops and preserves both before starting the candidate, then removes backups only after verification", () => { const r = simulate("success", "epicnext-cms", true); expect(r.status, r.output).toBe(0); expect(r.calls).toContain("docker stop epicnext-cms-app"); expect(r.calls).toContain("docker stop epicnext-cms\n"); expect(r.calls.indexOf("docker stop epicnext-cms\n")).toBeLessThan( r.calls.indexOf("docker run"), ); expect(r.calls.indexOf("docker stop epicnext-cms-app")).toBeLessThan( r.calls.indexOf("docker run"), ); expect(r.backup).toBe(false); expect(r.secondaryBackup).toBe(false); expect(r.app).toBe("new"); }); it("restores both original containers when candidate release validation fails", () => { const r = simulate("release-failure", "epicnext-cms", true); expect(r.status, r.output).not.toBe(0); expect(r.previousRestored).toBe(true); expect(r.app).toBe("old"); expect(r.secondaryBackup).toBe(false); expect(r.calls).toContain("docker start epicnext-cms"); }); });