Files
nextcraft/apps/cli/tests/daemon.test.ts
T
CIAgent 1a88ab3199
Release / release (push) Successful in 3m45s
CI / python (push) Successful in 1m52s
CI / node (push) Successful in 1m0s
merge: milestone/v0.8-ship-anywhere -> main (v0.8 complete — Ship Anywhere)
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---
2026-09-29 16:04:40 +00:00

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);
}
});