项目文件夹

文件
Anton Shpakovsky 948f017e75 test(workspace): cover line layout, showProject false, narrow widths
- Line layout renders 'Added dirs: a, b' on a separate line, the
  inline + prefix is suppressed, and two dirs do not trigger overflow.
- Added dirs still render when display.showProject is false (inline);
  the model bracket still renders as a sanity check.
- Caps (5 dirs / 24-char basenames) apply in the line layout too.
2026-04-30 10:23:07 +05:00

556 行
19 KiB
JavaScript

此文件含有不可见的 Unicode 字符
此文件含有人类无法区分的不可见的 Unicode 字符,但可以由计算机进行不同的处理。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { readFileSync } from "node:fs";
function stripAnsi(text) {
return text
.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g,
"",
)
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
}
function skipIfSpawnBlocked(result, t) {
if (result.error?.code === "EPERM") {
t.skip("spawnSync is blocked by sandbox policy in this environment");
return true;
}
return false;
}
test("CLI renders expected output for a basic transcript", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const expectedPath = fileURLToPath(
new URL("./fixtures/expected/render-basic.txt", import.meta.url),
);
const expected = readFileSync(expectedPath, "utf8").trimEnd();
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
// Use a fixed 3-level path for deterministic test output
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
await import("node:fs/promises").then((fs) =>
fs.mkdir(projectDir, { recursive: true }),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const normalized = stripAnsi(result.stdout)
.replace(/\u00A0/g, " ")
.trimEnd();
if (process.env.UPDATE_SNAPSHOTS === "1") {
await writeFile(expectedPath, normalized + "\n", "utf8");
return;
}
assert.equal(normalized, expected);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI renders added_dirs basenames on the project line", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const addedDirA = path.join(homeDir, "dev", "apps", "lib-foo");
const addedDirB = path.join(homeDir, "dev", "apps", "some-other-repo");
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(addedDirA, { recursive: true }),
fs.mkdir(addedDirB, { recursive: true }),
]),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: {
current_dir: projectDir,
added_dirs: [addedDirA, addedDirB],
},
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.match(firstLine, /\+lib-foo/);
assert.match(firstLine, /\+some-other-repo/);
assert.ok(
firstLine.indexOf("my-project") < firstLine.indexOf("+lib-foo"),
"added dirs should come after the project name",
);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI omits added dirs section when array is empty", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
await import("node:fs/promises").then((fs) =>
fs.mkdir(projectDir, { recursive: true }),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { current_dir: projectDir, added_dirs: [] },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.doesNotMatch(firstLine, /\+/);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI tolerates added_dirs: null without crashing", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
await import("node:fs/promises").then((fs) =>
fs.mkdir(projectDir, { recursive: true }),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: null },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.doesNotMatch(firstLine, /\+/);
assert.doesNotMatch(stripAnsi(result.stdout), /Added dirs:/);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI ignores non-string and post-sanitize-empty added_dirs entries", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const validA = path.join(homeDir, "dev", "apps", "valid-one");
const validB = path.join(homeDir, "dev", "apps", "valid-two");
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(validA, { recursive: true }),
fs.mkdir(validB, { recursive: true }),
]),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: {
added_dirs: [validA, null, 42, "", { foo: 1 }, "", validB],
},
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.match(firstLine, /\+valid-one/);
assert.match(firstLine, /\+valid-two/);
const plusCount = (firstLine.match(/\+valid-/g) || []).length;
assert.equal(plusCount, 2, "only the two valid basenames should render");
assert.doesNotMatch(firstLine, /\+ /, "no bare '+' from control-char-only basename");
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI caps inline added_dirs at 5 with overflow indicator", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const dirs = Array.from({ length: 7 }, (_, i) =>
path.join(homeDir, "dev", "apps", `dir-${i + 1}`),
);
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
...dirs.map((d) => fs.mkdir(d, { recursive: true })),
]),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: dirs },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
for (let i = 1; i <= 5; i++) {
assert.match(firstLine, new RegExp(`\\+dir-${i}\\b`));
}
assert.doesNotMatch(firstLine, /\+dir-6\b/);
assert.doesNotMatch(firstLine, /\+dir-7\b/);
assert.match(firstLine, /\+2 more/);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI truncates long inline added_dirs basenames", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const longName = "a".repeat(40);
const longDir = path.join(homeDir, "dev", "apps", longName);
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(longDir, { recursive: true }),
]),
);
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: [longDir] },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.match(firstLine, /\+a+…/, "long basename should be truncated with ellipsis");
assert.doesNotMatch(firstLine, new RegExp(`\\+${longName}`));
const m = firstLine.match(/\+(a+…)/);
assert.ok(m && m[1].length <= 24, `truncated name should be ≤24 chars, got ${m && m[1].length}`);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
async function writeHudConfig(homeDir, config) {
const fs = await import("node:fs/promises");
const dir = path.join(homeDir, ".claude", "plugins", "claude-hud");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "config.json"), JSON.stringify(config), "utf8");
}
test("CLI renders line layout 'Added dirs:' on a separate line", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const dirA = path.join(homeDir, "dev", "apps", "shared-utils");
const dirB = path.join(homeDir, "dev", "apps", "mobile-app");
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(dirA, { recursive: true }),
fs.mkdir(dirB, { recursive: true }),
]),
);
await writeHudConfig(homeDir, { display: { addedDirsLayout: "line" } });
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: [dirA, dirB] },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const lines = stripAnsi(result.stdout).split("\n");
assert.doesNotMatch(lines[0], /\+shared-utils|\+mobile-app/, "inline + prefix should not appear in line mode");
const dirsLine = lines.find((l) => l.includes("Added dirs:"));
assert.ok(dirsLine, `expected an 'Added dirs:' line, got:\n${result.stdout}`);
assert.match(dirsLine, /shared-utils/);
assert.match(dirsLine, /mobile-app/);
assert.match(dirsLine, /shared-utils,\s*mobile-app/);
assert.doesNotMatch(dirsLine, /\bmore\b/, "two dirs should not trigger overflow");
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI renders inline added_dirs even when showProject is false", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const addedDir = path.join(homeDir, "dev", "apps", "lib-foo");
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(addedDir, { recursive: true }),
]),
);
await writeHudConfig(homeDir, { display: { showProject: false } });
try {
const stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: [addedDir] },
});
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const firstLine = stripAnsi(result.stdout).split("\n")[0];
assert.match(firstLine, /\[Opus\]/, "model bracket should still render (sanity)");
assert.doesNotMatch(firstLine, /my-project/, "project name should be hidden");
assert.match(firstLine, /\+lib-foo/, "added dirs should still render");
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI applies caps in line layout (overflow + truncation)", async (t) => {
const fixturePath = fileURLToPath(
new URL("./fixtures/transcript-render.jsonl", import.meta.url),
);
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
const projectDir = path.join(homeDir, "dev", "apps", "my-project");
const dirs = Array.from({ length: 7 }, (_, i) =>
path.join(homeDir, "dev", "apps", `dir-${i + 1}`),
);
const longName = "b".repeat(40);
const longDir = path.join(homeDir, "dev", "apps", longName);
await import("node:fs/promises").then((fs) =>
Promise.all([
fs.mkdir(projectDir, { recursive: true }),
fs.mkdir(longDir, { recursive: true }),
...dirs.map((d) => fs.mkdir(d, { recursive: true })),
]),
);
await writeHudConfig(homeDir, { display: { addedDirsLayout: "line" } });
try {
let stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: dirs },
});
let result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
let dirsLine = stripAnsi(result.stdout).split("\n").find((l) => l.includes("Added dirs:"));
assert.ok(dirsLine, `expected 'Added dirs:' line, got:\n${result.stdout}`);
assert.match(dirsLine, /dir-1/);
assert.match(dirsLine, /dir-5/);
assert.doesNotMatch(dirsLine, /dir-6/);
assert.doesNotMatch(dirsLine, /dir-7/);
assert.match(dirsLine, /\+2 more/);
stdin = JSON.stringify({
model: { display_name: "Opus" },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
workspace: { added_dirs: [longDir] },
});
result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.status, 0, result.stderr || "non-zero exit");
dirsLine = stripAnsi(result.stdout).split("\n").find((l) => l.includes("Added dirs:"));
assert.ok(dirsLine, `expected 'Added dirs:' line, got:\n${result.stdout}`);
assert.match(dirsLine, /b+…/);
assert.doesNotMatch(dirsLine, new RegExp(longName));
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test("CLI prints initializing message on empty stdin", async (t) => {
const homeDir = await mkdtemp(path.join(tmpdir(), "claude-hud-home-"));
try {
const result = spawnSync("node", ["dist/index.js"], {
cwd: path.resolve(process.cwd()),
input: "",
encoding: "utf8",
env: { ...process.env, HOME: homeDir, LANG: "C" },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || "non-zero exit");
const normalized = stripAnsi(result.stdout)
.replace(/\u00A0/g, " ")
.trimEnd();
assert.ok(
normalized.startsWith("[claude-hud] Initializing..."),
`unexpected output: ${normalized}`,
);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});