/** * @file Tests for Node.js project detection and profile scaffolding * (server/lib/lane-detect.js): layout precedence, script selection, the * shell-identifier validator, scaffold output, and the profile checker. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { describe, it, after } = require("node:test"); const assert = require("node:assert/strict"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-")); // Must be set before the first require of anything that transitively loads // server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH // is bound once at that module's first load, and without this a test run on a // machine with a real ~/.ccam/secrets.env would read that file instead of a // deterministic, isolated one. process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env"); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); const detect = require("../lib/lane-detect"); let repoSeq = 0; function makeRepo() { repoSeq += 1; const dir = path.join(SUITE_ROOT, `repo-${repoSeq}`); fs.mkdirSync(dir, { recursive: true }); return dir; } function writePkg(dir, scripts) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts }, null, 2)); } describe("detectNode: layout", () => { it("returns null when nothing is detected", () => { const repo = makeRepo(); assert.equal(detect.detectNode(repo), null); }); it("detects single-service from a root package.json", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); assert.equal(facts.layout, "single-service"); assert.equal(facts.services.app.script, "start"); }); it("prefers monorepo when both backend/ and frontend/ package.json exist, even with a root one too", () => { const repo = makeRepo(); writePkg(repo, { start: "node root.js" }); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); const facts = detect.detectNode(repo); assert.equal(facts.layout, "monorepo"); assert.equal(facts.services.backend.script, "start"); assert.equal(facts.services.frontend.script, "dev"); }); it("does not detect a backend/ with no matching frontend/", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); assert.equal(detect.detectNode(repo), null); }); }); describe("detectNode: script selection", () => { it("backend prefers start over dev", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node a.js", dev: "node b.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); assert.equal(detect.detectNode(repo).services.backend.script, "start"); }); it("frontend prefers preview over dev", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node a.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite", preview: "vite preview" }); assert.equal(detect.detectNode(repo).services.frontend.script, "preview"); }); it("falls back to null (not a guess) when no candidate script exists", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { test: "jest" }); writePkg(path.join(repo, "frontend"), { build: "vite build" }); const facts = detect.detectNode(repo); assert.equal(facts.services.backend.script, null); assert.equal(facts.services.frontend.script, null); }); }); describe("IDENTIFIER_RE", () => { it("accepts plain identifiers", () => { for (const ok of ["start", "dev:watch", "build.prod", "api-server"]) { assert.ok(detect.IDENTIFIER_RE.test(ok), ok); } }); it("rejects shell metacharacters", () => { for (const bad of ["start; rm -rf /", "$(id)", "a b", "a|b", "a`b`"]) { assert.ok(!detect.IDENTIFIER_RE.test(bad), bad); } }); }); function writeCompose(repo, servicesYaml) { fs.writeFileSync(path.join(repo, "docker-compose.yml"), `services:\n${servicesYaml}\n`); } describe("detectNode: database + Redis + env wiring", () => { it("detects a postgres service in docker-compose.yml", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); const facts = detect.detectNode(repo); assert.equal(facts.database.dbKind, "postgres"); assert.equal(facts.database.dbService, "postgres"); assert.match(facts.database.dbPrefix, /_l$/); assert.equal(facts.redis, false); }); it("detects redis independently of postgres", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " cache_redis:\n image: redis:7\n"); const facts = detect.detectNode(repo); assert.equal(facts.database, null); assert.equal(facts.redis, true); }); it("finds no database when there is no docker-compose.yml", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); assert.equal(facts.database, null); assert.equal(facts.redis, false); assert.equal(facts.env, null); }); it("wires ENV_REWRITE when a database is found and backend/.env.example exists", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n"); fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n"); const facts = detect.detectNode(repo); assert.equal(facts.env.file, "backend/.env"); assert.equal(facts.env.source, "backend/.env.example"); assert.equal(facts.env.fromExample, true); assert.deepEqual(facts.env.rewrite, ["DATABASE_URL", "REDIS_URL"]); }); it("leaves env null when a database is found but there is no .env or .env.example", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); assert.equal(detect.detectNode(repo).env, null); }); it("rejects a docker-compose service name that fails the shell-identifier check", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, ' "postgres; rm -rf /":\n image: postgres:16\n'); assert.equal(detect.detectNode(repo).database, null); }); }); describe("scaffoldProfile", () => { it("writes a single-service profile with no database: bootstrap/boot/health only, zero TODOs", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); const result = detect.scaffoldProfile(repo, facts); const profileDir = path.join(repo, ".ccam", "profile"); const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8"); assert.match(env, /PORTS="app"/); assert.match(env, /PORT_BASE_app=3000/); assert.doesNotMatch(env, /DB_PREFIX/); assert.doesNotMatch(env, /TODO/); for (const hook of ["bootstrap.sh", "boot.sh", "health.sh"]) { const hookPath = path.join(profileDir, "hooks", hook); assert.ok(fs.existsSync(hookPath), hook); assert.ok(fs.statSync(hookPath).mode & 0o111, `${hook} must be executable`); } assert.match( fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"), /npm run start/ ); assert.equal(result.todos.length, 0); }); it("writes a monorepo profile with a database: DB_PREFIX, ENV_REWRITE, copied db hooks, TODO migrate/seed", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); // no preview -> exercises dev fallback writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n"); fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n"); const facts = detect.detectNode(repo); const result = detect.scaffoldProfile(repo, facts); const profileDir = path.join(repo, ".ccam", "profile"); const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8"); assert.match(env, /DB_PREFIX=/); assert.match(env, /REDIS=1/); assert.match(env, /ENV_FILES="backend\/\.env"/); assert.match(env, /ENV_REWRITE="DATABASE_URL REDIS_URL"/); for (const hook of ["db-create.sh", "db-drop.sh", "migrate.sh", "seed.sh"]) { assert.ok(fs.existsSync(path.join(profileDir, "hooks", hook)), hook); } assert.match(fs.readFileSync(path.join(profileDir, "hooks", "migrate.sh"), "utf8"), /exit 0/); assert.match( fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"), /npm run dev/ // frontend fallback, no preview script ); assert.ok(result.todos.length >= 2, "migrate and seed are both TODO"); }); it("writes a TODO boot line (never a guess) when no start/dev script was found", () => { const repo = makeRepo(); writePkg(repo, { test: "jest" }); // no start, no dev const facts = { layout: "single-service", services: { app: { dir: ".", script: null } }, database: null, redis: false, env: null, }; const result = detect.scaffoldProfile(repo, facts); const boot = fs.readFileSync(path.join(repo, ".ccam", "profile", "hooks", "boot.sh"), "utf8"); assert.match(boot, /# TODO: no start\/dev script found/); assert.equal(result.todos.length, 1); }); it("refuses to overwrite an existing profile without force", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); detect.scaffoldProfile(repo, facts); assert.throws( () => detect.scaffoldProfile(repo, facts), (err) => err.code === "EPROFILEEXISTS" ); }); it("overwrites when force is true", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); detect.scaffoldProfile(repo, facts); assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true })); }); }); describe("checkProfile", () => { it("passes a freshly-scaffolded no-database profile with a real script", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); const result = await detect.checkProfile(repo); assert.deepEqual(result.errors, []); assert.equal(result.ok, true); }); it("fails on a database profile with unresolved migrate/seed TODOs", async () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); detect.scaffoldProfile(repo, detect.detectNode(repo)); const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /TODO/.test(e))); }); it("fails when a declared port is already in use", async () => { const net = require("node:net"); const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); const server = net.createServer(() => {}); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(3000, "127.0.0.1", resolve); }); try { const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /3000/.test(e))); } finally { server.close(); } }); it("fails when a referenced hook is missing", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh")); const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /health/.test(e))); }); it("warns (does not fail) when secrets.env is missing for a database profile", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); detect.scaffoldProfile(repo, detect.detectNode(repo)); // Remove the migrate/seed TODO lines so only the secrets warning is left to check. const hooksDir = path.join(repo, ".ccam", "profile", "hooks"); fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n"); fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n"); const result = await detect.checkProfile(repo); assert.ok(result.warnings.some((w) => /secrets\.env/.test(w))); }); });