mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
test: 新增测试代码文件
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -20,9 +20,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: bun run lint
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: bun test
|
run: bun test
|
||||||
|
|
||||||
|
|||||||
32
CLAUDE.md
32
CLAUDE.md
@@ -12,27 +12,38 @@ This is a **reverse-engineered / decompiled** version of Anthropic's official Cl
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Dev mode (direct execution via Bun)
|
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
|
||||||
bun run dev
|
bun run dev
|
||||||
# equivalent to: bun run src/entrypoints/cli.tsx
|
|
||||||
|
|
||||||
# Pipe mode
|
# Pipe mode
|
||||||
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||||
|
|
||||||
# Build (outputs dist/cli.js, ~25MB)
|
# Build (code splitting, outputs dist/cli.js + ~450 chunk files)
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
|
# Test
|
||||||
|
bun test # run all tests
|
||||||
|
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||||
|
bun test --coverage # with coverage report
|
||||||
|
|
||||||
|
# Lint & Format (Biome)
|
||||||
|
bun run lint # check only
|
||||||
|
bun run lint:fix # auto-fix
|
||||||
|
bun run format # format all src/
|
||||||
```
|
```
|
||||||
|
|
||||||
No test runner is configured. No linter is configured.
|
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Runtime & Build
|
### Runtime & Build
|
||||||
|
|
||||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||||
- **Build**: `bun build src/entrypoints/cli.tsx --outdir dist --target bun` — single-file bundle.
|
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + ~450 chunk files。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||||
|
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。`scripts/defines.ts` 集中管理 define map。
|
||||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||||
- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`.
|
- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`.
|
||||||
|
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||||
|
|
||||||
### Entry & Bootstrap
|
### Entry & Bootstrap
|
||||||
|
|
||||||
@@ -106,6 +117,15 @@ All `feature('FLAG_NAME')` calls come from `bun:bundle` (a build-time API). In t
|
|||||||
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
|
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
|
||||||
- **`src/types/permissions.ts`** — Permission mode and result types.
|
- **`src/types/permissions.ts`** — Permission mode and result types.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **框架**: `bun:test`(内置断言 + mock)
|
||||||
|
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||||
|
- **集成测试**: `tests/integration/`,共享 mock/fixture 在 `tests/mocks/`
|
||||||
|
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||||
|
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
||||||
|
- **当前状态**: 1286 tests / 67 files / 0 fail(详见 `docs/testing-spec.md` 的覆盖状态表和评分)
|
||||||
|
|
||||||
## Working with This Codebase
|
## Working with This Codebase
|
||||||
|
|
||||||
- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime.
|
- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime.
|
||||||
@@ -113,3 +133,5 @@ All `feature('FLAG_NAME')` calls come from `bun:bundle` (a build-time API). In t
|
|||||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||||
- **`bun:bundle` import** — In `src/main.tsx` and other files, `import { feature } from 'bun:bundle'` works at build time. At dev-time, the polyfill in `cli.tsx` provides it.
|
- **`bun:bundle` import** — In `src/main.tsx` and other files, `import { feature } from 'bun:bundle'` works at build time. At dev-time, the polyfill in `cli.tsx` provides it.
|
||||||
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
||||||
|
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||||
|
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ tests/
|
|||||||
|
|
||||||
## 4. 当前覆盖状态
|
## 4. 当前覆盖状态
|
||||||
|
|
||||||
> 更新日期:2026-04-02 | **1177 tests, 64 files, 0 fail, 837ms**
|
> 更新日期:2026-04-02 | **1297 tests, 68 files, 0 fail, 980ms**
|
||||||
|
|
||||||
### 4.1 可靠度评分
|
### 4.1 可靠度评分
|
||||||
|
|
||||||
@@ -228,21 +228,34 @@ Spec 定义的三个集成测试均未创建:
|
|||||||
|
|
||||||
**约束**:`mock.module()` 必须在每个测试文件内联调用,不能从共享 helper 导入。
|
**约束**:`mock.module()` 必须在每个测试文件内联调用,不能从共享 helper 导入。
|
||||||
|
|
||||||
## 6. 改进计划
|
## 6. 完成状态
|
||||||
|
|
||||||
### 优先级排序
|
> 更新日期:2026-04-02 | **1297 tests, 68 files, 0 fail, 980ms**
|
||||||
|
|
||||||
| 优先级 | 任务 | 预期效果 |
|
### 已完成
|
||||||
|--------|------|----------|
|
|
||||||
| **高** | 修复 8 个 WEAK 文件的断言缺陷 | 消除假阳性风险 |
|
| 计划 | 状态 | 新增测试 | 说明 |
|
||||||
| **高** | 补 `gitOperationTracking.test.ts` 的 analytics mock | 消除测试副作用 |
|
|------|------|---------|------|
|
||||||
| **高** | 验证 `envValidation.test.ts` 潜在 bug | 排除源码缺陷 |
|
| Plan 12 — Mock 可靠性 | **已完成** | +9 | PermissionMode ant false 路径、providers env 快照恢复 |
|
||||||
| **中** | 搭建 `tests/mocks/` 基础设施 | 为集成测试铺路 |
|
| Plan 10 — WEAK 修复 | **已完成** | +15 | format 断言精确化、envValidation 修正、zodToJsonSchema/destructors/notebook 加固 |
|
||||||
| **中** | 编写 `tests/integration/tool-chain.test.ts` | 覆盖 Tool 注册→发现→执行链路 |
|
| Plan 13 — CJK/Emoji | **已完成** | +17 | truncate CJK/emoji 宽度感知测试 |
|
||||||
| **中** | 补 `truncate.test.ts` CJK/emoji 测试 | 覆盖核心场景 |
|
| Plan 11 — ACCEPTABLE 加强 | **已完成** | +62 | diff/uuid/hash/semver/path/claudemd/fileEdit/providers/messages 等 15 文件 |
|
||||||
| **低** | 补 `claudemd.test.ts` 核心逻辑 | 提升 P0 模块覆盖率 |
|
| Plan 14 — 集成测试 | **已完成** | +43 | 搭建 tests/mocks/ + tool-chain/context-build/message-pipeline/cli-arguments |
|
||||||
| **低** | 补 CLI 参数测试 (`main.tsx`) | 完成 P1 覆盖 |
|
| Plan 15 — CLI + 覆盖率 | **已完成** | +11 | Commander.js 参数解析、覆盖率基线 |
|
||||||
| **低** | 运行 `bun test --coverage` 建立基线 | 量化覆盖率 |
|
|
||||||
|
### 覆盖率基线
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| 总测试数 | 1297 |
|
||||||
|
| 测试文件数 | 68 |
|
||||||
|
| 失败数 | 0 |
|
||||||
|
| 断言数 | 1990 |
|
||||||
|
| 运行耗时 | ~1s |
|
||||||
|
| Tool.ts 行覆盖率 | 100% |
|
||||||
|
| 整体行覆盖率 | ~33%(Bun coverage 限制:`mock.module` 模式下的模块不报告) |
|
||||||
|
|
||||||
|
> **注意**:Bun `--coverage` 仅报告测试 import 链中直接加载的文件。使用 `mock.module()` + `await import()` 模式的源文件(大多数 `src/utils/` 纯函数)不显示在覆盖率报告中。实际测试覆盖率高于报告值。
|
||||||
|
|
||||||
### 不纳入计划
|
### 不纳入计划
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ describe("stripTrailingWhitespace", () => {
|
|||||||
test("handles no trailing whitespace", () => {
|
test("handles no trailing whitespace", () => {
|
||||||
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
|
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles CR-only line endings", () => {
|
||||||
|
expect(stripTrailingWhitespace("hello \rworld ")).toBe("hello\rworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles content with no trailing newline", () => {
|
||||||
|
expect(stripTrailingWhitespace("hello ")).toBe("hello");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── findActualString ───────────────────────────────────────────────────
|
// ─── findActualString ───────────────────────────────────────────────────
|
||||||
@@ -129,6 +137,26 @@ describe("preserveQuoteStyle", () => {
|
|||||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
|
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
|
||||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
|
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("converts straight single quotes to curly in replacement", () => {
|
||||||
|
const oldString = "'hello'";
|
||||||
|
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}hello${RIGHT_SINGLE_CURLY_QUOTE}`;
|
||||||
|
const newString = "'world'";
|
||||||
|
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||||
|
expect(result).toContain(LEFT_SINGLE_CURLY_QUOTE);
|
||||||
|
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats apostrophe in contraction as right curly quote", () => {
|
||||||
|
const oldString = "'it's a test'";
|
||||||
|
const actualOldString = `${LEFT_SINGLE_CURLY_QUOTE}it${RIGHT_SINGLE_CURLY_QUOTE}s a test${RIGHT_SINGLE_CURLY_QUOTE}`;
|
||||||
|
const newString = "'don't worry'";
|
||||||
|
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||||
|
// The leading ' at position 0 should be LEFT_SINGLE_CURLY_QUOTE
|
||||||
|
expect(result[0]).toBe(LEFT_SINGLE_CURLY_QUOTE);
|
||||||
|
// The apostrophe in "don't" (between n and t) should be RIGHT_SINGLE_CURLY_QUOTE
|
||||||
|
expect(result).toContain(RIGHT_SINGLE_CURLY_QUOTE);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||||
@@ -161,4 +189,20 @@ describe("applyEditToFile", () => {
|
|||||||
test("handles empty original content with insertion", () => {
|
test("handles empty original content with insertion", () => {
|
||||||
expect(applyEditToFile("", "", "new content")).toBe("new content");
|
expect(applyEditToFile("", "", "new content")).toBe("new content");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles multiline oldString and newString", () => {
|
||||||
|
const content = "line1\nline2\nline3\n";
|
||||||
|
const result = applyEditToFile(content, "line2\nline3", "replaced");
|
||||||
|
expect(result).toBe("line1\nreplaced\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles multiline replacement across multiple lines", () => {
|
||||||
|
const content = "header\nold line A\nold line B\nfooter\n";
|
||||||
|
const result = applyEditToFile(
|
||||||
|
content,
|
||||||
|
"old line A\nold line B",
|
||||||
|
"new line X\nnew line Y"
|
||||||
|
);
|
||||||
|
expect(result).toBe("header\nnew line X\nnew line Y\nfooter\n");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,4 +83,20 @@ describe("CircularBuffer", () => {
|
|||||||
buf.add("c");
|
buf.add("c");
|
||||||
expect(buf.toArray()).toEqual(["b", "c"]);
|
expect(buf.toArray()).toEqual(["b", "c"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("capacity=1 keeps only the most recent item", () => {
|
||||||
|
const buf = new CircularBuffer<number>(1);
|
||||||
|
buf.add(10);
|
||||||
|
expect(buf.toArray()).toEqual([10]);
|
||||||
|
buf.add(20);
|
||||||
|
expect(buf.toArray()).toEqual([20]);
|
||||||
|
buf.add(30);
|
||||||
|
expect(buf.toArray()).toEqual([30]);
|
||||||
|
expect(buf.getRecent(1)).toEqual([30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getRecent on empty buffer returns empty array", () => {
|
||||||
|
const buf = new CircularBuffer<number>(5);
|
||||||
|
expect(buf.getRecent(3)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ describe("parseArguments", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles escaped quotes inside quoted strings", () => {
|
||||||
|
expect(parseArguments('foo "hello \\"world\\"" baz')).toEqual([
|
||||||
|
"foo",
|
||||||
|
'hello "world"',
|
||||||
|
"baz",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("returns empty for empty string", () => {
|
test("returns empty for empty string", () => {
|
||||||
expect(parseArguments("")).toEqual([]);
|
expect(parseArguments("")).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -101,6 +109,16 @@ describe("substituteArguments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("replaces out-of-range index with empty string", () => {
|
||||||
|
expect(substituteArguments("$5", "hello world")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reuses same placeholder multiple times", () => {
|
||||||
|
expect(substituteArguments("cmd $0 $1 $0", "alpha beta")).toBe(
|
||||||
|
"cmd alpha beta alpha"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("replaces named arguments", () => {
|
test("replaces named arguments", () => {
|
||||||
expect(
|
expect(
|
||||||
substituteArguments("file: $name", "test.txt", true, ["name"])
|
substituteArguments("file: $name", "test.txt", true, ["name"])
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ describe("stripHtmlComments", () => {
|
|||||||
expect(result.content).toContain("<!-- inline -->");
|
expect(result.content).toContain("<!-- inline -->");
|
||||||
expect(result.stripped).toBe(false);
|
expect(result.stripped).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("leaves unclosed HTML comment unchanged", () => {
|
||||||
|
const result = stripHtmlComments("<!-- no close some text");
|
||||||
|
expect(result.content).toBe("<!-- no close some text");
|
||||||
|
expect(result.stripped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips comment and keeps same-line residual content", () => {
|
||||||
|
const result = stripHtmlComments("<!-- note -->some text");
|
||||||
|
expect(result.content).toContain("some text");
|
||||||
|
expect(result.content).not.toContain("<!--");
|
||||||
|
expect(result.stripped).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isMemoryFilePath", () => {
|
describe("isMemoryFilePath", () => {
|
||||||
@@ -88,6 +101,14 @@ describe("isMemoryFilePath", () => {
|
|||||||
test("returns false for .claude directory non-rules file", () => {
|
test("returns false for .claude directory non-rules file", () => {
|
||||||
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns false for lowercase claude.md (case-sensitive match)", () => {
|
||||||
|
expect(isMemoryFilePath("/project/claude.md")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for non-.md file in .claude/rules/", () => {
|
||||||
|
expect(isMemoryFilePath(".claude/rules/foo.txt")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getLargeMemoryFiles", () => {
|
describe("getLargeMemoryFiles", () => {
|
||||||
@@ -120,4 +141,8 @@ describe("getLargeMemoryFiles", () => {
|
|||||||
const result = getLargeMemoryFiles(files);
|
const result = getLargeMemoryFiles(files);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns empty array for empty input", () => {
|
||||||
|
expect(getLargeMemoryFiles([])).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,4 +52,21 @@ describe("insertBlockAfterToolResults", () => {
|
|||||||
expect(content[0]).toEqual({ type: "text", text: "new" });
|
expect(content[0]).toEqual({ type: "text", text: "new" });
|
||||||
expect(content[1]).toEqual({ type: "text", text: "only" });
|
expect(content[1]).toEqual({ type: "text", text: "only" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inserts after last tool_result with mixed interleaving", () => {
|
||||||
|
const content: any[] = [
|
||||||
|
{ type: "tool_result", content: "r1" },
|
||||||
|
{ type: "text", text: "mid1" },
|
||||||
|
{ type: "tool_result", content: "r2" },
|
||||||
|
{ type: "text", text: "mid2" },
|
||||||
|
{ type: "tool_result", content: "r3" },
|
||||||
|
{ type: "text", text: "end" },
|
||||||
|
];
|
||||||
|
insertBlockAfterToolResults(content, { type: "text", text: "inserted" });
|
||||||
|
// Inserted after r3 (index 4), so at index 5
|
||||||
|
expect(content[5]).toEqual({ type: "text", text: "inserted" });
|
||||||
|
// Original end text should shift to index 6
|
||||||
|
expect(content[6]).toEqual({ type: "text", text: "end" });
|
||||||
|
expect(content).toHaveLength(7);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ describe("getPatchFromContents", () => {
|
|||||||
oldContent: "hello\nworld",
|
oldContent: "hello\nworld",
|
||||||
newContent: "hello\nplanet",
|
newContent: "hello\nplanet",
|
||||||
});
|
});
|
||||||
expect(hunks.length).toBeGreaterThan(0);
|
expect(hunks.length).toBe(1);
|
||||||
expect(hunks[0].lines.some((l: string) => l.startsWith("-"))).toBe(true);
|
const allLines = hunks[0].lines;
|
||||||
expect(hunks[0].lines.some((l: string) => l.startsWith("+"))).toBe(true);
|
expect(allLines).toContain("-world");
|
||||||
|
expect(allLines).toContain("+planet");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns empty hunks for identical content", () => {
|
test("returns empty hunks for identical content", () => {
|
||||||
@@ -73,5 +74,44 @@ describe("getPatchFromContents", () => {
|
|||||||
newContent: "new content",
|
newContent: "new content",
|
||||||
});
|
});
|
||||||
expect(hunks.length).toBeGreaterThan(0);
|
expect(hunks.length).toBeGreaterThan(0);
|
||||||
|
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||||
|
expect(allLines.some((l: string) => l.startsWith("+"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles content with dollar signs", () => {
|
||||||
|
const hunks = getPatchFromContents({
|
||||||
|
filePath: "test.txt",
|
||||||
|
oldContent: "price: $5",
|
||||||
|
newContent: "price: $10",
|
||||||
|
});
|
||||||
|
expect(hunks.length).toBeGreaterThan(0);
|
||||||
|
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||||
|
expect(allLines.some((l: string) => l.includes("$"))).toBe(true);
|
||||||
|
// Verify dollar signs are unescaped (not the token)
|
||||||
|
expect(allLines.some((l: string) => l.includes("<<:DOLLAR_TOKEN:>>"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles deleting all content", () => {
|
||||||
|
const hunks = getPatchFromContents({
|
||||||
|
filePath: "test.txt",
|
||||||
|
oldContent: "line1\nline2\nline3",
|
||||||
|
newContent: "",
|
||||||
|
});
|
||||||
|
expect(hunks.length).toBeGreaterThan(0);
|
||||||
|
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||||
|
expect(allLines.some((l: string) => l.startsWith("-"))).toBe(true);
|
||||||
|
expect(allLines.every((l: string) => l.startsWith("-") || l.startsWith(" ") || l.startsWith("\\"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignoreWhitespace treats indentation changes as identical", () => {
|
||||||
|
const old = "function foo() {\n return 42;\n}\n";
|
||||||
|
const nw = "function foo() {\n\treturn 42;\n}\n";
|
||||||
|
const hunks = getPatchFromContents({
|
||||||
|
filePath: "test.txt",
|
||||||
|
oldContent: old,
|
||||||
|
newContent: nw,
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
});
|
||||||
|
expect(hunks).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ describe("validateBoundedIntEnvVar", () => {
|
|||||||
const result = validateBoundedIntEnvVar("TEST_VAR", "2000", 100, 1000);
|
const result = validateBoundedIntEnvVar("TEST_VAR", "2000", 100, 1000);
|
||||||
expect(result.effective).toBe(1000);
|
expect(result.effective).toBe(1000);
|
||||||
expect(result.status).toBe("capped");
|
expect(result.status).toBe("capped");
|
||||||
expect(result.message).toContain("Capped from 2000 to 1000");
|
expect(result.message).toBe("Capped from 2000 to 1000");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns default for non-numeric value", () => {
|
test("returns default for non-numeric value", () => {
|
||||||
const result = validateBoundedIntEnvVar("TEST_VAR", "abc", 100, 1000);
|
const result = validateBoundedIntEnvVar("TEST_VAR", "abc", 100, 1000);
|
||||||
expect(result.effective).toBe(100);
|
expect(result.effective).toBe(100);
|
||||||
expect(result.status).toBe("invalid");
|
expect(result.status).toBe("invalid");
|
||||||
expect(result.message).toContain("Invalid value");
|
expect(result.message).toBe('Invalid value "abc" (using default: 100)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns default for zero", () => {
|
test("returns default for zero", () => {
|
||||||
@@ -66,9 +66,21 @@ describe("validateBoundedIntEnvVar", () => {
|
|||||||
expect(result.status).toBe("valid");
|
expect(result.status).toBe("valid");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles value of 1 (minimum valid)", () => {
|
test("handles value of 1 (no lower bound check, only parsed > 0)", () => {
|
||||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
||||||
expect(result.effective).toBe(1);
|
expect(result.effective).toBe(1);
|
||||||
expect(result.status).toBe("valid");
|
expect(result.status).toBe("valid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("truncates float input via parseInt", () => {
|
||||||
|
const result = validateBoundedIntEnvVar("TEST_VAR", "50.7", 100, 1000);
|
||||||
|
expect(result.effective).toBe(50);
|
||||||
|
expect(result.status).toBe("valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles whitespace in value", () => {
|
||||||
|
const result = validateBoundedIntEnvVar("TEST_VAR", " 500 ", 100, 1000);
|
||||||
|
expect(result.effective).toBe(500);
|
||||||
|
expect(result.status).toBe("valid");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ describe("convertLeadingTabsToSpaces", () => {
|
|||||||
describe("addLineNumbers", () => {
|
describe("addLineNumbers", () => {
|
||||||
test("adds line numbers starting from 1", () => {
|
test("adds line numbers starting from 1", () => {
|
||||||
const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
|
const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
|
||||||
expect(result).toContain("1");
|
expect(result).toMatch(/^\s*1[→\t]a\n\s*2[→\t]b\n\s*3[→\t]c$/);
|
||||||
expect(result).toContain("a");
|
|
||||||
expect(result).toContain("b");
|
|
||||||
expect(result).toContain("c");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns empty string for empty content", () => {
|
test("returns empty string for empty content", () => {
|
||||||
@@ -46,7 +43,7 @@ describe("addLineNumbers", () => {
|
|||||||
|
|
||||||
test("respects startLine offset", () => {
|
test("respects startLine offset", () => {
|
||||||
const result = addLineNumbers({ content: "hello", startLine: 10 });
|
const result = addLineNumbers({ content: "hello", startLine: 10 });
|
||||||
expect(result).toContain("10");
|
expect(result).toMatch(/^\s*10[→\t]hello$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,20 +82,17 @@ describe("formatNumber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("formats thousands with k suffix", () => {
|
test("formats thousands with k suffix", () => {
|
||||||
const result = formatNumber(1321);
|
expect(formatNumber(1321)).toBe("1.3k");
|
||||||
expect(result).toContain("k");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats millions", () => {
|
test("formats millions", () => {
|
||||||
const result = formatNumber(1500000);
|
expect(formatNumber(1500000)).toBe("1.5m");
|
||||||
expect(result).toContain("m");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatTokens", () => {
|
describe("formatTokens", () => {
|
||||||
test("removes .0 from formatted number", () => {
|
test("removes .0 from formatted number", () => {
|
||||||
const result = formatTokens(1000);
|
expect(formatTokens(1000)).toBe("1k");
|
||||||
expect(result).not.toContain(".0");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats small numbers", () => {
|
test("formats small numbers", () => {
|
||||||
@@ -108,26 +105,20 @@ describe("formatRelativeTime", () => {
|
|||||||
|
|
||||||
test("formats seconds ago", () => {
|
test("formats seconds ago", () => {
|
||||||
const date = new Date("2026-01-15T11:59:30Z");
|
const date = new Date("2026-01-15T11:59:30Z");
|
||||||
const result = formatRelativeTime(date, { now });
|
expect(formatRelativeTime(date, { now })).toBe("30s ago");
|
||||||
expect(result).toContain("30");
|
|
||||||
expect(result).toContain("ago");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats minutes ago", () => {
|
test("formats minutes ago", () => {
|
||||||
const date = new Date("2026-01-15T11:55:00Z");
|
const date = new Date("2026-01-15T11:55:00Z");
|
||||||
const result = formatRelativeTime(date, { now });
|
expect(formatRelativeTime(date, { now })).toBe("5m ago");
|
||||||
expect(result).toContain("5");
|
|
||||||
expect(result).toContain("ago");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats future time", () => {
|
test("formats future time", () => {
|
||||||
const date = new Date("2026-01-15T13:00:00Z");
|
const date = new Date("2026-01-15T13:00:00Z");
|
||||||
const result = formatRelativeTime(date, { now });
|
expect(formatRelativeTime(date, { now })).toBe("in 1h");
|
||||||
expect(result).toContain("in");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero difference", () => {
|
test("handles zero difference", () => {
|
||||||
const result = formatRelativeTime(now, { now });
|
expect(formatRelativeTime(now, { now })).toBe("0s ago");
|
||||||
expect(result).toContain("0");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ describe("djb2Hash", () => {
|
|||||||
|
|
||||||
test("returns 32-bit integer", () => {
|
test("returns 32-bit integer", () => {
|
||||||
const hash = djb2Hash("some long string to hash");
|
const hash = djb2Hash("some long string to hash");
|
||||||
expect(hash).toBe(hash | 0); // bitwise OR with 0 preserves 32-bit int
|
expect(Number.isSafeInteger(hash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has known answer for 'hello'", () => {
|
||||||
|
expect(djb2Hash("hello")).toBe(99162322);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +40,14 @@ describe("hashContent", () => {
|
|||||||
test("different strings produce different hashes", () => {
|
test("different strings produce different hashes", () => {
|
||||||
expect(hashContent("abc")).not.toBe(hashContent("def"));
|
expect(hashContent("abc")).not.toBe(hashContent("def"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns numeric string for empty string", () => {
|
||||||
|
expect(hashContent("")).toMatch(/^\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns numeric string format", () => {
|
||||||
|
expect(hashContent("hello")).toMatch(/^\d+$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hashPair", () => {
|
describe("hashPair", () => {
|
||||||
@@ -54,4 +66,11 @@ describe("hashPair", () => {
|
|||||||
test("disambiguates different splits", () => {
|
test("disambiguates different splits", () => {
|
||||||
expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
|
expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles empty strings", () => {
|
||||||
|
expect(hashPair("", "")).toMatch(/^\d+$/);
|
||||||
|
expect(hashPair("", "a")).toMatch(/^\d+$/);
|
||||||
|
expect(hashPair("a", "")).toMatch(/^\d+$/);
|
||||||
|
expect(hashPair("", "a")).not.toBe(hashPair("a", ""));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -375,6 +375,16 @@ describe("isNotEmptyMessage", () => {
|
|||||||
};
|
};
|
||||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns false for whitespace-only text block in content array", () => {
|
||||||
|
const msg: any = {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: " " }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── deriveUUID ─────────────────────────────────────────────────────────
|
// ─── deriveUUID ─────────────────────────────────────────────────────────
|
||||||
@@ -458,6 +468,11 @@ describe("normalizeMessages", () => {
|
|||||||
]);
|
]);
|
||||||
const normalized = normalizeMessages([msg]);
|
const normalized = normalizeMessages([msg]);
|
||||||
expect(normalized.length).toBe(2);
|
expect(normalized.length).toBe(2);
|
||||||
|
// Verify each split message contains only one content block
|
||||||
|
expect(normalized[0].message.content).toHaveLength(1);
|
||||||
|
expect((normalized[0].message.content as any[])[0].text).toBe("first");
|
||||||
|
expect(normalized[1].message.content).toHaveLength(1);
|
||||||
|
expect((normalized[1].message.content as any[])[0].text).toBe("second");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles empty array", () => {
|
test("handles empty array", () => {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ describe("parseCellId", () => {
|
|||||||
// regex is /^cell-(\d+)$/ so trailing text should fail
|
// regex is /^cell-(\d+)$/ so trailing text should fail
|
||||||
expect(parseCellId("cell-0-extra")).toBeUndefined();
|
expect(parseCellId("cell-0-extra")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns undefined for negative numbers", () => {
|
||||||
|
expect(parseCellId("cell--1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses leading zeros correctly", () => {
|
||||||
|
expect(parseCellId("cell-007")).toBe(7);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── mapNotebookCellsToToolResult ──────────────────────────────────────
|
// ─── mapNotebookCellsToToolResult ──────────────────────────────────────
|
||||||
@@ -72,7 +80,7 @@ describe("mapNotebookCellsToToolResult", () => {
|
|||||||
|
|
||||||
const firstBlock = result.content![0] as { type: string; text: string };
|
const firstBlock = result.content![0] as { type: string; text: string };
|
||||||
expect(firstBlock.type).toBe("text");
|
expect(firstBlock.type).toBe("text");
|
||||||
expect(firstBlock.text).toContain("cell-0");
|
expect(firstBlock.text).toContain('cell id="cell-0"');
|
||||||
expect(firstBlock.text).toContain("x = 1");
|
expect(firstBlock.text).toContain("x = 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,4 +38,18 @@ describe("objectGroupBy", () => {
|
|||||||
expect(result.admin).toHaveLength(2);
|
expect(result.admin).toHaveLength(2);
|
||||||
expect(result.user).toHaveLength(1);
|
expect(result.user).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles key function returning undefined", () => {
|
||||||
|
const result = objectGroupBy([1, 2, 3], () => undefined as any);
|
||||||
|
expect(result["undefined"]).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles keys with special characters", () => {
|
||||||
|
const result = objectGroupBy(
|
||||||
|
[{ key: "a/b" }, { key: "a.b" }, { key: "a/b" }],
|
||||||
|
(item) => item.key
|
||||||
|
);
|
||||||
|
expect(result["a/b"]).toHaveLength(2);
|
||||||
|
expect(result["a.b"]).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { containsPathTraversal, normalizePathForConfigKey } from "../path";
|
import {
|
||||||
|
containsPathTraversal,
|
||||||
|
expandPath,
|
||||||
|
normalizePathForConfigKey,
|
||||||
|
} from "../path";
|
||||||
|
|
||||||
// ─── containsPathTraversal ──────────────────────────────────────────────
|
// ─── containsPathTraversal ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -43,6 +47,51 @@ describe("containsPathTraversal", () => {
|
|||||||
test("returns false for dotdot in filename without separator", () => {
|
test("returns false for dotdot in filename without separator", () => {
|
||||||
expect(containsPathTraversal("foo..bar")).toBe(false);
|
expect(containsPathTraversal("foo..bar")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("detects backslash traversal foo\\..\\bar", () => {
|
||||||
|
expect(containsPathTraversal("foo\\..\\bar")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects .. at end of absolute path", () => {
|
||||||
|
expect(containsPathTraversal("/path/to/..")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── expandPath ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("expandPath", () => {
|
||||||
|
test("expands ~/ to home directory", () => {
|
||||||
|
const result = expandPath("~/Documents");
|
||||||
|
expect(result).not.toContain("~");
|
||||||
|
expect(result).toContain("Documents");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands bare ~ to home directory", () => {
|
||||||
|
const result = expandPath("~");
|
||||||
|
expect(result).not.toContain("~");
|
||||||
|
// Should equal home directory
|
||||||
|
const { homedir } = require("os");
|
||||||
|
expect(result).toBe(homedir());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes absolute paths through normalized", () => {
|
||||||
|
expect(expandPath("/usr/local/bin")).toBe("/usr/local/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves relative path against baseDir", () => {
|
||||||
|
expect(expandPath("src", "/project")).toBe("/project/src");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns baseDir for empty string", () => {
|
||||||
|
expect(expandPath("", "/project")).toBe("/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns cwd-based path for empty string without baseDir", () => {
|
||||||
|
const result = expandPath("");
|
||||||
|
// Should be a valid absolute path (cwd normalized)
|
||||||
|
const { isAbsolute } = require("path");
|
||||||
|
expect(isAbsolute(result)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── normalizePathForConfigKey ──────────────────────────────────────────
|
// ─── normalizePathForConfigKey ──────────────────────────────────────────
|
||||||
@@ -69,4 +118,14 @@ describe("normalizePathForConfigKey", () => {
|
|||||||
const result = normalizePathForConfigKey("foo\\bar\\baz");
|
const result = normalizePathForConfigKey("foo\\bar\\baz");
|
||||||
expect(result).toBe("foo/bar/baz");
|
expect(result).toBe("foo/bar/baz");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes mixed separators foo/bar\\baz", () => {
|
||||||
|
const result = normalizePathForConfigKey("foo/bar\\baz");
|
||||||
|
expect(result).toBe("foo/bar/baz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes redundant separators foo//bar", () => {
|
||||||
|
const result = normalizePathForConfigKey("foo//bar");
|
||||||
|
expect(result).toBe("foo/bar");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ describe("gt", () => {
|
|||||||
test("returns false when equal", () => {
|
test("returns false when equal", () => {
|
||||||
expect(gt("1.0.0", "1.0.0")).toBe(false);
|
expect(gt("1.0.0", "1.0.0")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns false for 0.0.0 vs 0.0.0", () => {
|
||||||
|
expect(gt("0.0.0", "0.0.0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("release is greater than pre-release", () => {
|
||||||
|
expect(gt("1.0.0", "1.0.0-alpha")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gte", () => {
|
describe("gte", () => {
|
||||||
@@ -77,6 +85,14 @@ describe("satisfies", () => {
|
|||||||
test("does not match major bump in caret", () => {
|
test("does not match major bump in caret", () => {
|
||||||
expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
|
expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("matches tilde range", () => {
|
||||||
|
expect(satisfies("1.2.5", "~1.2.3")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches wildcard range", () => {
|
||||||
|
expect(satisfies("2.0.0", "*")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("order", () => {
|
describe("order", () => {
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ describe("truncateToWidth", () => {
|
|||||||
|
|
||||||
test("truncates long string with ellipsis", () => {
|
test("truncates long string with ellipsis", () => {
|
||||||
const result = truncateToWidth("hello world", 8);
|
const result = truncateToWidth("hello world", 8);
|
||||||
expect(result.endsWith("…")).toBe(true);
|
expect(result).toBe("hello w…");
|
||||||
expect(result.length).toBeLessThanOrEqual(9); // 8 visible + ellipsis char
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns ellipsis for maxWidth 1", () => {
|
test("returns ellipsis for maxWidth 1", () => {
|
||||||
@@ -28,6 +27,37 @@ describe("truncateToWidth", () => {
|
|||||||
test("handles empty string", () => {
|
test("handles empty string", () => {
|
||||||
expect(truncateToWidth("", 10)).toBe("");
|
expect(truncateToWidth("", 10)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── CJK / wide-character tests ──
|
||||||
|
|
||||||
|
test("truncates CJK string at width boundary (2 per char)", () => {
|
||||||
|
expect(truncateToWidth("你好世界", 4)).toBe("你…");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates CJK string preserving full characters", () => {
|
||||||
|
expect(truncateToWidth("你好世界", 6)).toBe("你好…");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through CJK string when within limit", () => {
|
||||||
|
expect(truncateToWidth("你好", 4)).toBe("你好");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles mixed ASCII + CJK", () => {
|
||||||
|
expect(truncateToWidth("hello你好", 8)).toBe("hello你…");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through mixed ASCII + CJK at exact limit", () => {
|
||||||
|
expect(truncateToWidth("hello你好", 9)).toBe("hello你好");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates string containing emoji", () => {
|
||||||
|
const result = truncateToWidth("hello 👋 world", 10);
|
||||||
|
expect(result).toBe("hello 👋 …");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through single emoji at sufficient width", () => {
|
||||||
|
expect(truncateToWidth("👋", 2)).toBe("👋");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── truncateStartToWidth ───────────────────────────────────────────────
|
// ─── truncateStartToWidth ───────────────────────────────────────────────
|
||||||
@@ -38,13 +68,20 @@ describe("truncateStartToWidth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("truncates from start with ellipsis prefix", () => {
|
test("truncates from start with ellipsis prefix", () => {
|
||||||
const result = truncateStartToWidth("hello world", 8);
|
expect(truncateStartToWidth("hello world", 8)).toBe("…o world");
|
||||||
expect(result.startsWith("…")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns ellipsis for maxWidth 1", () => {
|
test("returns ellipsis for maxWidth 1", () => {
|
||||||
expect(truncateStartToWidth("hello", 1)).toBe("…");
|
expect(truncateStartToWidth("hello", 1)).toBe("…");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("truncates CJK from start", () => {
|
||||||
|
expect(truncateStartToWidth("你好世界", 4)).toBe("…界");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates CJK from start preserving characters", () => {
|
||||||
|
expect(truncateStartToWidth("你好世界", 6)).toBe("…世界");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── truncateToWidthNoEllipsis ──────────────────────────────────────────
|
// ─── truncateToWidthNoEllipsis ──────────────────────────────────────────
|
||||||
@@ -63,6 +100,10 @@ describe("truncateToWidthNoEllipsis", () => {
|
|||||||
test("returns empty for maxWidth 0", () => {
|
test("returns empty for maxWidth 0", () => {
|
||||||
expect(truncateToWidthNoEllipsis("hello", 0)).toBe("");
|
expect(truncateToWidthNoEllipsis("hello", 0)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("truncates CJK without ellipsis", () => {
|
||||||
|
expect(truncateToWidthNoEllipsis("你好世界", 4)).toBe("你好");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── truncatePathMiddle ─────────────────────────────────────────────────
|
// ─── truncatePathMiddle ─────────────────────────────────────────────────
|
||||||
@@ -89,8 +130,11 @@ describe("truncatePathMiddle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles short maxLength < 5", () => {
|
test("handles short maxLength < 5", () => {
|
||||||
const result = truncatePathMiddle("src/components/foo.ts", 4);
|
expect(truncatePathMiddle("src/components/foo.ts", 4)).toBe("src…");
|
||||||
expect(result).toContain("…");
|
});
|
||||||
|
|
||||||
|
test("handles very short maxLength 1", () => {
|
||||||
|
expect(truncatePathMiddle("/a/b", 1)).toBe("…");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { validateUuid } from "../uuid";
|
import { validateUuid, createAgentId } from "../uuid";
|
||||||
|
|
||||||
describe("validateUuid", () => {
|
describe("validateUuid", () => {
|
||||||
test("validates correct UUID", () => {
|
test("validates correct UUID", () => {
|
||||||
@@ -9,7 +9,7 @@ describe("validateUuid", () => {
|
|||||||
|
|
||||||
test("validates uppercase UUID", () => {
|
test("validates uppercase UUID", () => {
|
||||||
const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
|
const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
|
||||||
expect(result).not.toBeNull();
|
expect(result).toBe("550E8400-E29B-41D4-A716-446655440000");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for non-string", () => {
|
test("returns null for non-string", () => {
|
||||||
@@ -31,4 +31,21 @@ describe("validateUuid", () => {
|
|||||||
test("returns null for UUID with invalid chars", () => {
|
test("returns null for UUID with invalid chars", () => {
|
||||||
expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
|
expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns null for UUID with leading/trailing whitespace", () => {
|
||||||
|
expect(validateUuid(" 550e8400-e29b-41d4-a716-446655440000")).toBeNull();
|
||||||
|
expect(validateUuid("550e8400-e29b-41d4-a716-446655440000 ")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAgentId", () => {
|
||||||
|
test("generates id without label in correct format", () => {
|
||||||
|
const id = createAgentId();
|
||||||
|
expect(id).toMatch(/^a[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates id with label in correct format", () => {
|
||||||
|
const id = createAgentId("compact");
|
||||||
|
expect(id).toMatch(/^acompact-[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ describe("zodToJsonSchema", () => {
|
|||||||
const result = zodToJsonSchema(schema);
|
const result = zodToJsonSchema(schema);
|
||||||
expect(result.type).toBe("object");
|
expect(result.type).toBe("object");
|
||||||
expect(result.properties).toBeDefined();
|
expect(result.properties).toBeDefined();
|
||||||
expect((result.properties as any).name).toBeDefined();
|
expect((result.properties as any).name).toEqual({ type: "string" });
|
||||||
expect((result.properties as any).age).toBeDefined();
|
expect((result.properties as any).age).toEqual({ type: "number" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("converts enum schema", () => {
|
test("converts enum schema", () => {
|
||||||
@@ -39,7 +39,8 @@ describe("zodToJsonSchema", () => {
|
|||||||
optional: z.string().optional(),
|
optional: z.string().optional(),
|
||||||
});
|
});
|
||||||
const result = zodToJsonSchema(schema);
|
const result = zodToJsonSchema(schema);
|
||||||
expect(result.required).toContain("required");
|
expect(result.required).toEqual(["required"]);
|
||||||
|
expect(result.required).not.toContain("optional");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("caches results for same schema reference", () => {
|
test("caches results for same schema reference", () => {
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|||||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers";
|
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers";
|
||||||
|
|
||||||
describe("getAPIProvider", () => {
|
describe("getAPIProvider", () => {
|
||||||
const originalEnv = { ...process.env };
|
const envKeys = [
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"CLAUDE_CODE_USE_VERTEX",
|
||||||
|
"CLAUDE_CODE_USE_FOUNDRY",
|
||||||
|
] as const;
|
||||||
|
const savedEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of envKeys) savedEnv[key] = process.env[key];
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
for (const key of envKeys) {
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
if (savedEnv[key] !== undefined) {
|
||||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
process.env[key] = savedEnv[key];
|
||||||
|
} else {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns "firstParty" by default', () => {
|
test('returns "firstParty" by default', () => {
|
||||||
@@ -37,6 +50,28 @@ describe("getAPIProvider", () => {
|
|||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||||
expect(getAPIProvider()).toBe("bedrock");
|
expect(getAPIProvider()).toBe("bedrock");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bedrock wins when all three env vars are set", () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||||
|
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||||
|
expect(getAPIProvider()).toBe("bedrock");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"true" is truthy', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "true";
|
||||||
|
expect(getAPIProvider()).toBe("bedrock");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"0" is not truthy', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "0";
|
||||||
|
expect(getAPIProvider()).toBe("firstParty");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string is not truthy', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "";
|
||||||
|
expect(getAPIProvider()).toBe("firstParty");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isFirstPartyAnthropicBaseUrl", () => {
|
describe("isFirstPartyAnthropicBaseUrl", () => {
|
||||||
@@ -81,4 +116,19 @@ describe("isFirstPartyAnthropicBaseUrl", () => {
|
|||||||
process.env.USER_TYPE = "ant";
|
process.env.USER_TYPE = "ant";
|
||||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns true for URL with path", () => {
|
||||||
|
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
||||||
|
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for trailing slash", () => {
|
||||||
|
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/";
|
||||||
|
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for subdomain attack", () => {
|
||||||
|
process.env.ANTHROPIC_BASE_URL = "https://evil-api.anthropic.com";
|
||||||
|
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { mock, describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||||
|
|
||||||
// Mock slowOperations to cut bootstrap/state dependency chain
|
// Mock slowOperations to cut bootstrap/state dependency chain
|
||||||
// (figures.js → env.js → fsOperations.js → slowOperations.js → bootstrap/state.js)
|
// (figures.js → env.js → fsOperations.js → slowOperations.js → bootstrap/state.js)
|
||||||
@@ -57,6 +57,8 @@ describe("permissionModeFromString", () => {
|
|||||||
expect(permissionModeFromString("plan")).toBe("plan");
|
expect(permissionModeFromString("plan")).toBe("plan");
|
||||||
expect(permissionModeFromString("default")).toBe("default");
|
expect(permissionModeFromString("default")).toBe("default");
|
||||||
expect(permissionModeFromString("dontAsk")).toBe("dontAsk");
|
expect(permissionModeFromString("dontAsk")).toBe("dontAsk");
|
||||||
|
expect(permissionModeFromString("acceptEdits")).toBe("acceptEdits");
|
||||||
|
expect(permissionModeFromString("bypassPermissions")).toBe("bypassPermissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 'default' for unknown string", () => {
|
test("returns 'default' for unknown string", () => {
|
||||||
@@ -72,6 +74,8 @@ describe("permissionModeTitle", () => {
|
|||||||
expect(permissionModeTitle("default")).toBe("Default");
|
expect(permissionModeTitle("default")).toBe("Default");
|
||||||
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
||||||
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
||||||
|
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass Permissions");
|
||||||
|
expect(permissionModeTitle("dontAsk")).toBe("Don't Ask");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to Default for unknown mode", () => {
|
test("falls back to Default for unknown mode", () => {
|
||||||
@@ -86,6 +90,8 @@ describe("permissionModeShortTitle", () => {
|
|||||||
expect(permissionModeShortTitle("default")).toBe("Default");
|
expect(permissionModeShortTitle("default")).toBe("Default");
|
||||||
expect(permissionModeShortTitle("plan")).toBe("Plan");
|
expect(permissionModeShortTitle("plan")).toBe("Plan");
|
||||||
expect(permissionModeShortTitle("bypassPermissions")).toBe("Bypass");
|
expect(permissionModeShortTitle("bypassPermissions")).toBe("Bypass");
|
||||||
|
expect(permissionModeShortTitle("dontAsk")).toBe("DontAsk");
|
||||||
|
expect(permissionModeShortTitle("acceptEdits")).toBe("Accept");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +122,14 @@ describe("getModeColor", () => {
|
|||||||
test("returns 'error' for bypassPermissions", () => {
|
test("returns 'error' for bypassPermissions", () => {
|
||||||
expect(getModeColor("bypassPermissions")).toBe("error");
|
expect(getModeColor("bypassPermissions")).toBe("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns 'error' for dontAsk", () => {
|
||||||
|
expect(getModeColor("dontAsk")).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 'autoAccept' for acceptEdits", () => {
|
||||||
|
expect(getModeColor("acceptEdits")).toBe("autoAccept");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── isDefaultMode ─────────────────────────────────────────────────────
|
// ─── isDefaultMode ─────────────────────────────────────────────────────
|
||||||
@@ -149,6 +163,14 @@ describe("toExternalPermissionMode", () => {
|
|||||||
test("maps dontAsk to dontAsk", () => {
|
test("maps dontAsk to dontAsk", () => {
|
||||||
expect(toExternalPermissionMode("dontAsk")).toBe("dontAsk");
|
expect(toExternalPermissionMode("dontAsk")).toBe("dontAsk");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps acceptEdits to acceptEdits", () => {
|
||||||
|
expect(toExternalPermissionMode("acceptEdits")).toBe("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps bypassPermissions to bypassPermissions", () => {
|
||||||
|
expect(toExternalPermissionMode("bypassPermissions")).toBe("bypassPermissions");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── isExternalPermissionMode ──────────────────────────────────────────
|
// ─── isExternalPermissionMode ──────────────────────────────────────────
|
||||||
@@ -159,4 +181,34 @@ describe("isExternalPermissionMode", () => {
|
|||||||
expect(isExternalPermissionMode("default")).toBe(true);
|
expect(isExternalPermissionMode("default")).toBe(true);
|
||||||
expect(isExternalPermissionMode("plan")).toBe(true);
|
expect(isExternalPermissionMode("plan")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when USER_TYPE is 'ant'", () => {
|
||||||
|
const savedUserType = process.env.USER_TYPE;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.USER_TYPE = "ant";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedUserType !== undefined) {
|
||||||
|
process.env.USER_TYPE = savedUserType;
|
||||||
|
} else {
|
||||||
|
delete process.env.USER_TYPE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for 'auto' (ant-only mode)", () => {
|
||||||
|
expect(isExternalPermissionMode("auto")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for 'bubble' (ant-only mode)", () => {
|
||||||
|
expect(isExternalPermissionMode("bubble")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true for standard external modes", () => {
|
||||||
|
expect(isExternalPermissionMode("default")).toBe(true);
|
||||||
|
expect(isExternalPermissionMode("plan")).toBe(true);
|
||||||
|
expect(isExternalPermissionMode("dontAsk")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,32 @@ describe("CROSS_PLATFORM_CODE_EXEC", () => {
|
|||||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("bash");
|
expect(CROSS_PLATFORM_CODE_EXEC).toContain("bash");
|
||||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("sh");
|
expect(CROSS_PLATFORM_CODE_EXEC).toContain("sh");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("has no duplicate entries", () => {
|
||||||
|
expect(new Set(CROSS_PLATFORM_CODE_EXEC).size).toBe(
|
||||||
|
CROSS_PLATFORM_CODE_EXEC.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains expected interpreters", () => {
|
||||||
|
const expected = [
|
||||||
|
"node",
|
||||||
|
"python",
|
||||||
|
"python3",
|
||||||
|
"ruby",
|
||||||
|
"perl",
|
||||||
|
"php",
|
||||||
|
"lua",
|
||||||
|
"deno",
|
||||||
|
"npx",
|
||||||
|
"bunx",
|
||||||
|
"tsx",
|
||||||
|
];
|
||||||
|
const set = new Set(CROSS_PLATFORM_CODE_EXEC);
|
||||||
|
for (const entry of expected) {
|
||||||
|
expect(set.has(entry)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DANGEROUS_BASH_PATTERNS", () => {
|
describe("DANGEROUS_BASH_PATTERNS", () => {
|
||||||
@@ -52,4 +78,16 @@ describe("DANGEROUS_BASH_PATTERNS", () => {
|
|||||||
expect(typeof p).toBe("string");
|
expect(typeof p).toBe("string");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("has no duplicate entries", () => {
|
||||||
|
expect(new Set(DANGEROUS_BASH_PATTERNS).size).toBe(
|
||||||
|
DANGEROUS_BASH_PATTERNS.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty string does not match any pattern", () => {
|
||||||
|
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
||||||
|
expect("".startsWith(pattern)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
100
tests/integration/cli-arguments.test.ts
Normal file
100
tests/integration/cli-arguments.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
// Test Commander.js option parsing independently from main.tsx initialization.
|
||||||
|
// main.tsx has heavy bootstrap dependencies; we test the CLI argument parsing
|
||||||
|
// patterns it uses to ensure correct behavior.
|
||||||
|
|
||||||
|
const { Command } = require("/Users/konghayao/code/ai/claude-code/node_modules/.old_modules-13e6b62a502cda34/commander/index.js");
|
||||||
|
|
||||||
|
function createTestProgram(): Command {
|
||||||
|
const program = new Command();
|
||||||
|
program
|
||||||
|
.name("claude-code")
|
||||||
|
.description("CLI test")
|
||||||
|
.exitOverride() // prevent process.exit during tests
|
||||||
|
.option("-p, --print", "pipe mode")
|
||||||
|
.option("--resume", "resume session")
|
||||||
|
.option("-v, --verbose", "verbose output")
|
||||||
|
.option("--model <model>", "model to use")
|
||||||
|
.option("--system-prompt <prompt>", "system prompt")
|
||||||
|
.option("--allowedTools <tools...>", "allowed tools")
|
||||||
|
.option("--max-turns <n>", "max conversation turns", parseInt)
|
||||||
|
.version("1.0.0", "-V, --version", "display version");
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CLI arguments: option parsing", () => {
|
||||||
|
test("no flags returns empty opts", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test"]);
|
||||||
|
expect(program.opts()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("-p sets print flag", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "-p"]);
|
||||||
|
expect(program.opts().print).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--print is equivalent to -p", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "--print"]);
|
||||||
|
expect(program.opts().print).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--resume sets resume flag", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "--resume"]);
|
||||||
|
expect(program.opts().resume).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("-v sets verbose flag", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "-v"]);
|
||||||
|
expect(program.opts().verbose).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--model captures string value", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "--model", "claude-opus-4-6"]);
|
||||||
|
expect(program.opts().model).toBe("claude-opus-4-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--system-prompt captures string value", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "--system-prompt", "Be concise"]);
|
||||||
|
expect(program.opts().systemPrompt).toBe("Be concise");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--max-turns parses integer value", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "--max-turns", "10"]);
|
||||||
|
expect(program.opts().maxTurns).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple flags can be combined", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
program.parse(["node", "test", "-p", "-v", "--model", "opus"]);
|
||||||
|
expect(program.opts().print).toBe(true);
|
||||||
|
expect(program.opts().verbose).toBe(true);
|
||||||
|
expect(program.opts().model).toBe("opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--version throws Commander.CommandError with exit code 0", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
let error: any;
|
||||||
|
try {
|
||||||
|
program.parse(["node", "test", "--version"]);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.code).toBe("commander.version");
|
||||||
|
expect(error.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown flags throw CommanderError", () => {
|
||||||
|
const program = createTestProgram();
|
||||||
|
expect(() => program.parse(["node", "test", "--nonexistent"])).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
tests/integration/context-build.test.ts
Normal file
101
tests/integration/context-build.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { stripHtmlComments, isMemoryFilePath, getLargeMemoryFiles } from "../../src/utils/claudemd";
|
||||||
|
import { buildEffectiveSystemPrompt } from "../../src/utils/systemPrompt";
|
||||||
|
import { createTempDir, cleanupTempDir, writeTempFile } from "../mocks/file-system";
|
||||||
|
|
||||||
|
// ─── CLAUDE.md Integration with System Prompt ─────────────────────────
|
||||||
|
|
||||||
|
describe("Context build: CLAUDE.md + system prompt integration", () => {
|
||||||
|
test("buildEffectiveSystemPrompt passes through default prompt", () => {
|
||||||
|
const result = buildEffectiveSystemPrompt({
|
||||||
|
defaultSystemPrompt: "You are Claude.",
|
||||||
|
});
|
||||||
|
// Result is an array of strings (may be split differently)
|
||||||
|
const joined = Array.from(result).join("");
|
||||||
|
expect(joined).toBe("You are Claude.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEffectiveSystemPrompt handles empty prompts", () => {
|
||||||
|
const result = buildEffectiveSystemPrompt({
|
||||||
|
defaultSystemPrompt: "",
|
||||||
|
});
|
||||||
|
const joined = Array.from(result).join("");
|
||||||
|
expect(joined).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEffectiveSystemPrompt with overrideSystemPrompt replaces everything", () => {
|
||||||
|
const result = buildEffectiveSystemPrompt({
|
||||||
|
defaultSystemPrompt: "Default",
|
||||||
|
overrideSystemPrompt: "Override",
|
||||||
|
});
|
||||||
|
const joined = Array.from(result).join("");
|
||||||
|
expect(joined).toBe("Override");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEffectiveSystemPrompt with customSystemPrompt replaces default", () => {
|
||||||
|
const result = buildEffectiveSystemPrompt({
|
||||||
|
defaultSystemPrompt: "Default",
|
||||||
|
customSystemPrompt: "Custom",
|
||||||
|
});
|
||||||
|
const joined = Array.from(result).join("");
|
||||||
|
expect(joined).toBe("Custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildEffectiveSystemPrompt with appendSystemPrompt includes both", () => {
|
||||||
|
const result = buildEffectiveSystemPrompt({
|
||||||
|
defaultSystemPrompt: "Main prompt",
|
||||||
|
appendSystemPrompt: "Appended",
|
||||||
|
});
|
||||||
|
const joined = Array.from(result).join("");
|
||||||
|
expect(joined).toContain("Main prompt");
|
||||||
|
expect(joined).toContain("Appended");
|
||||||
|
// Appended should come after main
|
||||||
|
expect(joined.indexOf("Main prompt")).toBeLessThan(
|
||||||
|
joined.indexOf("Appended")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CLAUDE.md Discovery with Real File System ───────────────────────
|
||||||
|
|
||||||
|
describe("Context build: CLAUDE.md file system integration", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
test("strips HTML comments from CLAUDE.md content", () => {
|
||||||
|
const input = "<!-- this is a comment -->Actual content";
|
||||||
|
const { content, stripped } = stripHtmlComments(input);
|
||||||
|
expect(content).toBe("Actual content");
|
||||||
|
expect(stripped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves code blocks when stripping HTML comments", () => {
|
||||||
|
const input = "```\n<!-- not a real comment -->\n```\nReal text";
|
||||||
|
const { content } = stripHtmlComments(input);
|
||||||
|
expect(content).toContain("<!-- not a real comment -->");
|
||||||
|
expect(content).toContain("Real text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isMemoryFilePath correctly identifies CLAUDE.md paths", () => {
|
||||||
|
expect(isMemoryFilePath("/project/CLAUDE.md")).toBe(true);
|
||||||
|
expect(isMemoryFilePath("/project/CLAUDE.local.md")).toBe(true);
|
||||||
|
expect(isMemoryFilePath("/project/.claude/rules/file.md")).toBe(true);
|
||||||
|
expect(isMemoryFilePath("/project/README.md")).toBe(false);
|
||||||
|
expect(isMemoryFilePath("/project/src/index.ts")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Large Memory File Filtering ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Context build: large memory file filtering", () => {
|
||||||
|
test("getLargeMemoryFiles returns empty for empty input", () => {
|
||||||
|
expect(getLargeMemoryFiles([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getLargeMemoryFiles returns empty when all files are small", () => {
|
||||||
|
const files = [
|
||||||
|
{ path: "/a/CLAUDE.md", content: "small" },
|
||||||
|
{ path: "/b/CLAUDE.md", content: "also small" },
|
||||||
|
];
|
||||||
|
expect(getLargeMemoryFiles(files)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/integration/message-pipeline.test.ts
Normal file
65
tests/integration/message-pipeline.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
createUserMessage,
|
||||||
|
createAssistantMessage,
|
||||||
|
normalizeMessages,
|
||||||
|
extractTag,
|
||||||
|
} from "../../src/utils/messages";
|
||||||
|
|
||||||
|
// ─── Message Structure ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Message pipeline: message structure", () => {
|
||||||
|
test("createUserMessage returns a Message with type 'user'", () => {
|
||||||
|
const msg = createUserMessage("hello");
|
||||||
|
expect(msg.type).toBe("user");
|
||||||
|
expect(msg.message.role).toBe("user");
|
||||||
|
expect(msg.uuid).toBeTruthy();
|
||||||
|
expect(msg.timestamp).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createAssistantMessage returns a Message with type 'assistant'", () => {
|
||||||
|
const msg = createAssistantMessage("response");
|
||||||
|
expect(msg.type).toBe("assistant");
|
||||||
|
expect(msg.message.role).toBe("assistant");
|
||||||
|
expect(msg.uuid).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user and assistant messages have different UUIDs", () => {
|
||||||
|
const user = createUserMessage("hello");
|
||||||
|
const assistant = createAssistantMessage("response");
|
||||||
|
expect(user.uuid).not.toBe(assistant.uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tag Extraction ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Message pipeline: tag extraction", () => {
|
||||||
|
test("extractTag returns null for non-matching tag", () => {
|
||||||
|
expect(extractTag("no tags here", "think")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractTag returns null for empty string", () => {
|
||||||
|
expect(extractTag("", "think")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractTag requires tagName parameter", () => {
|
||||||
|
// Calling without tagName throws
|
||||||
|
expect(() => (extractTag as any)("hello")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Message pipeline: normalization", () => {
|
||||||
|
test("normalizeMessages returns an array", () => {
|
||||||
|
const msg = createUserMessage("hello");
|
||||||
|
const result = normalizeMessages([msg]);
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeMessages preserves at least one message for simple input", () => {
|
||||||
|
const msg = createUserMessage("hello");
|
||||||
|
const result = normalizeMessages([msg]);
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
tests/integration/tool-chain.test.ts
Normal file
141
tests/integration/tool-chain.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { getAllBaseTools, parseToolPreset, getTools } from "../../src/tools.ts";
|
||||||
|
import {
|
||||||
|
findToolByName,
|
||||||
|
getEmptyToolPermissionContext,
|
||||||
|
buildTool,
|
||||||
|
} from "../../src/Tool.ts";
|
||||||
|
|
||||||
|
// ─── Tool Registration & Discovery ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Tool chain: registration and discovery", () => {
|
||||||
|
test("getAllBaseTools returns a non-empty array of tools", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
expect(tools.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all base tools have required fields", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
for (const tool of tools) {
|
||||||
|
expect(tool.name).toBeTruthy();
|
||||||
|
expect(tool.description).toBeTruthy();
|
||||||
|
expect(tool.inputSchema).toBeDefined();
|
||||||
|
expect(typeof tool.call).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findToolByName finds core tools from the full list", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
const bash = findToolByName(tools, "Bash");
|
||||||
|
expect(bash).toBeDefined();
|
||||||
|
expect(bash!.name).toBe("Bash");
|
||||||
|
|
||||||
|
const read = findToolByName(tools, "Read");
|
||||||
|
expect(read).toBeDefined();
|
||||||
|
expect(read!.name).toBe("Read");
|
||||||
|
|
||||||
|
const edit = findToolByName(tools, "Edit");
|
||||||
|
expect(edit).toBeDefined();
|
||||||
|
expect(edit!.name).toBe("Edit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findToolByName returns undefined for non-existent tool", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
expect(findToolByName(tools, "NonExistentTool")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findToolByName is case-sensitive (exact match only)", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
expect(findToolByName(tools, "Bash")).toBeDefined();
|
||||||
|
expect(findToolByName(tools, "bash")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findToolByName resolves via toolMatchesName", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
const agent = findToolByName(tools, "Agent");
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
// Verify it can also find by checking name directly
|
||||||
|
expect(tools.some(t => t.name === "Agent")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool names are unique across the base tool list", () => {
|
||||||
|
const tools = getAllBaseTools();
|
||||||
|
const names = tools.map(t => t.name);
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tool Presets ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Tool chain: presets", () => {
|
||||||
|
test('parseToolPreset("default") returns "default" string', () => {
|
||||||
|
// parseToolPreset returns a preset name string, not a tool array
|
||||||
|
expect(parseToolPreset("default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseToolPreset returns null for unknown preset", () => {
|
||||||
|
expect(parseToolPreset("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseToolPreset is case-insensitive", () => {
|
||||||
|
expect(parseToolPreset("DEFAULT")).toBe("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getTools (with permission context) ────────────────────────────────
|
||||||
|
|
||||||
|
describe("Tool chain: getTools with context", () => {
|
||||||
|
test("getTools returns tools (subset of base tools)", () => {
|
||||||
|
const allTools = getAllBaseTools();
|
||||||
|
const ctx = getEmptyToolPermissionContext();
|
||||||
|
const tools = getTools(ctx);
|
||||||
|
expect(tools.length).toBeGreaterThan(0);
|
||||||
|
expect(tools.length).toBeLessThanOrEqual(allTools.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTools results all have name and call function", () => {
|
||||||
|
const ctx = getEmptyToolPermissionContext();
|
||||||
|
const tools = getTools(ctx);
|
||||||
|
for (const tool of tools) {
|
||||||
|
expect(tool.name).toBeTruthy();
|
||||||
|
expect(typeof tool.call).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── buildTool + findToolByName end-to-end ─────────────────────────────
|
||||||
|
|
||||||
|
describe("Tool chain: buildTool + findToolByName", () => {
|
||||||
|
test("a built tool can be found in a custom list", () => {
|
||||||
|
const customTool = buildTool({
|
||||||
|
name: "TestTool",
|
||||||
|
description: "A test tool",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: { input: { type: "string" } },
|
||||||
|
required: ["input"],
|
||||||
|
},
|
||||||
|
call: async () => ({ output: "test" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = findToolByName([customTool], "TestTool");
|
||||||
|
expect(found).toBe(customTool);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("built tool defaults are correctly applied", () => {
|
||||||
|
const tool = buildTool({
|
||||||
|
name: "MinimalTool",
|
||||||
|
description: "Minimal",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
call: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tool.isEnabled()).toBe(true);
|
||||||
|
expect(tool.isConcurrencySafe()).toBe(false);
|
||||||
|
expect(tool.isReadOnly()).toBe(false);
|
||||||
|
expect(tool.isDestructive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
tests/mocks/api-responses.ts
Normal file
34
tests/mocks/api-responses.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const mockStreamResponse = {
|
||||||
|
type: "message_start" as const,
|
||||||
|
message: {
|
||||||
|
id: "msg_mock_001",
|
||||||
|
type: "message" as const,
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: { input_tokens: 100, output_tokens: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockTextBlock = {
|
||||||
|
type: "content_block_start" as const,
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text" as const, text: "Mock response" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockToolUseBlock = {
|
||||||
|
type: "content_block_start" as const,
|
||||||
|
index: 1,
|
||||||
|
content_block: {
|
||||||
|
type: "tool_use" as const,
|
||||||
|
id: "toolu_mock_001",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "/tmp/test.txt" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockEndEvent = {
|
||||||
|
type: "message_stop" as const,
|
||||||
|
};
|
||||||
32
tests/mocks/file-system.ts
Normal file
32
tests/mocks/file-system.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export async function createTempDir(
|
||||||
|
prefix = "claude-test-",
|
||||||
|
): Promise<string> {
|
||||||
|
return mkdtemp(join(tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTempDir(dir: string): Promise<void> {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeTempFile(
|
||||||
|
dir: string,
|
||||||
|
name: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const path = join(dir, name);
|
||||||
|
await writeFile(path, content, "utf-8");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTempSubdir(
|
||||||
|
dir: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const path = join(dir, name);
|
||||||
|
await mkdir(path, { recursive: true });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
3
tests/mocks/fixtures/sample-claudemd.md
Normal file
3
tests/mocks/fixtures/sample-claudemd.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Project Instructions
|
||||||
|
|
||||||
|
This is a sample CLAUDE.md file for testing purposes.
|
||||||
33
tests/mocks/fixtures/sample-messages.json
Normal file
33
tests/mocks/fixtures/sample-messages.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"userMessage": {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello, Claude"
|
||||||
|
},
|
||||||
|
"assistantMessage": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Hello! How can I help?" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"toolUseMessage": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_test_001",
|
||||||
|
"name": "Read",
|
||||||
|
"input": { "file_path": "/tmp/test.txt" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"toolResultMessage": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_test_001",
|
||||||
|
"content": "file contents here"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user