Files
lab/bastion/src/cli/tests/smoke-bastion.test.ts
Michal 6807632d46
Some checks failed
CI/CD / lint (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
feat: Asahi rootfs build pipeline + serve from bastion
- Add scripts/build-asahi-rootfs.sh: downloads upstream Fedora Asahi
  Remix Server, injects lab firstboot script + systemd service + SSH
  keys, repackages with installer_data.json that adds LVM Data partition
- Bastion serves built artifacts at /asahi/repo/* via fastify-static
- installer_data.json prefers built config, falls back to minimal
- Fix __dirname crash in ESM module (use import.meta.url)
- Fix smoke test timeout (was crashing due to __dirname)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:20:12 +01:00

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 = 15_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");
});
});