Skip to content

Commit 3c5eb0e

Browse files
Merge branch 'test/test-most-core-func'
2 parents 2ca5697 + 4f323ef commit 3c5eb0e

41 files changed

Lines changed: 4168 additions & 32 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readdir, readFile, writeFile } from "fs/promises";
22
import { join } from "path";
3+
import { getMacroDefines } from "./scripts/defines.ts";
34

45
const outdir = "dist";
56

@@ -13,6 +14,7 @@ const result = await Bun.build({
1314
outdir,
1415
target: "bun",
1516
splitting: true,
17+
define: getMacroDefines(),
1618
});
1719

1820
if (!result.success) {

docs/testing-spec.md

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ bun test --watch
300300

301301
## 11. 当前测试覆盖状态
302302

303-
> 更新日期:2026-04-02 | 总计:**647 tests, 32 files, 0 failures**
303+
> 更新日期:2026-04-02 | 总计:**1177 tests, 64 files, 0 failures**
304304
305305
### P0 — 核心模块
306306

@@ -348,6 +348,58 @@ bun test --watch
348348
| 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) |
349349
| 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError |
350350

351+
### P3 — Phase 1 纯函数扩展
352+
353+
| 测试文件 | 测试数 | 覆盖范围 |
354+
|----------|--------|----------|
355+
| `src/utils/__tests__/errors.test.ts` | 28 | ClaudeError, AbortError, ConfigParseError, ShellError, TelemetrySafeError, isAbortError, hasExactErrorMessage, toError, errorMessage, getErrnoCode, isENOENT, getErrnoPath, shortErrorStack, isFsInaccessible, classifyAxiosError |
356+
| `src/utils/permissions/__tests__/shellRuleMatching.test.ts` | 22 | permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule, suggestionForExactCommand, suggestionForPrefix |
357+
| `src/utils/__tests__/argumentSubstitution.test.ts` | 18 | parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments |
358+
| `src/utils/__tests__/CircularBuffer.test.ts` | 12 | CircularBuffer class: add, addAll, getRecent, toArray, clear, length |
359+
| `src/utils/__tests__/sanitization.test.ts` | 14 | partiallySanitizeUnicode, recursivelySanitizeUnicode |
360+
| `src/utils/__tests__/slashCommandParsing.test.ts` | 8 | parseSlashCommand |
361+
| `src/utils/__tests__/contentArray.test.ts` | 6 | insertBlockAfterToolResults |
362+
| `src/utils/__tests__/objectGroupBy.test.ts` | 5 | objectGroupBy |
363+
364+
### P4 — Phase 2 轻 Mock 扩展
365+
366+
| 测试文件 | 测试数 | 覆盖范围 |
367+
|----------|--------|----------|
368+
| `src/utils/__tests__/envUtils.test.ts` | 34 | isEnvTruthy, isEnvDefinedFalsy, parseEnvVars, hasNodeOption, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, isBareMode, shouldMaintainProjectWorkingDir, getClaudeConfigHomeDir |
369+
| `src/utils/__tests__/sleep.test.ts` | 14 | sleep (abort, throwOnAbort, abortError), withTimeout, sequential |
370+
| `src/utils/__tests__/memoize.test.ts` | 16 | memoizeWithTTL, memoizeWithTTLAsync (dedup/cache/clear), memoizeWithLRU (eviction/cache methods) |
371+
| `src/utils/__tests__/groupToolUses.test.ts` | 10 | applyGrouping (verbose, grouping, result collection, mixed messages) |
372+
| `src/utils/permissions/__tests__/dangerousPatterns.test.ts` | 7 | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS 常量验证 |
373+
| `src/utils/shell/__tests__/outputLimits.test.ts` | 7 | getMaxOutputLength, BASH_MAX_OUTPUT_UPPER_LIMIT, BASH_MAX_OUTPUT_DEFAULT |
374+
375+
### P5 — Phase 3 补全 + Phase 4 工具模块
376+
377+
| 测试文件 | 测试数 | 覆盖范围 |
378+
|----------|--------|----------|
379+
| `src/utils/__tests__/zodToJsonSchema.test.ts` | 9 | zodToJsonSchema (string/number/object/enum/optional/array/boolean + caching) |
380+
| `src/utils/permissions/__tests__/PermissionMode.test.ts` | 19 | PERMISSION_MODES, permissionModeFromString, permissionModeTitle, permissionModeShortTitle, permissionModeSymbol, getModeColor, isDefaultMode, toExternalPermissionMode, isExternalPermissionMode |
381+
| `src/utils/__tests__/envValidation.test.ts` | 9 | validateBoundedIntEnvVar (default/valid/capped/invalid/boundary) |
382+
| `src/services/mcp/__tests__/mcpStringUtils.test.ts` | 18 | mcpInfoFromString, getMcpPrefix, buildMcpToolName, getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName |
383+
| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) |
384+
| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) |
385+
386+
### P6 — Phase 5 扩展覆盖
387+
388+
| 测试文件 | 测试数 | 覆盖范围 |
389+
|----------|--------|----------|
390+
| `src/utils/__tests__/tokenBudget.test.ts` | 20 | parseTokenBudget, findTokenBudgetPositions, getBudgetContinuationMessage |
391+
| `src/utils/__tests__/displayTags.test.ts` | 17 | stripDisplayTags, stripDisplayTagsAllowEmpty, stripIdeContextTags |
392+
| `src/utils/__tests__/taggedId.test.ts` | 10 | toTaggedId (prefix/uniqueness/format) |
393+
| `src/utils/__tests__/controlMessageCompat.test.ts` | 15 | normalizeControlMessageKeys (snake_case→camelCase 转换) |
394+
| `src/services/mcp/__tests__/normalization.test.ts` | 11 | normalizeNameForMCP (特殊字符/截断/空字符串/Unicode) |
395+
| `src/services/mcp/__tests__/envExpansion.test.ts` | 14 | expandEnvVarsInString ($VAR/${VAR}/嵌套/未定义/转义) |
396+
| `src/utils/git/__tests__/gitConfigParser.test.ts` | 20 | parseConfigString (key=value/section/subsection/多行/注释/引号) |
397+
| `src/utils/__tests__/formatBriefTimestamp.test.ts` | 10 | formatBriefTimestamp (秒/分/时/天/周/月/年) |
398+
| `src/utils/__tests__/hyperlink.test.ts` | 10 | createHyperlink (OSC 8 序列/file:///path/fallback) |
399+
| `src/utils/__tests__/windowsPaths.test.ts` | 20 | windowsPathToPosixPath, posixPathToWindowsPath (驱动器/UNC/相对路径) |
400+
| `src/utils/__tests__/notebook.test.ts` | 14 | parseCellId, mapNotebookCellsToToolResult (code/markdown/output) |
401+
| `src/utils/__tests__/effort.test.ts` | 38 | isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, resolvePickerEffortPersistence |
402+
351403
### 已知限制
352404

353405
以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试:
@@ -365,13 +417,39 @@ bun test --watch
365417

366418
| 被 Mock 模块 | 解锁的测试 |
367419
|-------------|-----------|
368-
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts |
420+
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts, memoize.ts, PermissionMode.ts |
369421
| `src/services/tokenEstimation.ts` | tokens.ts |
370-
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts |
422+
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts |
423+
| `src/utils/debug.ts` | envValidation.ts, outputLimits.ts |
424+
| `src/utils/bash/commands.ts` | commandSemantics.ts |
425+
| `src/utils/thinking.js` | effort.ts |
426+
| `src/utils/settings/settings.js` | effort.ts |
427+
| `src/utils/auth.js` | effort.ts |
428+
| `src/services/analytics/growthbook.js` | effort.ts, tokenBudget.ts |
429+
| `src/utils/model/modelSupportOverrides.js` | effort.ts |
371430

372431
**关键约束**`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。
373432

374-
## 12. 参考
433+
## 12. 后续测试覆盖计划
434+
435+
> **已完成** — Phase 1-4 增加 321 tests (647 → 968),Phase 5 增加 209 tests (968 → 1177)
436+
>
437+
> Phase 1-4 全部完成,详见上方 P3-P5 表格。
438+
> Phase 5 新增 12 个测试文件覆盖:effort、tokenBudget、displayTags、taggedId、controlMessageCompat、MCP normalization/envExpansion、gitConfigParser、formatBriefTimestamp、hyperlink、windowsPaths、notebook,详见 P6 表格。
439+
> 实际调整:Phase 3 中 `context.ts` 因极重依赖链(bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null,替换为 `envValidation.ts`(更实用);Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`
440+
441+
### 不纳入计划的模块
442+
443+
| 模块 | 原因 |
444+
|------|------|
445+
| `query.ts` / `QueryEngine.ts` | 核心循环,需集成测试环境 |
446+
| `services/api/claude.ts` | 需 mock SDK 流式响应 |
447+
| `spawnMultiAgent.ts` | 50+ 依赖,mock 不可行 |
448+
| `modelCost.ts` | 依赖 bootstrap/state + analytics |
449+
| `mcp/dateTimeParser.ts` | 调用 Haiku API |
450+
| `screens/` / `components/` | UI 组件,需 Ink 渲染测试 |
451+
452+
## 13. 参考
375453

376454
- [Bun Test 文档](https://bun.sh/docs/cli/test)
377455
- 现有测试示例:`src/utils/__tests__/set.test.ts`, `src/utils/__tests__/array.test.ts`

mint.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@
111111
]
112112
}
113113
],
114+
"excludes": [
115+
"docs/test-plans/**",
116+
"docs/testing-spec.md",
117+
"docs/REVISION-PLAN.md"
118+
],
114119
"footerSocials": {
115120
"github": "https://github.com/anthropics/claude-code"
116121
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
],
3737
"scripts": {
3838
"build": "bun run build.ts",
39-
"dev": "bun run src/entrypoints/cli.tsx",
39+
"dev": "bun run scripts/dev.ts",
4040
"prepublishOnly": "bun run build",
4141
"lint": "biome lint src/",
4242
"lint:fix": "biome lint --fix src/",

scripts/defines.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Shared MACRO define map used by both dev.ts (runtime -d flags)
3+
* and build.ts (Bun.build define option).
4+
*
5+
* Each value is a JSON-stringified expression that replaces the
6+
* corresponding MACRO.* identifier at transpile / bundle time.
7+
*/
8+
export function getMacroDefines(): Record<string, string> {
9+
return {
10+
"MACRO.VERSION": JSON.stringify("2.1.888"),
11+
"MACRO.BUILD_TIME": JSON.stringify(new Date().toISOString()),
12+
"MACRO.FEEDBACK_CHANNEL": JSON.stringify(""),
13+
"MACRO.ISSUES_EXPLAINER": JSON.stringify(""),
14+
"MACRO.NATIVE_PACKAGE_URL": JSON.stringify(""),
15+
"MACRO.PACKAGE_URL": JSON.stringify(""),
16+
"MACRO.VERSION_CHANGELOG": JSON.stringify(""),
17+
};
18+
}

scripts/dev.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Dev entrypoint — launches cli.tsx with MACRO.* defines injected
4+
* via Bun's -d flag (bunfig.toml [define] doesn't propagate to
5+
* dynamically imported modules at runtime).
6+
*/
7+
import { getMacroDefines } from "./defines.ts";
8+
9+
const defines = getMacroDefines();
10+
11+
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
12+
"-d",
13+
`${k}:${v}`,
14+
]);
15+
16+
const result = Bun.spawnSync(
17+
["bun", "run", ...defineArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)],
18+
{ stdio: ["inherit", "inherit", "inherit"] },
19+
);
20+
21+
process.exit(result.exitCode ?? 0);

