// Smoke tests for bastion CLI commands. // These tests spawn real processes and verify they work end-to-end. import { describe, it, expect, afterEach } from "vitest"; import { spawn, execSync, type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; const CLI_PATH = join(import.meta.dirname, "..", "src", "index.ts"); const TEST_DIR = join(tmpdir(), `lab-bastion-smoke-${process.pid}`); const PID_FILE = join(TEST_DIR, "bastion.pid"); const LOG_FILE = join(TEST_DIR, "bastion.log"); const TEST_PORT = 18932; // Unlikely to conflict function runCli(args: string[], timeoutMs = 10_000): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn("node", ["--import", "tsx", CLI_PATH, ...args], { timeout: timeoutMs, env: { ...process.env, NODE_NO_WARNINGS: "1" }, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); child.on("close", (code) => { resolve({ code: code ?? 1, stdout, stderr }); }); child.on("error", reject); }); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } function killPid(pid: number): void { try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ } } describe("bastion smoke tests", () => { let daemonPid: number | undefined; afterEach(() => { // Kill any daemon we started if (daemonPid) { killPid(daemonPid); daemonPid = undefined; } // Also try PID file try { const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); if (!isNaN(pid)) killPid(pid); } catch { /* no pid file */ } // Clean up test directory try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ignore */ } }); it("--help prints usage without error", async () => { const result = await runCli(["--help"]); expect(result.code).toBe(0); expect(result.stdout).toContain("labctl"); expect(result.stdout).toContain("Commands:"); }); it("--version prints version", async () => { const result = await runCli(["--version"]); expect(result.code).toBe(0); expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); }); it("version subcommand prints detailed info", async () => { const result = await runCli(["version"]); expect(result.code).toBe(0); expect(result.stdout).toContain("labctl"); expect(result.stdout).toContain("node"); expect(result.stdout).toContain("platform"); }); it("config list works without config file", async () => { const result = await runCli(["config", "list"]); expect(result.code).toBe(0); expect(result.stdout).toContain("labdUrl"); }); it("config path prints a path", async () => { const result = await runCli(["config", "path"]); expect(result.code).toBe(0); expect(result.stdout.trim()).toContain(".labctl"); }); it("start without root prints helpful error", async () => { // Only run if we're NOT root (CI may run as root) if (process.getuid?.() === 0) return; const result = await runCli([ "init", "bastion", "standalone", "start", "--dir", TEST_DIR, "--port", String(TEST_PORT), ]); expect(result.code).toBe(1); expect(result.stderr).toContain("root"); expect(result.stderr).toContain("sudo"); }); it("foreground start with --skip-dnsmasq --skip-artifacts works and stays alive", async () => { mkdirSync(TEST_DIR, { recursive: true }); // Start in foreground as a child process const child = spawn( "node", [ "--import", "tsx", CLI_PATH, "init", "bastion", "standalone", "start", "--foreground", "--skip-dnsmasq", "--skip-artifacts", "--dir", TEST_DIR, "--port", String(TEST_PORT), ], { env: { ...process.env, NODE_NO_WARNINGS: "1" }, stdio: ["ignore", "pipe", "pipe"], }, ); daemonPid = child.pid; // Collect output let stdout = ""; child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); let stderr = ""; child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); // Wait for the server to start (look for the banner) const startedAt = Date.now(); const maxWait = 10_000; while (Date.now() - startedAt < maxWait) { if (stdout.includes("Waiting for PXE boot requests")) break; await sleep(200); } expect(stdout).toContain("Waiting for PXE boot requests"); expect(stdout).toContain("HTTP server listening"); // Verify the process is still alive after startup await sleep(1000); let alive = false; try { process.kill(child.pid!, 0); alive = true; } catch { /* dead */ } expect(alive).toBe(true); // Verify PID file was created expect(existsSync(PID_FILE)).toBe(true); const pidFromFile = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); expect(pidFromFile).toBe(child.pid); // Verify HTTP server responds try { const resp = await fetch(`http://127.0.0.1:${TEST_PORT}/api/machines`); expect(resp.ok).toBe(true); } catch (err) { // If fetch fails, that's a real problem throw new Error(`HTTP server not responding: ${err}`); } // Clean shutdown child.kill("SIGTERM"); await new Promise((resolve) => { child.on("close", () => resolve()); setTimeout(resolve, 3000); }); daemonPid = undefined; }, 20_000); it("status shows bastion info or reports labd unreachable", async () => { const result = await runCli([ "init", "bastion", "standalone", "status", ]); // Status queries labd — may show bastions (if labd running) or error (if not) const output = result.stdout + result.stderr; expect(output).toMatch(/HOSTNAME|Cannot reach labd|No bastions/i); }); it("doctor runs without crashing", async () => { const result = await runCli(["doctor"]); // Doctor may report errors (no labd running) but should not crash expect(result.code).toBeLessThanOrEqual(1); // 0 = all ok, 1 = errors found expect(result.stdout).toContain("diagnostics"); }); });