/* Unit tests for deploy execution purge covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1092: initial tests for purgeCapable() adapter interface and pipeline behavior. REVIEW-CODE-2026-10-01-01: stub bootSmokeCapable() on makeAdapter (required DeploymentAdapter method). */ import { afterEach, beforeEach, test, expect, vi } from "vitest"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { executeDeployPhases } from "../deploy-execution.ts"; import type { DeployExecutionContext } from "../deploy-execution.ts"; import type { DeploymentAdapter, PropagateInput, RollbackInput, RollbackResult, HealthInput, } from "../adapter.ts"; import type { PropagationResult, HealthCheck } from "@warpgogol/werkstatt-engine/schemas"; import type { AuthorizeResult } from "../deploy-helpers.ts"; import type { DeploymentStaticConfig } from "@warpgogol/werkstatt-engine/schemas"; import type { Sha256Digest } from "@warpgogol/werkstatt-shared/fingerprint"; import { createCloudflareWorkersAdapter } from "../adapters/cloudflare-workers.ts"; import { createGitHubPagesAdapter } from "../adapters/github-pages.ts"; vi.mock("../leitstand-commands.ts", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, verifyFreshness: vi.fn().mockResolvedValue({ verified: false, cdnDistTreeHash: null, }), }; }); // RFC-1138: executeDeployPhases runs dns.record.upsert via executeKernelCommand // before propagate/purge — without a mock it fails in the temp workspace and // short-circuits the result (failingPhase: "dns-upsert", purgeResult undefined). vi.mock("@warpgogol/werkstatt-engine/kernel", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, executeKernelCommand: vi.fn().mockResolvedValue({ exitCode: 0, summary: "ok" }), }; }); function makeAdapter(purgeCapable: boolean): DeploymentAdapter { return { name: purgeCapable ? "cloudflare-workers" : "github-pages", async propagate(input: PropagateInput): Promise { return { systemId: input.systemId, releaseId: input.releaseId, state: "succeeded", deploymentUrl: input.url, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), healthChecks: [], }; }, async rollback(input: RollbackInput): Promise { return { systemId: input.systemId, channel: input.channel, state: "succeeded", workerName: input.workerName, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), stdout: "", stderr: "", }; }, async health( _input: HealthInput, ): Promise<{ state: "healthy" | "unhealthy" | "unknown"; checks: HealthCheck[] }> { return { state: "healthy", checks: [] }; }, getLimits() { return { maxTotalSize: 100 * 1024 * 1024, maxFileSize: 10 * 1024 * 1024 }; }, purgeCapable() { return purgeCapable; }, bootSmokeCapable() { return purgeCapable; }, }; } const systemConfig: DeploymentStaticConfig = { adapter: "cloudflare-workers", channels: { dev: { workerName: "test-dev", url: "https://test-dev.workers.dev" }, alt: { workerName: "test-alt", url: "https://alt.example.com" }, main: { workerName: "test-main", url: "https://main.example.com" }, }, }; const authResult: AuthorizeResult = { ok: true, outcome: { ok: true, authorized: true, gate: "dev-deploy", channel: "dev", candidateId: "test-sys", decisionId: "gd-000001", requiresMainVerification: false, requiresDurableSync: false, }, gateDecision: { schema: "werkstatt/gate-decision@1", decisionId: "gd-000001", candidateId: "test-sys", policyBundleRoot: "sha256:0000000000000000000000000000000000000000000000000000000000000000" as Sha256Digest, gate: "dev", evaluationCut: 1, selectedEvidence: [], status: "pass", coverage: { schema: "werkstatt/coverage-report@1", totalRequirements: 9, coveredRequirements: 9, uncoveredRequirements: [], }, reasons: [], actionPackRef: null, decidedAt: new Date().toISOString(), }, }; function makeCtx( adapter: DeploymentAdapter, workspaceRoot: string, _channel: "dev" | "alt" | "main" = "dev", ): DeployExecutionContext { return { systemId: "test-sys", releaseId: "r000001", candidateId: "test-sys", artifactHash: "sha256:abc" as Sha256Digest, authResult, workspaceRoot, cacheCloneDir: join(workspaceRoot, "..", "systems-cache", "test-sys"), systemConfig, adapter, operationId: "op-test", gateDecisionPath: "/tmp/gd.json", secretsFilePath: undefined, skipEvidenceSync: true, forceBuild: false, }; } function makeWorkspace(): string { const tmp = mkdtempSync(join(tmpdir(), "leitstand-purge-test-")); rmSync(resolve(tmp, "..", "systems-cache"), { recursive: true, force: true }); writeFileSync(join(tmp, "package.json"), JSON.stringify({ version: "1.0.0" }) + "\n"); const distDir = join(tmp, "releases", "r000001", "dist", "client"); mkdirSync(distDir, { recursive: true }); return tmp; } // Kernel env leaks into vitest under `werkstatt run` (dotenv/config loads // .env): SIGNING_PRIVATE_KEY(_PATH) activate the release-sign phase of // executeDeployPhases, which fails in tmp fixtures and short-circuits before // purge (purgeResult stays undefined). Pin them empty so tests always // exercise the unsigned path. beforeEach(() => { vi.stubEnv("SIGNING_PRIVATE_KEY", ""); vi.stubEnv("SIGNING_PRIVATE_KEY_PATH", ""); vi.stubEnv("PUBLIC_KEY_URL", ""); }); afterEach(() => { vi.unstubAllEnvs(); }); // AC-3: DeploymentAdapter interface includes purgeCapable() returning boolean test("AC-3: DeploymentAdapter interface includes purgeCapable() returning boolean", () => { const adapter = makeAdapter(true); expect(typeof adapter.purgeCapable).toBe("function"); expect(typeof adapter.purgeCapable()).toBe("boolean"); }); // AC-4: cloudflare-workers adapter purgeCapable() returns true test("AC-4: cloudflare-workers adapter purgeCapable() returns true", () => { const adapter = createCloudflareWorkersAdapter(); expect(adapter.purgeCapable()).toBe(true); }); // AC-5: github-pages adapter purgeCapable() returns false test("AC-5: github-pages adapter purgeCapable() returns false", () => { const adapter = createGitHubPagesAdapter(); expect(adapter.purgeCapable()).toBe(false); }); // AC-1: When adapter purgeCapable() returns false, executeDeployPhases skips runPurgeStep test("AC-1: adapter with purgeCapable()=false skips purge — purgeResult is undefined", async () => { const tmp = makeWorkspace(); try { const adapter = makeAdapter(false); const result = await executeDeployPhases(makeCtx(adapter, tmp), "dev"); expect(result.purgeResult).toBeUndefined(); } finally { rmSync(tmp, { recursive: true, force: true }); rmSync(resolve(tmp, "..", "systems-cache"), { recursive: true, force: true }); } }); // AC-2: When adapter purgeCapable() returns true, executeDeployPhases calls runPurgeStep test("AC-2: adapter with purgeCapable()=true calls purge — purgeResult is defined", async () => { const tmp = makeWorkspace(); try { const adapter = makeAdapter(true); const ctx = makeCtx(adapter, tmp, "dev"); ctx.systemConfig = { ...systemConfig, channels: { ...systemConfig.channels, dev: { workerName: "test-dev", url: "https://test-dev.example.com" }, }, }; const result = await executeDeployPhases(ctx, "dev"); expect(result.purgeResult).toBeDefined(); } finally { rmSync(tmp, { recursive: true, force: true }); rmSync(resolve(tmp, "..", "systems-cache"), { recursive: true, force: true }); } }); // AC-6: When purge is skipped, verifyFreshness still runs test("AC-6: verifyFreshness still runs when purge is skipped (purgeCapable()=false)", async () => { const tmp = makeWorkspace(); try { const adapter = makeAdapter(false); const result = await executeDeployPhases(makeCtx(adapter, tmp), "dev"); expect(result.purgeResult).toBeUndefined(); expect(result.freshness).toBeDefined(); expect(result.freshness.verified).toBeDefined(); } finally { rmSync(tmp, { recursive: true, force: true }); rmSync(resolve(tmp, "..", "systems-cache"), { recursive: true, force: true }); } });