test(harness): run seeded scenarios from the command line

For manual testing, reproducing user setups, and the docs screenshots.
This commit is contained in:
Aarnav Tale
2026-10-17 18:01:41 -07:00
parent fc1d99a24e
commit c86bc5a929
7 changed files with 280 additions and 0 deletions
+1
View File
@@ -15,6 +15,7 @@
"typecheck": "react-router typegen && tsc",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration --project integration:agent",
"harness": "node --disable-warning=ExperimentalWarning --import tsx tests/harness/cli.ts",
"docs:dev": "cd docs && react-router dev",
"docs:build": "cd docs && react-router build && node scripts/finish-build.ts",
"lint": "oxlint",
+138
View File
@@ -0,0 +1,138 @@
// Keeps a scenario's tailnet running for manual testing, reproducing user
// setups, and docs screenshots. Usage:
//
// pnpm harness up [scenario] [--version 0.29.5] [--headplane]
// pnpm harness list
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { parseArgs } from "node:util";
import { stringify } from "yaml";
// Headplane's modules expect the build-time globals Vite defines.
Object.assign(globalThis, { __VERSION__: "harness", __PREFIX__: "/admin" });
const { HARNESS_LABEL } = await import("./constants");
const { applyScenario } = await import("./scenario");
const { createTailnet } = await import("./tailnet");
const { HEADSCALE_VERSIONS, LATEST_HEADSCALE } = await import("./versions");
type Scenario = import("./scenario").Scenario;
type HeadscaleVersion = import("./versions").HeadscaleVersion;
const scenariosDir = join(import.meta.dirname, "scenarios");
const { positionals, values } = parseArgs({
allowPositionals: true,
options: {
version: { type: "string", default: LATEST_HEADSCALE },
headplane: { type: "boolean", default: false },
port: { type: "string", default: "3001" },
},
});
const [command = "up", name = "homelab"] = positionals;
async function loadScenario(scenario: string): Promise<Scenario> {
const module = await import(join(scenariosDir, `${scenario}.ts`)).catch(() => undefined);
if (!module) {
throw new Error(`No scenario named ${scenario}. Run \`pnpm harness list\` to see them.`);
}
return module.default;
}
if (command === "list") {
for (const file of (await readdir(scenariosDir)).sort()) {
const scenario = await loadScenario(basename(file, ".ts"));
console.log(`${basename(file, ".ts").padEnd(12)} ${scenario.description}`);
}
process.exit(0);
}
if (command !== "up") {
console.error(`Unknown command ${command}. Use \`up\` or \`list\`.`);
process.exit(1);
}
const version = values.version as HeadscaleVersion;
if (!HEADSCALE_VERSIONS.includes(version)) {
console.error(
`Headscale ${version} isn't supported. Pick one of ${HEADSCALE_VERSIONS.join(", ")}.`,
);
process.exit(1);
}
const scenario = await loadScenario(name);
console.log(`Starting ${name} on Headscale ${version}: ${scenario.description}`);
const tailnet = await createTailnet({ version, headscaleConfig: scenario.headscaleConfig });
let headplane: ReturnType<typeof spawn> | undefined;
async function shutdown() {
console.log("\nStopping the tailnet...");
headplane?.kill("SIGTERM");
await tailnet.dispose();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await applyScenario(tailnet, scenario);
const dataDir = await mkdtemp(join(tmpdir(), "hp-harness-headplane-"));
const configPath = join(dataDir, "config.yaml");
await writeFile(
configPath,
stringify({
server: {
host: "127.0.0.1",
port: Number(values.port),
cookie_secret: randomBytes(16).toString("hex"),
cookie_secure: false,
data_path: dataDir,
},
headscale: {
url: tailnet.headscale.url,
api_key: tailnet.headscale.apiKey,
tls_cert_path: tailnet.headscale.certPath,
config_path: tailnet.headscale.configPath,
},
integration: {
docker: {
enabled: true,
container_label: `${HARNESS_LABEL}=${tailnet.id}`,
socket: process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock",
},
},
}),
);
console.log(`
Headscale ${tailnet.headscale.url}
API key ${tailnet.headscale.apiKey}
CA cert ${tailnet.headscale.certPath}
Config ${tailnet.headscale.configPath}
Headplane ${configPath}
Exec docker exec ${tailnet.headscale.container.getId().slice(0, 12)} headscale <command>
`);
if (values.headplane) {
headplane = spawn("pnpm", ["exec", "react-router", "dev", "--port", values.port!], {
stdio: "inherit",
env: { ...process.env, HEADPLANE_CONFIG_PATH: configPath },
});
console.log(`Headplane http://127.0.0.1:${values.port}/admin (sign in with the API key)\n`);
} else {
console.log(`Run Headplane against it with:
HEADPLANE_CONFIG_PATH=${configPath} pnpm exec react-router dev --port ${values.port}
`);
}
console.log("Press Ctrl-C to stop.");
+34
View File
@@ -0,0 +1,34 @@
import type { NodeOptions } from "./node";
import type { Tailnet, TailnetOptions } from "./tailnet";
export interface ScenarioNode extends NodeOptions {
// Routes to approve once the node has joined.
approve?: string[];
offline?: boolean;
}
export interface Scenario {
description: string;
users: Array<{ name: string; email?: string; displayName?: string }>;
nodes: ScenarioNode[];
policy?: TailnetOptions["policy"];
headscaleConfig?: TailnetOptions["headscaleConfig"];
}
export async function applyScenario(tailnet: Tailnet, scenario: Scenario) {
for (const user of scenario.users) {
await tailnet.user(user.name, { email: user.email, displayName: user.displayName });
}
if (scenario.policy) {
await tailnet.setPolicy(scenario.policy);
}
await Promise.all(
scenario.nodes.map(async ({ approve, offline, ...options }) => {
const node = await tailnet.node(options);
if (approve?.length) await tailnet.api.nodes.approveRoutes(node.id, approve);
if (offline) await node.down();
}),
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Scenario } from "../scenario";
// HP_FLEET_SIZE machines spread over ten users, for performance work.
const size = Number(process.env.HP_FLEET_SIZE ?? 50);
const users = Array.from({ length: 10 }, (_, i) => ({ name: `user${i + 1}` }));
export default {
description: `${size} machines across ${users.length} users`,
users,
nodes: Array.from({ length: size }, (_, i) => ({
name: `machine-${String(i + 1).padStart(3, "0")}`,
user: users[i % users.length].name,
offline: i % 7 === 0,
})),
} satisfies Scenario;
+60
View File
@@ -0,0 +1,60 @@
import type { Scenario } from "../scenario";
// A small household tailnet, used for the docs screenshots.
export default {
description: "Three people, a NAS sharing the home LAN, an exit node, and tagged servers",
users: [
{ name: "alice", email: "alice@example.com", displayName: "Alice Chen" },
{ name: "bob", email: "bob@example.com", displayName: "Bob Okafor" },
{ name: "carol", email: "carol@example.com", displayName: "Carol Diaz" },
],
headscaleConfig: {
dns: {
magic_dns: true,
base_domain: "home.arpa",
override_local_dns: true,
nameservers: { global: ["1.1.1.1", "9.9.9.9"], split: { "lan.home.arpa": ["192.168.1.1"] } },
search_domains: ["lan.home.arpa"],
extra_records: [{ name: "grafana.home.arpa", type: "A", value: "100.64.0.4" }],
},
},
policy: {
groups: { "group:admins": ["alice@"], "group:family": ["alice@", "bob@", "carol@"] },
tagOwners: {
"tag:server": ["group:admins"],
"tag:media": ["group:admins"],
"tag:ci": ["group:admins"],
},
acls: [
{ action: "accept", src: ["group:admins"], dst: ["*:*"] },
{ action: "accept", src: ["group:family"], dst: ["tag:media:*", "192.168.1.0/24:*"] },
{ action: "accept", src: ["tag:ci"], dst: ["tag:server:22,443"] },
],
ssh: [
{
action: "accept",
src: ["group:admins"],
dst: ["tag:server"],
users: ["root", "autogroup:nonroot"],
},
],
},
nodes: [
{ name: "alice-laptop", user: "alice" },
{ name: "alice-phone", user: "alice" },
{ name: "bob-desktop", user: "bob" },
{ name: "bob-tablet", user: "bob", offline: true },
{ name: "carol-laptop", user: "carol" },
{
name: "nas",
user: "alice",
ssh: true,
routes: ["192.168.1.0/24"],
approve: ["192.168.1.0/24"],
},
{ name: "gateway", user: "alice", exitNode: true },
{ name: "jellyfin", user: "alice", tags: ["tag:media"], ssh: true },
{ name: "grafana", user: "alice", tags: ["tag:server"], ssh: true },
{ name: "runner", user: "alice", tags: ["tag:ci"] },
],
} satisfies Scenario;
+7
View File
@@ -0,0 +1,7 @@
import type { Scenario } from "../scenario";
export default {
description: "One user with one machine",
users: [{ name: "alice", email: "alice@example.com" }],
nodes: [{ name: "alice-laptop", user: "alice" }],
} satisfies Scenario;
@@ -0,0 +1,25 @@
import { expect, test } from "vitest";
import { applyScenario, type Scenario } from "../../harness/scenario";
import homelab from "../../harness/scenarios/homelab";
import minimal from "../../harness/scenarios/minimal";
import { createTailnet } from "../../harness/tailnet";
// The docs screenshots depend on these, so they shouldn't rot.
test.concurrent.for<[string, Scenario]>([
["homelab", homelab],
["minimal", minimal],
])("the %s scenario starts", async ([, scenario]) => {
await using tailnet = await createTailnet({ headscaleConfig: scenario.headscaleConfig });
await applyScenario(tailnet, scenario);
const nodes = await tailnet.api.nodes.list();
expect(nodes.map((node) => node.name).sort()).toEqual(
scenario.nodes.map((node) => node.name).sort(),
);
for (const node of scenario.nodes) {
const machine = nodes.find((n) => n.name === node.name)!;
expect(machine.online, node.name).toBe(!node.offline);
}
});