Files
nextcraft/apps/cli/tests/container-mode.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

183 lines
6.9 KiB
TypeScript

// In-container CLI semantics (v0.8 P2, D-066): refusals + doctor/verify
// container branches. Detection: NC_CONTAINER=1 or /.dockerenv — the
// tests set the marker env (the image sets it) with a stubbed exists() so
// no real /.dockerenv is consulted.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { SpawnOpts } from "../src/ctx.ts";
import type { SpawnResult } from "../src/lib/spawn.ts";
import { run } from "../src/index.ts";
import { testCtx, type TestCtx } from "./helpers.ts";
function containerCtx(overrides: {
spawn?: (cmd: string, args: string[], opts: SpawnOpts) => Promise<SpawnResult>;
envExtra?: Record<string, string | undefined>;
}): TestCtx {
const env = {
...process.env,
NC_CONTAINER: "1",
AI_WEB_STATIC_DIR: "/opt/nextcraft/web-out",
...overrides.envExtra,
} as Record<string, string | undefined>;
return testCtx({
env,
exists: (p: string) =>
p === "/opt/nextcraft/web-out/index.html" ||
p === "/.dockerenv" ||
p === "/opt/nextcraft/web-out",
spawn:
overrides.spawn ??
(async () => ({ code: 0, signal: null, timedOut: false, stdout: "", stderr: "" })),
});
}
// -- refusals: the repo/daemon commands refuse honestly, exit 1 ----------
test("container: bootstrap refuses with remediation", async () => {
const ctx = containerCtx({});
const code = await run(["bootstrap"], ctx);
assert.equal(code, 1);
assert.ok(ctx.err().includes("bootstrap"));
assert.ok(ctx.err().includes("docker pull"));
assert.ok(ctx.err().includes("doctor"));
});
test("container: deploy refuses with remediation", async () => {
const ctx = containerCtx({});
const code = await run(["deploy"], ctx);
assert.equal(code, 1);
assert.ok(ctx.err().includes("deploy"));
assert.ok(ctx.err().includes("docker pull"));
});
test("container: dev refuses with remediation (incl. -d)", async () => {
const ctx = containerCtx({});
assert.equal(await run(["dev"], ctx), 1);
assert.ok(ctx.err().includes("dev"));
assert.equal(await run(["dev", "-d"], ctx), 1);
});
test("container: stop refuses with remediation", async () => {
const ctx = containerCtx({});
assert.equal(await run(["stop"], ctx), 1);
assert.ok(ctx.err().includes("stop"));
});
test("container: log refuses with remediation", async () => {
const ctx = containerCtx({});
assert.equal(await run(["log"], ctx), 1);
assert.ok(ctx.err().includes("log"));
});
test("container: refusal text names the image-immutability + upgrade path", async () => {
const ctx = containerCtx({});
await run(["deploy"], ctx);
const err = ctx.err();
assert.ok(err.includes("immutable"), "names immutability");
assert.ok(err.includes("docker pull"), "names the docker upgrade path");
assert.ok(err.includes("nextcraft doctor"), "points at the in-container commands");
});
// -- doctor container branch -----------------------------------------------
test("container: doctor reports the docker channel + export mount", async () => {
const ctx = containerCtx({
spawn: async (cmd: string, args: string[]) => {
if (cmd === "python3" && args[0] === "--version") {
return { code: 0, signal: null, timedOut: false, stdout: "Python 3.11.9\n", stderr: "" };
}
if (cmd === "which") {
// unshare absent inside the image — the documented degrade
return { code: 1, signal: null, timedOut: false, stdout: "", stderr: "" };
}
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
},
});
const code = await run(["doctor"], ctx);
// unshare fails = the documented degrade → doctor exits 1 with the warn
assert.equal(code, 1);
assert.ok(ctx.out().includes("docker image"), "reports the channel");
assert.ok(ctx.out().includes("web export mounted"), "checks the export mount");
assert.ok(ctx.out().includes("documented degrade"), "names the degrade honestly");
});
test("container: doctor fails when AI_WEB_STATIC_DIR is wrong", async () => {
const ctx = containerCtx({
envExtra: { AI_WEB_STATIC_DIR: "/nope" },
spawn: async (cmd: string, args: string[]) =>
cmd === "which"
? { code: 1, signal: null, timedOut: false, stdout: "", stderr: "" }
: cmd === "python3" && args[0] === "--version"
? { code: 0, signal: null, timedOut: false, stdout: "Python 3.11.9\n", stderr: "" }
: { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" },
});
const code = await run(["doctor"], ctx);
assert.equal(code, 1);
assert.ok(
ctx.err().includes("AI_WEB_STATIC_DIR") && ctx.err().includes("export"),
"names the export failure",
);
});
// -- verify container branch ----------------------------------------------
test("container: verify probes mount + import + live listener", async () => {
const ctx = containerCtx({
spawn: async (cmd: string, args: string[]) => {
const script = args.join(" ");
if (cmd === "python3" && script.includes("import ai_service")) {
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
}
if (cmd === "python3" && script.includes("urllib.request")) {
return { code: 0, signal: null, timedOut: false, stdout: "HTML\n", stderr: "" };
}
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
},
});
const code = await run(["verify"], ctx);
assert.equal(code, 0);
assert.ok(ctx.out().includes("docker image"), "reports the channel");
assert.ok(ctx.out().includes("web export"), "checks the export");
assert.ok(ctx.out().includes("importable"), "checks the venv import");
assert.ok(ctx.out().includes("live mount probe"), "probes the listener");
});
test("container: verify catches the JSON-404 site-down class", async () => {
const ctx = containerCtx({
spawn: async (cmd: string, args: string[]) => {
const script = args.join(" ");
if (cmd === "python3" && script.includes("urllib.request")) {
return {
code: 0,
signal: null,
timedOut: false,
stdout: "OTHER:{\"detail\":\"Not Found\"}\n",
stderr: "",
};
}
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
},
});
const code = await run(["verify"], ctx);
assert.equal(code, 1);
assert.ok(ctx.err().includes("JSON 404"), "names the site-down class");
});
// -- non-container path unchanged -------------------------------------------
test("host: commands run as before when NC_CONTAINER is unset", async () => {
const env = { ...process.env } as Record<string, string | undefined>;
delete env.NC_CONTAINER;
const ctx = testCtx({
env,
exists: () => false,
cwd: "/definitely/not/a/repo",
});
const code = await run(["bootstrap"], ctx);
// Not refused-as-container: it fails on the repo check instead (exit 1,
// clone hint — the v0.4 behavior)
assert.equal(code, 1);
assert.ok(ctx.err().includes("not inside a nextcraft clone"));
});