198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
|
|
// 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<void> {
|
||
|
|
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<void>((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");
|
||
|
|
});
|
||
|
|
});
|