Merge branch 'main' into main
This commit is contained in:
commit
e815002f96
@ -19,7 +19,7 @@
|
||||
>
|
||||
> 这个项目更新很快, 后台有 Opus 持续优化, 几乎几个小时就有新变化;
|
||||
>
|
||||
> Claude 已经烧了 1000$ 以上, 继续玩;
|
||||
> Claude 已经烧了 1000$ 以上, 没钱了, 换成 GLM 继续玩;
|
||||
>
|
||||
|
||||
存活记录:
|
||||
|
||||
2
build.ts
2
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) {
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -111,6 +111,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"excludes": [
|
||||
"docs/test-plans/**",
|
||||
"docs/testing-spec.md",
|
||||
"docs/REVISION-PLAN.md"
|
||||
],
|
||||
"footerSocials": {
|
||||
"github": "https://github.com/anthropics/claude-code"
|
||||
}
|
||||
|
||||
@ -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/",
|
||||
|
||||
18
scripts/defines.ts
Normal file
18
scripts/defines.ts
Normal file
@ -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<string, string> {
|
||||
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(""),
|
||||
};
|
||||
}
|
||||
21
scripts/dev.ts
Normal file
21
scripts/dev.ts
Normal file
@ -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);
|
||||
@ -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") {
|
||||
|
||||
139
src/services/mcp/__tests__/envExpansion.test.ts
Normal file
139
src/services/mcp/__tests__/envExpansion.test.ts
Normal file
@ -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<string, string | undefined> = {};
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
140
src/services/mcp/__tests__/mcpStringUtils.test.ts
Normal file
140
src/services/mcp/__tests__/mcpStringUtils.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
59
src/services/mcp/__tests__/normalization.test.ts
Normal file
59
src/services/mcp/__tests__/normalization.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
87
src/tools/BashTool/__tests__/commandSemantics.test.ts
Normal file
87
src/tools/BashTool/__tests__/commandSemantics.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
112
src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts
Normal file
112
src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
12
src/types/global.d.ts
vendored
12
src/types/global.d.ts
vendored
@ -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<T>(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
|
||||
|
||||
1
src/types/internal-modules.d.ts
vendored
1
src/types/internal-modules.d.ts
vendored
@ -9,7 +9,6 @@
|
||||
// ============================================================================
|
||||
declare module "bun:bundle" {
|
||||
export function feature(name: string): boolean;
|
||||
export function MACRO<T>(fn: () => T): T;
|
||||
}
|
||||
|
||||
declare module "bun:ffi" {
|
||||
|
||||
86
src/utils/__tests__/CircularBuffer.test.ts
Normal file
86
src/utils/__tests__/CircularBuffer.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CircularBuffer } from "../CircularBuffer";
|
||||
|
||||
describe("CircularBuffer", () => {
|
||||
test("starts empty", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
expect(buf.length()).toBe(0);
|
||||
expect(buf.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
test("adds items up to capacity", () => {
|
||||
const buf = new CircularBuffer<number>(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<number>(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<number>(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<number>(5);
|
||||
buf.addAll([1, 2, 3]);
|
||||
expect(buf.toArray()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("addAll with overflow", () => {
|
||||
const buf = new CircularBuffer<number>(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<number>(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<number>(5);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
expect(buf.getRecent(5)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("getRecent works after wraparound", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.addAll([1, 2, 3, 4, 5]);
|
||||
expect(buf.getRecent(2)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
test("clear resets buffer", () => {
|
||||
const buf = new CircularBuffer<number>(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<string>(2);
|
||||
buf.add("a");
|
||||
buf.add("b");
|
||||
buf.add("c");
|
||||
expect(buf.toArray()).toEqual(["b", "c"]);
|
||||
});
|
||||
});
|
||||
127
src/utils/__tests__/argumentSubstitution.test.ts
Normal file
127
src/utils/__tests__/argumentSubstitution.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
55
src/utils/__tests__/contentArray.test.ts
Normal file
55
src/utils/__tests__/contentArray.test.ts
Normal file
@ -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" });
|
||||
});
|
||||
});
|
||||
103
src/utils/__tests__/controlMessageCompat.test.ts
Normal file
103
src/utils/__tests__/controlMessageCompat.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
134
src/utils/__tests__/displayTags.test.ts
Normal file
134
src/utils/__tests__/displayTags.test.ts
Normal file
@ -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("<system-reminder>secret stuff</system-reminder>text")
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
test("strips multiple tags and preserves text between them", () => {
|
||||
const input =
|
||||
"<hook-output>data</hook-output>hello <task-info>info</task-info>world";
|
||||
expect(stripDisplayTags(input)).toBe("hello world");
|
||||
});
|
||||
|
||||
test("preserves uppercase JSX component names", () => {
|
||||
expect(stripDisplayTags("fix the <Button> layout")).toBe(
|
||||
"fix the <Button> layout"
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves angle brackets in prose (when x < y)", () => {
|
||||
expect(stripDisplayTags("when x < y")).toBe("when x < y");
|
||||
});
|
||||
|
||||
test("preserves DOCTYPE declarations", () => {
|
||||
expect(stripDisplayTags("<!DOCTYPE html>")).toBe("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
test("returns original text when stripping would result in empty", () => {
|
||||
const input = "<system-reminder>all tags</system-reminder>";
|
||||
expect(stripDisplayTags(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("strips tags with attributes", () => {
|
||||
expect(
|
||||
stripDisplayTags('<context type="ide">data</context>hello')
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles multi-line tag content", () => {
|
||||
const input = "<info>\nline1\nline2\n</info>remaining";
|
||||
expect(stripDisplayTags(input)).toBe("remaining");
|
||||
});
|
||||
|
||||
test("returns trimmed result", () => {
|
||||
expect(
|
||||
stripDisplayTags(" <tag>content</tag> hello ")
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles empty string input", () => {
|
||||
// Empty string is falsy, so stripDisplayTags returns original
|
||||
expect(stripDisplayTags("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles whitespace-only input", () => {
|
||||
// After trim, result is empty string which is falsy, returns original
|
||||
expect(stripDisplayTags(" ")).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripDisplayTagsAllowEmpty", () => {
|
||||
test("returns empty string when all content is tags", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<system-reminder>stuff</system-reminder>")
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("strips tags and returns remaining text", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag>hello")
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(stripDisplayTagsAllowEmpty("")).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for whitespace-only content after strip", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag> ")
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripIdeContextTags", () => {
|
||||
test("strips ide_opened_file tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
"<ide_opened_file>path/to/file.ts</ide_opened_file>hello"
|
||||
)
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("strips ide_selection tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags("<ide_selection>selected code</ide_selection>world")
|
||||
).toBe("world");
|
||||
});
|
||||
|
||||
test("strips ide tags with attributes", () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
'<ide_opened_file path="foo.ts">content</ide_opened_file>text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
test("preserves other lowercase tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags("<system-reminder>data</system-reminder>hello")
|
||||
).toBe("<system-reminder>data</system-reminder>hello");
|
||||
});
|
||||
|
||||
test("preserves user-typed HTML like <code>", () => {
|
||||
expect(stripIdeContextTags("use <code>foo</code> here")).toBe(
|
||||
"use <code>foo</code> here"
|
||||
);
|
||||
});
|
||||
|
||||
test("strips only IDE tags while preserving other tags and text", () => {
|
||||
const input =
|
||||
"<ide_opened_file>f.ts</ide_opened_file><system-reminder>x</system-reminder>text";
|
||||
expect(stripIdeContextTags(input)).toBe(
|
||||
"<system-reminder>x</system-reminder>text"
|
||||
);
|
||||
});
|
||||
});
|
||||
255
src/utils/__tests__/effort.test.ts
Normal file
255
src/utils/__tests__/effort.test.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
||||
|
||||
// Mock heavy dependencies to avoid import chain issues
|
||||
mock.module("src/utils/thinking.js", () => ({
|
||||
isUltrathinkEnabled: () => false,
|
||||
}));
|
||||
mock.module("src/utils/settings/settings.js", () => ({
|
||||
getInitialSettings: () => ({}),
|
||||
}));
|
||||
mock.module("src/utils/auth.js", () => ({
|
||||
isProSubscriber: () => false,
|
||||
isMaxSubscriber: () => false,
|
||||
isTeamSubscriber: () => false,
|
||||
}));
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => null,
|
||||
}));
|
||||
mock.module("src/utils/model/modelSupportOverrides.js", () => ({
|
||||
get3PModelCapabilityOverride: () => undefined,
|
||||
}));
|
||||
|
||||
const {
|
||||
isEffortLevel,
|
||||
parseEffortValue,
|
||||
isValidNumericEffort,
|
||||
convertEffortValueToLevel,
|
||||
getEffortLevelDescription,
|
||||
resolvePickerEffortPersistence,
|
||||
EFFORT_LEVELS,
|
||||
} = await import("src/utils/effort.js");
|
||||
|
||||
// ─── EFFORT_LEVELS constant ────────────────────────────────────────────
|
||||
|
||||
describe("EFFORT_LEVELS", () => {
|
||||
test("contains the four canonical levels", () => {
|
||||
expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "max"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEffortLevel ─────────────────────────────────────────────────────
|
||||
|
||||
describe("isEffortLevel", () => {
|
||||
test("returns true for 'low'", () => {
|
||||
expect(isEffortLevel("low")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'medium'", () => {
|
||||
expect(isEffortLevel("medium")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'high'", () => {
|
||||
expect(isEffortLevel("high")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'max'", () => {
|
||||
expect(isEffortLevel("max")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 'invalid'", () => {
|
||||
expect(isEffortLevel("invalid")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEffortLevel("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseEffortValue ──────────────────────────────────────────────────
|
||||
|
||||
describe("parseEffortValue", () => {
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(parseEffortValue(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(parseEffortValue(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseEffortValue("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns number for integer input", () => {
|
||||
expect(parseEffortValue(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("returns string for valid effort level string", () => {
|
||||
expect(parseEffortValue("low")).toBe("low");
|
||||
expect(parseEffortValue("medium")).toBe("medium");
|
||||
expect(parseEffortValue("high")).toBe("high");
|
||||
expect(parseEffortValue("max")).toBe("max");
|
||||
});
|
||||
|
||||
test("parses numeric string to number", () => {
|
||||
expect(parseEffortValue("42")).toBe(42);
|
||||
});
|
||||
|
||||
test("returns undefined for invalid string", () => {
|
||||
expect(parseEffortValue("invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("non-integer number falls through to string parsing (parseInt truncates)", () => {
|
||||
// 3.14 fails isValidNumericEffort, then String(3.14) -> "3.14" -> parseInt = 3
|
||||
expect(parseEffortValue(3.14)).toBe(3);
|
||||
});
|
||||
|
||||
test("handles case-insensitive effort level strings", () => {
|
||||
expect(parseEffortValue("LOW")).toBe("low");
|
||||
expect(parseEffortValue("HIGH")).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidNumericEffort ──────────────────────────────────────────────
|
||||
|
||||
describe("isValidNumericEffort", () => {
|
||||
test("returns true for integer", () => {
|
||||
expect(isValidNumericEffort(50)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for zero", () => {
|
||||
expect(isValidNumericEffort(0)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for negative integer", () => {
|
||||
expect(isValidNumericEffort(-1)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for float", () => {
|
||||
expect(isValidNumericEffort(3.14)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for NaN", () => {
|
||||
expect(isValidNumericEffort(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for Infinity", () => {
|
||||
expect(isValidNumericEffort(Infinity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── convertEffortValueToLevel ─────────────────────────────────────────
|
||||
|
||||
describe("convertEffortValueToLevel", () => {
|
||||
test("returns valid effort level string as-is", () => {
|
||||
expect(convertEffortValueToLevel("low")).toBe("low");
|
||||
expect(convertEffortValueToLevel("medium")).toBe("medium");
|
||||
expect(convertEffortValueToLevel("high")).toBe("high");
|
||||
expect(convertEffortValueToLevel("max")).toBe("max");
|
||||
});
|
||||
|
||||
test("returns 'high' for unknown string", () => {
|
||||
expect(convertEffortValueToLevel("unknown" as any)).toBe("high");
|
||||
});
|
||||
|
||||
test("non-ant numeric value returns 'high'", () => {
|
||||
const saved = process.env.USER_TYPE;
|
||||
delete process.env.USER_TYPE;
|
||||
|
||||
expect(convertEffortValueToLevel(50)).toBe("high");
|
||||
expect(convertEffortValueToLevel(100)).toBe("high");
|
||||
|
||||
process.env.USER_TYPE = saved;
|
||||
});
|
||||
|
||||
describe("ant numeric mapping", () => {
|
||||
let savedUserType: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedUserType = process.env.USER_TYPE;
|
||||
process.env.USER_TYPE = "ant";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedUserType === undefined) {
|
||||
delete process.env.USER_TYPE;
|
||||
} else {
|
||||
process.env.USER_TYPE = savedUserType;
|
||||
}
|
||||
});
|
||||
|
||||
test("value <= 50 maps to 'low'", () => {
|
||||
expect(convertEffortValueToLevel(50)).toBe("low");
|
||||
expect(convertEffortValueToLevel(0)).toBe("low");
|
||||
expect(convertEffortValueToLevel(-10)).toBe("low");
|
||||
});
|
||||
|
||||
test("value 51-85 maps to 'medium'", () => {
|
||||
expect(convertEffortValueToLevel(51)).toBe("medium");
|
||||
expect(convertEffortValueToLevel(85)).toBe("medium");
|
||||
});
|
||||
|
||||
test("value 86-100 maps to 'high'", () => {
|
||||
expect(convertEffortValueToLevel(86)).toBe("high");
|
||||
expect(convertEffortValueToLevel(100)).toBe("high");
|
||||
});
|
||||
|
||||
test("value > 100 maps to 'max'", () => {
|
||||
expect(convertEffortValueToLevel(101)).toBe("max");
|
||||
expect(convertEffortValueToLevel(200)).toBe("max");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getEffortLevelDescription ─────────────────────────────────────────
|
||||
|
||||
describe("getEffortLevelDescription", () => {
|
||||
test("returns description for 'low'", () => {
|
||||
const desc = getEffortLevelDescription("low");
|
||||
expect(desc).toContain("Quick");
|
||||
});
|
||||
|
||||
test("returns description for 'medium'", () => {
|
||||
const desc = getEffortLevelDescription("medium");
|
||||
expect(desc).toContain("Balanced");
|
||||
});
|
||||
|
||||
test("returns description for 'high'", () => {
|
||||
const desc = getEffortLevelDescription("high");
|
||||
expect(desc).toContain("Comprehensive");
|
||||
});
|
||||
|
||||
test("returns description for 'max'", () => {
|
||||
const desc = getEffortLevelDescription("max");
|
||||
expect(desc).toContain("Maximum");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolvePickerEffortPersistence ────────────────────────────────────
|
||||
|
||||
describe("resolvePickerEffortPersistence", () => {
|
||||
test("returns undefined when picked matches model default and no prior persistence", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", undefined, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns picked when it differs from model default", () => {
|
||||
const result = resolvePickerEffortPersistence("low", "high", undefined, false);
|
||||
expect(result).toBe("low");
|
||||
});
|
||||
|
||||
test("returns picked when priorPersisted is set (even if same as default)", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", "high", false);
|
||||
expect(result).toBe("high");
|
||||
});
|
||||
|
||||
test("returns picked when toggledInPicker is true (even if same as default)", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", undefined, true);
|
||||
expect(result).toBe("high");
|
||||
});
|
||||
|
||||
test("returns undefined picked value when no explicit and matches default", () => {
|
||||
const result = resolvePickerEffortPersistence(undefined, "high" as any, undefined, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
333
src/utils/__tests__/envUtils.test.ts
Normal file
333
src/utils/__tests__/envUtils.test.ts
Normal file
@ -0,0 +1,333 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
isEnvTruthy,
|
||||
isEnvDefinedFalsy,
|
||||
parseEnvVars,
|
||||
hasNodeOption,
|
||||
getAWSRegion,
|
||||
getDefaultVertexRegion,
|
||||
getVertexRegionForModel,
|
||||
isBareMode,
|
||||
shouldMaintainProjectWorkingDir,
|
||||
getClaudeConfigHomeDir,
|
||||
} from "../envUtils";
|
||||
|
||||
// ─── isEnvTruthy ───────────────────────────────────────────────────────
|
||||
|
||||
describe("isEnvTruthy", () => {
|
||||
test("returns true for '1'", () => {
|
||||
expect(isEnvTruthy("1")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'true'", () => {
|
||||
expect(isEnvTruthy("true")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'TRUE'", () => {
|
||||
expect(isEnvTruthy("TRUE")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'yes'", () => {
|
||||
expect(isEnvTruthy("yes")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'on'", () => {
|
||||
expect(isEnvTruthy("on")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for boolean true", () => {
|
||||
expect(isEnvTruthy(true)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for '0'", () => {
|
||||
expect(isEnvTruthy("0")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for 'false'", () => {
|
||||
expect(isEnvTruthy("false")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEnvTruthy("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isEnvTruthy(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for boolean false", () => {
|
||||
expect(isEnvTruthy(false)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for ' true ' (trimmed)", () => {
|
||||
expect(isEnvTruthy(" true ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEnvDefinedFalsy ─────────────────────────────────────────────────
|
||||
|
||||
describe("isEnvDefinedFalsy", () => {
|
||||
test("returns true for '0'", () => {
|
||||
expect(isEnvDefinedFalsy("0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'false'", () => {
|
||||
expect(isEnvDefinedFalsy("false")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'no'", () => {
|
||||
expect(isEnvDefinedFalsy("no")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'off'", () => {
|
||||
expect(isEnvDefinedFalsy("off")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for boolean false", () => {
|
||||
expect(isEnvDefinedFalsy(false)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isEnvDefinedFalsy(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for '1'", () => {
|
||||
expect(isEnvDefinedFalsy("1")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for 'true'", () => {
|
||||
expect(isEnvDefinedFalsy("true")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEnvDefinedFalsy("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseEnvVars ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parseEnvVars", () => {
|
||||
test("parses KEY=VALUE pairs", () => {
|
||||
const result = parseEnvVars(["FOO=bar", "BAZ=qux"]);
|
||||
expect(result).toEqual({ FOO: "bar", BAZ: "qux" });
|
||||
});
|
||||
|
||||
test("handles value with equals sign", () => {
|
||||
const result = parseEnvVars(["URL=http://host?a=1&b=2"]);
|
||||
expect(result).toEqual({ URL: "http://host?a=1&b=2" });
|
||||
});
|
||||
|
||||
test("returns empty object for undefined", () => {
|
||||
expect(parseEnvVars(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseEnvVars([])).toEqual({});
|
||||
});
|
||||
|
||||
test("throws for missing value", () => {
|
||||
expect(() => parseEnvVars(["NOVALUE"])).toThrow("Invalid environment variable format");
|
||||
});
|
||||
|
||||
test("throws for empty key", () => {
|
||||
expect(() => parseEnvVars(["=value"])).toThrow("Invalid environment variable format");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasNodeOption ─────────────────────────────────────────────────────
|
||||
|
||||
describe("hasNodeOption", () => {
|
||||
const saved = process.env.NODE_OPTIONS;
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.NODE_OPTIONS;
|
||||
else process.env.NODE_OPTIONS = saved;
|
||||
});
|
||||
|
||||
test("returns true when flag present", () => {
|
||||
process.env.NODE_OPTIONS = "--max-old-space-size=4096 --inspect";
|
||||
expect(hasNodeOption("--inspect")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when flag absent", () => {
|
||||
process.env.NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when NODE_OPTIONS not set", () => {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not match partial flags", () => {
|
||||
process.env.NODE_OPTIONS = "--inspect-brk";
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAWSRegion ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getAWSRegion", () => {
|
||||
const savedRegion = process.env.AWS_REGION;
|
||||
const savedDefault = process.env.AWS_DEFAULT_REGION;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedRegion === undefined) delete process.env.AWS_REGION;
|
||||
else process.env.AWS_REGION = savedRegion;
|
||||
if (savedDefault === undefined) delete process.env.AWS_DEFAULT_REGION;
|
||||
else process.env.AWS_DEFAULT_REGION = savedDefault;
|
||||
});
|
||||
|
||||
test("uses AWS_REGION when set", () => {
|
||||
process.env.AWS_REGION = "eu-west-1";
|
||||
expect(getAWSRegion()).toBe("eu-west-1");
|
||||
});
|
||||
|
||||
test("falls back to AWS_DEFAULT_REGION", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
process.env.AWS_DEFAULT_REGION = "ap-northeast-1";
|
||||
expect(getAWSRegion()).toBe("ap-northeast-1");
|
||||
});
|
||||
|
||||
test("defaults to us-east-1", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
expect(getAWSRegion()).toBe("us-east-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDefaultVertexRegion ────────────────────────────────────────────
|
||||
|
||||
describe("getDefaultVertexRegion", () => {
|
||||
const saved = process.env.CLOUD_ML_REGION;
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLOUD_ML_REGION;
|
||||
else process.env.CLOUD_ML_REGION = saved;
|
||||
});
|
||||
|
||||
test("uses CLOUD_ML_REGION when set", () => {
|
||||
process.env.CLOUD_ML_REGION = "europe-west4";
|
||||
expect(getDefaultVertexRegion()).toBe("europe-west4");
|
||||
});
|
||||
|
||||
test("defaults to us-east5", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getDefaultVertexRegion()).toBe("us-east5");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getVertexRegionForModel ───────────────────────────────────────────
|
||||
|
||||
describe("getVertexRegionForModel", () => {
|
||||
const envKeys = [
|
||||
"VERTEX_REGION_CLAUDE_HAIKU_4_5",
|
||||
"VERTEX_REGION_CLAUDE_4_0_SONNET",
|
||||
"VERTEX_REGION_CLAUDE_4_6_SONNET",
|
||||
"CLOUD_ML_REGION",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of envKeys) saved[k] = process.env[k];
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const k of envKeys) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
test("returns model-specific override when set", () => {
|
||||
process.env.VERTEX_REGION_CLAUDE_HAIKU_4_5 = "us-central1";
|
||||
expect(getVertexRegionForModel("claude-haiku-4-5-20251001")).toBe("us-central1");
|
||||
});
|
||||
|
||||
test("falls back to default vertex region when override not set", () => {
|
||||
delete process.env.VERTEX_REGION_CLAUDE_4_0_SONNET;
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel("claude-sonnet-4-some-variant")).toBe("us-east5");
|
||||
});
|
||||
|
||||
test("returns default region for unknown model prefix", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel("unknown-model-123")).toBe("us-east5");
|
||||
});
|
||||
|
||||
test("returns default region for undefined model", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel(undefined)).toBe("us-east5");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isBareMode ────────────────────────────────────────────────────────
|
||||
|
||||
describe("isBareMode", () => {
|
||||
const saved = process.env.CLAUDE_CODE_SIMPLE;
|
||||
const originalArgv = [...process.argv];
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CODE_SIMPLE;
|
||||
else process.env.CLAUDE_CODE_SIMPLE = saved;
|
||||
process.argv.length = 0;
|
||||
process.argv.push(...originalArgv);
|
||||
});
|
||||
|
||||
test("returns true when CLAUDE_CODE_SIMPLE=1", () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
expect(isBareMode()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when --bare in argv", () => {
|
||||
process.argv.push("--bare");
|
||||
expect(isBareMode()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when neither set", () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE;
|
||||
// argv doesn't have --bare by default
|
||||
expect(isBareMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shouldMaintainProjectWorkingDir ───────────────────────────────────
|
||||
|
||||
describe("shouldMaintainProjectWorkingDir", () => {
|
||||
const saved = process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
else process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = saved;
|
||||
});
|
||||
|
||||
test("returns true when set to truthy", () => {
|
||||
process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1";
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when not set", () => {
|
||||
delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getClaudeConfigHomeDir ────────────────────────────────────────────
|
||||
|
||||
describe("getClaudeConfigHomeDir", () => {
|
||||
const saved = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved;
|
||||
});
|
||||
|
||||
test("uses CLAUDE_CONFIG_DIR when set", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/test-claude";
|
||||
// Memoized by CLAUDE_CONFIG_DIR key, so changing env gives fresh value
|
||||
expect(getClaudeConfigHomeDir()).toBe("/tmp/test-claude");
|
||||
});
|
||||
|
||||
test("returns a string ending with .claude by default", () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const result = getClaudeConfigHomeDir();
|
||||
expect(result).toMatch(/\.claude$/);
|
||||
});
|
||||
});
|
||||
74
src/utils/__tests__/envValidation.test.ts
Normal file
74
src/utils/__tests__/envValidation.test.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock debug.ts to cut bootstrap/state dependency chain
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
getDebugFilter: () => null,
|
||||
getMinDebugLogLevel: () => "debug",
|
||||
getDebugLogPath: () => "/tmp/mock-debug.log",
|
||||
flushDebugLogs: async () => {},
|
||||
enableDebugLogging: () => false,
|
||||
setHasFormattedOutput: () => {},
|
||||
getHasFormattedOutput: () => false,
|
||||
logAntError: () => {},
|
||||
}));
|
||||
|
||||
const { validateBoundedIntEnvVar } = await import("../envValidation");
|
||||
|
||||
describe("validateBoundedIntEnvVar", () => {
|
||||
test("returns default when value is undefined", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", undefined, 100, 1000);
|
||||
expect(result).toEqual({ effective: 100, status: "valid" });
|
||||
});
|
||||
|
||||
test("returns default when value is empty string", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "", 100, 1000);
|
||||
expect(result).toEqual({ effective: 100, status: "valid" });
|
||||
});
|
||||
|
||||
test("returns parsed value when valid and within limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "500", 100, 1000);
|
||||
expect(result).toEqual({ effective: 500, status: "valid" });
|
||||
});
|
||||
|
||||
test("caps value at upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "2000", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("capped");
|
||||
expect(result.message).toContain("Capped from 2000 to 1000");
|
||||
});
|
||||
|
||||
test("returns default for non-numeric value", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "abc", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
expect(result.message).toContain("Invalid value");
|
||||
});
|
||||
|
||||
test("returns default for zero", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "0", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
|
||||
test("returns default for negative value", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "-5", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
|
||||
test("handles value at exact upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1000", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
|
||||
test("handles value of 1 (minimum valid)", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
||||
expect(result.effective).toBe(1);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
});
|
||||
289
src/utils/__tests__/errors.test.ts
Normal file
289
src/utils/__tests__/errors.test.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
AbortError,
|
||||
ClaudeError,
|
||||
MalformedCommandError,
|
||||
ConfigParseError,
|
||||
ShellError,
|
||||
TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
isAbortError,
|
||||
hasExactErrorMessage,
|
||||
toError,
|
||||
errorMessage,
|
||||
getErrnoCode,
|
||||
isENOENT,
|
||||
getErrnoPath,
|
||||
shortErrorStack,
|
||||
isFsInaccessible,
|
||||
classifyAxiosError,
|
||||
} from "../errors";
|
||||
|
||||
// ─── Error classes ──────────────────────────────────────────────────────
|
||||
|
||||
describe("ClaudeError", () => {
|
||||
test("sets name to constructor name", () => {
|
||||
const e = new ClaudeError("test");
|
||||
expect(e.name).toBe("ClaudeError");
|
||||
expect(e.message).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbortError", () => {
|
||||
test("sets name to AbortError", () => {
|
||||
const e = new AbortError("cancelled");
|
||||
expect(e.name).toBe("AbortError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigParseError", () => {
|
||||
test("stores filePath and defaultConfig", () => {
|
||||
const e = new ConfigParseError("bad", "/tmp/cfg", { x: 1 });
|
||||
expect(e.filePath).toBe("/tmp/cfg");
|
||||
expect(e.defaultConfig).toEqual({ x: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShellError", () => {
|
||||
test("stores stdout, stderr, code, interrupted", () => {
|
||||
const e = new ShellError("out", "err", 1, false);
|
||||
expect(e.stdout).toBe("out");
|
||||
expect(e.stderr).toBe("err");
|
||||
expect(e.code).toBe(1);
|
||||
expect(e.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelemetrySafeError", () => {
|
||||
test("uses message as telemetryMessage by default", () => {
|
||||
const e =
|
||||
new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS("msg");
|
||||
expect(e.telemetryMessage).toBe("msg");
|
||||
});
|
||||
|
||||
test("uses separate telemetryMessage when provided", () => {
|
||||
const e =
|
||||
new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
|
||||
"full msg",
|
||||
"safe msg"
|
||||
);
|
||||
expect(e.message).toBe("full msg");
|
||||
expect(e.telemetryMessage).toBe("safe msg");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isAbortError ───────────────────────────────────────────────────────
|
||||
|
||||
describe("isAbortError", () => {
|
||||
test("returns true for AbortError instance", () => {
|
||||
expect(isAbortError(new AbortError())).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for DOMException-style abort", () => {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
expect(isAbortError(e)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for regular error", () => {
|
||||
expect(isAbortError(new Error("nope"))).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-error", () => {
|
||||
expect(isAbortError("string")).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasExactErrorMessage ───────────────────────────────────────────────
|
||||
|
||||
describe("hasExactErrorMessage", () => {
|
||||
test("returns true for matching message", () => {
|
||||
expect(hasExactErrorMessage(new Error("test"), "test")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for different message", () => {
|
||||
expect(hasExactErrorMessage(new Error("a"), "b")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-Error", () => {
|
||||
expect(hasExactErrorMessage("string", "string")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toError ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toError", () => {
|
||||
test("returns Error as-is", () => {
|
||||
const e = new Error("test");
|
||||
expect(toError(e)).toBe(e);
|
||||
});
|
||||
|
||||
test("wraps string in Error", () => {
|
||||
const e = toError("oops");
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.message).toBe("oops");
|
||||
});
|
||||
|
||||
test("wraps number in Error", () => {
|
||||
expect(toError(42).message).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── errorMessage ───────────────────────────────────────────────────────
|
||||
|
||||
describe("errorMessage", () => {
|
||||
test("extracts message from Error", () => {
|
||||
expect(errorMessage(new Error("hello"))).toBe("hello");
|
||||
});
|
||||
|
||||
test("stringifies non-Error", () => {
|
||||
expect(errorMessage(42)).toBe("42");
|
||||
expect(errorMessage(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getErrnoCode / isENOENT / getErrnoPath ────────────────────────────
|
||||
|
||||
describe("getErrnoCode", () => {
|
||||
test("extracts code from errno-like error", () => {
|
||||
const e = Object.assign(new Error(), { code: "ENOENT" });
|
||||
expect(getErrnoCode(e)).toBe("ENOENT");
|
||||
});
|
||||
|
||||
test("returns undefined for no code", () => {
|
||||
expect(getErrnoCode(new Error())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for non-string code", () => {
|
||||
expect(getErrnoCode({ code: 123 })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for non-object", () => {
|
||||
expect(getErrnoCode(null)).toBeUndefined();
|
||||
expect(getErrnoCode("string")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isENOENT", () => {
|
||||
test("returns true for ENOENT", () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for other codes", () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: "EACCES" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getErrnoPath", () => {
|
||||
test("extracts path from errno error", () => {
|
||||
const e = Object.assign(new Error(), { path: "/tmp/file" });
|
||||
expect(getErrnoPath(e)).toBe("/tmp/file");
|
||||
});
|
||||
|
||||
test("returns undefined when no path", () => {
|
||||
expect(getErrnoPath(new Error())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shortErrorStack ────────────────────────────────────────────────────
|
||||
|
||||
describe("shortErrorStack", () => {
|
||||
test("returns string for non-Error", () => {
|
||||
expect(shortErrorStack("oops")).toBe("oops");
|
||||
});
|
||||
|
||||
test("returns message when no stack", () => {
|
||||
const e = new Error("test");
|
||||
e.stack = undefined;
|
||||
expect(shortErrorStack(e)).toBe("test");
|
||||
});
|
||||
|
||||
test("truncates long stacks", () => {
|
||||
const e = new Error("test");
|
||||
const frames = Array.from({ length: 20 }, (_, i) => ` at frame${i}`);
|
||||
e.stack = `Error: test\n${frames.join("\n")}`;
|
||||
const result = shortErrorStack(e, 3);
|
||||
const lines = result.split("\n");
|
||||
expect(lines).toHaveLength(4); // header + 3 frames
|
||||
});
|
||||
|
||||
test("preserves short stacks", () => {
|
||||
const e = new Error("test");
|
||||
e.stack = "Error: test\n at frame1\n at frame2";
|
||||
expect(shortErrorStack(e, 5)).toBe(e.stack);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isFsInaccessible ──────────────────────────────────────────────────
|
||||
|
||||
describe("isFsInaccessible", () => {
|
||||
test("returns true for ENOENT", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for EACCES", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EACCES" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for EPERM", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EPERM" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for ENOTDIR", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOTDIR" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for ELOOP", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ELOOP" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for other codes", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EEXIST" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── classifyAxiosError ─────────────────────────────────────────────────
|
||||
|
||||
describe("classifyAxiosError", () => {
|
||||
test("returns 'other' for non-axios error", () => {
|
||||
expect(classifyAxiosError(new Error("test")).kind).toBe("other");
|
||||
});
|
||||
|
||||
test("returns 'auth' for 401", () => {
|
||||
const e = { isAxiosError: true, response: { status: 401 }, message: "unauth" };
|
||||
expect(classifyAxiosError(e).kind).toBe("auth");
|
||||
});
|
||||
|
||||
test("returns 'auth' for 403", () => {
|
||||
const e = { isAxiosError: true, response: { status: 403 }, message: "forbidden" };
|
||||
expect(classifyAxiosError(e).kind).toBe("auth");
|
||||
});
|
||||
|
||||
test("returns 'timeout' for ECONNABORTED", () => {
|
||||
const e = { isAxiosError: true, code: "ECONNABORTED", message: "timeout" };
|
||||
expect(classifyAxiosError(e).kind).toBe("timeout");
|
||||
});
|
||||
|
||||
test("returns 'network' for ECONNREFUSED", () => {
|
||||
const e = { isAxiosError: true, code: "ECONNREFUSED", message: "refused" };
|
||||
expect(classifyAxiosError(e).kind).toBe("network");
|
||||
});
|
||||
|
||||
test("returns 'network' for ENOTFOUND", () => {
|
||||
const e = { isAxiosError: true, code: "ENOTFOUND", message: "nope" };
|
||||
expect(classifyAxiosError(e).kind).toBe("network");
|
||||
});
|
||||
|
||||
test("returns 'http' for other axios errors", () => {
|
||||
const e = { isAxiosError: true, response: { status: 500 }, message: "err" };
|
||||
const result = classifyAxiosError(e);
|
||||
expect(result.kind).toBe("http");
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
|
||||
test("returns 'other' for null", () => {
|
||||
expect(classifyAxiosError(null).kind).toBe("other");
|
||||
});
|
||||
});
|
||||
76
src/utils/__tests__/formatBriefTimestamp.test.ts
Normal file
76
src/utils/__tests__/formatBriefTimestamp.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatBriefTimestamp } from "../formatBriefTimestamp";
|
||||
|
||||
describe("formatBriefTimestamp", () => {
|
||||
// Fixed "now" for deterministic tests: 2026-04-02T14:00:00Z (Thursday)
|
||||
const now = new Date("2026-04-02T14:00:00Z");
|
||||
|
||||
test("same day timestamp returns time only (contains colon)", () => {
|
||||
const result = formatBriefTimestamp("2026-04-02T10:30:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
// Should NOT contain a weekday name since it's the same day
|
||||
expect(result).not.toMatch(
|
||||
/Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday/
|
||||
);
|
||||
});
|
||||
|
||||
test("yesterday returns weekday and time", () => {
|
||||
// 2026-04-01 is Wednesday
|
||||
const result = formatBriefTimestamp("2026-04-01T16:15:00Z", now);
|
||||
expect(result).toContain("Wednesday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("3 days ago returns weekday and time", () => {
|
||||
// 2026-03-30 is Monday
|
||||
const result = formatBriefTimestamp("2026-03-30T09:00:00Z", now);
|
||||
expect(result).toContain("Monday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("6 days ago returns weekday and time (still within 6-day window)", () => {
|
||||
// 2026-03-27 is Friday
|
||||
const result = formatBriefTimestamp("2026-03-27T12:00:00Z", now);
|
||||
expect(result).toContain("Friday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("7+ days ago returns weekday, month, day, and time", () => {
|
||||
// 2026-03-20 is Friday, 13 days ago
|
||||
const result = formatBriefTimestamp("2026-03-20T14:30:00Z", now);
|
||||
expect(result).toContain("Friday");
|
||||
expect(result).toContain(":");
|
||||
// Should contain month abbreviation (Mar)
|
||||
expect(result).toMatch(/Mar/);
|
||||
});
|
||||
|
||||
test("much older date returns full format with month", () => {
|
||||
const result = formatBriefTimestamp("2025-12-25T08:00:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
expect(result).toMatch(/Dec/);
|
||||
});
|
||||
|
||||
test("invalid ISO string returns empty string", () => {
|
||||
expect(formatBriefTimestamp("not-a-date", now)).toBe("");
|
||||
});
|
||||
|
||||
test("empty string returns empty string", () => {
|
||||
expect(formatBriefTimestamp("", now)).toBe("");
|
||||
});
|
||||
|
||||
test("same day early morning returns time format", () => {
|
||||
const result = formatBriefTimestamp("2026-04-02T01:05:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
// Should be time-only format
|
||||
expect(result.length).toBeLessThan(20);
|
||||
});
|
||||
|
||||
test("uses current time as default when now is not provided", () => {
|
||||
// Just verify it returns a non-empty string for a recent timestamp
|
||||
const recent = new Date();
|
||||
recent.setMinutes(recent.getMinutes() - 5);
|
||||
const result = formatBriefTimestamp(recent.toISOString());
|
||||
expect(result).not.toBe("");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
});
|
||||
152
src/utils/__tests__/groupToolUses.test.ts
Normal file
152
src/utils/__tests__/groupToolUses.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyGrouping } from "../groupToolUses";
|
||||
|
||||
// Helper: build minimal tool-use assistant message
|
||||
function makeToolUseMsg(
|
||||
uuid: string,
|
||||
messageId: string,
|
||||
toolUseId: string,
|
||||
toolName: string
|
||||
): any {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: {
|
||||
id: messageId,
|
||||
content: [{ type: "tool_use", id: toolUseId, name: toolName, input: {} }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: build minimal tool-result user message
|
||||
function makeToolResultMsg(uuid: string, toolUseId: string): any {
|
||||
return {
|
||||
type: "user",
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: build minimal text assistant message
|
||||
function makeTextMsg(uuid: string, text: string): any {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: { id: `msg-${uuid}`, content: [{ type: "text", text }] },
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal tool definitions
|
||||
const groupableTool: any = { name: "Grep", renderGroupedToolUse: true };
|
||||
const nonGroupableTool: any = { name: "Bash", renderGroupedToolUse: undefined };
|
||||
|
||||
// ─── applyGrouping ────────────────────────────────────────────────────
|
||||
|
||||
describe("applyGrouping", () => {
|
||||
test("returns all messages in verbose mode", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool], true);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).toBe(msgs); // same reference
|
||||
});
|
||||
|
||||
test("does not group when tool lacks renderGroupedToolUse", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Bash"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Bash"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [nonGroupableTool]);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
// Both messages should pass through as-is
|
||||
expect(result.messages[0]).toBe(msgs[0]);
|
||||
});
|
||||
|
||||
test("does not group single tool use", () => {
|
||||
const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect((result.messages[0] as any).type).toBe("assistant");
|
||||
});
|
||||
|
||||
test("groups 2+ tool uses of same type from same message", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolUseMsg("u3", "m1", "tu3", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const grouped = result.messages[0] as any;
|
||||
expect(grouped.type).toBe("grouped_tool_use");
|
||||
expect(grouped.toolName).toBe("Grep");
|
||||
expect(grouped.messages).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("does not group tool uses from different messages", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m2", "tu2", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
// Each belongs to a different message.id, so no group (< 2 per group)
|
||||
expect(result.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("collects tool results for grouped uses", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolResultMsg("u3", "tu1"),
|
||||
makeToolResultMsg("u4", "tu2"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
const grouped = result.messages[0] as any;
|
||||
expect(grouped.type).toBe("grouped_tool_use");
|
||||
expect(grouped.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("skips user messages whose tool_results are all grouped", () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolResultMsg("u3", "tu1"),
|
||||
makeToolResultMsg("u4", "tu2"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
// Only the grouped message should remain — result messages are consumed
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves non-grouped messages alongside groups", () => {
|
||||
const msgs = [
|
||||
makeTextMsg("u0", "thinking..."),
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeTextMsg("u3", "done"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(3); // text + grouped + text
|
||||
expect((result.messages[0] as any).type).toBe("assistant");
|
||||
expect((result.messages[1] as any).type).toBe("grouped_tool_use");
|
||||
expect((result.messages[2] as any).type).toBe("assistant");
|
||||
});
|
||||
|
||||
test("handles empty messages array", () => {
|
||||
const result = applyGrouping([], [groupableTool]);
|
||||
expect(result.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles empty tools array", () => {
|
||||
const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")];
|
||||
const result = applyGrouping(msgs, []);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
99
src/utils/__tests__/hyperlink.test.ts
Normal file
99
src/utils/__tests__/hyperlink.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHyperlink, OSC8_START, OSC8_END } from "../hyperlink";
|
||||
|
||||
// ─── OSC8 constants ────────────────────────────────────────────────────
|
||||
|
||||
describe("OSC8 constants", () => {
|
||||
test("OSC8_START is the correct escape sequence", () => {
|
||||
expect(OSC8_START).toBe("\x1b]8;;");
|
||||
});
|
||||
|
||||
test("OSC8_END is the BEL character", () => {
|
||||
expect(OSC8_END).toBe("\x07");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createHyperlink ───────────────────────────────────────────────────
|
||||
|
||||
describe("createHyperlink", () => {
|
||||
test("supported + no content: wraps URL in OSC 8 with URL as display text", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, undefined, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toContain(OSC8_START);
|
||||
expect(result).toContain(OSC8_END);
|
||||
// Structure: OSC8_START + url + OSC8_END + coloredText + OSC8_START + OSC8_END
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("supported + content: shows content as link text", () => {
|
||||
const url = "https://example.com";
|
||||
const content = "click here";
|
||||
const result = createHyperlink(url, content, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toContain("click here");
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("not supported: returns plain URL regardless of content", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, "some content", {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("not supported + no content: returns plain URL", () => {
|
||||
const url = "https://example.com/path?q=1";
|
||||
const result = createHyperlink(url, undefined, {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("URL with special characters works when supported", () => {
|
||||
const url = "https://example.com/path?a=1&b=2#section";
|
||||
const result = createHyperlink(url, undefined, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("URL with special characters works when not supported", () => {
|
||||
const url = "https://example.com/path?a=1&b=2#section";
|
||||
const result = createHyperlink(url, undefined, {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("supported link text contains the display content", () => {
|
||||
const result = createHyperlink("https://example.com", "text", {
|
||||
supportsHyperlinks: true,
|
||||
});
|
||||
|
||||
// The colored text portion is between the two OSC8 sequences
|
||||
const inner = result.slice(
|
||||
`${OSC8_START}https://example.com${OSC8_END}`.length,
|
||||
result.length - `${OSC8_START}${OSC8_END}`.length
|
||||
);
|
||||
// chalk.blue may or may not emit ANSI depending on environment,
|
||||
// but the display text must always be present
|
||||
expect(inner).toContain("text");
|
||||
});
|
||||
|
||||
test("empty content string is treated as display text when supported", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, "", { supportsHyperlinks: true });
|
||||
|
||||
// Empty string is falsy, so displayText falls back to url
|
||||
// Actually: content ?? url — "" is not null/undefined, so "" is used
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
});
|
||||
240
src/utils/__tests__/memoize.test.ts
Normal file
240
src/utils/__tests__/memoize.test.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { mock, describe, expect, test, beforeEach } from "bun:test";
|
||||
|
||||
// Mock heavy deps before importing memoize
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
}));
|
||||
mock.module("src/utils/slowOperations.ts", () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (v: any) => structuredClone(v),
|
||||
cloneDeep: (v: any) => structuredClone(v),
|
||||
callerFrame: () => "",
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}));
|
||||
|
||||
const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import(
|
||||
"../memoize"
|
||||
);
|
||||
|
||||
// ─── memoizeWithTTL ────────────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithTTL", () => {
|
||||
test("returns cached value on second call", () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
}, 60_000);
|
||||
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("different args get separate cache entries", () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x + 1;
|
||||
}, 60_000);
|
||||
|
||||
expect(fn(1)).toBe(2);
|
||||
expect(fn(2)).toBe(3);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("cache.clear empties the cache", () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTL(() => {
|
||||
calls++;
|
||||
return "val";
|
||||
}, 60_000);
|
||||
|
||||
fn();
|
||||
fn.cache.clear();
|
||||
fn();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns stale value and triggers background refresh after TTL", async () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x * calls;
|
||||
}, 1); // 1ms TTL
|
||||
|
||||
const first = fn(10);
|
||||
expect(first).toBe(10); // calls=1, 10*1
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Should return stale value (10) and trigger background refresh
|
||||
const second = fn(10);
|
||||
expect(second).toBe(10); // stale value returned immediately
|
||||
|
||||
// Wait for background refresh microtask
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Now cache should have refreshed value (calls=2 during refresh, 10*2=20)
|
||||
const third = fn(10);
|
||||
expect(third).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── memoizeWithTTLAsync ───────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithTTLAsync", () => {
|
||||
test("caches async result", async () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTLAsync(async (x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
}, 60_000);
|
||||
|
||||
expect(await fn(5)).toBe(10);
|
||||
expect(await fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent cold-miss calls", async () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTLAsync(async (x: number) => {
|
||||
calls++;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return x;
|
||||
}, 60_000);
|
||||
|
||||
const [a, b, c] = await Promise.all([fn(1), fn(1), fn(1)]);
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
expect(c).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("cache.clear forces re-computation", async () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTLAsync(async () => {
|
||||
calls++;
|
||||
return "v";
|
||||
}, 60_000);
|
||||
|
||||
await fn();
|
||||
fn.cache.clear();
|
||||
await fn();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns stale value on TTL expiry", async () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithTTLAsync(async () => {
|
||||
calls++;
|
||||
return calls;
|
||||
}, 1); // 1ms TTL
|
||||
|
||||
const first = await fn();
|
||||
expect(first).toBe(1);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Should return stale value (1) immediately
|
||||
const second = await fn();
|
||||
expect(second).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── memoizeWithLRU ────────────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithLRU", () => {
|
||||
test("caches results by key", () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
},
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("evicts least recently used when max reached", () => {
|
||||
let calls = 0;
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => {
|
||||
calls++;
|
||||
return x;
|
||||
},
|
||||
(x) => String(x),
|
||||
3
|
||||
);
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
fn(3);
|
||||
expect(calls).toBe(3);
|
||||
|
||||
fn(4); // evicts key "1"
|
||||
expect(fn.cache.has("1")).toBe(false);
|
||||
expect(fn.cache.has("4")).toBe(true);
|
||||
});
|
||||
|
||||
test("cache.size returns current size", () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
expect(fn.cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
test("cache.delete removes entry", () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
|
||||
fn(1);
|
||||
expect(fn.cache.has("1")).toBe(true);
|
||||
fn.cache.delete("1");
|
||||
expect(fn.cache.has("1")).toBe(false);
|
||||
});
|
||||
|
||||
test("cache.get returns value without updating recency", () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x * 10,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
|
||||
fn(5);
|
||||
expect(fn.cache.get("5")).toBe(50);
|
||||
});
|
||||
|
||||
test("cache.clear empties everything", () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
fn.cache.clear();
|
||||
expect(fn.cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
162
src/utils/__tests__/notebook.test.ts
Normal file
162
src/utils/__tests__/notebook.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCellId, mapNotebookCellsToToolResult } from "../notebook";
|
||||
|
||||
// ─── parseCellId ───────────────────────────────────────────────────────
|
||||
|
||||
describe("parseCellId", () => {
|
||||
test("parses cell-0 to 0", () => {
|
||||
expect(parseCellId("cell-0")).toBe(0);
|
||||
});
|
||||
|
||||
test("parses cell-5 to 5", () => {
|
||||
expect(parseCellId("cell-5")).toBe(5);
|
||||
});
|
||||
|
||||
test("parses cell-100 to 100", () => {
|
||||
expect(parseCellId("cell-100")).toBe(100);
|
||||
});
|
||||
|
||||
test("returns undefined for cell- (no number)", () => {
|
||||
expect(parseCellId("cell-")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for cell-abc (non-numeric)", () => {
|
||||
expect(parseCellId("cell-abc")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for other-format", () => {
|
||||
expect(parseCellId("other-format")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseCellId("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for prefix-only match like cell-0-extra", () => {
|
||||
// regex is /^cell-(\d+)$/ so trailing text should fail
|
||||
expect(parseCellId("cell-0-extra")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mapNotebookCellsToToolResult ──────────────────────────────────────
|
||||
|
||||
describe("mapNotebookCellsToToolResult", () => {
|
||||
test("returns tool result with correct tool_use_id", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: 'print("hello")',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-123");
|
||||
expect(result.tool_use_id).toBe("tool-123");
|
||||
expect(result.type).toBe("tool_result");
|
||||
});
|
||||
|
||||
test("content array contains text blocks for cell content", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: 'x = 1',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-1");
|
||||
expect(result.content).toBeInstanceOf(Array);
|
||||
expect(result.content!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstBlock = result.content![0] as { type: string; text: string };
|
||||
expect(firstBlock.type).toBe("text");
|
||||
expect(firstBlock.text).toContain("cell-0");
|
||||
expect(firstBlock.text).toContain("x = 1");
|
||||
});
|
||||
|
||||
test("merges adjacent text blocks from multiple cells", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "a = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "b = 2",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-2");
|
||||
// Two adjacent text blocks should be merged into one
|
||||
const textBlocks = result.content!.filter(
|
||||
(b: any) => b.type === "text"
|
||||
);
|
||||
expect(textBlocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves image blocks without merging", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "plot()",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
outputs: [
|
||||
{
|
||||
output_type: "display_data",
|
||||
text: "",
|
||||
image: {
|
||||
image_data: "iVBORw0KGgo=",
|
||||
media_type: "image/png" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "print(1)",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-3");
|
||||
const types = result.content!.map((b: any) => b.type);
|
||||
expect(types).toContain("image");
|
||||
});
|
||||
|
||||
test("markdown cell includes cell_type metadata", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "markdown",
|
||||
source: "# Title",
|
||||
cell_id: "cell-0",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-4");
|
||||
const textBlock = result.content![0] as { type: string; text: string };
|
||||
expect(textBlock.text).toContain("<cell_type>markdown</cell_type>");
|
||||
});
|
||||
|
||||
test("non-python code cell includes language metadata", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "val x = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "scala",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-5");
|
||||
const textBlock = result.content![0] as { type: string; text: string };
|
||||
expect(textBlock.text).toContain("<language>scala</language>");
|
||||
});
|
||||
});
|
||||
41
src/utils/__tests__/objectGroupBy.test.ts
Normal file
41
src/utils/__tests__/objectGroupBy.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { objectGroupBy } from "../objectGroupBy";
|
||||
|
||||
describe("objectGroupBy", () => {
|
||||
test("groups items by key", () => {
|
||||
const result = objectGroupBy([1, 2, 3, 4], (n) =>
|
||||
n % 2 === 0 ? "even" : "odd"
|
||||
);
|
||||
expect(result.even).toEqual([2, 4]);
|
||||
expect(result.odd).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test("returns empty object for empty input", () => {
|
||||
const result = objectGroupBy([], () => "key");
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles single group", () => {
|
||||
const result = objectGroupBy(["a", "b", "c"], () => "all");
|
||||
expect(result.all).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("passes index to keySelector", () => {
|
||||
const result = objectGroupBy(["a", "b", "c", "d"], (_, i) =>
|
||||
i < 2 ? "first" : "second"
|
||||
);
|
||||
expect(result.first).toEqual(["a", "b"]);
|
||||
expect(result.second).toEqual(["c", "d"]);
|
||||
});
|
||||
|
||||
test("works with objects", () => {
|
||||
const items = [
|
||||
{ name: "Alice", role: "admin" },
|
||||
{ name: "Bob", role: "user" },
|
||||
{ name: "Charlie", role: "admin" },
|
||||
];
|
||||
const result = objectGroupBy(items, (item) => item.role);
|
||||
expect(result.admin).toHaveLength(2);
|
||||
expect(result.user).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
75
src/utils/__tests__/sanitization.test.ts
Normal file
75
src/utils/__tests__/sanitization.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
partiallySanitizeUnicode,
|
||||
recursivelySanitizeUnicode,
|
||||
} from "../sanitization";
|
||||
|
||||
// ─── partiallySanitizeUnicode ───────────────────────────────────────────
|
||||
|
||||
describe("partiallySanitizeUnicode", () => {
|
||||
test("preserves normal ASCII text", () => {
|
||||
expect(partiallySanitizeUnicode("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
test("preserves CJK characters", () => {
|
||||
expect(partiallySanitizeUnicode("你好世界")).toBe("你好世界");
|
||||
});
|
||||
|
||||
test("removes zero-width spaces", () => {
|
||||
expect(partiallySanitizeUnicode("hello\u200Bworld")).toBe("helloworld");
|
||||
});
|
||||
|
||||
test("removes BOM", () => {
|
||||
expect(partiallySanitizeUnicode("\uFEFFhello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("removes directional formatting", () => {
|
||||
expect(partiallySanitizeUnicode("hello\u202Aworld")).toBe("helloworld");
|
||||
});
|
||||
|
||||
test("removes private use area characters", () => {
|
||||
expect(partiallySanitizeUnicode("hello\uE000world")).toBe("helloworld");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(partiallySanitizeUnicode("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles string with only dangerous characters", () => {
|
||||
const result = partiallySanitizeUnicode("\u200B\u200C\u200D\uFEFF");
|
||||
expect(result.length).toBeLessThanOrEqual(1); // ZWJ may survive NFKC
|
||||
});
|
||||
});
|
||||
|
||||
// ─── recursivelySanitizeUnicode ─────────────────────────────────────────
|
||||
|
||||
describe("recursivelySanitizeUnicode", () => {
|
||||
test("sanitizes string values", () => {
|
||||
expect(recursivelySanitizeUnicode("hello\u200Bworld")).toBe("helloworld");
|
||||
});
|
||||
|
||||
test("sanitizes array elements", () => {
|
||||
const result = recursivelySanitizeUnicode(["a\u200Bb", "c\uFEFFd"]);
|
||||
expect(result).toEqual(["ab", "cd"]);
|
||||
});
|
||||
|
||||
test("sanitizes object values recursively", () => {
|
||||
const result = recursivelySanitizeUnicode({
|
||||
key: "val\u200Bue",
|
||||
nested: { inner: "te\uFEFFst" },
|
||||
});
|
||||
expect(result).toEqual({ key: "value", nested: { inner: "test" } });
|
||||
});
|
||||
|
||||
test("preserves numbers", () => {
|
||||
expect(recursivelySanitizeUnicode(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("preserves booleans", () => {
|
||||
expect(recursivelySanitizeUnicode(true)).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves null", () => {
|
||||
expect(recursivelySanitizeUnicode(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/slashCommandParsing.test.ts
Normal file
58
src/utils/__tests__/slashCommandParsing.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseSlashCommand } from "../slashCommandParsing";
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
test("parses simple command", () => {
|
||||
const result = parseSlashCommand("/search foo bar");
|
||||
expect(result).toEqual({
|
||||
commandName: "search",
|
||||
args: "foo bar",
|
||||
isMcp: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses command without args", () => {
|
||||
const result = parseSlashCommand("/help");
|
||||
expect(result).toEqual({
|
||||
commandName: "help",
|
||||
args: "",
|
||||
isMcp: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses MCP command", () => {
|
||||
const result = parseSlashCommand("/tool (MCP) arg1 arg2");
|
||||
expect(result).toEqual({
|
||||
commandName: "tool (MCP)",
|
||||
args: "arg1 arg2",
|
||||
isMcp: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses MCP command without args", () => {
|
||||
const result = parseSlashCommand("/tool (MCP)");
|
||||
expect(result).toEqual({
|
||||
commandName: "tool (MCP)",
|
||||
args: "",
|
||||
isMcp: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for non-slash input", () => {
|
||||
expect(parseSlashCommand("hello")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseSlashCommand("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for just slash", () => {
|
||||
expect(parseSlashCommand("/")).toBeNull();
|
||||
});
|
||||
|
||||
test("trims whitespace before parsing", () => {
|
||||
const result = parseSlashCommand(" /search foo ");
|
||||
expect(result!.commandName).toBe("search");
|
||||
expect(result!.args).toBe("foo");
|
||||
});
|
||||
});
|
||||
130
src/utils/__tests__/sleep.test.ts
Normal file
130
src/utils/__tests__/sleep.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sleep, withTimeout } from "../sleep";
|
||||
import { sequential } from "../sequential";
|
||||
|
||||
// ─── sleep ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("sleep", () => {
|
||||
test("resolves after timeout", async () => {
|
||||
const start = Date.now();
|
||||
await sleep(50);
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
test("resolves immediately when signal already aborted", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const start = Date.now();
|
||||
await sleep(10_000, ac.signal);
|
||||
expect(Date.now() - start).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("resolves early on abort (default: no throw)", async () => {
|
||||
const ac = new AbortController();
|
||||
const start = Date.now();
|
||||
const p = sleep(10_000, ac.signal);
|
||||
setTimeout(() => ac.abort(), 30);
|
||||
await p;
|
||||
expect(Date.now() - start).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("rejects on abort with throwOnAbort", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
sleep(10_000, ac.signal, { throwOnAbort: true })
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
test("rejects with custom abortError", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const customErr = () => new Error("custom abort");
|
||||
await expect(
|
||||
sleep(10_000, ac.signal, { abortError: customErr })
|
||||
).rejects.toThrow("custom abort");
|
||||
});
|
||||
|
||||
test("throwOnAbort rejects on mid-sleep abort", async () => {
|
||||
const ac = new AbortController();
|
||||
const p = sleep(10_000, ac.signal, { throwOnAbort: true });
|
||||
setTimeout(() => ac.abort(), 20);
|
||||
await expect(p).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
test("works without signal", async () => {
|
||||
await sleep(10);
|
||||
// just verify it resolves
|
||||
});
|
||||
});
|
||||
|
||||
// ─── withTimeout ───────────────────────────────────────────────────────
|
||||
|
||||
describe("withTimeout", () => {
|
||||
test("resolves when promise completes before timeout", async () => {
|
||||
const result = await withTimeout(
|
||||
Promise.resolve(42),
|
||||
1000,
|
||||
"timed out"
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
test("rejects when promise takes too long", async () => {
|
||||
const slow = new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await expect(
|
||||
withTimeout(slow, 50, "operation timed out")
|
||||
).rejects.toThrow("operation timed out");
|
||||
});
|
||||
|
||||
test("rejects propagate through", async () => {
|
||||
await expect(
|
||||
withTimeout(Promise.reject(new Error("inner")), 1000, "timeout")
|
||||
).rejects.toThrow("inner");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sequential ────────────────────────────────────────────────────────
|
||||
|
||||
describe("sequential", () => {
|
||||
test("executes calls in order", async () => {
|
||||
const order: number[] = [];
|
||||
const fn = sequential(async (n: number) => {
|
||||
await sleep(10);
|
||||
order.push(n);
|
||||
return n;
|
||||
});
|
||||
|
||||
const results = await Promise.all([fn(1), fn(2), fn(3)]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("returns correct result for each call", async () => {
|
||||
const fn = sequential(async (x: number) => x * 2);
|
||||
const r1 = await fn(5);
|
||||
const r2 = await fn(10);
|
||||
expect(r1).toBe(10);
|
||||
expect(r2).toBe(20);
|
||||
});
|
||||
|
||||
test("propagates errors without blocking queue", async () => {
|
||||
const fn = sequential(async (x: number) => {
|
||||
if (x === 2) throw new Error("fail");
|
||||
return x;
|
||||
});
|
||||
|
||||
const p1 = fn(1);
|
||||
const p2 = fn(2);
|
||||
const p3 = fn(3);
|
||||
|
||||
expect(await p1).toBe(1);
|
||||
await expect(p2).rejects.toThrow("fail");
|
||||
expect(await p3).toBe(3);
|
||||
});
|
||||
|
||||
test("handles single call", async () => {
|
||||
const fn = sequential(async (s: string) => s.toUpperCase());
|
||||
expect(await fn("hello")).toBe("HELLO");
|
||||
});
|
||||
});
|
||||
104
src/utils/__tests__/taggedId.test.ts
Normal file
104
src/utils/__tests__/taggedId.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { toTaggedId } from "../taggedId";
|
||||
|
||||
const BASE_58_CHARS =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
describe("toTaggedId", () => {
|
||||
test("zero UUID produces all base58 '1's (first char)", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
// base58 of 0 is all '1's (the first base58 character)
|
||||
expect(result).toBe("user_01" + "1".repeat(22));
|
||||
});
|
||||
|
||||
test("format is tag_01 + 22 base58 chars", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
expect(result).toMatch(
|
||||
new RegExp(`^user_01[${BASE_58_CHARS.replace(/[-]/g, "\\-")}]{22}$`)
|
||||
);
|
||||
});
|
||||
|
||||
test("output starts with the provided tag", () => {
|
||||
const result = toTaggedId("org", "550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result.startsWith("org_01")).toBe(true);
|
||||
});
|
||||
|
||||
test("UUID with hyphens equals UUID without hyphens", () => {
|
||||
const withHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
const withoutHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400e29b41d4a716446655440000"
|
||||
);
|
||||
expect(withHyphens).toBe(withoutHyphens);
|
||||
});
|
||||
|
||||
test("different tags produce different prefixes", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const userResult = toTaggedId("user", uuid);
|
||||
const orgResult = toTaggedId("org", uuid);
|
||||
const msgResult = toTaggedId("msg", uuid);
|
||||
// They share the same base58 suffix but different prefixes
|
||||
expect(userResult.slice(userResult.indexOf("_01") + 3)).toBe(
|
||||
orgResult.slice(orgResult.indexOf("_01") + 3)
|
||||
);
|
||||
expect(userResult).not.toBe(orgResult);
|
||||
expect(orgResult).not.toBe(msgResult);
|
||||
});
|
||||
|
||||
test("different UUIDs produce different encoded parts", () => {
|
||||
const result1 = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
const result2 = toTaggedId(
|
||||
"user",
|
||||
"661f9500-f3ac-52e5-b827-557766550111"
|
||||
);
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
test("encoded part is always exactly 22 characters", () => {
|
||||
const uuids = [
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
];
|
||||
for (const uuid of uuids) {
|
||||
const result = toTaggedId("test", uuid);
|
||||
const encoded = result.slice("test_01".length);
|
||||
expect(encoded).toHaveLength(22);
|
||||
}
|
||||
});
|
||||
|
||||
test("throws on invalid UUID (too short)", () => {
|
||||
expect(() => toTaggedId("user", "abcdef")).toThrow("Invalid UUID hex length");
|
||||
});
|
||||
|
||||
test("throws on invalid UUID (too long)", () => {
|
||||
expect(() =>
|
||||
toTaggedId("user", "550e8400e29b41d4a716446655440000ff")
|
||||
).toThrow("Invalid UUID hex length");
|
||||
});
|
||||
|
||||
test("max UUID (all f's) produces valid base58 output", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff"
|
||||
);
|
||||
expect(result.startsWith("user_01")).toBe(true);
|
||||
const encoded = result.slice("user_01".length);
|
||||
for (const ch of encoded) {
|
||||
expect(BASE_58_CHARS).toContain(ch);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
src/utils/__tests__/tokenBudget.test.ts
Normal file
150
src/utils/__tests__/tokenBudget.test.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseTokenBudget,
|
||||
findTokenBudgetPositions,
|
||||
getBudgetContinuationMessage,
|
||||
} from "../tokenBudget";
|
||||
|
||||
describe("parseTokenBudget", () => {
|
||||
// --- shorthand at start ---
|
||||
test("parses +500k at start", () => {
|
||||
expect(parseTokenBudget("+500k")).toBe(500_000);
|
||||
});
|
||||
|
||||
test("parses +2.5M at start", () => {
|
||||
expect(parseTokenBudget("+2.5M")).toBe(2_500_000);
|
||||
});
|
||||
|
||||
test("parses +1b at start", () => {
|
||||
expect(parseTokenBudget("+1b")).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
test("parses shorthand with leading whitespace", () => {
|
||||
expect(parseTokenBudget(" +500k")).toBe(500_000);
|
||||
});
|
||||
|
||||
// --- shorthand at end ---
|
||||
test("parses +1.5m at end of sentence", () => {
|
||||
expect(parseTokenBudget("do this +1.5m")).toBe(1_500_000);
|
||||
});
|
||||
|
||||
test("parses shorthand at end with trailing period", () => {
|
||||
expect(parseTokenBudget("please continue +100k.")).toBe(100_000);
|
||||
});
|
||||
|
||||
test("parses shorthand at end with trailing whitespace", () => {
|
||||
expect(parseTokenBudget("keep going +250k ")).toBe(250_000);
|
||||
});
|
||||
|
||||
// --- verbose ---
|
||||
test("parses 'use 2M tokens'", () => {
|
||||
expect(parseTokenBudget("use 2M tokens")).toBe(2_000_000);
|
||||
});
|
||||
|
||||
test("parses 'spend 500k tokens'", () => {
|
||||
expect(parseTokenBudget("spend 500k tokens")).toBe(500_000);
|
||||
});
|
||||
|
||||
test("parses verbose with singular 'token'", () => {
|
||||
expect(parseTokenBudget("use 1k token")).toBe(1_000);
|
||||
});
|
||||
|
||||
test("parses verbose embedded in sentence", () => {
|
||||
expect(parseTokenBudget("please use 3.5m tokens for this task")).toBe(
|
||||
3_500_000
|
||||
);
|
||||
});
|
||||
|
||||
// --- no match (returns null) ---
|
||||
test("returns null for plain text", () => {
|
||||
expect(parseTokenBudget("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for bare number without +", () => {
|
||||
expect(parseTokenBudget("500k")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for number without suffix", () => {
|
||||
expect(parseTokenBudget("+500")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseTokenBudget("")).toBeNull();
|
||||
});
|
||||
|
||||
// --- case insensitivity ---
|
||||
test("is case insensitive for suffix", () => {
|
||||
expect(parseTokenBudget("+500K")).toBe(500_000);
|
||||
expect(parseTokenBudget("+2m")).toBe(2_000_000);
|
||||
expect(parseTokenBudget("+1B")).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
// --- priority: start shorthand wins over end/verbose ---
|
||||
test("start shorthand takes priority over verbose in same text", () => {
|
||||
expect(parseTokenBudget("+100k use 2M tokens")).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTokenBudgetPositions", () => {
|
||||
test("returns single position for +500k at start", () => {
|
||||
const positions = findTokenBudgetPositions("+500k");
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(positions[0]!.start).toBe(0);
|
||||
expect(positions[0]!.end).toBe(5);
|
||||
});
|
||||
|
||||
test("returns position for shorthand at end", () => {
|
||||
const text = "do this +100k";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(positions[0]!.start).toBe(8);
|
||||
expect(text.slice(positions[0]!.start, positions[0]!.end)).toBe("+100k");
|
||||
});
|
||||
|
||||
test("returns position for verbose match", () => {
|
||||
const text = "please use 2M tokens here";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(text.slice(positions[0]!.start, positions[0]!.end)).toBe(
|
||||
"use 2M tokens"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns multiple positions for combined shorthand + verbose", () => {
|
||||
const text = "use 2M tokens and then +500k";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for no match", () => {
|
||||
expect(findTokenBudgetPositions("hello world")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not double-count when +500k matches both start and end", () => {
|
||||
const positions = findTokenBudgetPositions("+500k");
|
||||
expect(positions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetContinuationMessage", () => {
|
||||
test("formats a continuation message with correct values", () => {
|
||||
const msg = getBudgetContinuationMessage(50, 250_000, 500_000);
|
||||
expect(msg).toContain("50%");
|
||||
expect(msg).toContain("250,000");
|
||||
expect(msg).toContain("500,000");
|
||||
expect(msg).toContain("Keep working");
|
||||
expect(msg).toContain("do not summarize");
|
||||
});
|
||||
|
||||
test("formats zero values", () => {
|
||||
const msg = getBudgetContinuationMessage(0, 0, 100_000);
|
||||
expect(msg).toContain("0%");
|
||||
expect(msg).toContain("0 / 100,000");
|
||||
});
|
||||
|
||||
test("formats large numbers with commas", () => {
|
||||
const msg = getBudgetContinuationMessage(75, 7_500_000, 10_000_000);
|
||||
expect(msg).toContain("7,500,000");
|
||||
expect(msg).toContain("10,000,000");
|
||||
});
|
||||
});
|
||||
116
src/utils/__tests__/windowsPaths.test.ts
Normal file
116
src/utils/__tests__/windowsPaths.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
windowsPathToPosixPath,
|
||||
posixPathToWindowsPath,
|
||||
} from "../windowsPaths";
|
||||
|
||||
// ─── windowsPathToPosixPath ────────────────────────────────────────────
|
||||
|
||||
describe("windowsPathToPosixPath", () => {
|
||||
test("converts drive letter path to posix", () => {
|
||||
expect(windowsPathToPosixPath("C:\\Users\\foo")).toBe("/c/Users/foo");
|
||||
});
|
||||
|
||||
test("lowercases the drive letter", () => {
|
||||
expect(windowsPathToPosixPath("D:\\Work\\project")).toBe(
|
||||
"/d/Work/project"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles lowercase drive letter input", () => {
|
||||
expect(windowsPathToPosixPath("e:\\data")).toBe("/e/data");
|
||||
});
|
||||
|
||||
test("converts UNC path", () => {
|
||||
expect(windowsPathToPosixPath("\\\\server\\share\\dir")).toBe(
|
||||
"//server/share/dir"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts root drive path", () => {
|
||||
expect(windowsPathToPosixPath("D:\\")).toBe("/d/");
|
||||
});
|
||||
|
||||
test("converts relative path by flipping backslashes", () => {
|
||||
expect(windowsPathToPosixPath("src\\main.ts")).toBe("src/main.ts");
|
||||
});
|
||||
|
||||
test("handles forward slashes in windows drive path", () => {
|
||||
// The regex matches both / and \\ after drive letter
|
||||
expect(windowsPathToPosixPath("C:/Users/foo")).toBe("/c/Users/foo");
|
||||
});
|
||||
|
||||
test("already-posix relative path passes through", () => {
|
||||
expect(windowsPathToPosixPath("src/main.ts")).toBe("src/main.ts");
|
||||
});
|
||||
|
||||
test("handles deeply nested path", () => {
|
||||
expect(
|
||||
windowsPathToPosixPath("C:\\Users\\me\\Documents\\project\\src\\index.ts")
|
||||
).toBe("/c/Users/me/Documents/project/src/index.ts");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── posixPathToWindowsPath ────────────────────────────────────────────
|
||||
|
||||
describe("posixPathToWindowsPath", () => {
|
||||
test("converts MSYS2/Git Bash drive path to windows", () => {
|
||||
expect(posixPathToWindowsPath("/c/Users/foo")).toBe("C:\\Users\\foo");
|
||||
});
|
||||
|
||||
test("uppercases the drive letter", () => {
|
||||
expect(posixPathToWindowsPath("/d/Work/project")).toBe(
|
||||
"D:\\Work\\project"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts cygdrive path", () => {
|
||||
expect(posixPathToWindowsPath("/cygdrive/d/work")).toBe("D:\\work");
|
||||
});
|
||||
|
||||
test("converts cygdrive root path", () => {
|
||||
expect(posixPathToWindowsPath("/cygdrive/c/")).toBe("C:\\");
|
||||
});
|
||||
|
||||
test("converts UNC posix path to windows UNC", () => {
|
||||
expect(posixPathToWindowsPath("//server/share/dir")).toBe(
|
||||
"\\\\server\\share\\dir"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts root drive posix path", () => {
|
||||
expect(posixPathToWindowsPath("/d/")).toBe("D:\\");
|
||||
});
|
||||
|
||||
test("converts bare drive mount (no trailing slash)", () => {
|
||||
// /d matches the regex ^\/([A-Za-z])(\/|$) where $2 is empty
|
||||
expect(posixPathToWindowsPath("/d")).toBe("D:\\");
|
||||
});
|
||||
|
||||
test("converts relative path by flipping forward slashes", () => {
|
||||
expect(posixPathToWindowsPath("src/main.ts")).toBe("src\\main.ts");
|
||||
});
|
||||
|
||||
test("handles already-windows relative path", () => {
|
||||
// No leading / or //, just flips / to backslash
|
||||
expect(posixPathToWindowsPath("foo\\bar")).toBe("foo\\bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── round-trip conversions ────────────────────────────────────────────
|
||||
|
||||
describe("round-trip conversions", () => {
|
||||
test("drive path round-trips windows -> posix -> windows", () => {
|
||||
const original = "C:\\Users\\foo\\bar";
|
||||
const posix = windowsPathToPosixPath(original);
|
||||
const back = posixPathToWindowsPath(posix);
|
||||
expect(back).toBe(original);
|
||||
});
|
||||
|
||||
test("drive path round-trips posix -> windows -> posix", () => {
|
||||
const original = "/c/Users/foo/bar";
|
||||
const win = posixPathToWindowsPath(original);
|
||||
const back = windowsPathToPosixPath(win);
|
||||
expect(back).toBe(original);
|
||||
});
|
||||
});
|
||||
72
src/utils/__tests__/zodToJsonSchema.test.ts
Normal file
72
src/utils/__tests__/zodToJsonSchema.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import z from "zod/v4";
|
||||
import { zodToJsonSchema } from "../zodToJsonSchema";
|
||||
|
||||
describe("zodToJsonSchema", () => {
|
||||
test("converts string schema", () => {
|
||||
const schema = z.string();
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("string");
|
||||
});
|
||||
|
||||
test("converts number schema", () => {
|
||||
const schema = z.number();
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("number");
|
||||
});
|
||||
|
||||
test("converts object schema with properties", () => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("object");
|
||||
expect(result.properties).toBeDefined();
|
||||
expect((result.properties as any).name).toBeDefined();
|
||||
expect((result.properties as any).age).toBeDefined();
|
||||
});
|
||||
|
||||
test("converts enum schema", () => {
|
||||
const schema = z.enum(["a", "b", "c"]);
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.enum).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("converts optional fields", () => {
|
||||
const schema = z.object({
|
||||
required: z.string(),
|
||||
optional: z.string().optional(),
|
||||
});
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.required).toContain("required");
|
||||
});
|
||||
|
||||
test("caches results for same schema reference", () => {
|
||||
const schema = z.string();
|
||||
const first = zodToJsonSchema(schema);
|
||||
const second = zodToJsonSchema(schema);
|
||||
expect(first).toBe(second); // same reference (cached)
|
||||
});
|
||||
|
||||
test("different schemas get different results", () => {
|
||||
const s1 = z.string();
|
||||
const s2 = z.number();
|
||||
const r1 = zodToJsonSchema(s1);
|
||||
const r2 = zodToJsonSchema(s2);
|
||||
expect(r1).not.toBe(r2);
|
||||
expect(r1.type).not.toBe(r2.type);
|
||||
});
|
||||
|
||||
test("converts array schema", () => {
|
||||
const schema = z.array(z.string());
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("array");
|
||||
expect((result.items as any).type).toBe("string");
|
||||
});
|
||||
|
||||
test("converts boolean schema", () => {
|
||||
const result = zodToJsonSchema(z.boolean());
|
||||
expect(result.type).toBe("boolean");
|
||||
});
|
||||
});
|
||||
138
src/utils/git/__tests__/gitConfigParser.test.ts
Normal file
138
src/utils/git/__tests__/gitConfigParser.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseConfigString } from "../gitConfigParser";
|
||||
|
||||
describe("parseConfigString", () => {
|
||||
test("parses simple remote url", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://github.com/user/repo.git';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://github.com/user/repo.git"
|
||||
);
|
||||
});
|
||||
|
||||
test("section matching is case-insensitive", () => {
|
||||
const config = '[REMOTE "origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("subsection matching is case-sensitive", () => {
|
||||
const config = '[remote "Origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBeNull();
|
||||
});
|
||||
|
||||
test("subsection matching is case-sensitive (positive)", () => {
|
||||
const config = '[remote "Origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "Origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("key matching is case-insensitive", () => {
|
||||
const config = '[remote "origin"]\n\tURL = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("parses quoted value with spaces", () => {
|
||||
const config = '[user]\n\tname = "John Doe"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe("John Doe");
|
||||
});
|
||||
|
||||
test("handles escape sequence \\n inside quotes", () => {
|
||||
const config = '[user]\n\tname = "line1\\nline2"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe(
|
||||
"line1\nline2"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles escape sequence \\t inside quotes", () => {
|
||||
const config = '[user]\n\tname = "col1\\tcol2"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe(
|
||||
"col1\tcol2"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles escape sequence \\\\ inside quotes", () => {
|
||||
const config = '[user]\n\tname = "back\\\\slash"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe("back\\slash");
|
||||
});
|
||||
|
||||
test("handles escape sequence \\\" inside quotes", () => {
|
||||
const config = '[user]\n\tname = "say \\"hello\\""';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe('say "hello"');
|
||||
});
|
||||
|
||||
test("strips inline comment with #", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com # comment';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("strips inline comment with ;", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com ; comment';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("finds value in correct section among multiple sections", () => {
|
||||
const config = [
|
||||
'[remote "origin"]',
|
||||
"\turl = https://origin.example.com",
|
||||
'[remote "upstream"]',
|
||||
"\turl = https://upstream.example.com",
|
||||
].join("\n");
|
||||
expect(parseConfigString(config, "remote", "upstream", "url")).toBe(
|
||||
"https://upstream.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for missing key", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com';
|
||||
expect(
|
||||
parseConfigString(config, "remote", "origin", "pushurl")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for missing section", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "branch", "main", "merge")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for boolean key (no = sign)", () => {
|
||||
const config = "[core]\n\tbare";
|
||||
expect(parseConfigString(config, "core", null, "bare")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty config string", () => {
|
||||
expect(parseConfigString("", "remote", "origin", "url")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles section without subsection", () => {
|
||||
const config = "[core]\n\trepositoryformatversion = 0";
|
||||
expect(
|
||||
parseConfigString(config, "core", null, "repositoryformatversion")
|
||||
).toBe("0");
|
||||
});
|
||||
|
||||
test("does not match section without subsection when subsection is requested", () => {
|
||||
const config = "[core]\n\tbare = false";
|
||||
// Looking for [core "something"] but config has [core]
|
||||
expect(parseConfigString(config, "core", "something", "bare")).toBeNull();
|
||||
});
|
||||
|
||||
test("skips comment-only lines", () => {
|
||||
const config = [
|
||||
"# This is a comment",
|
||||
"; This is also a comment",
|
||||
'[remote "origin"]',
|
||||
"\turl = https://example.com",
|
||||
].join("\n");
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
162
src/utils/permissions/__tests__/PermissionMode.test.ts
Normal file
162
src/utils/permissions/__tests__/PermissionMode.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock slowOperations to cut bootstrap/state dependency chain
|
||||
// (figures.js → env.js → fsOperations.js → slowOperations.js → bootstrap/state.js)
|
||||
mock.module("src/utils/slowOperations.ts", () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (v: any) => structuredClone(v),
|
||||
cloneDeep: (v: any) => structuredClone(v),
|
||||
callerFrame: () => "",
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}));
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
isExternalPermissionMode,
|
||||
toExternalPermissionMode,
|
||||
permissionModeFromString,
|
||||
permissionModeTitle,
|
||||
isDefaultMode,
|
||||
permissionModeShortTitle,
|
||||
permissionModeSymbol,
|
||||
getModeColor,
|
||||
PERMISSION_MODES,
|
||||
EXTERNAL_PERMISSION_MODES,
|
||||
} = await import("../PermissionMode");
|
||||
|
||||
// ─── PERMISSION_MODES / EXTERNAL_PERMISSION_MODES ──────────────────────
|
||||
|
||||
describe("PERMISSION_MODES", () => {
|
||||
test("includes all external modes", () => {
|
||||
for (const m of EXTERNAL_PERMISSION_MODES) {
|
||||
expect(PERMISSION_MODES).toContain(m);
|
||||
}
|
||||
});
|
||||
|
||||
test("includes default, plan, acceptEdits, bypassPermissions, dontAsk", () => {
|
||||
expect(PERMISSION_MODES).toContain("default");
|
||||
expect(PERMISSION_MODES).toContain("plan");
|
||||
expect(PERMISSION_MODES).toContain("acceptEdits");
|
||||
expect(PERMISSION_MODES).toContain("bypassPermissions");
|
||||
expect(PERMISSION_MODES).toContain("dontAsk");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── permissionModeFromString ──────────────────────────────────────────
|
||||
|
||||
describe("permissionModeFromString", () => {
|
||||
test("returns valid mode for known string", () => {
|
||||
expect(permissionModeFromString("plan")).toBe("plan");
|
||||
expect(permissionModeFromString("default")).toBe("default");
|
||||
expect(permissionModeFromString("dontAsk")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
test("returns 'default' for unknown string", () => {
|
||||
expect(permissionModeFromString("unknown")).toBe("default");
|
||||
expect(permissionModeFromString("")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── permissionModeTitle ───────────────────────────────────────────────
|
||||
|
||||
describe("permissionModeTitle", () => {
|
||||
test("returns title for known modes", () => {
|
||||
expect(permissionModeTitle("default")).toBe("Default");
|
||||
expect(permissionModeTitle("plan")).toBe("Plan Mode");
|
||||
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
|
||||
});
|
||||
|
||||
test("falls back to Default for unknown mode", () => {
|
||||
expect(permissionModeTitle("nonexistent" as any)).toBe("Default");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── permissionModeShortTitle ──────────────────────────────────────────
|
||||
|
||||
describe("permissionModeShortTitle", () => {
|
||||
test("returns short title for known modes", () => {
|
||||
expect(permissionModeShortTitle("default")).toBe("Default");
|
||||
expect(permissionModeShortTitle("plan")).toBe("Plan");
|
||||
expect(permissionModeShortTitle("bypassPermissions")).toBe("Bypass");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── permissionModeSymbol ──────────────────────────────────────────────
|
||||
|
||||
describe("permissionModeSymbol", () => {
|
||||
test("returns empty string for default", () => {
|
||||
expect(permissionModeSymbol("default")).toBe("");
|
||||
});
|
||||
|
||||
test("returns non-empty for non-default modes", () => {
|
||||
expect(permissionModeSymbol("plan").length).toBeGreaterThan(0);
|
||||
expect(permissionModeSymbol("acceptEdits").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getModeColor ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getModeColor", () => {
|
||||
test("returns 'text' for default", () => {
|
||||
expect(getModeColor("default")).toBe("text");
|
||||
});
|
||||
|
||||
test("returns 'planMode' for plan", () => {
|
||||
expect(getModeColor("plan")).toBe("planMode");
|
||||
});
|
||||
|
||||
test("returns 'error' for bypassPermissions", () => {
|
||||
expect(getModeColor("bypassPermissions")).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isDefaultMode ─────────────────────────────────────────────────────
|
||||
|
||||
describe("isDefaultMode", () => {
|
||||
test("returns true for 'default'", () => {
|
||||
expect(isDefaultMode("default")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for undefined", () => {
|
||||
expect(isDefaultMode(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for other modes", () => {
|
||||
expect(isDefaultMode("plan")).toBe(false);
|
||||
expect(isDefaultMode("dontAsk")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toExternalPermissionMode ──────────────────────────────────────────
|
||||
|
||||
describe("toExternalPermissionMode", () => {
|
||||
test("maps default to default", () => {
|
||||
expect(toExternalPermissionMode("default")).toBe("default");
|
||||
});
|
||||
|
||||
test("maps plan to plan", () => {
|
||||
expect(toExternalPermissionMode("plan")).toBe("plan");
|
||||
});
|
||||
|
||||
test("maps dontAsk to dontAsk", () => {
|
||||
expect(toExternalPermissionMode("dontAsk")).toBe("dontAsk");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isExternalPermissionMode ──────────────────────────────────────────
|
||||
|
||||
describe("isExternalPermissionMode", () => {
|
||||
test("returns true for external modes (non-ant)", () => {
|
||||
// USER_TYPE is not 'ant' in tests, so always true
|
||||
expect(isExternalPermissionMode("default")).toBe(true);
|
||||
expect(isExternalPermissionMode("plan")).toBe(true);
|
||||
});
|
||||
});
|
||||
55
src/utils/permissions/__tests__/dangerousPatterns.test.ts
Normal file
55
src/utils/permissions/__tests__/dangerousPatterns.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
CROSS_PLATFORM_CODE_EXEC,
|
||||
DANGEROUS_BASH_PATTERNS,
|
||||
} from "../dangerousPatterns";
|
||||
|
||||
describe("CROSS_PLATFORM_CODE_EXEC", () => {
|
||||
test("is a non-empty readonly array of strings", () => {
|
||||
expect(CROSS_PLATFORM_CODE_EXEC.length).toBeGreaterThan(0);
|
||||
for (const p of CROSS_PLATFORM_CODE_EXEC) {
|
||||
expect(typeof p).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("includes core interpreters", () => {
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("python");
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("node");
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("ruby");
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("perl");
|
||||
});
|
||||
|
||||
test("includes package runners", () => {
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("npx");
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("bunx");
|
||||
});
|
||||
|
||||
test("includes shells", () => {
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("bash");
|
||||
expect(CROSS_PLATFORM_CODE_EXEC).toContain("sh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DANGEROUS_BASH_PATTERNS", () => {
|
||||
test("includes all cross-platform patterns", () => {
|
||||
for (const p of CROSS_PLATFORM_CODE_EXEC) {
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
test("includes unix-specific patterns", () => {
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("zsh");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("fish");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("eval");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("exec");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("sudo");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("xargs");
|
||||
expect(DANGEROUS_BASH_PATTERNS).toContain("env");
|
||||
});
|
||||
|
||||
test("all elements are strings", () => {
|
||||
for (const p of DANGEROUS_BASH_PATTERNS) {
|
||||
expect(typeof p).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
145
src/utils/permissions/__tests__/shellRuleMatching.test.ts
Normal file
145
src/utils/permissions/__tests__/shellRuleMatching.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
permissionRuleExtractPrefix,
|
||||
hasWildcards,
|
||||
matchWildcardPattern,
|
||||
parsePermissionRule,
|
||||
suggestionForExactCommand,
|
||||
suggestionForPrefix,
|
||||
} from "../shellRuleMatching";
|
||||
|
||||
// ─── permissionRuleExtractPrefix ────────────────────────────────────────
|
||||
|
||||
describe("permissionRuleExtractPrefix", () => {
|
||||
test("extracts prefix from legacy :* syntax", () => {
|
||||
expect(permissionRuleExtractPrefix("npm:*")).toBe("npm");
|
||||
});
|
||||
|
||||
test("extracts multi-word prefix", () => {
|
||||
expect(permissionRuleExtractPrefix("git commit:*")).toBe("git commit");
|
||||
});
|
||||
|
||||
test("returns null for non-prefix rule", () => {
|
||||
expect(permissionRuleExtractPrefix("npm install")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(permissionRuleExtractPrefix("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for wildcard without colon", () => {
|
||||
expect(permissionRuleExtractPrefix("npm *")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasWildcards ───────────────────────────────────────────────────────
|
||||
|
||||
describe("hasWildcards", () => {
|
||||
test("returns true for unescaped wildcard", () => {
|
||||
expect(hasWildcards("git *")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for legacy :* syntax", () => {
|
||||
expect(hasWildcards("npm:*")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for escaped wildcard", () => {
|
||||
expect(hasWildcards("git \\*")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for * with even backslashes", () => {
|
||||
expect(hasWildcards("git \\\\*")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for no wildcards", () => {
|
||||
expect(hasWildcards("npm install")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(hasWildcards("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── matchWildcardPattern ───────────────────────────────────────────────
|
||||
|
||||
describe("matchWildcardPattern", () => {
|
||||
test("matches simple wildcard", () => {
|
||||
expect(matchWildcardPattern("git *", "git add")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches bare command when pattern ends with space-wildcard", () => {
|
||||
expect(matchWildcardPattern("git *", "git")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-matching command", () => {
|
||||
expect(matchWildcardPattern("git *", "npm install")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches middle wildcard", () => {
|
||||
expect(matchWildcardPattern("git * --verbose", "git add --verbose")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles escaped asterisk as literal", () => {
|
||||
expect(matchWildcardPattern("echo \\*", "echo *")).toBe(true);
|
||||
expect(matchWildcardPattern("echo \\*", "echo hello")).toBe(false);
|
||||
});
|
||||
|
||||
test("case-insensitive matching", () => {
|
||||
expect(matchWildcardPattern("Git *", "git add", true)).toBe(true);
|
||||
});
|
||||
|
||||
test("exact match without wildcards", () => {
|
||||
expect(matchWildcardPattern("npm install", "npm install")).toBe(true);
|
||||
expect(matchWildcardPattern("npm install", "npm update")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles regex special characters in pattern", () => {
|
||||
expect(matchWildcardPattern("echo (hello)", "echo (hello)")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parsePermissionRule ────────────────────────────────────────────────
|
||||
|
||||
describe("parsePermissionRule", () => {
|
||||
test("parses exact command", () => {
|
||||
const result = parsePermissionRule("npm install");
|
||||
expect(result).toEqual({ type: "exact", command: "npm install" });
|
||||
});
|
||||
|
||||
test("parses legacy prefix syntax", () => {
|
||||
const result = parsePermissionRule("npm:*");
|
||||
expect(result).toEqual({ type: "prefix", prefix: "npm" });
|
||||
});
|
||||
|
||||
test("parses wildcard pattern", () => {
|
||||
const result = parsePermissionRule("git *");
|
||||
expect(result).toEqual({ type: "wildcard", pattern: "git *" });
|
||||
});
|
||||
|
||||
test("escaped wildcard is treated as exact", () => {
|
||||
const result = parsePermissionRule("echo \\*");
|
||||
expect(result.type).toBe("exact");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── suggestionForExactCommand ──────────────────────────────────────────
|
||||
|
||||
describe("suggestionForExactCommand", () => {
|
||||
test("creates addRules suggestion", () => {
|
||||
const result = suggestionForExactCommand("Bash", "npm install");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.type).toBe("addRules");
|
||||
expect(result[0]!.rules[0]!.toolName).toBe("Bash");
|
||||
expect(result[0]!.rules[0]!.ruleContent).toBe("npm install");
|
||||
expect(result[0]!.behavior).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── suggestionForPrefix ────────────────────────────────────────────────
|
||||
|
||||
describe("suggestionForPrefix", () => {
|
||||
test("creates prefix suggestion with :*", () => {
|
||||
const result = suggestionForPrefix("Bash", "npm");
|
||||
expect(result[0]!.rules[0]!.ruleContent).toBe("npm:*");
|
||||
});
|
||||
});
|
||||
67
src/utils/shell/__tests__/outputLimits.test.ts
Normal file
67
src/utils/shell/__tests__/outputLimits.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { mock, describe, expect, test, afterEach } from "bun:test";
|
||||
|
||||
// Mock debug.ts to cut the bootstrap/state dependency chain
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
getDebugFilter: () => null,
|
||||
getMinDebugLogLevel: () => "debug",
|
||||
getDebugLogPath: () => "/tmp/mock-debug.log",
|
||||
flushDebugLogs: async () => {},
|
||||
enableDebugLogging: () => false,
|
||||
setHasFormattedOutput: () => {},
|
||||
getHasFormattedOutput: () => false,
|
||||
logAntError: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
getMaxOutputLength,
|
||||
BASH_MAX_OUTPUT_UPPER_LIMIT,
|
||||
BASH_MAX_OUTPUT_DEFAULT,
|
||||
} = await import("../outputLimits");
|
||||
|
||||
describe("outputLimits constants", () => {
|
||||
test("BASH_MAX_OUTPUT_UPPER_LIMIT is 150000", () => {
|
||||
expect(BASH_MAX_OUTPUT_UPPER_LIMIT).toBe(150_000);
|
||||
});
|
||||
|
||||
test("BASH_MAX_OUTPUT_DEFAULT is 30000", () => {
|
||||
expect(BASH_MAX_OUTPUT_DEFAULT).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaxOutputLength", () => {
|
||||
const saved = process.env.BASH_MAX_OUTPUT_LENGTH;
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.BASH_MAX_OUTPUT_LENGTH;
|
||||
else process.env.BASH_MAX_OUTPUT_LENGTH = saved;
|
||||
});
|
||||
|
||||
test("returns default when env not set", () => {
|
||||
delete process.env.BASH_MAX_OUTPUT_LENGTH;
|
||||
expect(getMaxOutputLength()).toBe(30_000);
|
||||
});
|
||||
|
||||
test("returns parsed value when valid", () => {
|
||||
process.env.BASH_MAX_OUTPUT_LENGTH = "50000";
|
||||
expect(getMaxOutputLength()).toBe(50_000);
|
||||
});
|
||||
|
||||
test("caps at upper limit", () => {
|
||||
process.env.BASH_MAX_OUTPUT_LENGTH = "999999";
|
||||
expect(getMaxOutputLength()).toBe(150_000);
|
||||
});
|
||||
|
||||
test("returns default for invalid value", () => {
|
||||
process.env.BASH_MAX_OUTPUT_LENGTH = "not-a-number";
|
||||
expect(getMaxOutputLength()).toBe(30_000);
|
||||
});
|
||||
|
||||
test("returns default for negative value", () => {
|
||||
process.env.BASH_MAX_OUTPUT_LENGTH = "-1";
|
||||
expect(getMaxOutputLength()).toBe(30_000);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user