57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
/**
|
|
* @file cc-discovery-helpers.test.js
|
|
* @description Direct unit tests for the exported helpers in
|
|
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
|
|
* isFileLike, the symlinked skill-directory and agent/command markdown-file
|
|
* discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES
|
|
* constant. The integration tests in
|
|
* cc-config.test.js exercise these indirectly through HTTP routes; this
|
|
* file pins down their behavior at the function level so future refactors
|
|
* surface regressions immediately.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const {
|
|
parseFrontmatter,
|
|
redactSettings,
|
|
isUnder,
|
|
isFileLike,
|
|
readSkills,
|
|
readAgents,
|
|
MAX_FILE_BYTES,
|
|
HOOK_EVENT_TYPES,
|
|
} = require("../lib/cc-discovery");
|
|
|
|
describe("parseFrontmatter", () => {
|
|
it("returns null frontmatter for plain markdown", () => {
|
|
const r = parseFrontmatter("# Just a heading\n\nNo frontmatter here.");
|
|
assert.equal(r.frontmatter, null);
|
|
assert.match(r.body, /Just a heading/);
|
|
});
|
|
|
|
it("parses simple key/value frontmatter", () => {
|
|
const r = parseFrontmatter("---\nname: my-skill\ndescription: A simple skill\n---\nbody text");
|
|
assert.equal(r.frontmatter.name, "my-skill");
|
|
assert.equal(r.frontmatter.description, "A simple skill");
|
|
assert.equal(r.body.trim(), "body text");
|
|
});
|
|
|
|
it("strips surrounding double quotes from values", () => {
|
|
const r = parseFrontmatter('---\nname: "quoted name"\n---\n');
|
|
assert.equal(r.frontmatter.name, "quoted name");
|
|
});
|
|
|
|
it("strips surrounding single quotes from values", () => {
|
|
const r = parseFrontmatter("---\nname: 'single quoted'\n---\n");
|
|
assert.equal(r.frontmatter.name, "single quoted");
|
|
});
|
|
|
|
it("preserves quotes inside the value", () => {
|
|
const r = parseFrontmatter('---\ndescription: text with "inner" quotes\n---\n');
|
|
assert.equal(r.frontmatter.description, 'text with "inner" quotes');
|
|
});
|
|
|
|
it("supports multiline indented continuation values", () => {
|
|
const r = parseFrontmatter(
|
|
"---\ndescription: line one\n line two\n line three\nname: x\n---\n"
|
|
);
|
|
assert.match(r.frontmatter.description, /line one/);
|
|
assert.match(r.frontmatter.description, /line two/);
|
|
assert.match(r.frontmatter.description, /line three/);
|
|
assert.equal(r.frontmatter.name, "x");
|
|
});
|
|
|
|
it("returns null frontmatter when --- is present but never closed", () => {
|
|
const r = parseFrontmatter("---\nname: never closed\nbody text below");
|
|
assert.equal(r.frontmatter, null);
|
|
// body falls back to the entire input
|
|
assert.match(r.body, /never closed/);
|
|
});
|
|
|
|
it("ignores lines that don't match key:value", () => {
|
|
const r = parseFrontmatter("---\nname: ok\nthis is not a key value\n---\n");
|
|
assert.equal(r.frontmatter.name, "ok");
|
|
assert.equal(r.frontmatter["this is not a key value"], undefined);
|
|
});
|
|
|
|
it("handles non-string input gracefully", () => {
|
|
const r1 = parseFrontmatter(null);
|
|
const r2 = parseFrontmatter(undefined);
|
|
const r3 = parseFrontmatter(123);
|
|
for (const r of [r1, r2, r3]) {
|
|
assert.equal(r.frontmatter, null);
|
|
assert.equal(r.body, "");
|
|
}
|
|
});
|
|
|
|
it("trims trailing whitespace in values", () => {
|
|
const r = parseFrontmatter("---\nname: trailing-space \n---\n");
|
|
assert.equal(r.frontmatter.name, "trailing-space");
|
|
});
|
|
|
|
it("body preserves its content verbatim after the closing ---", () => {
|
|
const r = parseFrontmatter("---\nname: x\n---\n\n# Heading\n\nParagraph.");
|
|
assert.match(r.body, /^# Heading/);
|
|
assert.match(r.body, /Paragraph\.$/);
|
|
});
|
|
});
|
|
|
|
describe("redactSettings", () => {
|
|
it("redacts string values whose key matches token/secret/password/key/auth", () => {
|
|
const input = {
|
|
apiKey: "sk-abc123",
|
|
authToken: "bearer xyz",
|
|
password: "hunter2",
|
|
secret_key: "shh",
|
|
apiSecret: "private",
|
|
regularField: "stays",
|
|
};
|
|
const out = redactSettings(input);
|
|
assert.equal(out.apiKey, "<redacted>");
|
|
assert.equal(out.authToken, "<redacted>");
|
|
assert.equal(out.password, "<redacted>");
|
|
assert.equal(out.secret_key, "<redacted>");
|
|
assert.equal(out.apiSecret, "<redacted>");
|
|
assert.equal(out.regularField, "stays");
|
|
});
|
|
|
|
it("handles api-key and api_key variants", () => {
|
|
const out = redactSettings({ "api-key": "x", api_key: "y", apikey: "z" });
|
|
assert.equal(out["api-key"], "<redacted>");
|
|
assert.equal(out.api_key, "<redacted>");
|
|
assert.equal(out.apikey, "<redacted>");
|
|
});
|
|
|
|
it("does not redact non-string values even if key matches", () => {
|
|
// The redactor only swaps STRING values; nested objects/arrays/numbers
|
|
// pass through unchanged so structure is preserved.
|
|
const out = redactSettings({ apiKey: 12345, secrets: { x: "stay" } });
|
|
assert.equal(out.apiKey, 12345);
|
|
assert.deepEqual(out.secrets, { x: "stay" });
|
|
});
|
|
|
|
it("recurses into nested objects", () => {
|
|
const out = redactSettings({
|
|
provider: { name: "anthropic", apiKey: "sk-...", endpoint: "https://api" },
|
|
});
|
|
assert.equal(out.provider.apiKey, "<redacted>");
|
|
assert.equal(out.provider.name, "anthropic");
|
|
assert.equal(out.provider.endpoint, "https://api");
|
|
});
|
|
|
|
it("recurses into arrays of objects", () => {
|
|
const out = redactSettings({
|
|
mcpServers: [
|
|
{ name: "a", apiKey: "sk-1" },
|
|
{ name: "b", token: "tok-2" },
|
|
],
|
|
});
|
|
assert.equal(out.mcpServers[0].apiKey, "<redacted>");
|
|
assert.equal(out.mcpServers[1].token, "<redacted>");
|
|
assert.equal(out.mcpServers[0].name, "a");
|
|
});
|
|
|
|
it("passes scalars through unchanged", () => {
|
|
assert.equal(redactSettings(null), null);
|
|
assert.equal(redactSettings(undefined), undefined);
|
|
assert.equal(redactSettings(42), 42);
|
|
assert.equal(redactSettings(true), true);
|
|
assert.equal(redactSettings("plain"), "plain");
|
|
});
|
|
|
|
it("is case-insensitive on key match", () => {
|
|
const out = redactSettings({ APIKEY: "x", AuthHeader: "y", ToKeN: "z" });
|
|
assert.equal(out.APIKEY, "<redacted>");
|
|
assert.equal(out.AuthHeader, "<redacted>");
|
|
assert.equal(out.ToKeN, "<redacted>");
|
|
});
|
|
|
|
it("does not match keys that contain unrelated substrings", () => {
|
|
// 'kept' contains 'kep' not 'key' - should stay
|
|
const out = redactSettings({ kept: "stays", description: "stays too" });
|
|
assert.equal(out.kept, "stays");
|
|
assert.equal(out.description, "stays too");
|
|
});
|
|
});
|
|
|
|
describe("isUnder", () => {
|
|
it("returns true when target equals root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/b"), true);
|
|
});
|
|
|
|
it("returns true when target is inside root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/b/c"), true);
|
|
assert.equal(isUnder("/a/b", "/a/b/c/d/e.txt"), true);
|
|
});
|
|
|
|
it("returns false when target is outside root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/c"), false);
|
|
assert.equal(isUnder("/a/b", "/x/y"), false);
|
|
});
|
|
|
|
it("rejects sibling paths that share a prefix", () => {
|
|
// /a/b vs /a/bb - bb starts with b but is not under /a/b
|
|
assert.equal(isUnder("/a/b", "/a/bb"), false);
|
|
assert.equal(isUnder("/a/b", "/a/b-extra"), false);
|
|
});
|
|
|
|
it("normalises relative segments via path.resolve", () => {
|
|
// .. inside the target gets resolved away
|
|
assert.equal(isUnder("/a/b", "/a/b/c/../d"), true); // /a/b/d
|
|
assert.equal(isUnder("/a/b", "/a/b/../c"), false); // /a/c
|
|
});
|
|
|
|
it("returns true regardless of trailing slash", () => {
|
|
assert.equal(isUnder("/a/b/", "/a/b/c"), true);
|
|
assert.equal(isUnder("/a/b", "/a/b/c/"), true);
|
|
});
|
|
|
|
it("works with paths produced by path.resolve", () => {
|
|
const root = path.resolve("/tmp/cc-test");
|
|
const inside = path.resolve("/tmp/cc-test/skills/x/SKILL.md");
|
|
const outside = path.resolve("/tmp/elsewhere/file");
|
|
assert.equal(isUnder(root, inside), true);
|
|
assert.equal(isUnder(root, outside), false);
|
|
});
|
|
});
|
|
|
|
describe("readSkills (symlinked skill directories)", () => {
|
|
it("includes a skill directory that is a symlink to a real directory", () => {
|
|
// Dirent.isDirectory() returns false for a symlink even when it points
|
|
// to a directory (Node fs quirk) — readSkillsAt must still follow it.
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-symlink-"));
|
|
const realDir = path.join(tmp, "real-skills", "second-brain");
|
|
fs.mkdirSync(realDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(realDir, "SKILL.md"),
|
|
"---\nname: second-brain\n---\n\nBody text.\n"
|
|
);
|
|
const projectRoot = path.join(tmp, "project");
|
|
const skillsDir = path.join(projectRoot, ".claude", "skills");
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
fs.symlinkSync(realDir, path.join(skillsDir, "second-brain"), "dir");
|
|
|
|
const items = readSkills({ scope: "project", cwd: projectRoot });
|
|
const names = items.map((s) => s.name);
|
|
assert.ok(names.includes("second-brain"), `expected symlinked skill in ${names}`);
|
|
});
|
|
|
|
it("skips a broken symlink without throwing", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-broken-symlink-"));
|
|
const projectRoot = path.join(tmp, "project");
|
|
const skillsDir = path.join(projectRoot, ".claude", "skills");
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
fs.symlinkSync(path.join(tmp, "does-not-exist"), path.join(skillsDir, "broken"), "dir");
|
|
|
|
let items;
|
|
assert.doesNotThrow(() => {
|
|
items = readSkills({ scope: "project", cwd: projectRoot });
|
|
});
|
|
assert.ok(!items.map((s) => s.name).includes("broken"));
|
|
});
|
|
});
|
|
|
|
describe("readAgents (symlinked markdown files)", () => {
|
|
it("includes an agent .md that is a symlink to a real file", () => {
|
|
// Same Dirent quirk as symlinked skill directories, but for files:
|
|
// ent.isFile() is false for a symlink pointing at a regular file, so a
|
|
// version-controlled agent linked into .claude/agents/ was invisible.
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-symlink-"));
|
|
const realFile = path.join(tmp, "repo", "reviewer.md");
|
|
fs.mkdirSync(path.dirname(realFile), { recursive: true });
|
|
fs.writeFileSync(realFile, "---\nname: reviewer\ndescription: Reviews diffs\n---\n\nBody.\n");
|
|
const projectRoot = path.join(tmp, "project");
|
|
const agentsDir = path.join(projectRoot, ".claude", "agents");
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.symlinkSync(realFile, path.join(agentsDir, "reviewer.md"), "file");
|
|
|
|
const items = readAgents({ scope: "project", cwd: projectRoot });
|
|
const names = items.map((a) => a.name);
|
|
assert.ok(names.includes("reviewer"), `expected symlinked agent in ${names}`);
|
|
});
|
|
|
|
it("skips a broken .md symlink without throwing", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-broken-"));
|
|
const projectRoot = path.join(tmp, "project");
|
|
const agentsDir = path.join(projectRoot, ".claude", "agents");
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.symlinkSync(path.join(tmp, "gone.md"), path.join(agentsDir, "ghost.md"), "file");
|
|
|
|
let items;
|
|
assert.doesNotThrow(() => {
|
|
items = readAgents({ scope: "project", cwd: projectRoot });
|
|
});
|
|
assert.ok(!items.some((a) => /ghost/.test(a.file || "")));
|
|
});
|
|
});
|
|
|
|
describe("isFileLike", () => {
|
|
it("true for a regular file dirent", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
|
|
fs.writeFileSync(path.join(tmp, "a.md"), "x");
|
|
const ent = fs.readdirSync(tmp, { withFileTypes: true })[0];
|
|
assert.equal(isFileLike(ent, path.join(tmp, ent.name)), true);
|
|
});
|
|
|
|
it("true for a symlink resolving to a file, false for one resolving to a directory", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-sym-"));
|
|
fs.writeFileSync(path.join(tmp, "real.md"), "x");
|
|
fs.mkdirSync(path.join(tmp, "realdir"));
|
|
fs.symlinkSync(path.join(tmp, "real.md"), path.join(tmp, "file-link.md"), "file");
|
|
fs.symlinkSync(path.join(tmp, "realdir"), path.join(tmp, "dir-link"), "dir");
|
|
const ents = Object.fromEntries(
|
|
fs.readdirSync(tmp, { withFileTypes: true }).map((e) => [e.name, e])
|
|
);
|
|
assert.equal(isFileLike(ents["file-link.md"], path.join(tmp, "file-link.md")), true);
|
|
assert.equal(isFileLike(ents["dir-link"], path.join(tmp, "dir-link")), false);
|
|
});
|
|
});
|
|
|
|
describe("module exports", () => {
|
|
it("MAX_FILE_BYTES is 256 KB", () => {
|
|
assert.equal(MAX_FILE_BYTES, 256 * 1024);
|
|
});
|
|
|
|
it("HOOK_EVENT_TYPES covers all canonical Claude Code events", () => {
|
|
const expected = [
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"UserPromptSubmit",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SubagentStop",
|
|
"Notification",
|
|
"PreCompact",
|
|
];
|
|
for (const t of expected) assert.ok(HOOK_EVENT_TYPES.includes(t), `missing ${t}`);
|
|
});
|
|
});
|