diff --git a/README.md b/README.md index 3d01c1a..ff0e340 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ > > 这个项目更新很快, 后台有 Opus 持续优化, 几乎几个小时就有新变化; > -> Claude 已经烧了 1000$ 以上, 继续玩; +> Claude 已经烧了 1000$ 以上, 没钱了, 换成 GLM 继续玩; > 存活记录: diff --git a/build.ts b/build.ts index 85b2b20..e63498c 100644 --- a/build.ts +++ b/build.ts @@ -1,5 +1,6 @@ import { readdir, readFile, writeFile } from "fs/promises"; import { join } from "path"; +import { getMacroDefines } from "./scripts/defines.ts"; const outdir = "dist"; @@ -13,6 +14,7 @@ const result = await Bun.build({ outdir, target: "bun", splitting: true, + define: getMacroDefines(), }); if (!result.success) { diff --git a/docs/testing-spec.md b/docs/testing-spec.md index 7c47a01..4b0c88b 100644 --- a/docs/testing-spec.md +++ b/docs/testing-spec.md @@ -300,7 +300,7 @@ bun test --watch ## 11. 当前测试覆盖状态 -> 更新日期:2026-04-02 | 总计:**647 tests, 32 files, 0 failures** +> 更新日期:2026-04-02 | 总计:**1177 tests, 64 files, 0 failures** ### P0 — 核心模块 @@ -348,6 +348,58 @@ bun test --watch | 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) | | 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError | +### P3 — Phase 1 纯函数扩展 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/errors.test.ts` | 28 | ClaudeError, AbortError, ConfigParseError, ShellError, TelemetrySafeError, isAbortError, hasExactErrorMessage, toError, errorMessage, getErrnoCode, isENOENT, getErrnoPath, shortErrorStack, isFsInaccessible, classifyAxiosError | +| `src/utils/permissions/__tests__/shellRuleMatching.test.ts` | 22 | permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule, suggestionForExactCommand, suggestionForPrefix | +| `src/utils/__tests__/argumentSubstitution.test.ts` | 18 | parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments | +| `src/utils/__tests__/CircularBuffer.test.ts` | 12 | CircularBuffer class: add, addAll, getRecent, toArray, clear, length | +| `src/utils/__tests__/sanitization.test.ts` | 14 | partiallySanitizeUnicode, recursivelySanitizeUnicode | +| `src/utils/__tests__/slashCommandParsing.test.ts` | 8 | parseSlashCommand | +| `src/utils/__tests__/contentArray.test.ts` | 6 | insertBlockAfterToolResults | +| `src/utils/__tests__/objectGroupBy.test.ts` | 5 | objectGroupBy | + +### P4 — Phase 2 轻 Mock 扩展 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/envUtils.test.ts` | 34 | isEnvTruthy, isEnvDefinedFalsy, parseEnvVars, hasNodeOption, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, isBareMode, shouldMaintainProjectWorkingDir, getClaudeConfigHomeDir | +| `src/utils/__tests__/sleep.test.ts` | 14 | sleep (abort, throwOnAbort, abortError), withTimeout, sequential | +| `src/utils/__tests__/memoize.test.ts` | 16 | memoizeWithTTL, memoizeWithTTLAsync (dedup/cache/clear), memoizeWithLRU (eviction/cache methods) | +| `src/utils/__tests__/groupToolUses.test.ts` | 10 | applyGrouping (verbose, grouping, result collection, mixed messages) | +| `src/utils/permissions/__tests__/dangerousPatterns.test.ts` | 7 | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS 常量验证 | +| `src/utils/shell/__tests__/outputLimits.test.ts` | 7 | getMaxOutputLength, BASH_MAX_OUTPUT_UPPER_LIMIT, BASH_MAX_OUTPUT_DEFAULT | + +### P5 — Phase 3 补全 + Phase 4 工具模块 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/zodToJsonSchema.test.ts` | 9 | zodToJsonSchema (string/number/object/enum/optional/array/boolean + caching) | +| `src/utils/permissions/__tests__/PermissionMode.test.ts` | 19 | PERMISSION_MODES, permissionModeFromString, permissionModeTitle, permissionModeShortTitle, permissionModeSymbol, getModeColor, isDefaultMode, toExternalPermissionMode, isExternalPermissionMode | +| `src/utils/__tests__/envValidation.test.ts` | 9 | validateBoundedIntEnvVar (default/valid/capped/invalid/boundary) | +| `src/services/mcp/__tests__/mcpStringUtils.test.ts` | 18 | mcpInfoFromString, getMcpPrefix, buildMcpToolName, getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName | +| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) | +| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) | + +### P6 — Phase 5 扩展覆盖 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/tokenBudget.test.ts` | 20 | parseTokenBudget, findTokenBudgetPositions, getBudgetContinuationMessage | +| `src/utils/__tests__/displayTags.test.ts` | 17 | stripDisplayTags, stripDisplayTagsAllowEmpty, stripIdeContextTags | +| `src/utils/__tests__/taggedId.test.ts` | 10 | toTaggedId (prefix/uniqueness/format) | +| `src/utils/__tests__/controlMessageCompat.test.ts` | 15 | normalizeControlMessageKeys (snake_case→camelCase 转换) | +| `src/services/mcp/__tests__/normalization.test.ts` | 11 | normalizeNameForMCP (特殊字符/截断/空字符串/Unicode) | +| `src/services/mcp/__tests__/envExpansion.test.ts` | 14 | expandEnvVarsInString ($VAR/${VAR}/嵌套/未定义/转义) | +| `src/utils/git/__tests__/gitConfigParser.test.ts` | 20 | parseConfigString (key=value/section/subsection/多行/注释/引号) | +| `src/utils/__tests__/formatBriefTimestamp.test.ts` | 10 | formatBriefTimestamp (秒/分/时/天/周/月/年) | +| `src/utils/__tests__/hyperlink.test.ts` | 10 | createHyperlink (OSC 8 序列/file:///path/fallback) | +| `src/utils/__tests__/windowsPaths.test.ts` | 20 | windowsPathToPosixPath, posixPathToWindowsPath (驱动器/UNC/相对路径) | +| `src/utils/__tests__/notebook.test.ts` | 14 | parseCellId, mapNotebookCellsToToolResult (code/markdown/output) | +| `src/utils/__tests__/effort.test.ts` | 38 | isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, resolvePickerEffortPersistence | + ### 已知限制 以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试: @@ -365,13 +417,39 @@ bun test --watch | 被 Mock 模块 | 解锁的测试 | |-------------|-----------| -| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts | +| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts, memoize.ts, PermissionMode.ts | | `src/services/tokenEstimation.ts` | tokens.ts | -| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts | +| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts | +| `src/utils/debug.ts` | envValidation.ts, outputLimits.ts | +| `src/utils/bash/commands.ts` | commandSemantics.ts | +| `src/utils/thinking.js` | effort.ts | +| `src/utils/settings/settings.js` | effort.ts | +| `src/utils/auth.js` | effort.ts | +| `src/services/analytics/growthbook.js` | effort.ts, tokenBudget.ts | +| `src/utils/model/modelSupportOverrides.js` | effort.ts | **关键约束**:`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。 -## 12. 参考 +## 12. 后续测试覆盖计划 + +> **已完成** — Phase 1-4 增加 321 tests (647 → 968),Phase 5 增加 209 tests (968 → 1177) +> +> Phase 1-4 全部完成,详见上方 P3-P5 表格。 +> Phase 5 新增 12 个测试文件覆盖:effort、tokenBudget、displayTags、taggedId、controlMessageCompat、MCP normalization/envExpansion、gitConfigParser、formatBriefTimestamp、hyperlink、windowsPaths、notebook,详见 P6 表格。 +> 实际调整:Phase 3 中 `context.ts` 因极重依赖链(bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null,替换为 `envValidation.ts`(更实用);Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`。 + +### 不纳入计划的模块 + +| 模块 | 原因 | +|------|------| +| `query.ts` / `QueryEngine.ts` | 核心循环,需集成测试环境 | +| `services/api/claude.ts` | 需 mock SDK 流式响应 | +| `spawnMultiAgent.ts` | 50+ 依赖,mock 不可行 | +| `modelCost.ts` | 依赖 bootstrap/state + analytics | +| `mcp/dateTimeParser.ts` | 调用 Haiku API | +| `screens/` / `components/` | UI 组件,需 Ink 渲染测试 | + +## 13. 参考 - [Bun Test 文档](https://bun.sh/docs/cli/test) - 现有测试示例:`src/utils/__tests__/set.test.ts`, `src/utils/__tests__/array.test.ts` diff --git a/mint.json b/mint.json index 3f78e45..3f2ed8b 100644 --- a/mint.json +++ b/mint.json @@ -111,6 +111,11 @@ ] } ], + "excludes": [ + "docs/test-plans/**", + "docs/testing-spec.md", + "docs/REVISION-PLAN.md" + ], "footerSocials": { "github": "https://github.com/anthropics/claude-code" } diff --git a/package.json b/package.json index 8ed6f2d..feba267 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ], "scripts": { "build": "bun run build.ts", - "dev": "bun run src/entrypoints/cli.tsx", + "dev": "bun run scripts/dev.ts", "prepublishOnly": "bun run build", "lint": "biome lint src/", "lint:fix": "biome lint --fix src/", diff --git a/scripts/defines.ts b/scripts/defines.ts new file mode 100644 index 0000000..33ed2b5 --- /dev/null +++ b/scripts/defines.ts @@ -0,0 +1,18 @@ +/** + * Shared MACRO define map used by both dev.ts (runtime -d flags) + * and build.ts (Bun.build define option). + * + * Each value is a JSON-stringified expression that replaces the + * corresponding MACRO.* identifier at transpile / bundle time. + */ +export function getMacroDefines(): Record { + return { + "MACRO.VERSION": JSON.stringify("2.1.888"), + "MACRO.BUILD_TIME": JSON.stringify(new Date().toISOString()), + "MACRO.FEEDBACK_CHANNEL": JSON.stringify(""), + "MACRO.ISSUES_EXPLAINER": JSON.stringify(""), + "MACRO.NATIVE_PACKAGE_URL": JSON.stringify(""), + "MACRO.PACKAGE_URL": JSON.stringify(""), + "MACRO.VERSION_CHANGELOG": JSON.stringify(""), + }; +} diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..5a9ec8f --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +/** + * Dev entrypoint — launches cli.tsx with MACRO.* defines injected + * via Bun's -d flag (bunfig.toml [define] doesn't propagate to + * dynamically imported modules at runtime). + */ +import { getMacroDefines } from "./defines.ts"; + +const defines = getMacroDefines(); + +const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ + "-d", + `${k}:${v}`, +]); + +const result = Bun.spawnSync( + ["bun", "run", ...defineArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)], + { stdio: ["inherit", "inherit", "inherit"] }, +); + +process.exit(result.exitCode ?? 0); diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index d23b9f3..fd72411 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,4 +1,6 @@ #!/usr/bin/env bun + +import { feature } from 'bun:bundle' // Runtime polyfill for bun:bundle (build-time macros) const feature = (name: string) => name === "BUDDY"; if (typeof globalThis.MACRO === "undefined") { diff --git a/src/services/mcp/__tests__/envExpansion.test.ts b/src/services/mcp/__tests__/envExpansion.test.ts new file mode 100644 index 0000000..fe2032f --- /dev/null +++ b/src/services/mcp/__tests__/envExpansion.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { expandEnvVarsInString } from "../envExpansion"; + +describe("expandEnvVarsInString", () => { + // Save and restore env vars touched by tests + const savedEnv: Record = {}; + const trackedKeys = [ + "TEST_HOME", + "MISSING", + "TEST_A", + "TEST_B", + "TEST_EMPTY", + "TEST_X", + "VAR", + "TEST_FOUND", + ]; + + beforeEach(() => { + for (const key of trackedKeys) { + savedEnv[key] = process.env[key]; + } + }); + + afterEach(() => { + for (const key of trackedKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + test("expands a single env var that exists", () => { + process.env.TEST_HOME = "/home/user"; + const result = expandEnvVarsInString("${TEST_HOME}"); + expect(result.expanded).toBe("/home/user"); + expect(result.missingVars).toEqual([]); + }); + + test("returns original placeholder and tracks missing var when not found", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING}"); + expect(result.expanded).toBe("${MISSING}"); + expect(result.missingVars).toEqual(["MISSING"]); + }); + + test("uses default value when var is missing and default is provided", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING:-fallback}"); + expect(result.expanded).toBe("fallback"); + expect(result.missingVars).toEqual([]); + }); + + test("expands multiple vars", () => { + process.env.TEST_A = "hello"; + process.env.TEST_B = "world"; + const result = expandEnvVarsInString("${TEST_A}/${TEST_B}"); + expect(result.expanded).toBe("hello/world"); + expect(result.missingVars).toEqual([]); + }); + + test("handles mix of found and missing vars", () => { + process.env.TEST_FOUND = "yes"; + delete process.env.MISSING; + const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}"); + expect(result.expanded).toBe("yes-${MISSING}"); + expect(result.missingVars).toEqual(["MISSING"]); + }); + + test("returns plain string unchanged with empty missingVars", () => { + const result = expandEnvVarsInString("plain string"); + expect(result.expanded).toBe("plain string"); + expect(result.missingVars).toEqual([]); + }); + + test("expands empty env var value", () => { + process.env.TEST_EMPTY = ""; + const result = expandEnvVarsInString("${TEST_EMPTY}"); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("prefers env var value over default when var exists", () => { + process.env.TEST_X = "real"; + const result = expandEnvVarsInString("${TEST_X:-default}"); + expect(result.expanded).toBe("real"); + expect(result.missingVars).toEqual([]); + }); + + test("handles default value containing colons", () => { + // split(':-', 2) means only the first :- is the delimiter + delete process.env.TEST_X; + const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}"); + // The default is "value" because split(':-', 2) gives ["TEST_X", "value"] + // Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives: + // ["TEST_X", "value"] because limit=2 stops at 2 pieces + expect(result.expanded).toBe("value"); + expect(result.missingVars).toEqual([]); + }); + + test("handles nested-looking syntax as literal (not supported)", () => { + // ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first }) + // so varName would be "${VAR" which won't be found in env + delete process.env.VAR; + const result = expandEnvVarsInString("${${VAR}}"); + // The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR" + // That env var won't exist, so it stays as "${${VAR}" + remaining "}" + expect(result.missingVars).toEqual(["${VAR"]); + expect(result.expanded).toBe("${${VAR}}"); + }); + + test("handles empty string input", () => { + const result = expandEnvVarsInString(""); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("handles var surrounded by text", () => { + process.env.TEST_A = "middle"; + const result = expandEnvVarsInString("before-${TEST_A}-after"); + expect(result.expanded).toBe("before-middle-after"); + expect(result.missingVars).toEqual([]); + }); + + test("handles default value that is empty string", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING:-}"); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("does not expand $VAR without braces", () => { + process.env.TEST_A = "value"; + const result = expandEnvVarsInString("$TEST_A"); + expect(result.expanded).toBe("$TEST_A"); + expect(result.missingVars).toEqual([]); + }); +}); diff --git a/src/services/mcp/__tests__/mcpStringUtils.test.ts b/src/services/mcp/__tests__/mcpStringUtils.test.ts new file mode 100644 index 0000000..0b8d22b --- /dev/null +++ b/src/services/mcp/__tests__/mcpStringUtils.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { + mcpInfoFromString, + buildMcpToolName, + getMcpPrefix, + getMcpDisplayName, + getToolNameForPermissionCheck, + extractMcpToolDisplayName, +} from "../mcpStringUtils"; + +// ─── mcpInfoFromString ───────────────────────────────────────────────── + +describe("mcpInfoFromString", () => { + test("parses standard mcp tool name", () => { + const result = mcpInfoFromString("mcp__github__list_issues"); + expect(result).toEqual({ serverName: "github", toolName: "list_issues" }); + }); + + test("returns null for non-mcp string", () => { + expect(mcpInfoFromString("Bash")).toBeNull(); + expect(mcpInfoFromString("grep__pattern")).toBeNull(); + }); + + test("returns null when no server name", () => { + expect(mcpInfoFromString("mcp__")).toBeNull(); + }); + + test("handles server name only (no tool)", () => { + const result = mcpInfoFromString("mcp__server"); + expect(result).toEqual({ serverName: "server", toolName: undefined }); + }); + + test("preserves double underscores in tool name", () => { + const result = mcpInfoFromString("mcp__server__tool__with__underscores"); + expect(result).toEqual({ + serverName: "server", + toolName: "tool__with__underscores", + }); + }); + + test("returns null for empty string", () => { + expect(mcpInfoFromString("")).toBeNull(); + }); +}); + +// ─── getMcpPrefix ────────────────────────────────────────────────────── + +describe("getMcpPrefix", () => { + test("creates prefix from server name", () => { + expect(getMcpPrefix("github")).toBe("mcp__github__"); + }); + + test("normalizes server name with special chars", () => { + expect(getMcpPrefix("my-server")).toBe("mcp__my-server__"); + }); + + test("normalizes dots to underscores", () => { + expect(getMcpPrefix("my.server")).toBe("mcp__my_server__"); + }); +}); + +// ─── buildMcpToolName ────────────────────────────────────────────────── + +describe("buildMcpToolName", () => { + test("builds fully qualified name", () => { + expect(buildMcpToolName("github", "list_issues")).toBe( + "mcp__github__list_issues" + ); + }); + + test("normalizes both server and tool names", () => { + expect(buildMcpToolName("my.server", "my.tool")).toBe( + "mcp__my_server__my_tool" + ); + }); +}); + +// ─── getMcpDisplayName ───────────────────────────────────────────────── + +describe("getMcpDisplayName", () => { + test("strips mcp prefix from full name", () => { + expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe( + "list_issues" + ); + }); + + test("returns full name if prefix doesn't match", () => { + expect(getMcpDisplayName("mcp__other__tool", "github")).toBe( + "mcp__other__tool" + ); + }); +}); + +// ─── getToolNameForPermissionCheck ───────────────────────────────────── + +describe("getToolNameForPermissionCheck", () => { + test("returns built MCP name for MCP tools", () => { + const tool = { + name: "list_issues", + mcpInfo: { serverName: "github", toolName: "list_issues" }, + }; + expect(getToolNameForPermissionCheck(tool)).toBe( + "mcp__github__list_issues" + ); + }); + + test("returns tool name for non-MCP tools", () => { + const tool = { name: "Bash" }; + expect(getToolNameForPermissionCheck(tool)).toBe("Bash"); + }); + + test("returns tool name when mcpInfo is undefined", () => { + const tool = { name: "Write", mcpInfo: undefined }; + expect(getToolNameForPermissionCheck(tool)).toBe("Write"); + }); +}); + +// ─── extractMcpToolDisplayName ───────────────────────────────────────── + +describe("extractMcpToolDisplayName", () => { + test("extracts display name from full user-facing name", () => { + expect( + extractMcpToolDisplayName("github - Add comment to issue (MCP)") + ).toBe("Add comment to issue"); + }); + + test("removes (MCP) suffix only", () => { + expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool"); + }); + + test("handles name without (MCP) suffix", () => { + expect(extractMcpToolDisplayName("github - List issues")).toBe( + "List issues" + ); + }); + + test("handles name without dash separator", () => { + expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name"); + }); +}); diff --git a/src/services/mcp/__tests__/normalization.test.ts b/src/services/mcp/__tests__/normalization.test.ts new file mode 100644 index 0000000..9b3b699 --- /dev/null +++ b/src/services/mcp/__tests__/normalization.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeNameForMCP } from "../normalization"; + +describe("normalizeNameForMCP", () => { + test("returns simple valid name unchanged", () => { + expect(normalizeNameForMCP("my-server")).toBe("my-server"); + }); + + test("replaces dots with underscores", () => { + expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name"); + }); + + test("replaces spaces with underscores", () => { + expect(normalizeNameForMCP("my server")).toBe("my_server"); + }); + + test("replaces special characters with underscores", () => { + expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_"); + }); + + test("returns already valid name unchanged", () => { + expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123"); + }); + + test("returns empty string for empty input", () => { + expect(normalizeNameForMCP("")).toBe(""); + }); + + test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => { + // "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server" + // starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server" + expect(normalizeNameForMCP("claude.ai My Server")).toBe( + "claude_ai_My_Server" + ); + }); + + test("handles claude.ai prefix with consecutive invalid chars", () => { + // "claude.ai ...test..." -> replace invalid -> "claude_ai____test___" + // collapse consecutive _ -> "claude_ai_test_" + // strip leading/trailing _ -> "claude_ai_test" + expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test"); + }); + + test("non-claude.ai name preserves consecutive underscores", () => { + // "a..b" -> "a__b", no claude.ai prefix so no collapse + expect(normalizeNameForMCP("a..b")).toBe("a__b"); + }); + + test("non-claude.ai name preserves trailing underscores", () => { + expect(normalizeNameForMCP("name!")).toBe("name_"); + }); + + test("handles claude.ai prefix that results in only underscores", () => { + // "claude.ai ..." -> replace invalid -> "claude_ai____" + // collapse -> "claude_ai_" + // strip trailing -> "claude_ai" + expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai"); + }); +}); diff --git a/src/tools/BashTool/__tests__/commandSemantics.test.ts b/src/tools/BashTool/__tests__/commandSemantics.test.ts new file mode 100644 index 0000000..0d7f147 --- /dev/null +++ b/src/tools/BashTool/__tests__/commandSemantics.test.ts @@ -0,0 +1,87 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain +mock.module("src/utils/bash/commands.ts", () => ({ + splitCommand_DEPRECATED: (cmd: string) => + cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean), + quote: (args: string[]) => args.join(" "), +})); + +const { interpretCommandResult } = await import("../commandSemantics"); + +describe("interpretCommandResult", () => { + // ─── Default semantics ──────────────────────────────────────────── + test("exit 0 is not an error for unknown commands", () => { + const result = interpretCommandResult("echo hello", 0, "hello", ""); + expect(result.isError).toBe(false); + }); + + test("non-zero exit is an error for unknown commands", () => { + const result = interpretCommandResult("echo hello", 1, "", "fail"); + expect(result.isError).toBe(true); + expect(result.message).toContain("exit code 1"); + }); + + // ─── grep semantics ────────────────────────────────────────────── + test("grep exit 0 is not an error", () => { + const result = interpretCommandResult("grep pattern file", 0, "match", ""); + expect(result.isError).toBe(false); + }); + + test("grep exit 1 means no matches (not error)", () => { + const result = interpretCommandResult("grep pattern file", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + test("grep exit 2 is an error", () => { + const result = interpretCommandResult("grep pattern file", 2, "", "err"); + expect(result.isError).toBe(true); + }); + + // ─── diff semantics ────────────────────────────────────────────── + test("diff exit 1 means files differ (not error)", () => { + const result = interpretCommandResult("diff a.txt b.txt", 1, "diff", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Files differ"); + }); + + test("diff exit 2 is an error", () => { + const result = interpretCommandResult("diff a.txt b.txt", 2, "", "err"); + expect(result.isError).toBe(true); + }); + + // ─── test/[ semantics ──────────────────────────────────────────── + test("test exit 1 means condition false (not error)", () => { + const result = interpretCommandResult("test -f nofile", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Condition is false"); + }); + + // ─── piped commands ────────────────────────────────────────────── + test("uses last command in pipe for semantics", () => { + // "cat file | grep pattern" → last command is "grep pattern" + const result = interpretCommandResult( + "cat file | grep pattern", + 1, + "", + "" + ); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + // ─── rg (ripgrep) semantics ────────────────────────────────────── + test("rg exit 1 means no matches (not error)", () => { + const result = interpretCommandResult("rg pattern", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + // ─── find semantics ────────────────────────────────────────────── + test("find exit 1 is partial success", () => { + const result = interpretCommandResult("find . -name '*.ts'", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Some directories were inaccessible"); + }); +}); diff --git a/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts b/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts new file mode 100644 index 0000000..af16c3b --- /dev/null +++ b/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { getDestructiveCommandWarning } from "../destructiveCommandWarning"; + +describe("getDestructiveCommandWarning", () => { + // ─── Git data loss ───────────────────────────────────────────────── + test("detects git reset --hard", () => { + const w = getDestructiveCommandWarning("git reset --hard HEAD~1"); + expect(w).toContain("discard uncommitted changes"); + }); + + test("detects git push --force", () => { + const w = getDestructiveCommandWarning("git push --force origin main"); + expect(w).toContain("overwrite remote history"); + }); + + test("detects git push -f", () => { + expect(getDestructiveCommandWarning("git push -f")).toContain( + "overwrite remote history" + ); + }); + + test("detects git clean -f", () => { + const w = getDestructiveCommandWarning("git clean -fd"); + expect(w).toContain("delete untracked files"); + }); + + test("does not flag git clean --dry-run", () => { + expect(getDestructiveCommandWarning("git clean -fdn")).toBeNull(); + }); + + test("detects git checkout .", () => { + const w = getDestructiveCommandWarning("git checkout -- ."); + expect(w).toContain("discard all working tree changes"); + }); + + test("detects git restore .", () => { + const w = getDestructiveCommandWarning("git restore -- ."); + expect(w).toContain("discard all working tree changes"); + }); + + test("detects git stash drop", () => { + const w = getDestructiveCommandWarning("git stash drop"); + expect(w).toContain("remove stashed changes"); + }); + + test("detects git branch -D", () => { + const w = getDestructiveCommandWarning("git branch -D feature"); + expect(w).toContain("force-delete a branch"); + }); + + // ─── Git safety bypass ──────────────────────────────────────────── + test("detects --no-verify", () => { + const w = getDestructiveCommandWarning("git commit --no-verify -m 'x'"); + expect(w).toContain("skip safety hooks"); + }); + + test("detects git commit --amend", () => { + const w = getDestructiveCommandWarning("git commit --amend"); + expect(w).toContain("rewrite the last commit"); + }); + + // ─── File deletion ──────────────────────────────────────────────── + test("detects rm -rf", () => { + const w = getDestructiveCommandWarning("rm -rf /tmp/dir"); + expect(w).toContain("recursively force-remove"); + }); + + test("detects rm -r", () => { + const w = getDestructiveCommandWarning("rm -r dir"); + expect(w).toContain("recursively remove"); + }); + + test("detects rm -f", () => { + const w = getDestructiveCommandWarning("rm -f file.txt"); + expect(w).toContain("force-remove"); + }); + + // ─── Database ───────────────────────────────────────────────────── + test("detects DROP TABLE", () => { + const w = getDestructiveCommandWarning("psql -c 'DROP TABLE users'"); + expect(w).toContain("drop or truncate"); + }); + + test("detects TRUNCATE TABLE", () => { + const w = getDestructiveCommandWarning("TRUNCATE TABLE logs"); + expect(w).toContain("drop or truncate"); + }); + + test("detects DELETE FROM without WHERE", () => { + const w = getDestructiveCommandWarning("DELETE FROM users;"); + expect(w).toContain("delete all rows"); + }); + + // ─── Infrastructure ─────────────────────────────────────────────── + test("detects kubectl delete", () => { + const w = getDestructiveCommandWarning("kubectl delete pod my-pod"); + expect(w).toContain("delete Kubernetes"); + }); + + test("detects terraform destroy", () => { + const w = getDestructiveCommandWarning("terraform destroy"); + expect(w).toContain("destroy Terraform"); + }); + + // ─── Safe commands ──────────────────────────────────────────────── + test("returns null for safe commands", () => { + expect(getDestructiveCommandWarning("ls -la")).toBeNull(); + expect(getDestructiveCommandWarning("git status")).toBeNull(); + expect(getDestructiveCommandWarning("npm install")).toBeNull(); + expect(getDestructiveCommandWarning("cat file.txt")).toBeNull(); + }); +}); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ceb2f55..bff3970 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -4,9 +4,8 @@ */ // ============================================================================ -// MACRO — Bun compile-time macro function (from bun:bundle) -// Expands the function body at build time and removes the call in production. -// Also supports property access like MACRO.VERSION (compile-time constants). +// MACRO — Bun compile-time constants injected via bunfig.toml [define] (dev) +// and Bun.build({ define }) (production). See bunfig.toml & build.ts. declare namespace MACRO { export const VERSION: string export const BUILD_TIME: string @@ -16,7 +15,6 @@ declare namespace MACRO { export const PACKAGE_URL: string export const VERSION_CHANGELOG: string } -declare function MACRO(fn: () => T): T // ============================================================================ // Internal Anthropic-only identifiers (dead-code eliminated in open-source) @@ -62,11 +60,7 @@ declare type T = unknown declare function TungstenPill(props?: { key?: string; selected?: boolean }): JSX.Element | null // ============================================================================ -// Build-time constants — replaced by Bun bundler, polyfilled at runtime -// Using `string` (not literal types) so comparisons don't produce TS2367 -declare const BUILD_TARGET: string -declare const BUILD_ENV: string -declare const INTERFACE_TYPE: string +// Build-time constants BUILD_TARGET/BUILD_ENV/INTERFACE_TYPE — removed (zero runtime usage) // ============================================================================ // Ink custom JSX intrinsic elements — see src/types/ink-jsx.d.ts diff --git a/src/types/internal-modules.d.ts b/src/types/internal-modules.d.ts index bf1d214..95ff7a0 100644 --- a/src/types/internal-modules.d.ts +++ b/src/types/internal-modules.d.ts @@ -9,7 +9,6 @@ // ============================================================================ declare module "bun:bundle" { export function feature(name: string): boolean; - export function MACRO(fn: () => T): T; } declare module "bun:ffi" { diff --git a/src/utils/__tests__/CircularBuffer.test.ts b/src/utils/__tests__/CircularBuffer.test.ts new file mode 100644 index 0000000..0e2c561 --- /dev/null +++ b/src/utils/__tests__/CircularBuffer.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { CircularBuffer } from "../CircularBuffer"; + +describe("CircularBuffer", () => { + test("starts empty", () => { + const buf = new CircularBuffer(5); + expect(buf.length()).toBe(0); + expect(buf.toArray()).toEqual([]); + }); + + test("adds items up to capacity", () => { + const buf = new CircularBuffer(3); + buf.add(1); + buf.add(2); + buf.add(3); + expect(buf.length()).toBe(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + test("evicts oldest when full", () => { + const buf = new CircularBuffer(3); + buf.add(1); + buf.add(2); + buf.add(3); + buf.add(4); + expect(buf.length()).toBe(3); + expect(buf.toArray()).toEqual([2, 3, 4]); + }); + + test("evicts multiple oldest items", () => { + const buf = new CircularBuffer(2); + buf.add(1); + buf.add(2); + buf.add(3); + buf.add(4); + buf.add(5); + expect(buf.toArray()).toEqual([4, 5]); + }); + + test("addAll adds multiple items", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3]); + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + test("addAll with overflow", () => { + const buf = new CircularBuffer(3); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.toArray()).toEqual([3, 4, 5]); + }); + + test("getRecent returns last N items", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.getRecent(3)).toEqual([3, 4, 5]); + }); + + test("getRecent returns fewer when not enough items", () => { + const buf = new CircularBuffer(5); + buf.add(1); + buf.add(2); + expect(buf.getRecent(5)).toEqual([1, 2]); + }); + + test("getRecent works after wraparound", () => { + const buf = new CircularBuffer(3); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.getRecent(2)).toEqual([4, 5]); + }); + + test("clear resets buffer", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3]); + buf.clear(); + expect(buf.length()).toBe(0); + expect(buf.toArray()).toEqual([]); + }); + + test("works with string type", () => { + const buf = new CircularBuffer(2); + buf.add("a"); + buf.add("b"); + buf.add("c"); + expect(buf.toArray()).toEqual(["b", "c"]); + }); +}); diff --git a/src/utils/__tests__/argumentSubstitution.test.ts b/src/utils/__tests__/argumentSubstitution.test.ts new file mode 100644 index 0000000..4c875a1 --- /dev/null +++ b/src/utils/__tests__/argumentSubstitution.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { + parseArguments, + parseArgumentNames, + generateProgressiveArgumentHint, + substituteArguments, +} from "../argumentSubstitution"; + +// ─── parseArguments ───────────────────────────────────────────────────── + +describe("parseArguments", () => { + test("splits simple arguments", () => { + expect(parseArguments("foo bar baz")).toEqual(["foo", "bar", "baz"]); + }); + + test("handles quoted strings", () => { + expect(parseArguments('foo "hello world" baz')).toEqual([ + "foo", + "hello world", + "baz", + ]); + }); + + test("handles single-quoted strings", () => { + expect(parseArguments("foo 'hello world' baz")).toEqual([ + "foo", + "hello world", + "baz", + ]); + }); + + test("returns empty for empty string", () => { + expect(parseArguments("")).toEqual([]); + }); + + test("returns empty for whitespace only", () => { + expect(parseArguments(" ")).toEqual([]); + }); +}); + +// ─── parseArgumentNames ───────────────────────────────────────────────── + +describe("parseArgumentNames", () => { + test("parses space-separated string", () => { + expect(parseArgumentNames("foo bar baz")).toEqual(["foo", "bar", "baz"]); + }); + + test("accepts array input", () => { + expect(parseArgumentNames(["foo", "bar"])).toEqual(["foo", "bar"]); + }); + + test("filters out numeric-only names", () => { + expect(parseArgumentNames("foo 123 bar")).toEqual(["foo", "bar"]); + }); + + test("filters out empty strings", () => { + expect(parseArgumentNames(["foo", "", "bar"])).toEqual(["foo", "bar"]); + }); + + test("returns empty for undefined", () => { + expect(parseArgumentNames(undefined)).toEqual([]); + }); +}); + +// ─── generateProgressiveArgumentHint ──────────────────────────────────── + +describe("generateProgressiveArgumentHint", () => { + test("shows remaining arguments", () => { + expect(generateProgressiveArgumentHint(["a", "b", "c"], ["x"])).toBe( + "[b] [c]" + ); + }); + + test("returns undefined when all filled", () => { + expect( + generateProgressiveArgumentHint(["a"], ["x"]) + ).toBeUndefined(); + }); + + test("shows all when none typed", () => { + expect(generateProgressiveArgumentHint(["a", "b"], [])).toBe("[a] [b]"); + }); +}); + +// ─── substituteArguments ──────────────────────────────────────────────── + +describe("substituteArguments", () => { + test("replaces $ARGUMENTS with full args", () => { + expect(substituteArguments("run $ARGUMENTS", "foo bar")).toBe( + "run foo bar" + ); + }); + + test("replaces indexed $ARGUMENTS[0]", () => { + expect(substituteArguments("run $ARGUMENTS[0]", "foo bar")).toBe("run foo"); + }); + + test("replaces shorthand $0, $1", () => { + expect(substituteArguments("$0 and $1", "hello world")).toBe( + "hello and world" + ); + }); + + test("replaces named arguments", () => { + expect( + substituteArguments("file: $name", "test.txt", true, ["name"]) + ).toBe("file: test.txt"); + }); + + test("returns content unchanged for undefined args", () => { + expect(substituteArguments("hello", undefined)).toBe("hello"); + }); + + test("appends ARGUMENTS when no placeholder found", () => { + expect(substituteArguments("run this", "extra")).toBe( + "run this\n\nARGUMENTS: extra" + ); + }); + + test("does not append when appendIfNoPlaceholder is false", () => { + expect(substituteArguments("run this", "extra", false)).toBe("run this"); + }); + + test("does not append for empty args string", () => { + expect(substituteArguments("run this", "")).toBe("run this"); + }); +}); diff --git a/src/utils/__tests__/contentArray.test.ts b/src/utils/__tests__/contentArray.test.ts new file mode 100644 index 0000000..d708af8 --- /dev/null +++ b/src/utils/__tests__/contentArray.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { insertBlockAfterToolResults } from "../contentArray"; + +describe("insertBlockAfterToolResults", () => { + test("inserts after last tool_result", () => { + const content: any[] = [ + { type: "tool_result", content: "r1" }, + { type: "text", text: "hello" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "inserted" }); + expect(content[1]).toEqual({ type: "text", text: "inserted" }); + expect(content).toHaveLength(3); + }); + + test("inserts after last of multiple tool_results", () => { + const content: any[] = [ + { type: "tool_result", content: "r1" }, + { type: "tool_result", content: "r2" }, + { type: "text", text: "end" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[2]).toEqual({ type: "text", text: "new" }); + }); + + test("appends continuation when inserted block would be last", () => { + const content: any[] = [{ type: "tool_result", content: "r1" }]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content).toHaveLength(3); // original + inserted + continuation + expect(content[2]).toEqual({ type: "text", text: "." }); + }); + + test("inserts before last block when no tool_results", () => { + const content: any[] = [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[1]).toEqual({ type: "text", text: "new" }); + expect(content).toHaveLength(3); + }); + + test("handles empty array", () => { + const content: any[] = []; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content).toHaveLength(1); + expect(content[0]).toEqual({ type: "text", text: "new" }); + }); + + test("handles single element array with no tool_result", () => { + const content: any[] = [{ type: "text", text: "only" }]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[0]).toEqual({ type: "text", text: "new" }); + expect(content[1]).toEqual({ type: "text", text: "only" }); + }); +}); diff --git a/src/utils/__tests__/controlMessageCompat.test.ts b/src/utils/__tests__/controlMessageCompat.test.ts new file mode 100644 index 0000000..9396f53 --- /dev/null +++ b/src/utils/__tests__/controlMessageCompat.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeControlMessageKeys } from "../controlMessageCompat"; + +describe("normalizeControlMessageKeys", () => { + // --- basic camelCase to snake_case --- + test("converts requestId to request_id", () => { + const obj = { requestId: "123" }; + const result = normalizeControlMessageKeys(obj); + expect(result).toEqual({ request_id: "123" }); + expect((result as any).requestId).toBeUndefined(); + }); + + test("leaves request_id unchanged", () => { + const obj = { request_id: "123" }; + normalizeControlMessageKeys(obj); + expect(obj).toEqual({ request_id: "123" }); + }); + + // --- both present: snake_case wins --- + test("keeps snake_case when both requestId and request_id exist", () => { + const obj = { requestId: "camel", request_id: "snake" }; + const result = normalizeControlMessageKeys(obj) as any; + expect(result.request_id).toBe("snake"); + // requestId is NOT deleted when request_id already exists + // because the condition `!('request_id' in record)` prevents the branch + expect(result.requestId).toBe("camel"); + }); + + // --- nested response --- + test("normalizes nested response.requestId", () => { + const obj = { response: { requestId: "456" } }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("456"); + expect((obj as any).response.requestId).toBeUndefined(); + }); + + test("leaves nested response.request_id unchanged", () => { + const obj = { response: { request_id: "789" } }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("789"); + }); + + test("nested response: snake_case wins when both present", () => { + const obj = { + response: { requestId: "camel", request_id: "snake" }, + }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("snake"); + expect((obj as any).response.requestId).toBe("camel"); + }); + + // --- non-object inputs --- + test("returns null as-is", () => { + expect(normalizeControlMessageKeys(null)).toBeNull(); + }); + + test("returns undefined as-is", () => { + expect(normalizeControlMessageKeys(undefined)).toBeUndefined(); + }); + + test("returns string as-is", () => { + expect(normalizeControlMessageKeys("hello")).toBe("hello"); + }); + + test("returns number as-is", () => { + expect(normalizeControlMessageKeys(42)).toBe(42); + }); + + // --- empty and edge cases --- + test("empty object is unchanged", () => { + const obj = {}; + normalizeControlMessageKeys(obj); + expect(obj).toEqual({}); + }); + + test("mutates the original object in place", () => { + const obj = { requestId: "abc", other: "data" }; + const result = normalizeControlMessageKeys(obj); + expect(result).toBe(obj); // same reference + expect(obj).toEqual({ request_id: "abc", other: "data" }); + }); + + test("does not affect other keys on the object", () => { + const obj = { requestId: "123", type: "control_request", payload: {} }; + normalizeControlMessageKeys(obj); + expect((obj as any).type).toBe("control_request"); + expect((obj as any).payload).toEqual({}); + expect((obj as any).request_id).toBe("123"); + }); + + test("handles response being null", () => { + const obj = { response: null, requestId: "x" }; + normalizeControlMessageKeys(obj); + expect((obj as any).request_id).toBe("x"); + expect((obj as any).response).toBeNull(); + }); + + test("handles response being a non-object (string)", () => { + const obj = { response: "not-an-object" }; + normalizeControlMessageKeys(obj); + expect((obj as any).response).toBe("not-an-object"); + }); +}); diff --git a/src/utils/__tests__/displayTags.test.ts b/src/utils/__tests__/displayTags.test.ts new file mode 100644 index 0000000..46ed46e --- /dev/null +++ b/src/utils/__tests__/displayTags.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + stripDisplayTags, + stripDisplayTagsAllowEmpty, + stripIdeContextTags, +} from "../displayTags"; + +describe("stripDisplayTags", () => { + test("strips a single system tag and returns remaining text", () => { + expect( + stripDisplayTags("secret stufftext") + ).toBe("text"); + }); + + test("strips multiple tags and preserves text between them", () => { + const input = + "datahello infoworld"; + expect(stripDisplayTags(input)).toBe("hello world"); + }); + + test("preserves uppercase JSX component names", () => { + expect(stripDisplayTags("fix the