feat(lanes): detect a Node.js project's layout and boot scripts (A3)

This commit is contained in:
2026-08-04 09:06:45 +07:00
parent 2c313084a8
commit 0ecc2eec24
2 changed files with 195 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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);
}
});
});