/* RFC-0566: unit tests for deploy commands — atomicity, hash verification, first-deploy, rollback, immutability. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-0566: initial deploy unit tests. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import { test, expect, beforeEach, afterEach } from "vitest"; import fs from "node:fs/promises"; import { existsSync, symlinkSync, readlinkSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import type { KernelCommandInput, KernelRuntimeContext } from "@warpgogol/werkstatt-engine/kernel"; import { artifactDir, atomicSymlinkSwap, currentSymlinkPath, previousSymlinkPath, hashArtifactDir, listArtifactHashes, distPath, platformArtifactsBase, writeManifest, readSymlinkBasename, } from "../deploy-utils.ts"; import { runDeployArtifactVerify } from "../artifact-verify.ts"; import { runDeployAtomicSwap } from "../atomic-swap.ts"; import { runDeployAtomicRollback } from "../atomic-rollback.ts"; import { runDeployArtifactGc } from "../artifact-gc.ts"; import { runDeployStatus } from "../deploy-status.ts"; import type { ArtifactManifest } from "../types.ts"; import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; let tmpDir: string; function mockContext(workspaceRoot: string): KernelRuntimeContext { return { io: createDefaultIO().io, workspaceRoot, logger: { info: () => {}, success: () => {}, error: () => {}, warn: () => {}, event: () => {}, getEvents: () => [], }, } as unknown as KernelRuntimeContext; } function mockInput(flags: Record = {}): KernelCommandInput { return { argv: [], flags } as KernelCommandInput; } async function createArtifact( workspaceRoot: string, _label: string, files: Record = {}, ): Promise { const stagingDir = path.join( platformArtifactsBase(workspaceRoot), `.staging-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); const stagingDist = path.join(stagingDir, "dist"); await fs.mkdir(stagingDist, { recursive: true }); for (const [filePath, content] of Object.entries(files)) { const fullPath = path.join(stagingDist, filePath); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content, "utf8"); } const { treeHash, files: fileRecords, totalSize } = await hashArtifactDir(stagingDist); const finalDir = artifactDir(workspaceRoot, treeHash); if (!existsSync(finalDir)) { await fs.mkdir(path.dirname(finalDir), { recursive: true }); await fs.rename(stagingDir, finalDir); } else { await fs.rm(stagingDir, { recursive: true, force: true }); } const manifest: ArtifactManifest = { hash: treeHash, files: fileRecords, totalSize, builtAt: new Date().toISOString(), gitSha: "abc123", buildHost: "test", signature: "", signatureAlgorithm: "Ed25519", }; await writeManifest(workspaceRoot, treeHash, manifest); return treeHash; } beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "deploy-test-")); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); test("hashArtifactDir: empty directory returns empty file list", async () => { const emptyDir = path.join(tmpDir, "empty"); await fs.mkdir(emptyDir, { recursive: true }); const result = await hashArtifactDir(emptyDir); expect(result.files).toEqual([]); expect(result.totalSize).toBe(0); }); test("hashArtifactDir: produces deterministic hash for same content", async () => { const dir1 = path.join(tmpDir, "dir1"); const dir2 = path.join(tmpDir, "dir2"); await fs.mkdir(path.join(dir1, "pkg"), { recursive: true }); await fs.mkdir(path.join(dir2, "pkg"), { recursive: true }); await fs.writeFile(path.join(dir1, "pkg", "index.js"), "hello"); await fs.writeFile(path.join(dir2, "pkg", "index.js"), "hello"); const r1 = await hashArtifactDir(dir1); const r2 = await hashArtifactDir(dir2); expect(r1.treeHash).toBe(r2.treeHash); expect(r1.files).toHaveLength(1); }); test("atomicSymlinkSwap: creates symlink atomically", async () => { const targetDir = path.join(tmpDir, "target"); await fs.mkdir(targetDir, { recursive: true }); const symlinkPath = path.join(tmpDir, "link"); await atomicSymlinkSwap(symlinkPath, targetDir); expect(existsSync(symlinkPath)).toBe(true); expect(readlinkSync(symlinkPath)).toBe(targetDir); }); test("atomicSymlinkSwap: replaces existing symlink", async () => { const target1 = path.join(tmpDir, "t1"); const target2 = path.join(tmpDir, "t2"); await fs.mkdir(target1, { recursive: true }); await fs.mkdir(target2, { recursive: true }); const symlinkPath = path.join(tmpDir, "link"); await atomicSymlinkSwap(symlinkPath, target1); await atomicSymlinkSwap(symlinkPath, target2); expect(readlinkSync(symlinkPath)).toBe(target2); }); test("deploy.artifact.verify: passes for valid artifact", async () => { const hash = await createArtifact(tmpDir, "test", { "pkg/index.js": "content" }); const result = await runDeployArtifactVerify(mockInput({ hash }), mockContext(tmpDir)); expect(result.data?.verified).toBe(true); expect(result.exitCode).toBe(0); }); test("deploy.artifact.verify: fails for corrupted artifact", async () => { const hash = await createArtifact(tmpDir, "test", { "pkg/index.js": "content" }); const distDir = distPath(tmpDir, hash); await fs.writeFile(path.join(distDir, "pkg/index.js"), "corrupted"); const result = await runDeployArtifactVerify(mockInput({ hash }), mockContext(tmpDir)); expect(result.data?.verified).toBe(false); expect(result.exitCode).toBe(1); }); test("deploy.artifact.verify: fails for missing artifact", async () => { const result = await runDeployArtifactVerify( mockInput({ hash: "sha256:nonexistent" }), mockContext(tmpDir), ); expect(result.data?.verified).toBe(false); expect(result.exitCode).toBe(1); }); test("deploy.atomic.swap: first deploy creates current symlink, no previous", async () => { const hash = await createArtifact(tmpDir, "test", { "pkg/index.js": "v1" }); const result = await runDeployAtomicSwap(mockInput({ hash }), mockContext(tmpDir)); expect(result.data?.swapped).toBe(true); expect(result.data?.newHash).toBe(hash); expect(result.data?.previousHash).toBeNull(); expect(await readSymlinkBasename(currentSymlinkPath(tmpDir))).toBe(hash); expect(await readSymlinkBasename(previousSymlinkPath(tmpDir))).toBeNull(); }); test("deploy.atomic.swap: second deploy updates current and creates previous", async () => { const hash1 = await createArtifact(tmpDir, "v1", { "pkg/index.js": "v1" }); await runDeployAtomicSwap(mockInput({ hash: hash1 }), mockContext(tmpDir)); const hash2 = await createArtifact(tmpDir, "v2", { "pkg/index.js": "v2" }); const result = await runDeployAtomicSwap(mockInput({ hash: hash2 }), mockContext(tmpDir)); expect(result.data?.swapped).toBe(true); expect(result.data?.newHash).toBe(hash2); expect(result.data?.previousHash).toBe(hash1); expect(await readSymlinkBasename(currentSymlinkPath(tmpDir))).toBe(hash2); expect(await readSymlinkBasename(previousSymlinkPath(tmpDir))).toBe(hash1); }); test("deploy.atomic.swap: rejects corrupted artifact with hash-mismatch", async () => { const hash = await createArtifact(tmpDir, "test", { "pkg/index.js": "content" }); const distDir = distPath(tmpDir, hash); await fs.writeFile(path.join(distDir, "pkg/index.js"), "corrupted"); await expect(runDeployAtomicSwap(mockInput({ hash }), mockContext(tmpDir))).rejects.toThrow( "hash-mismatch", ); }); test("deploy.atomic.rollback: rolls back to previous artifact", async () => { const hash1 = await createArtifact(tmpDir, "v1", { "pkg/index.js": "v1" }); await runDeployAtomicSwap(mockInput({ hash: hash1 }), mockContext(tmpDir)); const hash2 = await createArtifact(tmpDir, "v2", { "pkg/index.js": "v2" }); await runDeployAtomicSwap(mockInput({ hash: hash2 }), mockContext(tmpDir)); const result = await runDeployAtomicRollback(mockInput(), mockContext(tmpDir)); expect(result.data?.swapped).toBe(true); expect(result.data?.newHash).toBe(hash1); expect(await readSymlinkBasename(currentSymlinkPath(tmpDir))).toBe(hash1); }); test("deploy.atomic.rollback: fails with no-previous-artifact on first deploy", async () => { const hash1 = await createArtifact(tmpDir, "v1", { "pkg/index.js": "v1" }); await runDeployAtomicSwap(mockInput({ hash: hash1 }), mockContext(tmpDir)); const result = await runDeployAtomicRollback(mockInput(), mockContext(tmpDir)); expect(result.data?.swapped).toBe(false); expect(result.exitCode).toBe(1); expect(result.summary).toContain("no-previous-artifact"); }); test("deploy.artifact.gc: dry-run reports candidates without deleting", async () => { const base = platformArtifactsBase(tmpDir); await fs.mkdir(base, { recursive: true }); for (let i = 0; i < 7; i++) { const dir = path.join(base, `sha256:fake${i}`); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "manifest.json"), "{}"); } const result = await runDeployArtifactGc( mockInput({ "dry-run": true, retain: "3" }), mockContext(tmpDir), ); expect(result.data?.dryRun).toBe(true); expect(result.data?.deleted).toBe(0); expect(result.data?.candidates.length).toBeGreaterThan(0); }); test("deploy.artifact.gc: never deletes artifacts referenced by symlinks", async () => { const hash1 = await createArtifact(tmpDir, "v1", { "pkg/index.js": "v1" }); await runDeployAtomicSwap(mockInput({ hash: hash1 }), mockContext(tmpDir)); const hash2 = await createArtifact(tmpDir, "v2", { "pkg/index.js": "v2" }); await runDeployAtomicSwap(mockInput({ hash: hash2 }), mockContext(tmpDir)); for (let i = 0; i < 5; i++) { const dir = path.join(platformArtifactsBase(tmpDir), `sha256:fake${i}`); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "manifest.json"), "{}"); } const result = await runDeployArtifactGc(mockInput({ retain: "2" }), mockContext(tmpDir)); expect(result.data?.deleted).toBeGreaterThan(0); expect(existsSync(artifactDir(tmpDir, hash1))).toBe(true); expect(existsSync(artifactDir(tmpDir, hash2))).toBe(true); }); test("deploy.status: reports current and previous hashes", async () => { const hash1 = await createArtifact(tmpDir, "v1", { "pkg/index.js": "v1" }); await runDeployAtomicSwap(mockInput({ hash: hash1 }), mockContext(tmpDir)); const hash2 = await createArtifact(tmpDir, "v2", { "pkg/index.js": "v2" }); await runDeployAtomicSwap(mockInput({ hash: hash2 }), mockContext(tmpDir)); const result = await runDeployStatus(mockInput(), mockContext(tmpDir)); expect(result.data?.currentHash).toBe(hash2); expect(result.data?.previousHash).toBe(hash1); expect(result.data?.currentGitSha).toBe("abc123"); expect(result.data?.deployedAt).toBeTruthy(); }); test("deploy.status: returns nulls when no artifacts deployed", async () => { const result = await runDeployStatus(mockInput(), mockContext(tmpDir)); expect(result.data?.currentHash).toBeNull(); expect(result.data?.previousHash).toBeNull(); expect(result.data?.currentGitSha).toBeNull(); expect(result.data?.deployedAt).toBeNull(); }); test("immutability: modifying an artifact directory causes verify to fail", async () => { const hash = await createArtifact(tmpDir, "test", { "pkg/index.js": "original" }); const verifyBefore = await runDeployArtifactVerify(mockInput({ hash }), mockContext(tmpDir)); expect(verifyBefore.data?.verified).toBe(true); const distDir = distPath(tmpDir, hash); await fs.writeFile(path.join(distDir, "pkg/index.js"), "tampered"); const verifyAfter = await runDeployArtifactVerify(mockInput({ hash }), mockContext(tmpDir)); expect(verifyAfter.data?.verified).toBe(false); expect(verifyAfter.exitCode).toBe(1); }); test("listArtifactHashes: excludes current and previous symlinks", async () => { const base = platformArtifactsBase(tmpDir); await fs.mkdir(base, { recursive: true }); const realDir = path.join(base, "sha256:real"); await fs.mkdir(realDir, { recursive: true }); symlinkSync(realDir, path.join(base, "current")); symlinkSync(realDir, path.join(base, "previous")); const hashes = await listArtifactHashes(tmpDir); expect(hashes).toEqual(["sha256:real"]); });