src/entrypoints/cli.tsx

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
11
#!/usr/bin/env bun
2-
// Runtime polyfill for bun:bundle (build-time macros)
3-
const feature = (_name: string) => false;
4-
if (typeof globalThis.MACRO === "undefined") {
5-
(globalThis as any).MACRO = {
6-
VERSION: "2.1.888",
7-
BUILD_TIME: new Date().toISOString(),
8-
FEEDBACK_CHANNEL: "",
9-
ISSUES_EXPLAINER: "",
10-
NATIVE_PACKAGE_URL: "",
11-
PACKAGE_URL: "",
12-
VERSION_CHANGELOG: "",
13-
};
14-
}
15-
// Build-time constants — normally replaced by Bun bundler at compile time
16-
(globalThis as any).BUILD_TARGET = "external";
17-
(globalThis as any).BUILD_ENV = "production";
18-
(globalThis as any).INTERFACE_TYPE = "stdio";
2+
import { feature } from 'bun:bundle'
193

204
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
215
// eslint-disable-next-line custom-rules/no-top-level-side-effects
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2+
import { expandEnvVarsInString } from "../envExpansion";
3+
4+
describe("expandEnvVarsInString", () => {
5+
// Save and restore env vars touched by tests
6+
const savedEnv: Record<string, string | undefined> = {};
7+
const trackedKeys = [
8+
"TEST_HOME",
9+
"MISSING",
10+
"TEST_A",
11+
"TEST_B",
12+
"TEST_EMPTY",
13+
"TEST_X",
14+
"VAR",
15+
"TEST_FOUND",
16+
];
17+
18+
beforeEach(() => {
19+
for (const key of trackedKeys) {
20+
savedEnv[key] = process.env[key];
21+
}
22+
});
23+
24+
afterEach(() => {
25+
for (const key of trackedKeys) {
26+
if (savedEnv[key] === undefined) {
27+
delete process.env[key];
28+
} else {
29+
process.env[key] = savedEnv[key];
30+
}
31+
}
32+
});
33+
34+
test("expands a single env var that exists", () => {
35+
process.env.TEST_HOME = "/home/user";
36+
const result = expandEnvVarsInString("${TEST_HOME}");
37+
expect(result.expanded).toBe("/home/user");
38+
expect(result.missingVars).toEqual([]);
39+
});
40+
41+
test("returns original placeholder and tracks missing var when not found", () => {
42+
delete process.env.MISSING;
43+
const result = expandEnvVarsInString("${MISSING}");
44+
expect(result.expanded).toBe("${MISSING}");
45+
expect(result.missingVars).toEqual(["MISSING"]);
46+
});
47+
48+
test("uses default value when var is missing and default is provided", () => {
49+
delete process.env.MISSING;
50+
const result = expandEnvVarsInString("${MISSING:-fallback}");
51+
expect(result.expanded).toBe("fallback");
52+
expect(result.missingVars).toEqual([]);
53+
});
54+
55+
test("expands multiple vars", () => {
56+
process.env.TEST_A = "hello";
57+
process.env.TEST_B = "world";
58+
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
59+
expect(result.expanded).toBe("hello/world");
60+
expect(result.missingVars).toEqual([]);
61+
});
62+
63+
test("handles mix of found and missing vars", () => {
64+
process.env.TEST_FOUND = "yes";
65+
delete process.env.MISSING;
66+
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
67+
expect(result.expanded).toBe("yes-${MISSING}");
68+
expect(result.missingVars).toEqual(["MISSING"]);
69+
});
70+
71+
test("returns plain string unchanged with empty missingVars", () => {
72+
const result = expandEnvVarsInString("plain string");
73+
expect(result.expanded).toBe("plain string");
74+
expect(result.missingVars).toEqual([]);
75+
});
76+
77+
test("expands empty env var value", () => {
78+
process.env.TEST_EMPTY = "";
79+
const result = expandEnvVarsInString("${TEST_EMPTY}");
80+
expect(result.expanded).toBe("");
81+
expect(result.missingVars).toEqual([]);
82+
});
83+
84+
test("prefers env var value over default when var exists", () => {
85+
process.env.TEST_X = "real";
86+
const result = expandEnvVarsInString("${TEST_X:-default}");
87+
expect(result.expanded).toBe("real");
88+
expect(result.missingVars).toEqual([]);
89+
});
90+
91+
test("handles default value containing colons", () => {
92+
// split(':-', 2) means only the first :- is the delimiter
93+
delete process.env.TEST_X;
94+
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
95+
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
96+
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
97+
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
98+
expect(result.expanded).toBe("value");
99+
expect(result.missingVars).toEqual([]);
100+
});
101+
102+
test("handles nested-looking syntax as literal (not supported)", () => {
103+
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
104+
// so varName would be "${VAR" which won't be found in env
105+
delete process.env.VAR;
106+
const result = expandEnvVarsInString("${${VAR}}");
107+
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
108+
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
109+
expect(result.missingVars).toEqual(["${VAR"]);
110+
expect(result.expanded).toBe("${${VAR}}");
111+
});
112+
113+
test("handles empty string input", () => {
114+
const result = expandEnvVarsInString("");
115+
expect(result.expanded).toBe("");
116+
expect(result.missingVars).toEqual([]);
117+
});
118+
119+
test("handles var surrounded by text", () => {
120+
process.env.TEST_A = "middle";
121+
const result = expandEnvVarsInString("before-${TEST_A}-after");
122+
expect(result.expanded).toBe("before-middle-after");
123+
expect(result.missingVars).toEqual([]);
124+
});
125+
126+
test("handles default value that is empty string", () => {
127+
delete process.env.MISSING;
128+
const result = expandEnvVarsInString("${MISSING:-}");
129+
expect(result.expanded).toBe("");
130+
expect(result.missingVars).toEqual([]);
131+
});
132+
133+
test("does not expand $VAR without braces", () => {
134+
process.env.TEST_A = "value";
135+
const result = expandEnvVarsInString("$TEST_A");
136+
expect(result.expanded).toBe("$TEST_A");
137+
expect(result.missingVars).toEqual([]);
138+
});
139+
});

0 commit comments

Comments
 (0)