1a88ab3199
The v0.8 milestone: installable on any Debian-based server via docker (Gitea registry image, honest sandbox degrade), deb (amd64+arm64, advisory postinst), and arch-aware binary — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113, machine-made releases with the full 8-asset matrix + registry image), the CLI carrying the full suite (guide, channel-aware doctor), and the admin runbook (deploy/README.md, evidence-backed recipes). ---ci--- phase: 6 milestone: v0.8 status: complete requirements: covered: [REQ-8-001, REQ-8-002, REQ-8-003, REQ-8-004, REQ-8-005] partial: [] ---/ci---
135 lines
4.8 KiB
TypeScript
135 lines
4.8 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { run } from "../src/index.ts";
|
|
import { testCtx } from "./helpers.ts";
|
|
import { daemonPaths, pidAlive, readDaemonPid, daemonRunning } from "../src/lib/daemon.ts";
|
|
|
|
// Hermetic fixture: a fake repo clone whose dev.sh prints + sleeps briefly,
|
|
// so daemon lifecycle tests run against a REAL detached child without
|
|
// touching the developer's actual clone or home state more than needed.
|
|
function fixtureRepo(): string {
|
|
const dir = join(tmpdir(), `nc-daemon-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(join(dir, "apps", "ai-service", "scripts"), { recursive: true });
|
|
mkdirSync(join(dir, "apps", "web", "out"), { recursive: true });
|
|
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
|
|
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "nextcraft", private: true }));
|
|
// dev.sh: emit a marker line then idle so the daemon stays alive briefly
|
|
writeFileSync(
|
|
join(dir, "apps", "ai-service", "scripts", "dev.sh"),
|
|
"#!/usr/bin/env bash\necho \"dev-started pid=$$\"\nsleep 30\n",
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(join(dir, "apps", "web", "out", "index.html"), "<html>export</html>");
|
|
return dir;
|
|
}
|
|
|
|
function cleanup(dir: string) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
test("daemon lib: paths are keyed by repo path under ~/.nextcraft/run", () => {
|
|
const a = daemonPaths("/repo/one");
|
|
const b = daemonPaths("/repo/two");
|
|
assert.ok(a.pidFile.includes(join(".nextcraft", "run")));
|
|
assert.notEqual(a.pidFile, b.pidFile);
|
|
assert.ok(a.pidFile.endsWith("dev.pid"));
|
|
assert.ok(a.logFile.endsWith("dev.log"));
|
|
});
|
|
|
|
test("daemon lib: pid liveness against a real spawned process", async () => {
|
|
const { spawn } = await import("node:child_process");
|
|
const child = spawn("sleep", ["5"]);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
assert.equal(pidAlive(child.pid!), true);
|
|
child.kill("SIGKILL");
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
assert.equal(pidAlive(child.pid!), false);
|
|
});
|
|
|
|
test("dev -d: starts daemon, writes pidfile, auto-wires AI_WEB_STATIC_DIR, refuses double-start", async () => {
|
|
const repo = fixtureRepo();
|
|
try {
|
|
const ctx = testCtx({ cwd: repo });
|
|
const code = await run(["dev", "-d"], ctx);
|
|
assert.equal(code, 0);
|
|
assert.ok(ctx.out().includes("started ai-service dev server as daemon"));
|
|
|
|
const paths = daemonPaths(repo);
|
|
assert.ok(existsSync(paths.pidFile), "pidfile written");
|
|
const pid = readDaemonPid(paths)!;
|
|
assert.ok(pid! > 0);
|
|
assert.equal(daemonRunning(paths), pid, "daemon is alive");
|
|
|
|
// log should show the marker line from the stub dev.sh
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
const logCtx = testCtx({ cwd: repo });
|
|
assert.equal(await run(["log"], logCtx), 0);
|
|
assert.ok(logCtx.out().includes("dev-started"), "log tails the daemon output");
|
|
|
|
// second detach start must refuse
|
|
const ctx2 = testCtx({ cwd: repo });
|
|
assert.equal(await run(["dev", "-d"], ctx2), 1);
|
|
assert.ok(ctx2.err().includes("already running"));
|
|
|
|
// stop kills it and clears the pidfile
|
|
const stopCtx = testCtx({ cwd: repo });
|
|
assert.equal(await run(["stop"], stopCtx), 0);
|
|
assert.ok(stopCtx.out().includes("stopped"));
|
|
assert.equal(existsSync(paths.pidFile), false, "pidfile removed");
|
|
assert.equal(pidAlive(pid), false, "process actually dead");
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
});
|
|
|
|
test("dev -d: unknown flag is a usage error", async () => {
|
|
const repo = fixtureRepo();
|
|
try {
|
|
const ctx = testCtx({ cwd: repo });
|
|
assert.equal(await run(["dev", "--bogus"], ctx), 2);
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
});
|
|
|
|
test("stop: no daemon running is a clean 0-exit", async () => {
|
|
const repo = fixtureRepo();
|
|
try {
|
|
const ctx = testCtx({ cwd: repo });
|
|
const code = await run(["stop"], ctx);
|
|
assert.equal(code, 0);
|
|
assert.ok(ctx.out().includes("no dev daemon running"));
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
});
|
|
|
|
test("log: missing log file exits 1 with a hint", async () => {
|
|
const repo = fixtureRepo();
|
|
try {
|
|
const ctx = testCtx({ cwd: repo });
|
|
const code = await run(["log"], ctx);
|
|
assert.equal(code, 1);
|
|
assert.ok(ctx.err().includes("nextcraft dev -d"));
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
});
|
|
|
|
test("log: -n N limits the tail", async () => {
|
|
const repo = fixtureRepo();
|
|
try {
|
|
const paths = daemonPaths(repo);
|
|
mkdirSync(dirname(paths.logFile), { recursive: true });
|
|
writeFileSync(paths.logFile, "l1\nl2\nl3\nl4\nl5\n");
|
|
const ctx = testCtx({ cwd: repo });
|
|
assert.equal(await run(["log", "-n", "2"], ctx), 0);
|
|
assert.equal(ctx.out(), "l4\nl5\n");
|
|
} finally {
|
|
cleanup(repo);
|
|
}
|
|
});
|