import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const deployScript = path.join(repoRoot, "ops/evo-deploy/deploy.sh"); const temporaryDirectories: string[] = []; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); } }); function write(file: string, content: string, mode?: number) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, content); if (mode !== undefined) chmodSync(file, mode); } function temporaryRoot(prefix: string) { const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix))); temporaryDirectories.push(root); return root; } function searchPath(bin: string) { return [bin, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter); } function readLines(file: string) { return existsSync(file) ? readFileSync(file, "utf8").trim().split("\n") : []; } interface Revision { build?: "ok" | "fail"; health?: "ok" | "down"; } /** * A checkout at revision A whose served build came from A, with revision B to deploy. Fake pnpm, * curl and restart hook record what ran against which checked-out revision. */ function deployFixture(next: Revision, previous: Revision = {}) { const root = temporaryRoot("evo-deploy-"); const checkout = path.join(root, "checkout"); const bin = path.join(root, "bin"); const home = path.join(root, "home"); const commandLog = path.join(root, "commands.log"); const env = { HOME: home, PATH: searchPath(bin), COMMAND_LOG: commandLog, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com", GIT_CONFIG_NOSYSTEM: "1", }; const git = (...args: string[]) => execFileSync("git", args, { cwd: checkout, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); // deploy.sh runs pnpm through a login shell, which may reset PATH from the system profile. write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); write( path.join(bin, "pnpm"), `#!/bin/bash printf '%s %s\\n' "$(cat version.txt)" "$*" >> "$COMMAND_LOG" if [[ "$1 $2 $3 $4" == "--filter @rakazo/web build --outDir" ]]; then echo "vite building for production" mkdir -p "$5" if [[ "$(cat build.txt)" != ok ]]; then echo "simulated build error" >&2; exit 1; fi echo "built $(cat version.txt)" > "$5/index.html" fi `, 0o755, ); write( path.join(bin, "curl"), `#!/bin/bash\n[[ "$(cat "$EVO_DEPLOY_DIR/health.txt")" == ok ]]\n`, 0o755, ); write( path.join(bin, "restart"), `#!/bin/bash served="$(cat "$EVO_DEPLOY_DIR/apps/web/dist/index.html" 2>/dev/null || echo none)" printf 'restart %s %s\\n' "$(cat "$EVO_DEPLOY_DIR/version.txt")" "$served" >> "$COMMAND_LOG" `, 0o755, ); mkdirSync(checkout, { recursive: true }); git("init", "-q", "-b", "main"); const commit = (version: string, revision: Revision) => { write( path.join(checkout, "package.json"), '{"scripts":{"db:migrate":"prisma migrate deploy"}}', ); write(path.join(checkout, "version.txt"), version); write(path.join(checkout, "build.txt"), revision.build ?? "ok"); write(path.join(checkout, "health.txt"), revision.health ?? "ok"); git("add", "-A"); git("commit", "-q", "-m", version); return git("rev-parse", "HEAD"); }; const a = commit("A", previous); const b = commit("B", next); git("reset", "-q", "--hard", a); git("remote", "add", "origin", checkout); write(path.join(checkout, "apps/web/dist/index.html"), "built A"); return { a, b, checkout, run(args: string[] = []) { const result = spawnSync("/bin/bash", [deployScript, ...args], { env: { ...env, EVO_DEPLOY_DIR: checkout, EVO_DEPLOY_REF: b, EVO_RESTART_MODE: "script", EVO_RESTART_SCRIPT: path.join(bin, "restart"), EVO_HEALTH_TIMEOUT_SEC: "1", }, encoding: "utf8", timeout: 30_000, }); expect(result.error).toBeUndefined(); return result; }, commands: () => readLines(commandLog), head: () => git("rev-parse", "HEAD"), served: () => readFileSync(path.join(checkout, "apps/web/dist/index.html"), "utf8").trim(), state: () => JSON.parse(readFileSync(path.join(checkout, ".evo-deploy/state.json"), "utf8")), build: (version: string) => `${version} --filter @rakazo/web build --outDir ${path.join(checkout, ".evo-deploy/web-dist")} --emptyOutDir`, }; } describe("evo deploy", () => { it("builds before migrating and restarts on the new build", () => { const f = deployFixture({}); const result = f.run(); expect(result.status, result.stderr).toBe(0); expect(f.commands()).toEqual([ "B install --frozen-lockfile", f.build("B"), "B db:generate", "B db:migrate", "restart B built B", ]); expect(result.stderr).not.toContain("vite building for production"); expect(f.served()).toBe("built B"); expect(existsSync(path.join(f.checkout, ".evo-deploy/web-dist"))).toBe(false); // Regression: git output on stdout once polluted the captured SHAs. expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.b, status: "success" }); }); it("stops a failing build before the restart and keeps the served build", () => { const f = deployFixture({ build: "fail" }); const result = f.run(); expect(result.status).not.toBe(0); expect(result.stderr).toContain("simulated build error"); expect(result.stderr).toContain(`deploy failed; rolled back to ${f.a}`); expect(f.commands()).toEqual([ "B install --frozen-lockfile", f.build("B"), "A install --frozen-lockfile", "A db:generate", "restart A built A", ]); expect(f.head()).toBe(f.a); expect(f.served()).toBe("built A"); expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.a, status: "rolled-back" }); }); it("rolls back code, dependencies and build when the new revision is unhealthy", () => { const f = deployFixture({ health: "down" }); const result = f.run(); expect(result.status).not.toBe(0); expect(f.commands()).toEqual([ "B install --frozen-lockfile", f.build("B"), "B db:generate", "B db:migrate", "restart B built B", "A install --frozen-lockfile", "A db:generate", f.build("A"), "restart A built A", ]); expect(f.head()).toBe(f.a); expect(f.served()).toBe("built A"); expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rolled-back" }); }); it("reports a rollback that does not come back healthy", () => { const f = deployFixture({ health: "down" }, { health: "down" }); const result = f.run(); expect(result.status).not.toBe(0); expect(result.stderr).toContain(`rollback to ${f.a} did not complete`); expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rollback-failed" }); }); it("changes nothing in a dry run", () => { const f = deployFixture({}); const result = f.run(["--dry-run"]); expect(result.status, result.stderr).toBe(0); expect(f.commands()).toEqual([]); expect(f.head()).toBe(f.a); expect(f.served()).toBe("built A"); expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); }); it("rejects script mode without EVO_RESTART_SCRIPT before mutating the checkout", () => { const f = deployFixture({}); const root = path.dirname(f.checkout); const bin = path.join(root, "bin"); const home = path.join(root, "home"); const result = spawnSync("/bin/bash", [deployScript], { env: { HOME: home, PATH: searchPath(bin), GIT_CONFIG_NOSYSTEM: "1", EVO_DEPLOY_DIR: f.checkout, EVO_DEPLOY_REF: f.b, EVO_RESTART_MODE: "script", EVO_HEALTH_TIMEOUT_SEC: "1", }, encoding: "utf8", timeout: 30_000, }); expect(result.error).toBeUndefined(); expect(result.status).toBe(1); expect(result.stderr).toContain("EVO_RESTART_MODE=script requires EVO_RESTART_SCRIPT"); expect(f.commands()).toEqual([]); expect(f.head()).toBe(f.a); expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false); }); it("systemd mode without EVO_SYSTEMD_UNIT uses the auto unit name before deploy", () => { const f = deployFixture({}); const root = path.dirname(f.checkout); const bin = path.join(root, "bin"); const home = path.join(root, "home"); write( path.join(bin, "systemctl"), `#!/bin/bash case "$*" in "--user restart evoo-local.service") exit 0 ;; esac exit 1 `, 0o755, ); write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); const result = spawnSync("/bin/bash", [deployScript], { env: { HOME: home, PATH: searchPath(bin), COMMAND_LOG: path.join(root, "commands.log"), GIT_CONFIG_NOSYSTEM: "1", EVO_DEPLOY_DIR: f.checkout, EVO_DEPLOY_REF: f.b, EVO_RESTART_MODE: "systemd", EVO_SYSTEMD_SCOPE: "user", EVO_HEALTH_TIMEOUT_SEC: "1", }, encoding: "utf8", timeout: 30_000, }); expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(0); expect(result.stderr).toContain("EVO_SYSTEMD_UNIT unset — using evoo-local.service"); expect(result.stderr).toContain("restart mode: systemd"); }); it("auto mode prefers systemd over compose when the default unit is active", () => { const root = temporaryRoot("evo-deploy-autodetect-"); const checkout = path.join(root, "checkout"); const bin = path.join(root, "bin"); const home = path.join(root, "home"); const env = { HOME: home, PATH: searchPath(bin), GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com", GIT_CONFIG_NOSYSTEM: "1", }; write( path.join(bin, "systemctl"), `#!/bin/bash case "$*" in "--user is-active --quiet evoo-local.service") exit 0 ;; "--user is-enabled --quiet evoo-local.service") exit 1 ;; "--user show -p LoadState --value evoo-local.service") echo loaded; exit 0 ;; esac exit 1 `, 0o755, ); write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`); mkdirSync(checkout, { recursive: true }); execFileSync("git", ["init", "-q", "-b", "main"], { cwd: checkout, env }); write(path.join(checkout, "package.json"), "{}"); execFileSync("git", ["add", "-A"], { cwd: checkout, env }); execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: checkout, env }); execFileSync("git", ["remote", "add", "origin", checkout], { cwd: checkout, env }); write(path.join(checkout, "infra/compose/docker-compose.yml"), "services: {}\n"); const result = spawnSync("/bin/bash", [deployScript, "--dry-run"], { env: { ...env, EVO_DEPLOY_DIR: checkout, EVO_RESTART_MODE: "auto", EVO_HEALTH_TIMEOUT_SEC: "1", }, encoding: "utf8", timeout: 30_000, }); expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(0); expect(result.stderr).toContain("restart mode: systemd"); expect(result.stderr).not.toContain("restart mode: compose"); }); }); /** A checkout layout around a copy of start-stack.sh, with the real tsx and vite installs. */ function stackFixture(options: { build?: boolean; env?: string } = {}) { const root = temporaryRoot("evo-stack-"); const bin = path.join(root, "bin"); const commandLog = path.join(root, "commands.log"); const script = path.join(root, "ops/evo-deploy/start-stack.sh"); mkdirSync(path.dirname(script), { recursive: true }); copyFileSync(path.join(repoRoot, "ops/evo-deploy/start-stack.sh"), script); for (const linked of ["node_modules", "packages/core", "apps/web/node_modules"]) { mkdirSync(path.dirname(path.join(root, linked)), { recursive: true }); symlinkSync(path.join(repoRoot, linked), path.join(root, linked)); } if (options.build !== false) write(path.join(root, "apps/web/dist/index.html"), ""); if (options.env !== undefined) write(path.join(root, ".env"), options.env); write( path.join(bin, "pnpm"), `#!/bin/bash printf '%s|%s|%s\\n' "$PWD" "\${NODE_ENV:-}" "$*" >> "$COMMAND_LOG" if [[ "$*" == "dev --filter=!@rakazo/web" ]]; then exec sleep 30; fi if [[ "$1" == --filter ]]; then # Exit only after the services were started, so both launches are on record. for _ in $(seq 50); do grep -q -e "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3 sleep 0.1 done exit 4 fi `, 0o755, ); return { root, run(args: string[] = [], mode?: string) { const result = spawnSync("/bin/bash", [script, ...args], { env: { HOME: path.join(root, "home"), PATH: searchPath(bin), COMMAND_LOG: commandLog, ...(mode ? { EVO_WEB_MODE: mode } : {}), }, encoding: "utf8", timeout: 30_000, }); expect(result.error).toBeUndefined(); return result; }, commands: () => readLines(commandLog), }; } const previewEnv = "RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=fake-screen-proxy-secret-for-tests-0123456789\n"; describe("evo stack start script", () => { it("runs pnpm dev by default", () => { const f = stackFixture(); const result = f.run(); expect(result.status, result.stderr).toBe(0); expect(f.commands()).toEqual([`${f.root}||dev`]); }); it("rejects an unknown web mode", () => { const result = stackFixture().run(["--check"], "production"); expect(result.status).toBe(1); expect(result.stderr).toContain("EVO_WEB_MODE must be dev or preview (got: production)"); }); it.each([ ["no build", { build: false, env: previewEnv }, ["apps/web/dist has no build"]], [ "no host or secret", { env: "" }, ["Set RAKAZO_HOST", "Set SCREEN_PROXY_SECRET to a dedicated random screen proxy credential."], ], [ "the example secret", { env: "RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=replace-with-32-plus-character-screen-proxy-secret\n", }, ["Replace the SCREEN_PROXY_SECRET placeholder"], ], ])("refuses preview with %s before starting anything", (_name, options, messages) => { const f = stackFixture(options); const result = f.run([], "preview"); expect(result.status).toBe(1); for (const message of messages) expect(result.stderr).toContain(message); expect(f.commands()).toEqual([]); }); it("accepts a preview configuration from the root .env", () => { const f = stackFixture({ env: previewEnv }); const result = f.run(["--check"], "preview"); expect(result.status, result.stderr).toBe(0); expect(result.stderr).toContain("ok (preview)"); expect(f.commands()).toEqual([]); }); it("serves the build next to the other services and stops when one exits", () => { const f = stackFixture({ env: previewEnv }); const result = f.run([], "preview"); expect(result.status).toBe(1); expect(result.stderr).toContain("a service exited"); expect(f.commands().sort()).toEqual([ `${f.root}|production|--filter @rakazo/web preview --strictPort`, `${f.root}||dev --filter=!@rakazo/web`, ]); }); });