mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c3feb56a | ||
|
|
e2f4d558e1 | ||
|
|
9afcb398ca | ||
|
|
c80a6d062b | ||
|
|
a05242cef0 | ||
|
|
27b334aceb | ||
|
|
27b665ac79 | ||
|
|
ea399f1862 | ||
|
|
c499bfb4ed | ||
|
|
b67e9f9d38 | ||
|
|
2bca31e525 | ||
|
|
2cc9a7daef | ||
|
|
d66a6f6124 | ||
|
|
48a19b8a0d | ||
|
|
5157b09743 | ||
|
|
ecd3f9d791 | ||
|
|
5b941d4ad4 | ||
|
|
ae7a4e5ae5 | ||
|
|
e5f31afebd | ||
|
|
fc8d531a7d | ||
|
|
835dd2d804 | ||
|
|
0face46fbe | ||
|
|
d451e30741 | ||
|
|
e7070e072f | ||
|
|
833181e025 | ||
|
|
80b46d2221 | ||
|
|
78d46aa233 | ||
|
|
b3d28bcdf1 | ||
|
|
1f80043928 | ||
|
|
3d7b32f52e | ||
|
|
2c8a22d4b3 | ||
|
|
ea5147420d | ||
|
|
3d0f1acfb7 | ||
|
|
478091567d | ||
|
|
b4e52d0c9e | ||
|
|
d11b35e023 | ||
|
|
8570b6ba01 | ||
|
|
db606b5589 | ||
|
|
27a01113e4 | ||
|
|
4a39fd74b1 | ||
|
|
5486d3c02c | ||
|
|
aaabf0c168 | ||
|
|
43c20a43c2 | ||
|
|
17c06690d8 | ||
|
|
89800137b6 |
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: Bug 报告
|
||||
description: 报告一个可复现的 bug
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 我使用的是 **最新版本**(`bun run build` 或最新 release)。
|
||||
- [ ] 我已经阅读过 [README](https://github.com/claude-code-best/claude-code) 和相关文档。
|
||||
|
||||
**未完成以上检查的 Issue 将被直接关闭。**
|
||||
|
||||
---
|
||||
|
||||
## 运行环境
|
||||
|
||||
| 项目| 值|
|
||||
|---|---|
|
||||
| 操作系统| 例如 macOS 15.4、Ubuntu 24.04|
|
||||
| Bun 版本| 例如 `bun --version` 的输出|
|
||||
| Claude Code 版本| 例如 `2.4.3` 或 commit hash|
|
||||
| 安装方式| `bun run build` / npm / 其他|
|
||||
| 模型| 例如 claude-sonnet-4-6、claude-opus-4-7|
|
||||
|
||||
## 复现步骤
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 期望行为
|
||||
|
||||
<!-- 应该发生什么? -->
|
||||
|
||||
## 实际行为
|
||||
|
||||
<!-- 实际发生了什么?如有必要可附截图。 -->
|
||||
|
||||
## 相关日志
|
||||
|
||||
<!-- 粘贴终端输出或错误信息,请使用 triple backticks 代码块。 -->
|
||||
|
||||
```text
|
||||
```
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 其他上下文 — 配置、环境变量、尝试过的 workaround 等。 -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/claude-code-best/claude-code/discussions
|
||||
about: 使用问题、功能建议和一般讨论 — 请使用 Discussions 而非 Issues。
|
||||
- name: 📖 项目文档
|
||||
url: https://github.com/claude-code-best/claude-code
|
||||
about: 提交 issue 前,请先阅读 README 和相关文档,你的问题可能已经有答案了。
|
||||
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 功能建议
|
||||
description: 提出新功能或改进建议
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
## 发帖前必读
|
||||
|
||||
- [ ] 我已经搜索过 [现有 Issues](https://github.com/claude-code-best/claude-code/issues),没有找到重复。
|
||||
- [ ] 这是功能建议,不是 Bug 报告或使用问题。
|
||||
- [ ] 使用问题请前往 [Discussions](https://github.com/claude-code-best/claude-code/discussions)。
|
||||
|
||||
---
|
||||
|
||||
## 要解决的问题
|
||||
|
||||
<!-- 这个功能解决什么问题?为什么需要它? -->
|
||||
|
||||
## 建议方案
|
||||
|
||||
<!-- 描述你建议的实现方式,尽量简洁具体。 -->
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
<!-- 还有没有想到的其他实现思路? -->
|
||||
|
||||
## 补充信息
|
||||
|
||||
<!-- 截图、草图、参考资料,或其他有助于说明需求的内容。 -->
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -42,7 +42,8 @@ jobs:
|
||||
run: |
|
||||
# Tolerate pre-existing flaky tests (Bun mock pollution / order-dependent state).
|
||||
# We still require lcov.info to be generated and contain real coverage data.
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s || true
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
|
||||
13
.github/workflows/publish-npm.yml
vendored
13
.github/workflows/publish-npm.yml
vendored
@@ -3,11 +3,11 @@ name: Publish to npm
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
description: "版本号 (例如: v1.9.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -24,11 +24,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
@@ -43,9 +38,9 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
run: bun publish -p --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
|
||||
47
CLAUDE.md
47
CLAUDE.md
@@ -119,11 +119,6 @@ bun run docs:dev
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Encoding Detection
|
||||
|
||||
- **`src/utils/encoding.ts`** — 文件编码检测的唯一入口。提供 `detectEncoding`(三层检测:BOM → UTF-8 fatal → ICU 回退链)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB,零外部依赖,仅使用 TextDecoder API。ISO-8859-1 作为最终兜底编码(单字节编码永远成功)。`FileEncoding` 类型扩展了 `BufferEncoding`,覆盖 gbk/gb18030/shift_jis/euc-kr/euc-jp/big5/iso-8859-1。
|
||||
- `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`,不支持 `gbk`/`shift_jis` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer,再用 `TextDecoder` 解码。项目中所有文件读取路径(fileRead.ts、fileReadCache.ts、file.ts)已统一使用 `decodeBuffer` 函数处理此逻辑。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
@@ -319,6 +314,48 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
#### 跨文件 mock 污染(process-global `mock.module`)
|
||||
|
||||
**Bun 的 `mock.module` 是进程全局的(last-write-wins),不是 per-file 隔离的。** 一个测试文件的 `mock.module` 会污染同一进程中所有其他测试文件的 `require`/`import`。
|
||||
|
||||
**关键事实(Bun 1.x 实测验证):**
|
||||
- 测试文件执行顺序**不是严格字母序**,不要假设文件 A 一定在文件 B 之前执行。
|
||||
- `mock.module` 在 `beforeAll` 内部调用时**不会被提升**(hoist),但仍会污染后续加载的文件。
|
||||
- `require()` 和 `import()` 共享同一模块注册表,`mock.module` 对两者都生效。
|
||||
- 一个模块一旦被某个文件的 `mock.module` 替换,同一进程中所有后续 `require`/`import` 都会返回 mock 值,即使调用方使用不同的 specifier 路径。
|
||||
|
||||
**核心规则:不要 mock 被测模块的上层业务模块。**
|
||||
|
||||
错误做法(会污染同目录的 `api.test.ts`):
|
||||
```ts
|
||||
// launchSchedule.test.ts — 直接 mock 源 API 模块 ❌
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
正确做法(mock 底层 HTTP 层,不污染业务模块):参考 `launchSkillStore.test.ts`、`launchVault.test.ts` 的模式。
|
||||
```ts
|
||||
// launchSchedule.test.ts — mock axios 而非 triggersApi ✅
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
|
||||
beforeAll(() => { axiosHandle.useStubs = true })
|
||||
afterAll(() => { axiosHandle.useStubs = false })
|
||||
```
|
||||
|
||||
**判断标准:** 如果目录下同时有 `launch*.test.ts`(集成测试)和 `api.test.ts`(回归测试),`launch*.test.ts` 必须 mock axios 而非源 API 模块。`api.test.ts` 需要测试真实 API 模块的 HTTP 方法/URL/错误处理逻辑,被 mock 后就无法测试。
|
||||
|
||||
**排查 mock 污染的方法:**
|
||||
1. 单独运行可疑文件确认其通过:`bun test path/to/suspect.test.ts`
|
||||
2. 与同目录其他文件一起运行定位污染源:`bun test path/to/__tests__/`
|
||||
3. 在两个文件中各加 `console.error('[file] milestone')` 追踪实际执行顺序
|
||||
4. 检查 `mock.module` 的 specifier 是否与同目录其他测试的 `require`/`import` 路径解析到同一模块
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.5",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -53,7 +53,7 @@
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --fix .",
|
||||
"prepare": "husky",
|
||||
"prepare": "bunx husky",
|
||||
"test": "bun test",
|
||||
"test:production": "bun run scripts/production-test.ts",
|
||||
"test:production:offline": "bun run scripts/production-test.ts --offline",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import {
|
||||
localPlatformLabel,
|
||||
type BridgePermissionRequest,
|
||||
toLoggerDetail,
|
||||
type ChromeExtensionInfo,
|
||||
type ClaudeForChromeContext,
|
||||
type PermissionMode,
|
||||
@@ -578,7 +579,7 @@ export class BridgeClient implements SocketClient {
|
||||
const durationMs = Date.now() - this.connectionStartTime
|
||||
logger.error(
|
||||
`[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
|
||||
error,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
trackEvent?.('chrome_bridge_connection_failed', {
|
||||
duration_ms: durationMs,
|
||||
@@ -618,7 +619,10 @@ export class BridgeClient implements SocketClient {
|
||||
)
|
||||
this.handleMessage(message)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Failed to parse bridge message:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Failed to parse bridge message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -862,7 +866,10 @@ export class BridgeClient implements SocketClient {
|
||||
const allowed = await pending.onPermissionRequest(request)
|
||||
this.sendPermissionResponse(requestId, allowed)
|
||||
} catch (error) {
|
||||
logger.error(`[${serverName}] Error handling permission request:`, error)
|
||||
logger.error(
|
||||
`[${serverName}] Error handling permission request:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.sendPermissionResponse(requestId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ChromeBridgeTrackEventMetadata,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
LoggerDetail,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
export { toLoggerDetail } from './types.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -87,7 +88,10 @@ class McpSocketClient {
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Security validation failed:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return
|
||||
@@ -145,14 +149,20 @@ class McpSocketClient {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to parse message:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
logger.info(
|
||||
`[${serverName}] Socket error (code: ${error.code}):`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -44,7 +45,10 @@ export const handleToolCall = async (
|
||||
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
context.logger.info(
|
||||
`[${context.serverName}] Error calling tool:`,
|
||||
toLoggerDetail(error),
|
||||
)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context)
|
||||
@@ -165,8 +169,7 @@ async function handleToolCallConnected(
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
`[${context.serverName}] Unexpected result format from socket bridge: ${JSON.stringify(response)}`,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
/**
|
||||
* Logger 第二参数的可选类型。
|
||||
* 调用方通过 util.format 追加详情,实践中多为 catch 到的异常对象。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口,与 DebugLogger(util.format)对齐。 */
|
||||
export interface Logger {
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge 连接失败时的 error_type 枚举。
|
||||
* 由 bridgeClient 在 getUserId / getOAuthToken / WebSocket 创建失败时上报。
|
||||
*/
|
||||
export type ChromeBridgeConnectionErrorType =
|
||||
| 'no_user_id' // 无法获取用户 UUID
|
||||
| 'no_oauth_token' // 无法获取 OAuth token
|
||||
| 'websocket_error' // WebSocket 创建或运行异常
|
||||
|
||||
/** 工具调用相关遥测元数据(started / completed / timeout / error)。 */
|
||||
export type ChromeBridgeToolCallMetadata = {
|
||||
tool_name: string // MCP 工具名
|
||||
tool_use_id: string // 本次调用的 UUID
|
||||
duration_ms?: number // 耗时(毫秒)
|
||||
timeout_ms?: number // 超时阈值(毫秒),仅 timeout 事件
|
||||
error_message?: string // 错误摘要(截断),仅 error 事件
|
||||
}
|
||||
|
||||
/** Bridge 连接失败遥测元数据。 */
|
||||
export type ChromeBridgeConnectionFailedMetadata = {
|
||||
duration_ms: number // 自连接开始到失败的耗时(毫秒)
|
||||
error_type: ChromeBridgeConnectionErrorType // 失败原因分类
|
||||
reconnect_attempt: number // 当前重连尝试次数
|
||||
}
|
||||
|
||||
/** Bridge 开始连接遥测元数据。 */
|
||||
export type ChromeBridgeConnectionStartedMetadata = {
|
||||
bridge_url: string // 目标 WebSocket URL(含用户路径)
|
||||
}
|
||||
|
||||
/** Bridge 断开连接遥测元数据。 */
|
||||
export type ChromeBridgeDisconnectedMetadata = {
|
||||
close_code: number // WebSocket 关闭码
|
||||
duration_since_connect_ms: number // 自连接成功到断开的时长(毫秒)
|
||||
reconnect_attempt: number // 即将进行的重连序号
|
||||
}
|
||||
|
||||
/** Bridge 连接成功遥测元数据。 */
|
||||
export type ChromeBridgeConnectionSucceededMetadata = {
|
||||
duration_ms: number // 自开始到连接就绪的耗时(毫秒)
|
||||
status: 'paired' | 'waiting' // paired=已配对扩展;waiting=等待扩展接入
|
||||
}
|
||||
|
||||
/** Bridge 重连次数耗尽遥测元数据。 */
|
||||
export type ChromeBridgeReconnectExhaustedMetadata = {
|
||||
total_attempts: number // 累计重连次数上限
|
||||
}
|
||||
|
||||
/**
|
||||
* trackEvent 回调的 metadata 联合类型。
|
||||
* 各变体对应 bridgeClient 内 chrome_bridge_* 事件;null 表示无附加字段。
|
||||
*/
|
||||
export type ChromeBridgeTrackEventMetadata =
|
||||
| ChromeBridgeToolCallMetadata
|
||||
| ChromeBridgeConnectionFailedMetadata
|
||||
| ChromeBridgeConnectionStartedMetadata
|
||||
| ChromeBridgeDisconnectedMetadata
|
||||
| ChromeBridgeConnectionSucceededMetadata
|
||||
| ChromeBridgeReconnectExhaustedMetadata
|
||||
| null // 无元数据(如 peer_connected / peer_disconnected)
|
||||
|
||||
export type PermissionMode =
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
@@ -48,10 +121,10 @@ export interface ClaudeForChromeContext {
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
/** Bridge 遥测回调;eventName 为 chrome_bridge_* 事件名 */
|
||||
trackEvent?: (
|
||||
eventName: string, // 事件名
|
||||
metadata: ChromeBridgeTrackEventMetadata, // 事件元数据
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
import { type Logger, toLoggerDetail } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
@@ -165,7 +165,10 @@ export async function validateClickTarget(
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
logger.debug(
|
||||
'[pixelCompare] validation error, skipping',
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
ResolvedAppRequest,
|
||||
TeachStepRequest,
|
||||
} from './types.js'
|
||||
import { toLoggerDetail } from './types.js'
|
||||
|
||||
/**
|
||||
* Finder is never hidden by the hide loop (hiding Finder kills the Desktop),
|
||||
@@ -4446,7 +4447,10 @@ export async function handleToolCall(
|
||||
// For ungated tools, the executor may have been mid-call; that's fine —
|
||||
// the result is still a tool error, never an implicit success.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`[${serverName}] tool=${name} threw: ${msg}`, err)
|
||||
logger.error(
|
||||
`[${serverName}] tool=${name} threw: ${msg}`,
|
||||
toLoggerDetail(err),
|
||||
)
|
||||
return errorResult(`Tool "${name}" failed: ${msg}`, 'executor_threw')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import type {
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
/**
|
||||
* Logger 第二参数的可选类型(与 claude-for-chrome-mcp 对齐)。
|
||||
* 实践中多为 catch 到的 Error。
|
||||
*/
|
||||
export type LoggerDetail = Error | NodeJS.ErrnoException
|
||||
|
||||
/** 将 unknown 收窄为 LoggerDetail,供 catch 块传给 logger 使用。 */
|
||||
export function toLoggerDetail(detail: unknown): LoggerDetail | undefined {
|
||||
return detail instanceof Error ? detail : undefined
|
||||
}
|
||||
|
||||
/** 宿主注入的日志接口(与 claude-for-chrome-mcp/src/types.ts 对齐)。 */
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
info: (message: string, detail?: LoggerDetail) => void // 信息
|
||||
error: (message: string, detail?: LoggerDetail) => void // 错误
|
||||
warn: (message: string, detail?: LoggerDetail) => void // 警告
|
||||
debug: (message: string, detail?: LoggerDetail) => void // 调试
|
||||
silly: (message: string, detail?: LoggerDetail) => void // 最细粒度调试
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any
|
||||
/** 渲染帧中虚拟终端光标的状态(列/行坐标与是否绘制),供 diff 与光标 preamble 使用。 */
|
||||
export type Cursor = {
|
||||
x: number // 光标所在列,从 0 开始计
|
||||
y: number // 光标所在行,从 0 开始计
|
||||
visible: boolean // 本帧是否应在终端绘制光标(隐藏时不发射光标移动序列)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
@@ -45,10 +46,9 @@ export type DOMElement = {
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
// 协调器写入的事件处理器(捕获/冒泡分发用)。
|
||||
// 与 attributes 分离,避免 handler 引用变化触发 dirty 破坏 blit 优化。
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 见 event-handlers.ts EventHandlerProps
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any
|
||||
/** Box 等组件上 `onPaste` / `onPasteCapture` 收到的粘贴事件形状(与括号粘贴解析结果对齐的占位约定)。 */
|
||||
export type PasteEvent = {
|
||||
pastedText: string // 终端括号粘贴模式下解析出的 UTF-8 文本;允许为空字符串以表示空粘贴
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any
|
||||
/** 终端尺寸变化时 `onResize` 回调收到的事件载荷(与 `stdout.columns` / `stdout.rows` 一致)。 */
|
||||
export type ResizeEvent = {
|
||||
columns: number // 当前终端列数(宽度)
|
||||
rows: number // 当前终端行数(高度)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ export class TerminalEvent extends Event {
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
import type { EventHandlerProps } from './event-handlers.js'
|
||||
|
||||
/** 终端事件系统的目标节点(DOM 树节点或根节点)。 */
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
parentNode: EventTarget | undefined // 父节点,根节点为 undefined
|
||||
_eventHandlers?: Partial<EventHandlerProps> // 事件处理器,与 dom.ts DOMElement 同构
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
type TextNode,
|
||||
} from './dom.js'
|
||||
import { Dispatcher } from './events/dispatcher.js'
|
||||
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
|
||||
import {
|
||||
EVENT_HANDLER_PROPS,
|
||||
type EventHandlerProps,
|
||||
} from './events/event-handlers.js'
|
||||
import { getFocusManager, getRootNode } from './focus.js'
|
||||
import { LayoutDisplay } from './layout/node.js'
|
||||
import applyStyles, { type Styles, type TextStyles } from './styles.js'
|
||||
@@ -111,7 +114,11 @@ type HostContext = {
|
||||
isInsideText: boolean
|
||||
}
|
||||
|
||||
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
|
||||
function setEventHandler<K extends keyof EventHandlerProps>(
|
||||
node: DOMElement,
|
||||
key: K,
|
||||
value: EventHandlerProps[K],
|
||||
): void {
|
||||
if (!node._eventHandlers) {
|
||||
node._eventHandlers = {}
|
||||
}
|
||||
@@ -135,7 +142,11 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +452,11 @@ const reconciler = createReconciler<
|
||||
}
|
||||
|
||||
if (EVENT_HANDLER_PROPS.has(key)) {
|
||||
setEventHandler(node, key, value)
|
||||
setEventHandler(
|
||||
node,
|
||||
key as keyof EventHandlerProps,
|
||||
value as EventHandlerProps[keyof EventHandlerProps],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
@@ -23,6 +24,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
@@ -41,7 +43,7 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
@@ -204,7 +206,10 @@ export async function* adaptGeminiStreamToAnthropic(
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export type GeminiUsageMetadata = {
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BASH_TOOL_NAME = any
|
||||
/** Bash 工具在 API 与 Agent 提示串中的注册名称字面量(与 `@claude-code-best/builtin-tools` 中 `BASH_TOOL_NAME` 常量一致)。 */
|
||||
export type BASH_TOOL_NAME = 'Bash'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = any
|
||||
/** ExitPlanMode 工具在 API 中的注册名称字面量(与内置 ExitPlanMode 工具 `name` 一致)。 */
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_EDIT_TOOL_NAME = any
|
||||
/** Edit(文件编辑)工具在 API 中的注册名称字面量(与 `FILE_EDIT_TOOL_NAME` 常量 `'Edit'` 一致)。 */
|
||||
export type FILE_EDIT_TOOL_NAME = 'Edit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_READ_TOOL_NAME = any
|
||||
/** Read(文件读取)工具在 API 中的注册名称字面量(与 `FILE_READ_TOOL_NAME` 常量 `'Read'` 一致)。 */
|
||||
export type FILE_READ_TOOL_NAME = 'Read'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_WRITE_TOOL_NAME = any
|
||||
/** Write(文件写入)工具在 API 中的注册名称字面量(与 `FILE_WRITE_TOOL_NAME` 常量 `'Write'` 一致)。 */
|
||||
export type FILE_WRITE_TOOL_NAME = 'Write'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GLOB_TOOL_NAME = any
|
||||
/** Glob(文件名模式匹配)工具在 API 中的注册名称字面量(与 `GLOB_TOOL_NAME` 常量 `'Glob'` 一致)。 */
|
||||
export type GLOB_TOOL_NAME = 'Glob'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GREP_TOOL_NAME = any
|
||||
/** Grep(内容搜索)工具在 API 中的注册名称字面量(与 `GREP_TOOL_NAME` 常量 `'Grep'` 一致)。 */
|
||||
export type GREP_TOOL_NAME = 'Grep'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = any
|
||||
/** NotebookEdit(笔记本单元格编辑)工具在 API 中的注册名称字面量(与 `NOTEBOOK_EDIT_TOOL_NAME` 常量一致)。 */
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = 'NotebookEdit'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SEND_MESSAGE_TOOL_NAME = any
|
||||
/** SendMessage(向用户/通道发消息)工具在 API 中的注册名称字面量(与 `SEND_MESSAGE_TOOL_NAME` 常量一致)。 */
|
||||
export type SEND_MESSAGE_TOOL_NAME = 'SendMessage'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_FETCH_TOOL_NAME = any
|
||||
/** WebFetch(拉取并处理 URL 内容)工具在 API 中的注册名称字面量(与 `WEB_FETCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_FETCH_TOOL_NAME = 'WebFetch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_SEARCH_TOOL_NAME = any
|
||||
/** WebSearch(联网搜索)工具在 API 中的注册名称字面量(与 `WEB_SEARCH_TOOL_NAME` 常量一致)。 */
|
||||
export type WEB_SEARCH_TOOL_NAME = 'WebSearch'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isUsing3PServices = any
|
||||
/** 是否正在使用第三方(非 Anthropic 直连)API 或服务;与仓库根 `src/utils/auth.ts` 中 `isUsing3PServices` 签名一致。 */
|
||||
export type isUsing3PServices = () => boolean // 返回 true 表示当前配置走兼容层或第三方模型端点
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type hasEmbeddedSearchTools = any
|
||||
/** 当前构建是否将 Glob/Grep 嵌入其它工具而不单独注册;与仓库根 `src/utils/embeddedTools.ts` 中 `hasEmbeddedSearchTools` 一致。 */
|
||||
export type hasEmbeddedSearchTools = () => boolean // 返回 true 时工具列表不包含独立的 Glob/Grep 工具名
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSettings_DEPRECATED = any
|
||||
import type { SettingsJson } from 'src/utils/settings/types.js'
|
||||
|
||||
/** 返回各设置来源合并后的快照(已废弃函数名,行为同 `getInitialSettings`);与 `src/utils/settings/settings.ts` 一致。 */
|
||||
export type getSettings_DEPRECATED = () => SettingsJson // 无参数;至少得到可空字段填充后的合并设置对象
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type buildTool = any
|
||||
export type ToolDef = any
|
||||
export type toolMatchesName = any
|
||||
/** 根据工具定义装配宿主侧可调用 `Tool` 实例的工厂函数类型。 */
|
||||
export type buildTool = typeof import('src/Tool.js').buildTool
|
||||
|
||||
/** 工具定义泛型(输入 Schema、权限、进度等);与宿主 `ToolDef` 一致。 */
|
||||
export type ToolDef = import('src/Tool.js').ToolDef
|
||||
|
||||
/** 判断工具主名称或别名是否与查询名称相等;与宿主 `toolMatchesName` 一致。 */
|
||||
export type toolMatchesName = typeof import('src/Tool.js').toolMatchesName
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ConfigurableShortcutHint = any
|
||||
/** 可配置快捷键提示组件(从 keybindings 解析展示文案);与宿主 `ConfigurableShortcutHint` 组件类型一致。 */
|
||||
export type ConfigurableShortcutHint =
|
||||
typeof import('src/components/ConfigurableShortcutHint.js').ConfigurableShortcutHint
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CtrlOToExpand = any
|
||||
export type SubAgentProvider = any
|
||||
/** 「Ctrl+O 展开」提示组件;与宿主 `src/components/CtrlOToExpand.tsx` 中 `CtrlOToExpand` 一致。 */
|
||||
export type CtrlOToExpand =
|
||||
typeof import('src/components/CtrlOToExpand.js').CtrlOToExpand
|
||||
|
||||
/** 标记子 Agent 输出上下文,用于抑制重复的展开提示;与宿主 `SubAgentProvider` 一致。 */
|
||||
export type SubAgentProvider =
|
||||
typeof import('src/components/CtrlOToExpand.js').SubAgentProvider
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Byline = any
|
||||
/** Ink 底部快捷键说明行容器组件;与 `@anthropic/ink` 导出的 `Byline` 一致。 */
|
||||
export type Byline = typeof import('@anthropic/ink').Byline
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type KeyboardShortcutHint = any
|
||||
/** Ink 快捷键「按键 + 动作」展示组件;与 `@anthropic/ink` 导出的 `KeyboardShortcutHint` 一致。 */
|
||||
export type KeyboardShortcutHint =
|
||||
typeof import('@anthropic/ink').KeyboardShortcutHint
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Message = any
|
||||
export type NormalizedUserMessage = any
|
||||
/** 对话消息联合类型(含用户/助手/系统等);与宿主 `src/types/message.js` 重导出一致。 */
|
||||
export type Message = import('src/types/message.js').Message
|
||||
|
||||
/** 归一化后的用户消息形状;与宿主 `src/types/message.js` 中 `NormalizedUserMessage` 一致。 */
|
||||
export type NormalizedUserMessage =
|
||||
import('src/types/message.js').NormalizedUserMessage
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any
|
||||
/** 写入调试日志文件(受日志级别与过滤规则约束);与宿主 `src/utils/debug.js` 中 `logForDebugging` 一致。 */
|
||||
export type logForDebugging =
|
||||
typeof import('src/utils/debug.js').logForDebugging
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getQuerySourceForAgent = any
|
||||
/** 按内置/自定义 Agent 类型解析用于遥测或分类的 `QuerySource`;与宿主 `getQuerySourceForAgent` 一致。 */
|
||||
export type getQuerySourceForAgent =
|
||||
typeof import('src/utils/promptCategory.js').getQuerySourceForAgent
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any
|
||||
/** 设置文件来源层级标识(用户/项目/本地等);与宿主 `src/utils/settings/constants.js` 中 `SettingSource` 一致。 */
|
||||
export type SettingSource =
|
||||
import('src/utils/settings/constants.js').SettingSource
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getAllowedChannels = any
|
||||
export type getQuestionPreviewFormat = any
|
||||
/** 返回当前允许展示的通道列表(含名称、连接状态等);与宿主 `src/bootstrap/state.js` 中 `getAllowedChannels` 一致。 */
|
||||
export type getAllowedChannels =
|
||||
typeof import('src/bootstrap/state.js').getAllowedChannels
|
||||
|
||||
/** 返回问题预览渲染格式(Markdown/HTML)或未配置;与宿主 `getQuestionPreviewFormat` 一致。 */
|
||||
export type getQuestionPreviewFormat =
|
||||
typeof import('src/bootstrap/state.js').getQuestionPreviewFormat
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type MessageResponse = any
|
||||
/** 工具结果在消息流中的外层布局组件;与宿主 `src/components/MessageResponse.js` 中 `MessageResponse` 一致。 */
|
||||
export type MessageResponse =
|
||||
typeof import('src/components/MessageResponse.js').MessageResponse
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BLACK_CIRCLE = any
|
||||
/** 列表/状态行中使用的实心圆点字符(平台相关);与宿主 `src/constants/figures.js` 中 `BLACK_CIRCLE` 常量类型一致。 */
|
||||
export type BLACK_CIRCLE =
|
||||
typeof import('src/constants/figures.js').BLACK_CIRCLE
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getModeColor = any
|
||||
/** 将权限模式映射为 Ink 主题颜色键,用于状态行等 UI;与宿主 `getModeColor` 一致。 */
|
||||
export type getModeColor =
|
||||
typeof import('src/utils/permissions/PermissionMode.js').getModeColor
|
||||
|
||||
@@ -29,7 +29,6 @@ import { extractClaudeCodeHints } from 'src/utils/claudeCodeHints.js';
|
||||
import { detectCodeIndexingFromCommand } from 'src/utils/codeIndexing.js';
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js';
|
||||
import { isENOENT, ShellError } from 'src/utils/errors.js';
|
||||
import { decodeBuffer } from 'src/utils/encoding.js';
|
||||
import { detectFileEncoding, detectLineEndings, getFileModificationTime, writeTextContent } from 'src/utils/file.js';
|
||||
import { fileHistoryEnabled, fileHistoryTrackEdit } from 'src/utils/fileHistory.js';
|
||||
import { truncate } from 'src/utils/format.js';
|
||||
@@ -512,8 +511,7 @@ async function applySedEdit(
|
||||
const encoding = detectFileEncoding(absoluteFilePath);
|
||||
let originalContent: string;
|
||||
try {
|
||||
const rawBuffer = await fs.readFileBytes(absoluteFilePath);
|
||||
originalContent = decodeBuffer(rawBuffer, encoding);
|
||||
originalContent = await fs.readFile(absoluteFilePath, { encoding });
|
||||
} catch (e) {
|
||||
if (isENOENT(e)) {
|
||||
return {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ToolPermissionContext = any
|
||||
/** 工具权限检查用的不可变上下文快照;与宿主 `src/Tool.js` 中 `ToolPermissionContext` 一致。 */
|
||||
export type ToolPermissionContext = import('src/Tool.js').ToolPermissionContext
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getOriginalCwd = any
|
||||
/** 返回进程启动时的原始工作目录(不受中途切换工作区影响);与宿主 `getOriginalCwd` 一致。 */
|
||||
export type getOriginalCwd =
|
||||
typeof import('src/bootstrap/state.js').getOriginalCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any
|
||||
/** 工具调用权限判定回调(交互/自动模式分支);与宿主 `src/hooks/useCanUseTool.tsx` 中 `CanUseToolFn` 一致。 */
|
||||
export type CanUseToolFn = import('src/hooks/useCanUseTool.js').CanUseToolFn
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any
|
||||
/** 从磁盘缓存读取 GrowthBook/门控配置(可能略旧);与宿主 `getFeatureValue_CACHED_MAY_BE_STALE` 一致。 */
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE =
|
||||
typeof import('src/services/analytics/growthbook.js').getFeatureValue_CACHED_MAY_BE_STALE
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any
|
||||
/** 同步记录分析事件(未附加 sink 时入队);与宿主 `src/services/analytics/index.js` 中 `logEvent` 一致。 */
|
||||
export type logEvent = typeof import('src/services/analytics/index.js').logEvent
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AppState = any
|
||||
/** REPL 全局 UI 与权限等状态快照类型;与宿主 `src/state/AppStateStore.js` 中 `AppState` 一致。 */
|
||||
export type AppState = import('src/state/AppStateStore.js').AppState
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any
|
||||
/** 将 Shell 会话当前目录设为解析后的物理路径;与宿主 `src/utils/Shell.js` 中 `setCwd` 一致。 */
|
||||
export type setCwd = typeof import('src/utils/Shell.js').setCwd
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any
|
||||
/** 返回当前 Shell/会话逻辑工作目录字符串;与宿主 `src/utils/cwd.js` 中 `getCwd` 一致。 */
|
||||
export type getCwd = typeof import('src/utils/cwd.js').getCwd
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type pathInAllowedWorkingPath = any
|
||||
/** 判断路径是否落在当前工具允许的合并工作目录内;与宿主 `pathInAllowedWorkingPath` 一致。 */
|
||||
export type pathInAllowedWorkingPath =
|
||||
typeof import('src/utils/permissions/filesystem.js').pathInAllowedWorkingPath
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type removeSandboxViolationTags = any
|
||||
/** 从展示文本中剥离沙箱违规相关的标记标签,避免 UI 噪音;与宿主 `removeSandboxViolationTags` 一致。 */
|
||||
export type removeSandboxViolationTags =
|
||||
typeof import('src/utils/sandbox/sandbox-ui-utils.js').removeSandboxViolationTags
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import {
|
||||
extractDiscoveredToolNames,
|
||||
isSearchExtraToolsEnabledOptimistic,
|
||||
isSearchExtraToolsToolAvailable,
|
||||
} from 'src/utils/searchExtraTools.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
import { isDeferredTool } from '../SearchExtraToolsTool/prompt.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -74,6 +80,32 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: block execution of undiscovered deferred tools.
|
||||
// When tool search is active, deferred tools must be discovered via
|
||||
// SearchExtraTools first so the model has seen their schemas and knows
|
||||
// the correct parameters. Executing an undiscovered tool almost always
|
||||
// fails with parameter validation errors.
|
||||
if (
|
||||
isSearchExtraToolsEnabledOptimistic() &&
|
||||
isSearchExtraToolsToolAvailable(tools) &&
|
||||
isDeferredTool(targetTool)
|
||||
) {
|
||||
const discovered = extractDiscoveredToolNames(context.messages)
|
||||
if (!discovered.has(input.tool_name)) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" has not been discovered yet. You must first use SearchExtraTools to discover this tool before executing it.\n\nUsage: SearchExtraTools("select:${input.tool_name}")`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the target tool is currently enabled
|
||||
if (!targetTool.isEnabled()) {
|
||||
return {
|
||||
@@ -89,6 +121,29 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate input before delegating — prevents crashes when the model
|
||||
// omits required params (e.g. TeamCreate without team_name →
|
||||
// sanitizeName(undefined).replace() TypeError).
|
||||
if (targetTool.validateInput) {
|
||||
const validation = await targetTool.validateInput(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (!validation.result) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Invalid parameters for tool "${input.tool_name}": ${validation.message}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
@@ -132,7 +187,7 @@ export const ExecuteTool = buildTool({
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
return `${input.tool_name}`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteExtraTool'
|
||||
|
||||
@@ -33,10 +33,10 @@ mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsToolAvailable: () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
@@ -154,6 +154,26 @@ describe('ExecuteTool', () => {
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('returns error when deferred tool has not been discovered via SearchExtraTools', async () => {
|
||||
const mockTarget = makeMockTool('UndiscoveredTool', 'result')
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'UndiscoveredTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'UndiscoveredTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages![0].content).toContain('has not been discovered')
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
* 薄层子进程包装器,在独立的 bun:test 进程中运行实际测试。
|
||||
* 这样可以防止其他测试文件的 mock.module() 漏出(例如 agentToolUtils.test.ts
|
||||
* 对 src/Tool.js 的 mock)影响 ExecuteTool 的测试。
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
|
||||
@@ -4,16 +4,34 @@ export const DESCRIPTION =
|
||||
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
|
||||
return `ExecuteExtraTool — always loaded, always available. Runs locally with full permissions — NOT a remote or external tool.
|
||||
|
||||
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
|
||||
## What it does
|
||||
Accepts a tool_name and params, looks up the target tool in the registry, and delegates execution to it. The target tool runs with the same permissions as if called directly.
|
||||
|
||||
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
|
||||
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
|
||||
## When to use
|
||||
ONLY for deferred tools discovered via SearchExtraTools. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always in your tool list — call them directly, NOT through ExecuteExtraTool.
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
## How to call — two-step workflow
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
|
||||
Step 1: SearchExtraTools discovers the tool name and schema.
|
||||
Step 2: This tool executes it.
|
||||
|
||||
Example — user asks to schedule a cron job:
|
||||
SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: "Found deferred tool(s): CronCreate"
|
||||
ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check deploy"}})
|
||||
→ Response: Cron job created
|
||||
|
||||
Example — MCP tool:
|
||||
SearchExtraTools({"query": "select:mcp__slack__send_message"})
|
||||
→ Response: "Found deferred tool(s): mcp__slack__send_message"
|
||||
ExecuteExtraTool({"tool_name": "mcp__slack__send_message", "params": {"channel": "C123", "text": "hello"}})
|
||||
|
||||
## Inputs
|
||||
- tool_name: Exact name of the target tool (string, e.g. "CronCreate", "mcp__slack__send_message")
|
||||
- params: Object with the target tool's parameters. Check the tool's schema from SearchExtraTools discover: response.
|
||||
|
||||
## Failure handling
|
||||
If this tool returns an error, do NOT retry or re-search. Tell the user what failed and suggest alternatives.`
|
||||
}
|
||||
|
||||
@@ -34,11 +34,6 @@ import {
|
||||
type LineEndingType,
|
||||
readFileSyncWithMetadata,
|
||||
} from 'src/utils/fileRead.js'
|
||||
import {
|
||||
detectEncoding,
|
||||
decodeBuffer,
|
||||
type FileEncoding,
|
||||
} from 'src/utils/encoding.js'
|
||||
import { formatFileSize } from 'src/utils/format.js'
|
||||
import { getFsImplementation } from 'src/utils/fsOperations.js'
|
||||
import { fetchSingleFileGitDiff, type ToolUseDiff } from 'src/utils/gitDiff.js'
|
||||
@@ -207,8 +202,13 @@ export const FileEditTool = buildTool({
|
||||
let fileContent: string | null
|
||||
try {
|
||||
const fileBuffer = await fs.readFileBytes(fullFilePath)
|
||||
const encoding: FileEncoding = detectEncoding(fileBuffer)
|
||||
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
const encoding: BufferEncoding =
|
||||
fileBuffer.length >= 2 &&
|
||||
fileBuffer[0] === 0xff &&
|
||||
fileBuffer[1] === 0xfe
|
||||
? 'utf16le'
|
||||
: 'utf8'
|
||||
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
|
||||
} catch (e) {
|
||||
if (isENOENT(e)) {
|
||||
fileContent = null
|
||||
@@ -584,7 +584,7 @@ export const FileEditTool = buildTool({
|
||||
function readFileForEdit(absoluteFilePath: string): {
|
||||
content: string
|
||||
fileExists: boolean
|
||||
encoding: FileEncoding
|
||||
encoding: BufferEncoding
|
||||
lineEndings: LineEndingType
|
||||
} {
|
||||
try {
|
||||
|
||||
@@ -383,8 +383,8 @@ export const NotebookEditTool = buildTool({
|
||||
const language = notebook.metadata.language_info?.name ?? 'python'
|
||||
let new_cell_id
|
||||
if (
|
||||
notebook.nbformat > 4 ||
|
||||
(notebook.nbformat === 4 && notebook.nbformat_minor >= 5)
|
||||
(notebook.nbformat ?? 4) > 4 ||
|
||||
((notebook.nbformat ?? 4) === 4 && (notebook.nbformat_minor ?? 0) >= 5)
|
||||
) {
|
||||
if (edit_mode === 'insert') {
|
||||
new_cell_id = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
@@ -25,13 +25,39 @@ function getToolLocationHint(): string {
|
||||
|
||||
const PROMPT_TAIL = ` Returns matching tool names.
|
||||
|
||||
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools — do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
|
||||
## Two-step workflow (MUST follow exactly)
|
||||
|
||||
Query forms:
|
||||
- "select:CronCreate,Snip" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
Deferred tools CANNOT be called directly. You MUST use this two-step pattern:
|
||||
|
||||
Step 1 — Search: Call this tool (SearchExtraTools) to discover the target tool.
|
||||
Input: {"query": "select:CronCreate"}
|
||||
Response: "Found 1 deferred tool(s): CronCreate. Use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}} to invoke."
|
||||
|
||||
Step 2 — Execute: Call ExecuteExtraTool to run the discovered tool.
|
||||
Input: {"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}}
|
||||
Response: the actual tool result.
|
||||
|
||||
## Example: user asks "schedule a cron to check deploy every 5 minutes"
|
||||
|
||||
1. SearchExtraTools({"query": "select:CronCreate"})
|
||||
→ Response: Found deferred tool CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {"schedule": "*/5 * * * *", "prompt": "check the deploy"}})
|
||||
→ Response: Cron job created successfully
|
||||
|
||||
If you don't know the exact tool name, use keyword search first:
|
||||
1. SearchExtraTools({"query": "cron schedule"})
|
||||
→ Response: Found deferred tool(s): CronCreate
|
||||
2. ExecuteExtraTool({"tool_name": "CronCreate", "params": {...}})
|
||||
|
||||
## Query forms
|
||||
- "select:CronCreate" — exact tool name (fastest, preferred when you know the name from <available-deferred-tools>)
|
||||
- "select:CronCreate,CronList" — comma-separated multi-select
|
||||
- "discover:schedule cron job" — returns tool name + description + schema without loading. Use to understand a tool before calling it.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms
|
||||
|
||||
## Failure policy
|
||||
If ExecuteExtraTool fails, do NOT re-search for the same tool — it will loop. Stop and tell the user what failed.`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires SearchExtraTools to load).
|
||||
|
||||
@@ -59,7 +59,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'HISTORY_SNIP', // 已禁用:snip 功能暂时关闭
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Feature: 20260510_F001 - multi-encoding-file-tools
|
||||
|
||||
## 需求背景
|
||||
|
||||
当前文件读写工具(FileReadTool、FileWriteTool、FileEditTool)的编码检测非常简单——仅通过 BOM 头识别 UTF-8 和 UTF-16LE,其他所有情况默认按 UTF-8 处理。对于 GBK/GB2312 等非 BOM 编码文件,读取时会产生乱码,导致 AI 模型无法正确理解和编辑这些文件。
|
||||
|
||||
这在中文 Windows 用户场景中尤其常见:许多旧项目、日志文件、配置文件使用 GBK 编码,当前工具链无法处理。
|
||||
|
||||
## 目标
|
||||
|
||||
- 文件读取时自动检测编码并正确解码,对 AI 模型完全透明(不增加 encoding 参数)
|
||||
- 文件写入时保持原文件编码,不改变用户的编码习惯
|
||||
- 覆盖 GBK 编码(最常见非 UTF-8 CJK 编码),latin1 作为最终兜底
|
||||
- 零外部依赖,仅使用 Node.js/Bun 内置的 TextDecoder/TextEncoder
|
||||
|
||||
## 范围变更
|
||||
|
||||
**仅保留 GBK 编码支持**。Shift_JIS、EUC-JP、EUC-KR、Big5、GB18030、ISO-8859-1 已移出范围。原因:多编码回退链存在字节序列歧义(如 GBK 和 Shift_JIS 共享大量有效字节范围),导致误检测。GBK 覆盖了最核心的中文 Windows 用户场景。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 架构概述
|
||||
|
||||
新增一个独立的编码工具模块 `src/utils/encoding.ts`,提供编码检测和解码/编码函数。现有文件读写路径通过调用此模块实现对非 UTF-8 编码的支持。
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ src/utils/encoding.ts │
|
||||
│ detectEncoding(buffer) │
|
||||
│ decodeBuffer(buf, enc) │
|
||||
│ encodeString(str, enc) │
|
||||
└─────────┬───────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
fileRead.ts readFileInRange.ts file.ts
|
||||
(readFileSync (异步读取路径) (writeTextContent)
|
||||
WithMetadata)
|
||||
```
|
||||
|
||||
### 编码检测算法(三层检测)
|
||||
|
||||
检测基于文件头部 4KB 数据,分三层依次判断:
|
||||
|
||||
**第一层:BOM 检测(现有逻辑保留)**
|
||||
- `FF FE` → UTF-16LE
|
||||
- `EF BB BF` → UTF-8(带 BOM)
|
||||
|
||||
**第二层:UTF-8 验证**
|
||||
- 用 `new TextDecoder('utf-8', { fatal: true })` 对头部 4KB 做解码
|
||||
- 成功 → 文件为 UTF-8(覆盖绝大多数现代源码文件)
|
||||
- 失败(抛出 TypeError)→ 进入第三层
|
||||
|
||||
**第三层:GBK 回退**
|
||||
- 用 `new TextDecoder('gbk', { fatal: true })` 尝试解码头部 4KB
|
||||
- 成功 → 文件为 GBK(覆盖中文 Windows 用户最常见的非 UTF-8 编码)
|
||||
- 失败 → `latin1`(单字节编码,永远成功,作为最终兜底)
|
||||
|
||||
```typescript
|
||||
// src/utils/encoding.ts 核心逻辑
|
||||
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
export type DetectedEncoding = string
|
||||
|
||||
export function detectEncoding(buffer: Buffer): FileEncoding {
|
||||
// Layer 1: BOM
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return 'utf-16le'
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
|
||||
return 'utf-8'
|
||||
}
|
||||
|
||||
// Layer 2: UTF-8 validation
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
return 'utf-8'
|
||||
} catch {}
|
||||
|
||||
// Layer 3: GBK fallback
|
||||
try {
|
||||
new TextDecoder('gbk', { fatal: true }).decode(buffer)
|
||||
return 'gbk'
|
||||
} catch {}
|
||||
|
||||
return 'latin1'
|
||||
}
|
||||
```
|
||||
|
||||
### 读取路径改造
|
||||
|
||||
#### `src/utils/fileRead.ts` — `detectEncodingForResolvedPath`
|
||||
|
||||
将现有的 BOM-only 检测替换为调用 `encoding.ts` 的 `detectEncoding` 函数。返回值从 `BufferEncoding` 改为 `FileEncoding`(`BufferEncoding | 'gbk'`)。
|
||||
|
||||
`readFileSyncWithMetadata` 函数先读 raw Buffer,再用 `decodeBuffer` 解码,而非使用 `fs.readFileSync` 的 encoding 选项(该选项只接受 `BufferEncoding`,不支持 `gbk`)。
|
||||
|
||||
#### `src/utils/readFileInRange.ts` — 异步读取
|
||||
|
||||
当前两个路径(fast path 和 streaming path)都硬编码 `encoding: 'utf8'`:
|
||||
|
||||
**Fast path 改造**:
|
||||
- `readFile` 改为读取 Buffer(去掉 encoding 参数)
|
||||
- 读取后调用 `detectEncoding(buffer)` 检测编码
|
||||
- 用 `decodeBuffer` 解码为字符串
|
||||
- 后续行处理逻辑不变
|
||||
|
||||
**Streaming path 改造**:
|
||||
- `createReadStream` 去掉 `encoding: 'utf8'`,改为 Buffer 模式
|
||||
- 第一个 chunk 做编码检测(同时保留 BOM 剥离逻辑)
|
||||
- 后续 chunk 拼接后用 `TextDecoder` 解码
|
||||
- 注意:streaming 路径需要特殊处理——先收集足够字节做检测,再逐行扫描
|
||||
|
||||
**Streaming 编码处理策略**:
|
||||
streaming 路径改为两阶段:
|
||||
1. **检测阶段**:前 4KB 数据到达后立即检测编码
|
||||
2. **解码阶段**:用检测到的编码创建一个 `TextDecoder`(`{ stream: true }` 模式),逐 chunk 解码
|
||||
|
||||
### 写入路径改造
|
||||
|
||||
#### 编码回写策略
|
||||
|
||||
写入时需要将内部 UTF-8 字符串编码回原文件编码。由于 `TextEncoder` 只支持 UTF-8 输出,需要使用 `TextDecoder` 的反向操作。
|
||||
|
||||
**最终决定**:对于非 UTF-8 文件的写回,尝试使用 `Buffer.from(content, encoding)` 编码,失败则自动转换为 UTF-8 并在结果消息中注明。这样既满足了零依赖约束,也避免了数据损坏。
|
||||
|
||||
#### `src/utils/file.ts` — `writeTextContent`
|
||||
|
||||
现有函数签名 `writeTextContent(filePath, content, encoding, lineEndings)` 已接受 encoding 参数。需要:
|
||||
- 扩展类型,接受 `FileEncoding` 而非仅 `BufferEncoding`
|
||||
- 对于 UTF-8 和 UTF-16LE,行为不变
|
||||
- 对于 GBK,使用 `encodeString` 函数尝试编码,失败则回退为 UTF-8 写入
|
||||
|
||||
#### `FileWriteTool` 和 `FileEditTool`
|
||||
|
||||
这两个工具的 `call` 方法中,`writeTextContent` 调用已传递 `encoding`(来自 `readFileSyncWithMetadata` 的返回值)。改动很小——只需确保类型系统接受新编码名。
|
||||
|
||||
### 类型扩展
|
||||
|
||||
```typescript
|
||||
// 扩展编码类型 — 仅添加 GBK
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
```
|
||||
|
||||
在 `readFileSyncWithMetadata` 返回类型中将 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`。
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 关键技术决策
|
||||
|
||||
1. **检测只用头部 4KB**:避免全文件扫描,性能开销极小(多几次 TextDecoder 调用,每次 ~1μs)
|
||||
2. **GBK 作为唯一回退**:中文 Windows 用户最多,且避免了多编码回退链的字节序列歧义问题
|
||||
3. **TextDecoder fatal 模式**:`{ fatal: true }` 是检测的关键——如果字节序列不符合编码规范会抛异常,借此区分不同编码
|
||||
4. **streaming 路径的两阶段设计**:先攒够检测数据再开始行扫描,避免半字符解码问题
|
||||
5. **latin1 最终兜底**:单字节编码永远成功,确保任何文件都能被读取
|
||||
|
||||
### 难点
|
||||
|
||||
1. **Streaming 编码解码**:`TextDecoder` 支持 `{ stream: true }` 模式处理多字节字符的 chunk 边界,但需要在检测完成前缓冲数据
|
||||
2. **编码回写的零依赖方案**:`TextEncoder` 只输出 UTF-8,非 UTF-8 编码回写需要额外处理。务实方案是 UTF-8 写入 + 消息提示
|
||||
3. **混合编码文件**:极少见,不在本次覆盖范围内
|
||||
|
||||
### 依赖
|
||||
|
||||
- 零外部依赖,仅使用 `TextDecoder`(Node.js 13+ / Bun 内置 full-icu)
|
||||
- Bun 运行时对 GBK 的 TextDecoder 支持已验证可用(Bun 1.3.13)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] FileReadTool 能正确读取 GBK 编码的中文文本文件,显示正确的中文内容
|
||||
- [x] FileReadTool 能正确读取 UTF-8 文件(行为不变,回归测试通过)
|
||||
- [x] FileReadTool 能正确读取 UTF-16LE 文件(行为不变)
|
||||
- [x] FileEditTool 能编辑 GBK 文件并写回,内容不乱码
|
||||
- [x] FileWriteTool 编辑 GBK 文件后写回,编码保持或合理转换
|
||||
- [x] readFileInRange 的 fast path 路径支持非 UTF-8 编码
|
||||
- [x] readFileInRange 的 streaming path 支持非 UTF-8 编码
|
||||
- [x] 编码检测性能:4KB 数据检测耗时 < 1ms
|
||||
- [x] `bun run precheck` typecheck + lint + 相关测试零错误
|
||||
- [x] 新增编码相关单元测试覆盖检测和解码逻辑
|
||||
@@ -1,161 +0,0 @@
|
||||
# 多编码文件工具 人工验收清单
|
||||
|
||||
**生成时间:** 2026-05-10
|
||||
**关联计划:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-plan.md
|
||||
**关联设计:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
|
||||
|
||||
---
|
||||
|
||||
所有验收项均可通过 Shell 命令自动化验证,无需人类参与。仍将生成清单用于自动执行。
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030 已移除。
|
||||
|
||||
---
|
||||
|
||||
## 验收前准备
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [x] [AUTO] 检查 Bun 运行时版本: `bun --version`
|
||||
- [x] [AUTO] 安装依赖: `bun install`
|
||||
|
||||
### 测试数据准备
|
||||
|
||||
- [x] [AUTO] 创建 GBK 编码测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
|
||||
- [x] [AUTO] 创建 UTF-8 测试文件: `bun -e "require('fs').writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n')"`
|
||||
- [x] [AUTO] 创建 UTF-16LE 测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from('Hello','utf16le'); fs.writeFileSync('/tmp/test-utf16le.txt', b)"`
|
||||
|
||||
---
|
||||
|
||||
## 验收项目
|
||||
|
||||
### 场景 1:读取 GBK 编码文件(中文场景)
|
||||
|
||||
**用户目标:** 用户有一个 GBK 编码的中文文件,通过 FileReadTool 读取后看到正确的中文内容
|
||||
|
||||
**触发路径:**
|
||||
1. 系统检测到非 UTF-8 字节序列
|
||||
2. 编码回退识别为 GBK
|
||||
3. 用 GBK 解码输出中文文本
|
||||
|
||||
#### - [x] 1.1 GBK 文件同步读取
|
||||
- **来源:** spec-plan-acceptance.md §2 / spec-design.md §验收标准
|
||||
- **目的:** 确认 GBK 文件读取解码正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `你好世界`
|
||||
2. [A] 上条命令输出 encoding 字段 → 期望包含: `gbk`
|
||||
|
||||
#### - [x] 1.2 GBK 文件异步路径读取
|
||||
- **来源:** spec-plan-acceptance.md §6 / spec-design.md §验收标准
|
||||
- **目的:** 确认 readFileInRange fast path 支持 GBK
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"` → 期望包含: `你好世界`
|
||||
2. [A] 上条命令输出 totalLines → 期望包含: `1`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:写入非 UTF-8 编码文件
|
||||
|
||||
**用户目标:** 用户通过 FileEditTool/FileWriteTool 编辑 GBK 文件后写回,内容不损坏
|
||||
|
||||
**触发路径:**
|
||||
1. 系统检测原文件编码
|
||||
2. 编辑内容后写回
|
||||
3. 非标准编码回退为 UTF-8 写入(零依赖约束)
|
||||
|
||||
#### - [x] 3.1 GBK 文件写入(UTF-8 回退)
|
||||
- **来源:** spec-plan-acceptance.md §7 / spec-design.md §写入路径改造
|
||||
- **目的:** 确认非 UTF-8 编码写入不损坏内容
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"` → 期望包含: `测试写入`
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:UTF-8 文件读取回归
|
||||
|
||||
**用户目标:** 用户读取 UTF-8 文件,行为与改动前完全一致
|
||||
|
||||
**触发路径:**
|
||||
1. UTF-8 fatal 验证通过
|
||||
2. 内容正常输出
|
||||
|
||||
#### - [x] 4.1 UTF-8 文件读取回归
|
||||
- **来源:** spec-plan-acceptance.md §4 / spec-design.md §验收标准
|
||||
- **目的:** 确认 UTF-8 读取无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `Hello 世界`
|
||||
2. [A] 上条命令输出 encoding 字段 → 期望包含: `utf`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:UTF-16LE 文件读取回归
|
||||
|
||||
**用户目标:** 用户读取 UTF-16LE(BOM)文件,行为与改动前完全一致
|
||||
|
||||
**触发路径:**
|
||||
1. BOM 检测层识别 FF FE 标记
|
||||
2. 用 UTF-16LE 解码
|
||||
|
||||
#### - [x] 5.1 UTF-16LE 文件读取回归
|
||||
- **来源:** spec-plan-acceptance.md §5 / spec-design.md §验收标准
|
||||
- **目的:** 确认 UTF-16LE BOM 读取无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `utf-16le`
|
||||
2. [A] 上条命令输出 content 字段 → 期望包含: `Hello`
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:编码检测性能
|
||||
|
||||
**用户目标:** 编码检测不应影响文件读取的响应速度
|
||||
|
||||
**触发路径:**
|
||||
1. 对 4KB 数据执行 1000 次检测
|
||||
2. 验证平均耗时 < 1ms
|
||||
|
||||
#### - [x] 6.1 检测性能基准
|
||||
- **来源:** spec-plan-acceptance.md §8 / spec-design.md §实现要点
|
||||
- **目的:** 确认编码检测性能达标
|
||||
- **操作步骤:**
|
||||
1. [A] `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); const avg = (performance.now() - start) / 1000; console.log('avg:', avg, 'ms'); process.exit(avg < 1 ? 0 : 1)"` → 期望包含: `avg:`
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:构建和测试完整性
|
||||
|
||||
**用户目标:** 整体代码质量无退化,所有测试通过
|
||||
|
||||
**触发路径:**
|
||||
1. 执行完整 precheck(typecheck + lint + test)
|
||||
2. 确认零错误
|
||||
|
||||
#### - [x] 7.1 编码相关单元测试
|
||||
- **来源:** spec-plan.md Task 1-4 检查步骤 / spec-design.md §验收标准
|
||||
- **目的:** 确认编码相关测试全部通过
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/utils/__tests__/encoding.test.ts` → 期望包含: `0 fail`
|
||||
2. [A] `bun test src/utils/__tests__/fileRead.test.ts` → 期望包含: `0 fail`
|
||||
3. [A] `bun test src/utils/__tests__/readFileInRange.test.ts` → 期望包含: `0 fail`
|
||||
4. [A] `bun test src/utils/__tests__/file.test.ts` → 期望包含: `0 fail`
|
||||
|
||||
---
|
||||
|
||||
## 验收后清理
|
||||
|
||||
- [x] [AUTO] 清理临时测试文件: `rm -f /tmp/test-gbk.txt /tmp/test-utf8.txt /tmp/test-utf16le.txt /tmp/test-gbk-write.txt`
|
||||
|
||||
---
|
||||
|
||||
## 验收结果汇总
|
||||
|
||||
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|
||||
|------|------|--------|-----|-----|------|
|
||||
| 场景 1 | 1.1 | GBK 同步读取 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.2 | GBK 异步路径读取 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.1 | GBK 写入(回退) | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.1 | UTF-8 回归 | 2 | 0 | ✅ |
|
||||
| 场景 5 | 5.1 | UTF-16LE 回归 | 2 | 0 | ✅ |
|
||||
| 场景 6 | 6.1 | 检测性能 | 1 | 0 | ✅ |
|
||||
| 场景 7 | 7.1 | 编码单元测试 | 4 | 0 | ✅ |
|
||||
|
||||
**验收结论:** ✅ 全部通过
|
||||
@@ -1,47 +0,0 @@
|
||||
### Acceptance Task: 多编码文件工具验收
|
||||
|
||||
**前置条件:**
|
||||
- 所有 Task 0-4 已执行完毕
|
||||
- 运行环境: 当前开发环境(Bun)
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. 运行完整测试套件确保无回归
|
||||
- `bun run precheck`
|
||||
- 预期: typecheck + lint fix + test 全部零错误通过
|
||||
- 失败排查: 检查各 Task 的测试步骤,特别是 Task 1 的编码检测测试和 Task 3 的 readFileInRange 测试
|
||||
|
||||
2. 验证 GBK 文件读取正确性
|
||||
- 创建 GBK 编码测试文件:`bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
|
||||
- 读取并验证:`bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `gbk`,content 为 "你好世界"
|
||||
- 失败排查: 检查 Task 1 的 detectEncoding 逻辑、Task 2 的 readFileSyncWithMetadata 集成
|
||||
|
||||
3. 验证 UTF-8 文件读取回归
|
||||
- `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const fs = require('fs'); fs.writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n'); const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `utf-8`,content 为 "Hello 世界"
|
||||
- 失败排查: 检查 Task 1 的 UTF-8 fatal 验证逻辑
|
||||
|
||||
4. 验证 UTF-16LE 文件读取回归
|
||||
- `bun -e "const fs = require('fs'); const b = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from('Hello', 'utf16le')]); fs.writeFileSync('/tmp/test-utf16le.txt', b); import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
|
||||
- 预期: encoding 为 `utf-16le`,content 为 "Hello"
|
||||
- 失败排查: 检查 Task 1 的 BOM 检测层、Task 2 的集成
|
||||
|
||||
5. 验证 readFileInRange 异步路径的 GBK 支持
|
||||
- `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"`
|
||||
- 预期: content 为 "你好世界",totalLines 为 1
|
||||
- 失败排查: 检查 Task 3 的 fast path 改造
|
||||
|
||||
6. 验证 GBK 文件写入(UTF-8 回退)
|
||||
- `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"`
|
||||
- 预期: 文件成功写入,内容为 "测试写入"(UTF-8 回退或 GBK 编码均可接受)
|
||||
- 失败排查: 检查 Task 4 的 writeTextContent 改造和 encodeString 函数
|
||||
|
||||
7. 验证编码检测性能
|
||||
- `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); console.log('avg:', (performance.now() - start) / 1000, 'ms')"`
|
||||
- 预期: 平均检测耗时 < 1ms
|
||||
- 失败排查: 检查 Task 1 的检测逻辑是否有不必要的重复操作
|
||||
|
||||
---
|
||||
@@ -1,34 +0,0 @@
|
||||
### Task 0: 环境准备
|
||||
|
||||
**背景:**
|
||||
确保构建和测试工具链在当前开发环境中可用,验证 Bun 运行时对 GBK 编码的 TextDecoder 支持情况。
|
||||
|
||||
**涉及文件:**
|
||||
- 无文件修改,仅验证环境
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 验证 Bun 运行时可用
|
||||
- 运行命令: `bun --version`
|
||||
- 预期: 输出 Bun 版本号
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- 运行命令: `bunx tsc --noEmit 2>&1 | tail -5`
|
||||
- 预期: 无错误输出(或仅有已知的 pre-existing 错误)
|
||||
- [x] 验证 Bun 对 GBK 编码的 TextDecoder 支持
|
||||
- 运行命令: `bun -e "const d = new TextDecoder('gbk', { fatal: true }); const buf = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]); console.log(d.decode(buf))"`
|
||||
- 预期: 输出 "你好"(GBK 编码的中文字符)
|
||||
- [x] 验证测试框架可用
|
||||
- 运行命令: `bun test src/utils/__tests__/hash.test.ts 2>&1 | tail -3`
|
||||
- 预期: 测试运行成功,无框架错误
|
||||
|
||||
**检查步骤:**
|
||||
- [x] Bun 版本确认
|
||||
- `bun --version`
|
||||
- 预期: 输出有效版本号
|
||||
- [x] GBK 编码支持确认
|
||||
- `bun -e "console.log(new TextDecoder('gbk').decode(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3])))"`
|
||||
- 预期: 输出 "你好"
|
||||
- [x] 现有测试通过
|
||||
- `bun test src/utils/__tests__/file.test.ts 2>&1 | tail -3`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
@@ -1,141 +0,0 @@
|
||||
### Task 1: 编码检测核心模块
|
||||
|
||||
**背景:**
|
||||
当前 `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 仅通过 BOM 头识别 UTF-8 和 UTF-16LE,其他所有文件一律返回 `utf8`,导致 GBK 等非 UTF-8 编码文件读取乱码。本 Task 新建独立的编码检测工具模块 `src/utils/encoding.ts`,实现三层编码检测算法(BOM → UTF-8 fatal 验证 → GBK 回退),为后续 Task 2/3/4 的读写路径改造提供统一的编码检测和解码能力。本 Task 无前置依赖,是后续所有 Task 的基础。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/utils/encoding.ts`
|
||||
- 新建: `src/utils/__tests__/encoding.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 创建 `src/utils/encoding.ts`,定义类型
|
||||
- 位置: 文件顶部
|
||||
- 导出以下类型:
|
||||
```typescript
|
||||
/** 扩展编码类型,覆盖最常见的非 UTF-8 CJK 编码 */
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
|
||||
/** TextDecoder 接受的编码名(string),比 FileEncoding 更宽泛 */
|
||||
export type DetectedEncoding = string
|
||||
```
|
||||
- 原因: 后续 Task 2/3/4 需要这些类型来做编码标注和类型收窄
|
||||
|
||||
- [x] 实现 `detectEncoding(buffer: Buffer): FileEncoding` 函数
|
||||
- 位置: `src/utils/encoding.ts`,类型定义之后
|
||||
- 三层检测逻辑:
|
||||
```typescript
|
||||
export function detectEncoding(buffer: Buffer): FileEncoding {
|
||||
// Layer 1: BOM 检测(与现有 fileRead.ts 逻辑一致)
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return 'utf-16le'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 3 &&
|
||||
buffer[0] === 0xef &&
|
||||
buffer[1] === 0xbb &&
|
||||
buffer[2] === 0xbf
|
||||
) {
|
||||
return 'utf-8'
|
||||
}
|
||||
|
||||
// Layer 2: UTF-8 fatal 验证
|
||||
// fatal: true 模式下,无效 UTF-8 字节序列会抛出 TypeError
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
return 'utf-8'
|
||||
} catch {
|
||||
// 不是合法 UTF-8,进入 Layer 3
|
||||
}
|
||||
|
||||
// Layer 3: GBK 回退
|
||||
try {
|
||||
new TextDecoder('gbk', { fatal: true }).decode(buffer)
|
||||
return 'gbk'
|
||||
} catch {
|
||||
// 不是合法 GBK,latin1 作为最终兜底
|
||||
}
|
||||
|
||||
return 'latin1'
|
||||
}
|
||||
```
|
||||
- 原因: BOM 必须优先于 fatal 验证;GBK 作为唯一回退避免了多编码链的字节歧义问题;latin1 单字节编码永远成功
|
||||
|
||||
- [x] 实现 `decodeBuffer(buffer: Buffer, encoding: DetectedEncoding): string` 函数
|
||||
- 位置: `src/utils/encoding.ts`,`detectEncoding` 之后
|
||||
- 逻辑:
|
||||
```typescript
|
||||
export function decodeBuffer(
|
||||
buffer: Buffer,
|
||||
encoding: DetectedEncoding,
|
||||
): string {
|
||||
return new TextDecoder(encoding).decode(buffer)
|
||||
}
|
||||
```
|
||||
- 原因: 统一解码入口,后续 Task 2/3 的读取路径都调用此函数
|
||||
|
||||
- [x] 实现 `encodeString(content: string, encoding: DetectedEncoding): { buffer: Buffer; converted: boolean }` 函数
|
||||
- 位置: `src/utils/encoding.ts`,`decodeBuffer` 之后
|
||||
- 逻辑:
|
||||
```typescript
|
||||
export function encodeString(
|
||||
content: string,
|
||||
encoding: DetectedEncoding,
|
||||
): { buffer: Buffer; converted: boolean } {
|
||||
if (encoding === 'utf-8' || encoding === 'utf8') {
|
||||
return { buffer: Buffer.from(content, 'utf-8'), converted: false }
|
||||
}
|
||||
if (encoding === 'utf-16le') {
|
||||
return { buffer: Buffer.from(content, 'utf-16le'), converted: false }
|
||||
}
|
||||
|
||||
// 其他编码(如 gbk):尝试 Buffer.from,失败则回退为 UTF-8
|
||||
try {
|
||||
const buf = Buffer.from(content, encoding as BufferEncoding)
|
||||
return { buffer: buf, converted: false }
|
||||
} catch {
|
||||
return { buffer: Buffer.from(content, 'utf-8'), converted: true }
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: `Buffer.from` 在 Bun 中可能支持 GBK 编码名,但 Node.js 不支持。try-catch 策略兼容两种运行时;`converted` 标志让 Task 4 的写入路径能向用户报告编码转换
|
||||
|
||||
- [x] 为编码检测和解码函数编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/encoding.test.ts`
|
||||
- 测试场景:
|
||||
- **BOM 检测 — UTF-16LE**: 输入 `Buffer.from([0xff, 0xfe, 0x48, 0x00])` → 预期返回 `'utf-16le'`
|
||||
- **BOM 检测 — UTF-8 BOM**: 输入 `Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65])` → 预期返回 `'utf-8'`
|
||||
- **UTF-8 验证**: 输入 `Buffer.from('Hello, 世界', 'utf-8')` → 预期返回 `'utf-8'`
|
||||
- **GBK 检测**: 输入 `Buffer.from([0xc4, 0xe3, 0xba, 0xc3])` → 预期返回 `'gbk'`
|
||||
- **空 buffer**: 输入 `Buffer.alloc(0)` → 预期返回 `'utf-8'`
|
||||
- **latin1 兜底**: 输入随机字节 `Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85])` → 预期返回 `'latin1'`
|
||||
- **BOM 优先于内容分析**: 输入带 UTF-8 BOM 的数据 → 预期返回 `'utf-8'`
|
||||
- **decodeBuffer — UTF-8**: 输入 UTF-8 编码的 buffer + encoding `'utf-8'` → 预期返回正确的中文字符串
|
||||
- **decodeBuffer — GBK**: 输入 GBK 编码的 buffer + encoding `'gbk'` → 预期返回正确的中文字符串
|
||||
- **decodeBuffer — UTF-16LE**: 输入 UTF-16LE 编码的 buffer + encoding `'utf-16le'` → 预期返回正确字符串
|
||||
- **decodeBuffer — 空 buffer**: 输入空 buffer → 预期返回空字符串
|
||||
- **encodeString — UTF-8**: 输入字符串 + encoding `'utf-8'` → 预期 `{ converted: false }`
|
||||
- **encodeString — utf8 别名**: 输入字符串 + encoding `'utf8'` → 预期 `{ converted: false }`
|
||||
- **encodeString — UTF-16LE**: 输入字符串 + encoding `'utf-16le'` → 预期 `{ converted: false }`
|
||||
- **encodeString — GBK**: 输入字符串 + encoding `'gbk'` → 预期返回有效的 Buffer(converted 视运行时而定)
|
||||
- 运行命令: `bun test src/utils/__tests__/encoding.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `encoding.ts` 文件存在且导出正确
|
||||
- `grep -c "export" src/utils/encoding.ts`
|
||||
- 预期: 输出 >= 4(至少导出 FileEncoding, DetectedEncoding, detectEncoding, decodeBuffer, encodeString 共 5 个导出)
|
||||
|
||||
- [x] 验证类型检查通过
|
||||
- `bunx tsc --noEmit src/utils/encoding.ts 2>&1 | head -5`
|
||||
- 预期: 无类型错误输出
|
||||
|
||||
- [x] 运行编码检测单元测试
|
||||
- `bun test src/utils/__tests__/encoding.test.ts`
|
||||
- 预期: 所有测试通过,无失败用例
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/utils/encoding.ts` 是文件编码检测的唯一入口,提供 `detectEncoding`(三层检测:BOM → UTF-8 fatal → GBK 回退)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB,零外部依赖,仅使用 TextDecoder API。`FileEncoding` 类型为 `BufferEncoding | 'gbk'`,覆盖最常见非 UTF-8 CJK 编码。latin1 作为最终兜底编码(单字节编码永远成功)。
|
||||
|
||||
---
|
||||
@@ -1,163 +0,0 @@
|
||||
### Task 2: 同步读取路径集成
|
||||
|
||||
**背景:**
|
||||
当前同步读取路径(`fileRead.ts` → `file.ts` → `fileReadCache.ts`)的编码检测仅通过 BOM 头识别 UTF-8 和 UTF-16LE,非 BOM 编码文件一律按 UTF-8 读取导致乱码。本 Task 将 `detectEncodingForResolvedPath` 的内部实现从 BOM-only 升级为调用 Task 1 创建的 `encoding.ts` 三层检测,并将返回类型从 `BufferEncoding` 扩展为 `FileEncoding`。同时将所有 `fs.readFileSync(path, { encoding })` 调用改为先读 Buffer 再用 `decodeBuffer` 解码,以支持 `gbk` 等非 `BufferEncoding` 编码。本 Task 依赖 Task 1(`src/utils/encoding.ts`),输出被 Task 4(写入路径适配)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/fileRead.ts`
|
||||
- 修改: `src/utils/file.ts`
|
||||
- 修改: `src/utils/fileReadCache.ts`
|
||||
- 新建: `src/utils/__tests__/fileRead.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `fileRead.ts` 中导入 `encoding.ts` 的类型和函数
|
||||
- 位置: `src/utils/fileRead.ts` 文件顶部 import 区域,在 `import { getFsImplementation, safeResolvePath } from './fsOperations.js'` 之后
|
||||
- 添加导入:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer, detectEncoding } from './encoding.js'
|
||||
```
|
||||
- 原因: 后续步骤需要 `FileEncoding` 类型、`detectEncoding` 检测函数和 `decodeBuffer` 解码函数
|
||||
|
||||
- [x] 改造 `detectEncodingForResolvedPath` 函数,使用 `encoding.ts` 的三层检测
|
||||
- 位置: `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 函数
|
||||
- 将函数体替换为以下逻辑:
|
||||
```typescript
|
||||
export function detectEncodingForResolvedPath(
|
||||
resolvedPath: string,
|
||||
): FileEncoding {
|
||||
const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, {
|
||||
length: 4096,
|
||||
})
|
||||
|
||||
// Empty files default to utf8 — nothing to detect
|
||||
if (bytesRead === 0) {
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
return detectEncoding(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
```
|
||||
- 关键变更:
|
||||
- 返回类型从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 删除内联的 BOM 检测逻辑,改为调用 `detectEncoding(buffer.subarray(0, bytesRead))`
|
||||
- 使用 `buffer.subarray(0, bytesRead)` 截取实际读取的字节,避免尾部零字节干扰检测
|
||||
- 原因: 将检测逻辑委托给 `encoding.ts` 的三层算法,消除代码重复
|
||||
|
||||
- [x] 改造 `readFileSyncWithMetadata` 函数,支持非 `BufferEncoding` 解码
|
||||
- 位置: `src/utils/fileRead.ts` 的 `readFileSyncWithMetadata` 函数
|
||||
- 将函数签名和内部逻辑改为:
|
||||
```typescript
|
||||
export function readFileSyncWithMetadata(filePath: string): {
|
||||
content: string
|
||||
encoding: FileEncoding
|
||||
lineEndings: LineEndingType
|
||||
} {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath, isSymlink } = safeResolvePath(fs, filePath)
|
||||
|
||||
if (isSymlink) {
|
||||
logForDebugging(`Reading through symlink: ${filePath} -> ${resolvedPath}`)
|
||||
}
|
||||
|
||||
const encoding = detectEncodingForResolvedPath(resolvedPath)
|
||||
// Read raw Buffer first — fs.readFileSync encoding option only accepts
|
||||
// BufferEncoding, not gbk etc.
|
||||
const rawBuffer = fs.readFileBytesSync(resolvedPath)
|
||||
const raw = decodeBuffer(rawBuffer, encoding)
|
||||
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096))
|
||||
return {
|
||||
content: raw.replaceAll('\r\n', '\n'),
|
||||
encoding,
|
||||
lineEndings,
|
||||
}
|
||||
}
|
||||
```
|
||||
- 关键变更:
|
||||
- 返回类型中 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- `fs.readFileSync(resolvedPath, { encoding })` 改为 `fs.readFileBytesSync(resolvedPath)` 读取 Buffer
|
||||
- 新增 `decodeBuffer(rawBuffer, encoding)` 解码为字符串
|
||||
- 原因: `fs.readFileSync` 的 `encoding` 选项只接受 `BufferEncoding`(utf8/utf16le/latin1 等),传入 `'gbk'` 会在运行时报错
|
||||
|
||||
- [x] 更新 `file.ts` 中 `detectFileEncoding` 的返回类型
|
||||
- 位置: `src/utils/file.ts` 的 `detectFileEncoding` 函数签名
|
||||
- 将 `): BufferEncoding {` 改为 `): FileEncoding {`
|
||||
- 在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'
|
||||
```
|
||||
- 原因: `detectFileEncoding` 调用 `detectEncodingForResolvedPath`,返回类型已改为 `FileEncoding`
|
||||
|
||||
- [x] 更新 `file.ts` 中 `detectLineEndings` 的 encoding 参数类型和解码逻辑
|
||||
- 位置: `src/utils/file.ts` 的 `detectLineEndings` 函数
|
||||
- 将函数签名改为:
|
||||
```typescript
|
||||
export function detectLineEndings(
|
||||
filePath: string,
|
||||
encoding: FileEncoding = 'utf8',
|
||||
): LineEndingType {
|
||||
```
|
||||
- 将内部 `buffer.toString(encoding, 0, bytesRead)` 改为:
|
||||
```typescript
|
||||
const content = decodeBuffer(buffer.subarray(0, bytesRead), encoding)
|
||||
```
|
||||
- 原因: `buffer.toString('gbk')` 不可靠,统一使用 `decodeBuffer` 通过 `TextDecoder` 解码
|
||||
|
||||
- [x] 更新 `fileReadCache.ts` 的类型和解码逻辑
|
||||
- 位置: `src/utils/fileReadCache.ts`
|
||||
- 在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { type FileEncoding, decodeBuffer } from './encoding.js'
|
||||
```
|
||||
- 将 `CachedFileData` 类型中 `encoding: BufferEncoding` 改为 `encoding: FileEncoding`
|
||||
- 将 `readFile` 方法返回类型改为 `{ content: string; encoding: FileEncoding }`
|
||||
- 将缓存未命中读取逻辑改为:
|
||||
```typescript
|
||||
const encoding = detectFileEncoding(filePath)
|
||||
const rawBuffer = fs.readFileBytesSync(filePath)
|
||||
const content = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 原因: 与 `fileRead.ts` 相同——必须改为 Buffer 读取 + `decodeBuffer` 解码
|
||||
|
||||
- [x] 为改造后的 `detectEncodingForResolvedPath` 和 `readFileSyncWithMetadata` 编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/fileRead.test.ts`
|
||||
- 测试场景:
|
||||
- **UTF-8 文件读取**: 创建临时 UTF-8 文件 → 返回 `encoding: 'utf-8'`,content 与写入内容一致
|
||||
- **GBK 文件读取**: 创建临时 GBK 编码文件 → 返回 `encoding: 'gbk'`,content 包含正确的中文字符
|
||||
- **空文件读取**: 创建空文件 → 返回 `encoding: 'utf8'`,content 为空字符串
|
||||
- **UTF-16LE BOM 文件读取**: 创建带 BOM 的 UTF-16LE 文件 → 返回 `encoding: 'utf-16le'`
|
||||
- **detectEncodingForResolvedPath 返回类型**: 验证返回值为 `FileEncoding` 类型
|
||||
- Mock 策略: 使用 `tests/mocks/debug.ts` mock `debug.ts`,使用 `tests/mocks/log.ts` mock `log.ts`
|
||||
- 运行命令: `bun test src/utils/__tests__/fileRead.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `fileRead.ts` 的导入和返回类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer\|detectEncoding" src/utils/fileRead.ts`
|
||||
- 预期: 输出包含 import 行中的 `FileEncoding`、`decodeBuffer`,以及函数体中的 `detectEncoding` 调用
|
||||
|
||||
- [x] 验证 `file.ts` 的类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer" src/utils/file.ts`
|
||||
- 预期: `detectFileEncoding` 返回 `FileEncoding`,`detectLineEndings` 参数类型为 `FileEncoding`
|
||||
|
||||
- [x] 验证 `fileReadCache.ts` 的类型已更新
|
||||
- `grep -n "FileEncoding\|decodeBuffer" src/utils/fileReadCache.ts`
|
||||
- 预期: `CachedFileData` 和 `readFile` 返回类型使用 `FileEncoding`
|
||||
|
||||
- [x] 验证 `fileRead.ts` 中不再有内联 BOM 检测逻辑
|
||||
- `grep -c "0xff\|0xfe\|0xef\|0xbb\|0xbf" src/utils/fileRead.ts`
|
||||
- 预期: 输出为 0
|
||||
|
||||
- [x] 运行 fileRead 单元测试
|
||||
- `bun test src/utils/__tests__/fileRead.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 运行 precheck 确认无类型/lint/测试错误
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`(utf8/utf16le/latin1/ascii/binary/hex/base64/ucs2/utf16le),不支持 `gbk` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer,再用 `TextDecoder` 解码。项目中所有文件读取路径(fileRead.ts、fileReadCache.ts、file.ts)已统一使用 `decodeBuffer` 函数处理此逻辑。
|
||||
|
||||
---
|
||||
@@ -1,161 +0,0 @@
|
||||
### Task 3: 异步读取路径改造
|
||||
|
||||
**背景:**
|
||||
当前 `src/utils/readFileInRange.ts` 是 FileReadTool 的核心异步读取函数,提供 fast path(小文件整体读入)和 streaming path(大文件逐块扫描)两条路径,两者均硬编码 `encoding: 'utf8'`,导致非 UTF-8 编码文件读取乱码。本 Task 将两条路径改造为 Buffer 读取 + 编码检测 + TextDecoder 解码模式。fast path 改造简单(整体读 Buffer 后检测解码),streaming path 需要两阶段设计(先收集前 4KB 做编码检测,再用 `TextDecoder({ stream: true })` 逐 chunk 解码)。本 Task 依赖 Task 1(`src/utils/encoding.ts` 的 `detectEncoding` 和 `decodeBuffer`),输出被 Task 4 依赖(通过 `readFileInRange` 的返回值间接影响)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/readFileInRange.ts`
|
||||
- 新建: `src/utils/__tests__/readFileInRange.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `readFileInRange.ts` 中导入 `encoding.ts` 的函数
|
||||
- 位置: `src/utils/readFileInRange.ts` 文件顶部 import 区域,在 `import { formatFileSize } from './format.js'` 之后
|
||||
- 添加导入:
|
||||
```typescript
|
||||
import { detectEncoding, decodeBuffer } from './encoding.js'
|
||||
```
|
||||
- 原因: fast path 和 streaming path 都需要 `detectEncoding` 做编码检测,fast path 需要 `decodeBuffer` 做一次性解码
|
||||
|
||||
- [x] 改造 fast path — 将 `readFile` 从 UTF-8 字符串读取改为 Buffer 读取 + 检测 + 解码
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRange` 函数内 fast path 分支
|
||||
- 将以下代码:
|
||||
```typescript
|
||||
const text = await readFile(filePath, { encoding: 'utf8', signal })
|
||||
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
|
||||
```
|
||||
替换为:
|
||||
```typescript
|
||||
const rawBuffer = await readFile(filePath, { signal })
|
||||
const encoding = detectEncoding(rawBuffer)
|
||||
const text = decodeBuffer(rawBuffer, encoding)
|
||||
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
|
||||
```
|
||||
- 关键变更: `readFile` 去掉 `encoding: 'utf8'` 选项,返回 `Buffer`;调用 `detectEncoding(rawBuffer)` 检测编码;调用 `decodeBuffer(rawBuffer, encoding)` 解码为字符串。
|
||||
- 原因: `readFile` 的 `encoding` 选项只支持 `BufferEncoding`,不支持 `gbk` 等 ICU 编码名
|
||||
|
||||
- [x] 改造 streaming path — 扩展 `StreamState` 类型,增加编码检测和解码相关字段
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `StreamState` 类型定义
|
||||
- 在现有字段之后添加以下字段:
|
||||
```typescript
|
||||
type StreamState = {
|
||||
// ... 现有字段保持不变 ...
|
||||
/** 编码检测状态:null 表示尚未检测,string 表示已检测完成 */
|
||||
encoding: string | null
|
||||
/** TextDecoder 实例:检测完成后创建,用于逐 chunk 流式解码 */
|
||||
decoder: TextDecoder | null
|
||||
/** 检测阶段缓冲区:收集原始字节直到满 4KB 或 stream 结束 */
|
||||
detectionBuffer: number[]
|
||||
}
|
||||
```
|
||||
- 原因: streaming 模式下 chunk 是增量到达的,需要缓冲阶段收集足够字节来调用 `detectEncoding`
|
||||
|
||||
- [x] 改造 `streamOnData` — 处理 Buffer chunk,实现两阶段(检测阶段 + 解码阶段)
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnData` 函数
|
||||
- 将函数签名从 `streamOnData(this: StreamState, chunk: string): void` 改为 `streamOnData(this: StreamState, chunk: Buffer): void`
|
||||
- 替换函数体为两阶段逻辑:
|
||||
```typescript
|
||||
function streamOnData(this: StreamState, chunk: Buffer): void {
|
||||
this.totalBytesRead += chunk.length
|
||||
|
||||
// ... maxBytes 检查保持不变 ...
|
||||
|
||||
// Phase 1: 编码检测阶段
|
||||
if (this.encoding === null) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
this.detectionBuffer.push(chunk[i])
|
||||
}
|
||||
if (this.detectionBuffer.length >= 4096) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, { stream: true })
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: 解码阶段
|
||||
const decoded = this.decoder!.decode(chunk, { stream: true })
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
```
|
||||
- 原因: 两阶段设计确保编码检测在足够数据上执行(至少 4KB),检测完成后用 `TextDecoder({ stream: true })` 逐 chunk 解码
|
||||
|
||||
- [x] 提取行扫描逻辑为独立的 `processTextChunk` 辅助函数
|
||||
- 位置: `src/utils/readFileInRange.ts`,在 `streamOnData` 函数定义之前
|
||||
- 从原 `streamOnData` 提取行扫描逻辑到独立函数 `processTextChunk(state: StreamState, text: string): void`
|
||||
- 行扫描逻辑与原实现完全一致,仅变量名从 `this.` 改为 `state.`
|
||||
- 原因: 检测阶段和解码阶段复用同一段行扫描逻辑
|
||||
|
||||
- [x] 改造 `streamOnEnd` — 处理检测阶段缓冲区残留和最终 fragment
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnEnd` 函数
|
||||
- 在函数体开头插入检测阶段完成逻辑:
|
||||
```typescript
|
||||
if (this.encoding === null) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, { stream: true })
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
```
|
||||
- 原因: 小文件可能 < 4KB,stream 在检测缓冲区未满时就结束。必须在 `streamOnEnd` 中完成检测和解码
|
||||
|
||||
- [x] 改造 `readFileInRangeStreaming` — 创建 Buffer 模式的 stream,初始化新增字段
|
||||
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRangeStreaming` 函数
|
||||
- 将 `createReadStream` 调用去掉 `encoding: 'utf8'` 选项
|
||||
- 在 `state` 对象初始化中添加新字段: `encoding: null, decoder: null, detectionBuffer: []`
|
||||
- 原因: 去掉 `encoding: 'utf8'` 后,`data` 事件回调接收 `Buffer` 对象
|
||||
|
||||
- [x] 更新文件顶部注释,反映编码检测能力
|
||||
- 位置: `src/utils/readFileInRange.ts` 文件顶部注释
|
||||
- 注释已更新为: `Both paths auto-detect encoding via encoding.ts (BOM → UTF-8 fatal → fallback chain), decode with TextDecoder, and strip BOM and \r (CRLF → LF).`
|
||||
|
||||
- [x] 为改造后的 `readFileInRange` 编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 测试场景:
|
||||
- **Fast path — UTF-8 文件**: 创建临时 UTF-8 文件 → 返回正确的 `content`、`lineCount`、`totalLines`
|
||||
- **Fast path — GBK 文件**: 创建临时 GBK 编码文件 → 返回正确的中文内容(非乱码),`totalBytes` 正确
|
||||
- **Fast path — 带行范围读取 GBK 文件**: 创建包含多行的 GBK 文件 → 返回指定行范围,内容正确
|
||||
- **Streaming path — 大 UTF-8 文件**: 创建超过 10MB 阈值的 UTF-8 文件 → 返回正确内容
|
||||
- **Streaming path — 大 GBK 文件**: 创建超过 10MB 阈值的 GBK 编码文件 → 返回正确的中文内容
|
||||
- **BOM 剥离**: 创建带 UTF-8 BOM 的文件 → `content` 不包含 BOM 字符
|
||||
- **空文件**: 创建空文件 → `content` 为空字符串,`totalLines` 为 1,`totalBytes` 为 0
|
||||
- 运行命令: `bun test src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `readFileInRange.ts` 已导入 `encoding.ts` 的函数
|
||||
- `grep -n "detectEncoding\|decodeBuffer" src/utils/readFileInRange.ts`
|
||||
- 预期: import 行包含 `detectEncoding` 和 `decodeBuffer`,函数体中包含调用
|
||||
|
||||
- [x] 验证 streaming path 不再硬编码 `encoding: 'utf8'`
|
||||
- `grep -n "encoding: 'utf8'\|encoding: \"utf8\"" src/utils/readFileInRange.ts`
|
||||
- 预期: 无匹配结果
|
||||
|
||||
- [x] 验证 `createReadStream` 调用无 encoding 选项
|
||||
- `grep -A3 "createReadStream" src/utils/readFileInRange.ts`
|
||||
- 预期: `createReadStream` 的选项对象中不包含 `encoding` 属性
|
||||
|
||||
- [x] 验证 `StreamState` 类型包含编码检测新字段
|
||||
- `grep -n "encoding:\|decoder:\|detectionBuffer:" src/utils/readFileInRange.ts`
|
||||
- 预期: `StreamState` 类型定义中包含 `encoding`、`decoder`、`detectionBuffer` 字段
|
||||
|
||||
- [x] 验证 `processTextChunk` 函数存在
|
||||
- `grep -n "function processTextChunk" src/utils/readFileInRange.ts`
|
||||
- 预期: 函数定义存在
|
||||
|
||||
- [x] 运行 readFileInRange 单元测试
|
||||
- `bun test src/utils/__tests__/readFileInRange.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 运行 precheck 确认无类型/lint/测试错误
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `readFileInRange.ts` 的 streaming path 使用两阶段编码检测:先收集前 4KB 字节调用 `detectEncoding`,再用 `TextDecoder({ stream: true })` 逐 chunk 流式解码。`TextDecoder` 的 `{ stream: true }` 模式会自动处理多字节字符跨 chunk 边界问题。对于 < 4KB 的小文件,检测在 `streamOnEnd` 中完成。
|
||||
|
||||
---
|
||||
@@ -1,155 +0,0 @@
|
||||
### Task 4: 写入路径和工具层适配
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 当用户通过 FileEditTool 或 FileWriteTool 编辑非 UTF-8 编码文件(如 GBK)时,写入操作需要将内部 UTF-8 字符串编码回原文件编码,否则写入的内容会乱码。当前 `writeTextContent` 只接受 `BufferEncoding` 类型,无法处理 gbk 等编码。
|
||||
[修改原因] — `writeTextContent` 的 `encoding` 参数类型为 `BufferEncoding`,`writeFileSyncAndFlush_DEPRECATED` 内部直接将 encoding 传给 `fs.writeFileSync`(只接受标准 BufferEncoding)。`FileEditTool.validateInput` 中硬编码了 BOM-only 编码检测,无法识别 GBK 文件。
|
||||
[上下游影响] — 本 Task 依赖 Task 1 创建的 `encodeString` 函数和 `FileEncoding` 类型。`FileEditTool` 和 `FileWriteTool` 通过 `writeTextContent` 间接依赖本 Task 的改造。BashTool 和 NotebookEditTool 也调用 `writeTextContent`,签名变更后它们无需额外改动(encoding 参数类型由上游传入,自动兼容)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/utils/file.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/utils/file.ts` 中合并 `encodeString` 到 Task 2 已创建的 `encoding.js` 导入
|
||||
- 位置: 文件导入区域,Task 2 已添加的 `import { type FileEncoding, decodeBuffer } from './encoding.js'` 行
|
||||
- 将该行改为: `import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'`
|
||||
- 原因: 避免对同一模块创建两个 import 语句
|
||||
|
||||
- [x] 将 `writeTextContent` 的 `encoding` 参数类型从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 位置: `src/utils/file.ts:writeTextContent()`
|
||||
- 修改函数签名:
|
||||
```typescript
|
||||
export function writeTextContent(
|
||||
filePath: string,
|
||||
content: string,
|
||||
encoding: FileEncoding,
|
||||
endings: LineEndingType,
|
||||
): void
|
||||
```
|
||||
- 修改函数体,在行尾处理之后、调用 `writeFileSyncAndFlush_DEPRECATED` 之前,增加编码判断逻辑:
|
||||
```typescript
|
||||
const BUFFER_ENCODINGS = new Set<string>([
|
||||
'utf8', 'utf-8', 'utf16le', 'ucs2', 'ucs-2',
|
||||
'ascii', 'latin1', 'binary', 'base64', 'hex',
|
||||
])
|
||||
|
||||
if (BUFFER_ENCODINGS.has(encoding)) {
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding: encoding as BufferEncoding })
|
||||
} else {
|
||||
// 非 BufferEncoding(如 gbk),使用 encodeString 获取 Buffer
|
||||
const { buffer, converted } = encodeString(toWrite, encoding)
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, buffer, { buffer })
|
||||
if (converted) {
|
||||
logForDebugging(
|
||||
`writeTextContent: encoding '${encoding}' unsupported for write, fell back to UTF-8 for ${filePath}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: `fs.writeFileSync` 只接受标准 BufferEncoding,对于 gbk 等编码必须先转为 Buffer 再写入
|
||||
|
||||
- [x] 扩展 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
|
||||
- 位置: `src/utils/file.ts:writeFileSyncAndFlush_DEPRECATED()`
|
||||
- 修改函数签名中 `content` 参数类型和 `options` 类型:
|
||||
```typescript
|
||||
export function writeFileSyncAndFlush_DEPRECATED(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
options: { encoding?: BufferEncoding; mode?: number; buffer?: Buffer } = {},
|
||||
): void
|
||||
```
|
||||
- 修改原子写入路径的 `writeOptions` 构建逻辑:
|
||||
```typescript
|
||||
const isBufferWrite = Buffer.isBuffer(content) || options.buffer !== undefined
|
||||
const writeData = options.buffer ?? content
|
||||
const writeOptions: {
|
||||
encoding?: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
flush: true,
|
||||
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
|
||||
}
|
||||
```
|
||||
- 修改非原子回退路径,使用相同的 `isBufferWrite` / `writeData` / `writeOptions` 模式
|
||||
- 原因: `fs.writeFileSync(path, buffer)` 可以直接写入 Buffer,不需要 encoding 参数
|
||||
|
||||
- [x] 在 `FileEditTool.ts` 中导入 `FileEncoding` 和 `detectEncoding` / `decodeBuffer`
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` 导入区域
|
||||
- 添加: `import { detectEncoding, decodeBuffer, type FileEncoding } from 'src/utils/encoding.js'`
|
||||
- 原因: `validateInput` 编码检测和 `readFileForEdit` 返回类型需要 `FileEncoding` 类型
|
||||
|
||||
- [x] 将 `readFileForEdit` 返回类型中的 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:readFileForEdit()`
|
||||
- 修改返回类型声明:
|
||||
```typescript
|
||||
function readFileForEdit(absoluteFilePath: string): {
|
||||
content: string
|
||||
fileExists: boolean
|
||||
encoding: FileEncoding
|
||||
lineEndings: LineEndingType
|
||||
}
|
||||
```
|
||||
- 原因: `readFileSyncWithMetadata` 返回的 `encoding` 类型已由 Task 2 改为 `FileEncoding`
|
||||
|
||||
- [x] 改造 `FileEditTool.validateInput` 中的编码检测逻辑
|
||||
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:validateInput()`
|
||||
- 将现有的 BOM-only 编码检测:
|
||||
```typescript
|
||||
const encoding: BufferEncoding =
|
||||
fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe
|
||||
? 'utf16le'
|
||||
: 'utf8'
|
||||
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 替换为:
|
||||
```typescript
|
||||
const encoding: FileEncoding = detectEncoding(fileBuffer)
|
||||
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
```
|
||||
- 原因: 使 validateInput 也能正确识别 GBK 文件,避免编辑时因编码检测不一致导致 old_string 匹配失败
|
||||
|
||||
- [x] 为 `writeTextContent` 的多编码写入能力编写单元测试
|
||||
- 测试文件: `src/utils/__tests__/file.test.ts`
|
||||
- 在现有测试 describe 块之后追加新的 describe('writeTextContent with multi-encoding') 块
|
||||
- 测试场景:
|
||||
- UTF-8 写入: 写入 UTF-8 内容 → 文件内容正确,无回退警告
|
||||
- UTF-16LE 写入: 写入 UTF-16LE 内容(含 BOM) → 文件二进制内容与预期一致
|
||||
- GBK 写入回退: 对 gbk 编码调用 `writeTextContent` → 文件以 UTF-8 写入(`encodeString` 回退行为),内容不损坏
|
||||
- CRLF 行尾 + GBK: `endings: 'CRLF'` + gbk 编码 → 行尾正确转换为 `\r\n`,编码回退为 UTF-8
|
||||
- 注意: 需要 mock `src/utils/debug.ts`(使用共享 mock `tests/mocks/debug.ts`)
|
||||
- 运行命令: `bun test src/utils/__tests__/file.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 `writeTextContent` 签名使用 `FileEncoding` 类型
|
||||
- `grep -n 'encoding: FileEncoding' src/utils/file.ts`
|
||||
- 预期: 输出包含 `writeTextContent` 函数定义行
|
||||
|
||||
- [x] 验证 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
|
||||
- `grep -n 'content: string | Buffer' src/utils/file.ts`
|
||||
- 预期: 输出包含 `writeFileSyncAndFlush_DEPRECATED` 函数定义行
|
||||
|
||||
- [x] 验证 `FileEditTool.readFileForEdit` 返回类型已更新
|
||||
- `grep -n 'encoding: FileEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
- 预期: 输出包含 `readFileForEdit` 函数的返回类型声明
|
||||
|
||||
- [x] 验证 `FileEditTool.validateInput` 使用 `detectEncoding`
|
||||
- `grep -n 'detectEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
|
||||
- 预期: 输出包含 validateInput 内部的调用
|
||||
|
||||
- [x] 运行 file.ts 单元测试
|
||||
- `bun test src/utils/__tests__/file.test.ts`
|
||||
- 预期: 所有测试通过,无新增失败
|
||||
|
||||
- [x] 运行 FileEditTool 工具函数测试
|
||||
- `bun test packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts`
|
||||
- 预期: 所有现有测试通过
|
||||
|
||||
- [x] 运行完整 precheck
|
||||
- `bun run precheck`
|
||||
- 预期: typecheck + lint + test 零错误通过
|
||||
|
||||
---
|
||||
@@ -1,49 +0,0 @@
|
||||
# 多编码文件工具 执行计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**目标:** 为文件读写工具添加自动编码检测,支持 GBK 编码的透明读写(latin1 作为最终兜底)。
|
||||
|
||||
**技术栈:** TextDecoder/TextEncoder(零外部依赖)、Bun test 框架、TypeScript strict mode
|
||||
|
||||
**设计文档:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
|
||||
|
||||
**范围变更:** 仅保留 GBK 编码支持,Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
|
||||
|
||||
## 改动总览
|
||||
|
||||
新建编码检测核心模块 `src/utils/encoding.ts`,提供三层检测(BOM → UTF-8 fatal 验证 → GBK 回退 → latin1 兜底)和解码工具函数。同步读取路径(fileRead.ts → file.ts → fileReadCache.ts)集成新检测逻辑,异步读取路径(readFileInRange.ts)改造为 Buffer 读取 + 检测后解码。写入路径(writeTextContent)扩展类型支持新编码名,非标准编码回退为 UTF-8 写入。FileEditTool 和 FileWriteTool 仅需类型适配。
|
||||
|
||||
---
|
||||
|
||||
## 任务索引
|
||||
|
||||
### Task 0: 环境准备
|
||||
📄 详情见: `spec-plan-task-0.md`
|
||||
|
||||
验证构建工具链和测试环境是否就绪,确认 Bun 运行时对 GBK 编码的 TextDecoder 支持。
|
||||
|
||||
### Task 1: 编码检测核心模块
|
||||
📄 详情见: `spec-plan-task-1.md`
|
||||
|
||||
新建 `src/utils/encoding.ts`,实现三层编码检测算法(BOM → UTF-8 fatal 验证 → GBK 回退)和 Buffer 解码/编码函数。
|
||||
|
||||
### Task 2: 同步读取路径集成
|
||||
📄 详情见: `spec-plan-task-2.md`
|
||||
|
||||
改造 `fileRead.ts` 和 `file.ts` 的编码检测,集成新模块,更新类型定义。
|
||||
|
||||
### Task 3: 异步读取路径改造
|
||||
📄 详情见: `spec-plan-task-3.md`
|
||||
|
||||
改造 `readFileInRange.ts` 的 fast path 和 streaming path,支持非 UTF-8 编码。
|
||||
|
||||
### Task 4: 写入路径和工具层适配
|
||||
📄 详情见: `spec-plan-task-4.md`
|
||||
|
||||
扩展写入路径类型,更新 FileEditTool/FileWriteTool 的类型注解。
|
||||
|
||||
### Acceptance Task
|
||||
📄 详情见: `spec-plan-acceptance.md`
|
||||
|
||||
端到端验证所有功能是否正确实现。
|
||||
@@ -377,9 +377,6 @@ const cronJitterConfigModule =
|
||||
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
|
||||
const cronGate =
|
||||
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
|
||||
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
const SHUTDOWN_TEAM_PROMPT = `<system-reminder>
|
||||
@@ -985,7 +982,14 @@ export async function runHeadless(
|
||||
// the forked agent mid-flight. Gated by isExtractModeActive so the
|
||||
// tengu_slate_thimble flag controls non-interactive extraction end-to-end.
|
||||
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
|
||||
await extractMemoriesModule!.drainPendingExtraction()
|
||||
try {
|
||||
const { drainPendingExtraction } = await import(
|
||||
'../services/extractMemories/extractMemories.js'
|
||||
)
|
||||
await drainPendingExtraction()
|
||||
} catch {
|
||||
// Module load failure — non-critical at shutdown
|
||||
}
|
||||
}
|
||||
|
||||
gracefulShutdownSync(
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Transport = any
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
|
||||
/** WebSocket / SSE+POST / Hybrid 等会话上行传输的共有接口。 */
|
||||
export type Transport = {
|
||||
setOnData(callback: (data: string) => void): void // 注册下行数据回调(按行文本)
|
||||
setOnClose(callback: (closeCode?: number) => void): void // 连接关闭时回调(可选关闭码)
|
||||
connect(): void | Promise<void> // 建立或重连传输
|
||||
write(message: StdoutMessage): void | Promise<void> // 向上游发送一条控制/流式消息
|
||||
close(): void // 主动关闭并释放资源
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Tests for AgentsPlatformView.tsx
|
||||
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
|
||||
// Mock cron utility before importing AgentsPlatformView
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
cronToHuman: (expr: string) => `HumanCron(${expr})`,
|
||||
parseCronExpression: () => null,
|
||||
computeNextCronRun: () => null,
|
||||
}));
|
||||
|
||||
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
|
||||
|
||||
const sampleAgent = {
|
||||
id: 'agt_abc123',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup report',
|
||||
status: 'active' as const,
|
||||
timezone: 'UTC',
|
||||
next_run: '2026-05-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('AgentsPlatformView list mode', () => {
|
||||
test('empty list shows placeholder message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
|
||||
expect(out).toContain('No scheduled agents');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent count', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Scheduled Agents (1)');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent status', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('active');
|
||||
});
|
||||
|
||||
test('non-empty list shows human-readable schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('list shows agent prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
|
||||
test('list shows next run date', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
// next_run is formatted via toLocaleString — just check it's rendered
|
||||
expect(out).toContain('Next run');
|
||||
});
|
||||
|
||||
test('list with null next_run shows em dash', async () => {
|
||||
const agentNoNextRun = { ...sampleAgent, next_run: null };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
|
||||
expect(out).toContain('—');
|
||||
});
|
||||
|
||||
test('multiple agents rendered', async () => {
|
||||
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
|
||||
expect(out).toContain('Scheduled Agents (2)');
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('agt_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView created mode', () => {
|
||||
test('shows Agent created', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Agent created');
|
||||
});
|
||||
|
||||
test('shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('shows schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('shows prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView deleted mode', () => {
|
||||
test('shows deleted confirmation with id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView ran mode', () => {
|
||||
test('shows triggered with agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('triggered');
|
||||
});
|
||||
|
||||
test('shows run id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('run_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView error mode', () => {
|
||||
test('shows error message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
|
||||
expect(out).toContain('Network failure');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchAgentsPlatform.tsx
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock agentsApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real agentsApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
|
||||
}))
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── agentsApi mock ──────────────────────────────────────────────────────────
|
||||
const listMock = mock(async () => [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
])
|
||||
const createMock = mock(async (cron: string, prompt: string) => ({
|
||||
id: 'agt_new',
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
|
||||
}))
|
||||
const deleteMock = mock(async () => undefined)
|
||||
const runMock = mock(async () => ({ run_id: 'run_123' }))
|
||||
|
||||
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
|
||||
listAgents: listMock,
|
||||
createAgent: createMock,
|
||||
deleteAgent: deleteMock,
|
||||
runAgent: runMock,
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ap',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key-ap',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-api-key-ap',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── cron mock ───────────────────────────────────────────────────────────────
|
||||
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
|
||||
computeNextCronRun: () => null,
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchAgentsPlatform.js')
|
||||
callAgentsPlatform = mod.callAgentsPlatform
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listMock.mockClear()
|
||||
createMock.mockClear()
|
||||
deleteMock.mockClear()
|
||||
runMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
})
|
||||
|
||||
function makeContext() {
|
||||
@@ -79,8 +118,23 @@ function makeContext() {
|
||||
describe('callAgentsPlatform', () => {
|
||||
test('list (empty args) calls listAgents and returns element', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('list sub-command calls listAgents', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('create with valid cron calls createAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
status: 201,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 Run standup',
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
|
||||
expect(cron).toBe('0 9 * * 1')
|
||||
expect(prompt).toBe('Run standup')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
const url = callArgs[0]
|
||||
const body = callArgs[1] as Record<string, unknown>
|
||||
expect(url).toContain('/v1/agents')
|
||||
expect(body.cron_expr).toBe('0 9 * * 1')
|
||||
expect(body.prompt).toBe('Run standup')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_create',
|
||||
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
|
||||
'create INVALID INVALID * * * my prompt',
|
||||
)
|
||||
// cron = 'INVALID INVALID * * *', mock returns null → no API call
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('delete with id calls deleteAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosDeleteMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_abc')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_delete',
|
||||
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('run with id calls runAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_123' },
|
||||
status: 200,
|
||||
})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(runMock).toHaveBeenCalledWith('agt_xyz')
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
]
|
||||
expect(callArgs[0]).toContain('agt_xyz')
|
||||
expect(callArgs[0]).toContain('/run')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_run',
|
||||
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(axiosGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('listAgents API error → error view returned', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('network error'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(result).not.toBeNull()
|
||||
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
|
||||
|
||||
test('started event fires on every call', async () => {
|
||||
const onDone = mock(() => {})
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: [] },
|
||||
status: 200,
|
||||
})
|
||||
await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_started',
|
||||
@@ -190,10 +287,10 @@ describe('callAgentsPlatform', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
|
||||
// ── Error-path branches ──────────────────────────────────────────────────
|
||||
|
||||
test('createAgent API error → error view returned', async () => {
|
||||
createMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('deleteAgent API error → error view returned', async () => {
|
||||
deleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
|
||||
})
|
||||
|
||||
test('runAgent API error → error view returned', async () => {
|
||||
runMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
|
||||
const onDone = mock(() => {})
|
||||
// Only 4 cron fields — parseArgs returns invalid
|
||||
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import { AutofixProgress } from '../AutofixProgress.js';
|
||||
|
||||
describe('AutofixProgress', () => {
|
||||
describe.skipIf(!!process.env.CI)('AutofixProgress', () => {
|
||||
test('renders target in header', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
|
||||
expect(out).toContain('acme/myrepo#42');
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for issue/index.ts gh-CLI paths.
|
||||
*
|
||||
* issue/index.ts uses `import * as childProcess from 'node:child_process'`
|
||||
* with lazy promisify, so mock.module('node:child_process') is effective.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── Mock control state ──
|
||||
let _execFileSyncImpl: (cmd: string, args: string[], opts?: unknown) => Buffer =
|
||||
() => Buffer.from('')
|
||||
|
||||
let _execFileImpl: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImpl(cmd, args, opts)
|
||||
|
||||
const execFileMockCore = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImpl(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockCore as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueGhCpStubs = false
|
||||
const wrappedIssueGhExecFile = ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueGhExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueGhCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImpl(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueGhExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueGhCpStubs
|
||||
? (execFileSyncMockCore as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-gh-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// Default: git remote fails (no GitHub remote), gh not available
|
||||
_execFileSyncImpl = (_cmd, _args, _opts) => {
|
||||
throw new Error('ENOENT: command not found')
|
||||
}
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) =>
|
||||
cb(new Error('ENOENT: command not found'), '', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
async function writeSessionLog(entries?: string[]): Promise<void> {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const content = entries ?? [
|
||||
JSON.stringify({ role: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will investigate' }],
|
||||
}),
|
||||
]
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n')
|
||||
}
|
||||
|
||||
// Create a .github/ISSUE_TEMPLATE dir in tmpDir
|
||||
function createIssueTemplate(
|
||||
content = '## Bug Report\n\nDescribe the bug.',
|
||||
): string {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug_report.md'), content)
|
||||
return templateDir
|
||||
}
|
||||
|
||||
// ── Sequence helpers ──
|
||||
type SeqBehavior =
|
||||
| { type: 'sync-ok'; stdout: string }
|
||||
| { type: 'sync-fail'; msg: string }
|
||||
| { type: 'async-ok'; stdout: string }
|
||||
| { type: 'async-fail'; msg: string }
|
||||
|
||||
/**
|
||||
* Sets sync/async behavior based on command name.
|
||||
* syncBehavior controls execFileSync (git, gh --version sync-check).
|
||||
* asyncBehaviors controls sequential async calls.
|
||||
*/
|
||||
function setupMocks(opts: {
|
||||
gitRemoteUrl?: string | null // null = git fails, string = succeeds with that URL
|
||||
ghCliAvailable?: boolean // whether gh --version sync call succeeds
|
||||
asyncSequence?: Array<
|
||||
{ ok: true; stdout: string } | { ok: false; msg: string }
|
||||
>
|
||||
}): void {
|
||||
const { gitRemoteUrl, ghCliAvailable = false, asyncSequence = [] } = opts
|
||||
|
||||
_execFileSyncImpl = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') {
|
||||
if (gitRemoteUrl !== null && gitRemoteUrl !== undefined) {
|
||||
return Buffer.from(gitRemoteUrl + '\n')
|
||||
}
|
||||
throw new Error('ENOENT: git not found or no remote')
|
||||
}
|
||||
if (cmd === 'gh') {
|
||||
if (ghCliAvailable) {
|
||||
return Buffer.from('gh version 2.0.0')
|
||||
}
|
||||
throw new Error('ENOENT: gh not found')
|
||||
}
|
||||
throw new Error(`Unexpected sync command: ${cmd}`)
|
||||
}
|
||||
|
||||
let asyncCallCount = 0
|
||||
_execFileImpl = (_cmd, _args, _opts, cb) => {
|
||||
const b = asyncSequence[asyncCallCount] ?? {
|
||||
ok: false,
|
||||
msg: 'unexpected async call',
|
||||
}
|
||||
asyncCallCount++
|
||||
if (b.ok) cb(null, b.stdout, '')
|
||||
else cb(new Error(b.msg), '', b.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Activate child_process stubs only for this suite.
|
||||
beforeAll(() => {
|
||||
useIssueGhCpStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useIssueGhCpStubs = false
|
||||
})
|
||||
|
||||
describe('issue command — tryDetectGitRemoteUrl catch path', () => {
|
||||
test('git fails → tryDetectGitRemoteUrl returns null → no remote detected', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// No remote + no gh → fallback URL path
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — ghCliAvailable paths', () => {
|
||||
test('gh not available → falls back to browser URL (with GitHub remote)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: false,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('github.com/owner/repo')
|
||||
expect(result.value).toContain('Install')
|
||||
})
|
||||
|
||||
test('gh not available + no remote → shows no GitHub remote message', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
|
||||
test('gh available + no remote → falls back to browser (no URL)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: null,
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('GitHub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — parseOwnerRepo null path', () => {
|
||||
test('non-GitHub remote → parseOwnerRepo returns null → no gh URL', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://gitlab.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — repoHasIssuesEnabled paths', () => {
|
||||
test('gh available + GitHub remote → issues enabled (true) → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // gh api repos → has_issues = true
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/42' }, // gh issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Fix login bug')
|
||||
expect(result.value).toContain('https://github.com/owner/repo/issues/42')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → issues disabled (false) → discussions fallback', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'false\n' }, // gh api repos → has_issues = false
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issues are disabled')
|
||||
expect(result.value).toContain('discussions')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled returns null (unexpected output)', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'null\n' }, // unexpected .has_issues value → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/99' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
// null → proceeds to create issue
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote → repoHasIssuesEnabled throws → returns null → creates issue', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: false, msg: 'network error' }, // gh api fails → catch → null
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/101' }, // issue create
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + issue create fails → error message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' }, // has_issues = true
|
||||
{ ok: false, msg: 'gh auth error' }, // issue create fails
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Failed to create issue')
|
||||
expect(result.value).toContain('gh auth error')
|
||||
})
|
||||
|
||||
test('gh available + GitHub remote + labels and assignees → issue created with labels', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/50' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug --assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
expect(result.value).toContain('Labels: bug')
|
||||
expect(result.value).toContain('Assignees: alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — detectIssueTemplate paths', () => {
|
||||
test('no .github/ISSUE_TEMPLATE → no template used', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/1' },
|
||||
],
|
||||
})
|
||||
process.env.INIT_CWD = tmpDir
|
||||
// Ensure no ISSUE_TEMPLATE exists
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test no template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with md file → template included in body', async () => {
|
||||
createIssueTemplate('---\nname: Bug Report\n---\n## Describe the bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/2' },
|
||||
],
|
||||
})
|
||||
// Override getOriginalCwd to return tmpDir by setting env
|
||||
// detectIssueTemplate uses `cwd = getOriginalCwd()` from state
|
||||
// which returns the real process cwd. We create template relative to real cwd
|
||||
// This test just verifies the path doesn't crash.
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test with template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
test('.github/ISSUE_TEMPLATE with only yml files → no md template', async () => {
|
||||
const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
writeFileSync(join(templateDir, 'bug.yml'), 'name: Bug\ndescription: A bug')
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/3' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test yml template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — getTranscriptSummary paths', () => {
|
||||
test('session log exists + projectDir=null → reads from standard path', async () => {
|
||||
await writeSessionLog()
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/4' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with tool_result errors → errors included in summary', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu1',
|
||||
is_error: true,
|
||||
content: 'Command failed with exit code 1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify({ role: 'user', content: 'help me' }),
|
||||
JSON.stringify({ role: 'assistant', content: 'let me look' }),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/5' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix crash')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('session log with array content user message', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What is the issue?' }],
|
||||
}),
|
||||
])
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/6' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test array content')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('no session log → getTranscriptSummary returns no session log found', async () => {
|
||||
// No log written → summary says "(no session log found)"
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/repo/issues/7' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix issue no log')
|
||||
expect(result.type).toBe('text')
|
||||
// Either creates issue successfully or fails, but passes the code paths
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — SSH GitHub remote', () => {
|
||||
test('SSH remote parsed correctly → issue created', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'git@github.com:owner/myrepo.git',
|
||||
ghCliAvailable: true,
|
||||
asyncSequence: [
|
||||
{ ok: true, stdout: 'true\n' },
|
||||
{ ok: true, stdout: 'https://github.com/owner/myrepo/issues/8' },
|
||||
],
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix SSH issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — no title with remote present', () => {
|
||||
test('no title + GitHub remote + gh available → usage with repo info and gh message', async () => {
|
||||
setupMocks({
|
||||
gitRemoteUrl: 'https://github.com/owner/repo.git',
|
||||
ghCliAvailable: true,
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
expect(result.value).toContain('owner/repo')
|
||||
})
|
||||
|
||||
test('no title + no remote + gh not available → usage with no repo info', async () => {
|
||||
setupMocks({ gitRemoteUrl: null, ghCliAvailable: false })
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Coverage tests for detectIssueTemplate paths.
|
||||
*
|
||||
* detectIssueTemplate uses getOriginalCwd() to find .github/ISSUE_TEMPLATE.
|
||||
* These tests create the template directory in the REAL project CWD and clean
|
||||
* up after each test.
|
||||
*
|
||||
* IMPORTANT: No state mock is used — this avoids global mock contamination.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// ── child_process mock ──
|
||||
let _execFileSyncImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
) => Buffer = () => Buffer.from('')
|
||||
let _execFileImplT: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => void = (_cmd, _args, _opts, cb) => cb(null, '', '')
|
||||
|
||||
const execFileSyncMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts?: unknown,
|
||||
): Buffer => _execFileSyncImplT(cmd, args, opts)
|
||||
const execFileMockT = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
cb: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => _execFileImplT(cmd, args, opts, cb)
|
||||
|
||||
;(execFileMockT as unknown as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> =>
|
||||
new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
else resolve({ stdout, stderr })
|
||||
}),
|
||||
)
|
||||
|
||||
// Spread real child_process + flag-gated stub (see share-gh.test.ts for the
|
||||
// promisify.custom rationale).
|
||||
let useIssueTemplateCpStubs = false
|
||||
const wrappedIssueTemplateExecFile = ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: // eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
(require('node:child_process').execFile as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as unknown as Record<symbol, unknown> & ((...a: unknown[]) => unknown)
|
||||
;(wrappedIssueTemplateExecFile as Record<symbol, unknown>)[
|
||||
promisify.custom as symbol
|
||||
] = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: unknown,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (useIssueTemplateCpStubs) {
|
||||
return new Promise((resolve, reject) =>
|
||||
_execFileImplT(cmd, args, opts, (err, stdout, stderr) =>
|
||||
err ? reject(err) : resolve({ stdout, stderr }),
|
||||
),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return promisify(real.execFile as never)(cmd, args, opts) as Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: wrappedIssueTemplateExecFile as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) =>
|
||||
useIssueTemplateCpStubs
|
||||
? (execFileSyncMockT as (...a: unknown[]) => unknown)(...args)
|
||||
: (real.execFileSync as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
// Re-mock bootstrap/state.js so getOriginalCwd points at the real process
|
||||
// cwd regardless of any prior test file's static state mock (e.g.
|
||||
// launchAutofixPr.test.ts pinning '/mock/cwd'). Without this override, in
|
||||
// the full suite detectIssueTemplate would see '/mock/cwd' and skip the
|
||||
// template loading body (lines 114-129).
|
||||
import { stateMock as _baseStateMockT } from '../../../../tests/mocks/state'
|
||||
let _dynamicCwdT: string = process.cwd()
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
..._baseStateMockT(),
|
||||
getSessionId: () => 'issue-tpl-session-id',
|
||||
getSessionProjectDir: () => null,
|
||||
getOriginalCwd: () => _dynamicCwdT,
|
||||
setOriginalCwd: (c: string) => {
|
||||
_dynamicCwdT = c
|
||||
},
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
// The real CWD where the issue command will look for .github/ISSUE_TEMPLATE
|
||||
// We determine this at import time (stable throughout test run)
|
||||
const realCwd = process.cwd()
|
||||
// We track whether we created the template dir so we can clean it up
|
||||
let createdTemplatePath: string | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-tpl-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
createdTemplatePath = null
|
||||
|
||||
// Default: git → GitHub remote, gh → available, async → issues true + create OK
|
||||
let n = 0
|
||||
_execFileSyncImplT = (cmd, _args, _opts) => {
|
||||
if (cmd === 'git') return Buffer.from('https://github.com/owner/repo.git\n')
|
||||
if (cmd === 'gh') return Buffer.from('gh version 2.0.0')
|
||||
return Buffer.from('')
|
||||
}
|
||||
_execFileImplT = (_cmd, _args, _opts, cb) => {
|
||||
n++
|
||||
if (n === 1) cb(null, 'true\n', '')
|
||||
else cb(null, 'https://github.com/owner/repo/issues/20', '')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Clean up any template dir we created in the real CWD
|
||||
if (createdTemplatePath && existsSync(createdTemplatePath)) {
|
||||
rmSync(createdTemplatePath, { recursive: true, force: true })
|
||||
}
|
||||
createdTemplatePath = null
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (args: string) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates .github/ISSUE_TEMPLATE in the REAL CWD.
|
||||
* Registers for cleanup in afterEach.
|
||||
*/
|
||||
function createTemplateInCwd(files: Record<string, string>): string {
|
||||
const templateDir = join(realCwd, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(templateDir, { recursive: true })
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(templateDir, name), content)
|
||||
}
|
||||
// Track the .github dir for cleanup (remove whole .github if it didn't exist)
|
||||
const githubDir = join(realCwd, '.github')
|
||||
createdTemplatePath = githubDir
|
||||
return templateDir
|
||||
}
|
||||
|
||||
// Activate child_process stubs only for this suite.
|
||||
beforeAll(() => {
|
||||
useIssueTemplateCpStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useIssueTemplateCpStubs = false
|
||||
})
|
||||
|
||||
describe('issue command — detectIssueTemplate template paths', () => {
|
||||
test('md template with front-matter → front-matter stripped', async () => {
|
||||
createTemplateInCwd({
|
||||
'bug.md':
|
||||
'---\nname: Bug Report\nabout: A bug\n---\n## Describe the bug\n\nDetails.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix bug with template')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('md template without front-matter → content returned as-is', async () => {
|
||||
createTemplateInCwd({
|
||||
'feature.md': '## Feature Request\n\nDescribe the feature.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Add feature')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('yml file only → mdFile not found → no template (null)', async () => {
|
||||
createTemplateInCwd({
|
||||
'bug.yml': 'name: Bug\ndescription: Describe the bug.',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix yml-only template issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
|
||||
test('md template stripped to empty → null (stripped || null)', async () => {
|
||||
// Front-matter only, empty body after stripping
|
||||
createTemplateInCwd({
|
||||
'empty.md': '---\nname: Empty\nabout: empty\n---',
|
||||
})
|
||||
const call = await getCallFn()
|
||||
const result = await call('Empty template test')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Issue created')
|
||||
})
|
||||
})
|
||||
@@ -1,611 +0,0 @@
|
||||
/**
|
||||
* Tests for issue/index.ts
|
||||
*
|
||||
* NOTE: issue/index.ts calls execFileSync at module-function level (not top-level).
|
||||
* The child_process functions are imported by reference and cannot be reliably
|
||||
* mocked after module load with Bun's mock.module. Tests here cover what's
|
||||
* testable without child_process control: parseIssueArgs, metadata, and
|
||||
* environment-agnostic paths.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
logEventAsync: () => Promise.resolve(),
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
_resetForTesting: () => {},
|
||||
attachAnalyticsSink: () => {},
|
||||
}))
|
||||
|
||||
// Re-mock bootstrap/state.js with a dynamic getOriginalCwd / setOriginalCwd
|
||||
// pair so this suite can drive cwd values regardless of any earlier test
|
||||
// file's static mock (e.g. launchAutofixPr.test.ts which sets a fixed
|
||||
// '/mock/cwd'). We start from the shared stateMock helper, then override
|
||||
// the four exports issue/index.ts cares about with closure-driven impls.
|
||||
//
|
||||
// Bun's mock.module is global / last-write-wins. After this suite finishes
|
||||
// we set `useIssueDynamicState=false` so launchAutofixPr's tests (which run
|
||||
// in the same process) see the values their suite originally expected.
|
||||
import { stateMock } from '../../../../tests/mocks/state'
|
||||
let _dynamicCwd = process.cwd()
|
||||
let _dynamicSessionId = `issue-test-${randomUUID()}`
|
||||
// Default OFF — autofix-pr/__tests__/launchAutofixPr.test.ts runs FIRST in
|
||||
// the combined suite (alphabetical: 'autofix-pr' < 'issue') and expects
|
||||
// '/mock/cwd'. Issue's beforeAll switches this on, afterAll switches off.
|
||||
let useIssueDynamicState = false
|
||||
// Default OFF — the long-body draft-save test below flips this on for its
|
||||
// body (so execFile/execFileSync return ENOENT + a fake GitHub remote URL)
|
||||
// then flips off in finally. Without the flag the child_process stub leaked
|
||||
// process-globally into every later test file via Bun's mock.module cache.
|
||||
let useIssueLongBodyCpStubs = false
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
...stateMock(),
|
||||
getSessionId: () =>
|
||||
useIssueDynamicState ? _dynamicSessionId : 'parent-session-id',
|
||||
getParentSessionId: () => undefined,
|
||||
getCwdState: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'),
|
||||
getSessionProjectDir: () => null,
|
||||
getOriginalCwd: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'),
|
||||
getProjectRoot: () => (useIssueDynamicState ? _dynamicCwd : '/mock/project'),
|
||||
setCwdState: (c: string) => {
|
||||
if (useIssueDynamicState) _dynamicCwd = c
|
||||
},
|
||||
setOriginalCwd: (c: string) => {
|
||||
if (useIssueDynamicState) _dynamicCwd = c
|
||||
},
|
||||
setLastAPIRequestMessages: () => {},
|
||||
getIsNonInteractiveSession: () => false,
|
||||
addSlowOperation: () => {},
|
||||
}))
|
||||
|
||||
// ── State ──
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
// Snapshot HOME so per-test mutations (lines below set process.env.HOME =
|
||||
// tmpDir for child-process branches) can be restored. Otherwise the leaked
|
||||
// /tmp/issue-test-XXX HOME pollutes downstream tests like
|
||||
// src/services/langfuse/__tests__/langfuse.test.ts whose sanitize logic
|
||||
// substitutes the current process.env.HOME.
|
||||
const _originalHomeForIssueSuite = process.env.HOME
|
||||
|
||||
// Mock envUtils to read CLAUDE_CONFIG_DIR from process.env dynamically so
|
||||
// other test files (cacheStats, SessionMemory/prompts) that mock with static
|
||||
// paths don't pollute this test in the full suite. Reading process.env at
|
||||
// call time lets each test drive its own dir.
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
getClaudeConfigHomeDir: () =>
|
||||
process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`,
|
||||
isEnvTruthy: (v: unknown) => Boolean(v),
|
||||
getTeamsDir: () =>
|
||||
join(process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, 'teams'),
|
||||
hasNodeOption: () => false,
|
||||
isEnvDefinedFalsy: () => false,
|
||||
isBareMode: () => false,
|
||||
parseEnvVars: (s: string) => s,
|
||||
getAWSRegion: () => 'us-east-1',
|
||||
getDefaultVertexRegion: () => 'us-central1',
|
||||
shouldMaintainProjectWorkingDir: () => false,
|
||||
}))
|
||||
|
||||
// Activate dynamic state mode for this suite only.
|
||||
beforeAll(() => {
|
||||
useIssueDynamicState = true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'issue-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// Reset dynamic cwd to a per-test deterministic default (the tmpDir).
|
||||
// Tests that need a different cwd call the mocked setOriginalCwd.
|
||||
_dynamicCwd = tmpDir
|
||||
_dynamicSessionId = `issue-test-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Restore HOME — individual tests may have set it to tmpDir.
|
||||
if (_originalHomeForIssueSuite === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = _originalHomeForIssueSuite
|
||||
}
|
||||
})
|
||||
|
||||
// After this suite finishes, switch off our dynamic mode so any subsequent
|
||||
// test file (e.g. launchAutofixPr.test.ts) that imports bootstrap/state.js
|
||||
// gets the static values its suite expects. Bun's mock.module is global and
|
||||
// our mock won the registration race; this flag flips behavior post-suite.
|
||||
afterAll(() => {
|
||||
useIssueDynamicState = false
|
||||
})
|
||||
|
||||
// ── Helpers ──
|
||||
type CallFn = (
|
||||
args: string,
|
||||
ctx?: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
|
||||
async function getCallFn(): Promise<CallFn> {
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as { load: () => Promise<{ call: CallFn }> }
|
||||
).load()
|
||||
return loaded.call.bind(loaded) as CallFn
|
||||
}
|
||||
|
||||
async function writeSessionLog(entries?: string[]): Promise<void> {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const content = entries ?? [
|
||||
JSON.stringify({ role: 'user', content: 'Fix the login bug' }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will investigate' }],
|
||||
}),
|
||||
]
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n')
|
||||
}
|
||||
|
||||
describe('issue command — metadata', () => {
|
||||
test('command has correct name and type', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('issue')
|
||||
expect(cmd.type).toBe('local')
|
||||
expect(
|
||||
(cmd as unknown as { supportsNonInteractive: boolean })
|
||||
.supportsNonInteractive,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('isEnabled returns true', async () => {
|
||||
const mod = await import('../index.js')
|
||||
expect(mod.default.isEnabled?.()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — parseIssueArgs', () => {
|
||||
test('--label without value → parse error message', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('--label with empty next flag → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label --public')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('--assignee without value → parse error message', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--assignee')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--assignee requires a value')
|
||||
})
|
||||
|
||||
test('-l without value → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('-l')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--label requires a value')
|
||||
})
|
||||
|
||||
test('-a without value → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('-a')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('--assignee requires a value')
|
||||
})
|
||||
|
||||
test('unknown flag → parse error', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--unknown Fix bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Unknown flag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — no title', () => {
|
||||
test('empty args → usage hint', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
|
||||
test('whitespace-only args → usage hint', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call(' ')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command — with title', () => {
|
||||
test('title only → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with --label → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with --assignee → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with both --label and --assignee → returns some text result', async () => {
|
||||
const call = await getCallFn()
|
||||
const result = await call('--label bug --assignee alice Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('title with log file present → exercises transcript summary paths', async () => {
|
||||
await writeSessionLog()
|
||||
const call = await getCallFn()
|
||||
const result = await call('Fix login bug')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('transcript with array content → covers array branch in getTranscriptSummary', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'What is the issue?' }],
|
||||
}),
|
||||
// tool_result with is_error → covers error collection
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tu1',
|
||||
is_error: true,
|
||||
content: 'Command failed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
// malformed line
|
||||
'NOT_JSON{{{',
|
||||
])
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test issue')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
test('transcript with only system entries → no conversation content', async () => {
|
||||
await writeSessionLog([
|
||||
JSON.stringify({ role: 'system', content: 'system prompt' }),
|
||||
])
|
||||
const call = await getCallFn()
|
||||
const result = await call('Test issue empty summary')
|
||||
expect(result.type).toBe('text')
|
||||
expect(typeof result.value).toBe('string')
|
||||
})
|
||||
|
||||
// ── H5 regression: browser fallback URL body must be ≤ 4096 chars before encode ──
|
||||
test('H5: URL-encoded body is capped at 4096 chars when session summary is very long', async () => {
|
||||
// Write a log with a very long user message to ensure summary exceeds 4096 chars
|
||||
const longText = 'A'.repeat(6000)
|
||||
await writeSessionLog([
|
||||
JSON.stringify({ role: 'user', content: longText }),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
])
|
||||
const call = await getCallFn()
|
||||
// No gh, no remote → falls into browser fallback path
|
||||
const result = await call('Some Long Issue Title')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Extract the URL from the output (if present)
|
||||
const urlMatch = result.value.match(/https?:\/\/\S+/)
|
||||
if (urlMatch) {
|
||||
// The URL must be ≤ ~8KB after encoding. Check the body= parameter specifically.
|
||||
const bodyParam = urlMatch[0].match(/[?&]body=([^&]*)/)
|
||||
if (bodyParam) {
|
||||
// decoded body text must be ≤ 4096 chars (plus truncation suffix)
|
||||
const decoded = decodeURIComponent(bodyParam[1])
|
||||
expect(decoded.length).toBeLessThanOrEqual(4096 + 60) // 60 for truncation suffix
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('long body session log does not crash', async () => {
|
||||
// Long session log content exercises the body-formatting branches.
|
||||
const longText = 'x'.repeat(4500)
|
||||
const entries: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
entries.push(JSON.stringify({ role: 'user', content: longText }))
|
||||
entries.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
)
|
||||
}
|
||||
await writeSessionLog(entries)
|
||||
process.env.HOME = tmpDir
|
||||
const call = await getCallFn()
|
||||
const result = await call('Long body issue')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('handles unreadable session log gracefully', async () => {
|
||||
// Write a corrupt log file that triggers parse errors but exists
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// Empty / whitespace-only file: should not crash, will produce empty session text
|
||||
writeFileSync(join(dir, `${sessionId}.jsonl`), '')
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue from empty session')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('template directory unreadable returns null template (graceful)', async () => {
|
||||
// Create issue-templates directory with no .md files (only a non-readable subfile name)
|
||||
const templatesDir = join(claudeDir, 'issue-templates')
|
||||
mkdirSync(templatesDir, { recursive: true })
|
||||
writeFileSync(join(templatesDir, 'README.txt'), 'not a markdown template')
|
||||
await writeSessionLog()
|
||||
const call = await getCallFn()
|
||||
// Should still succeed without template — template loading is best-effort
|
||||
const result = await call('Issue without templates')
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('session log read failure caught (path is a directory)', async () => {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const sessionId = getSessionId()
|
||||
const cwd = getOriginalCwd()
|
||||
const encoded = sanitizePath(cwd)
|
||||
const dir = join(claudeDir, 'projects', encoded)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// Create a directory at the log path so readFileSync throws EISDIR.
|
||||
mkdirSync(join(dir, `${sessionId}.jsonl`), { recursive: true })
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with broken log')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Should still produce output even when session log is unreadable
|
||||
expect(result.value.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate picks up first .md template from .github/ISSUE_TEMPLATE', async () => {
|
||||
// Issue command uses getOriginalCwd() (NOT process.cwd) — override via
|
||||
// setOriginalCwd. Restore after to avoid polluting other tests.
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(githubDir, 'bug.md'),
|
||||
'---\nname: Bug\nabout: Bug report\n---\n## Steps to reproduce\n\nSteps...\n',
|
||||
)
|
||||
writeFileSync(
|
||||
join(githubDir, 'config.yml'),
|
||||
'blank_issues_enabled: false\n',
|
||||
)
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with bug template')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate returns null when only non-md templates present', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(join(githubDir, 'bug.yml'), 'name: Bug')
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue YAML-only template')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate returns null when ISSUE_TEMPLATE is empty', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue empty template dir')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('detectIssueTemplate readdir failure is caught (catch branch)', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
// Create the ISSUE_TEMPLATE path as a regular file (not a directory) so
|
||||
// existsSync returns true but readdirSync throws ENOTDIR.
|
||||
const githubDir = join(tmpDir, '.github')
|
||||
mkdirSync(githubDir, { recursive: true })
|
||||
writeFileSync(join(githubDir, 'ISSUE_TEMPLATE'), 'not-a-directory')
|
||||
await writeSessionLog()
|
||||
const origCwd = getOriginalCwd()
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Issue with broken template path')
|
||||
expect(result.type).toBe('text')
|
||||
} finally {
|
||||
setOriginalCwd(origCwd)
|
||||
}
|
||||
})
|
||||
|
||||
test('long body triggers truncation + draft save', async () => {
|
||||
const { getOriginalCwd, setOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
// getTranscriptSummary clips each user/assistant text to 200 chars and
|
||||
// joins only the last 10 entries, so it can never organically exceed
|
||||
// ~2.7 KB. To exercise the >4096-char branch (lines 362-375), we
|
||||
// temporarily neutralise Array.prototype.slice for the `slice(-N)`
|
||||
// pattern (negative-only first arg, no second arg). String.slice and
|
||||
// positive Array.slice keep working, and we restore the original in
|
||||
// finally so no state leaks across tests.
|
||||
const longText = 'x'.repeat(200)
|
||||
const entries: string[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
entries.push(JSON.stringify({ role: 'user', content: longText }))
|
||||
entries.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: longText }],
|
||||
}),
|
||||
)
|
||||
}
|
||||
await writeSessionLog(entries)
|
||||
process.env.HOME = tmpDir
|
||||
const origCwd = getOriginalCwd()
|
||||
const origSlice = Array.prototype.slice
|
||||
// Force the fallback URL branch with a *parsed* GitHub remote so the
|
||||
// draft-path output (lines 392-393) is reached: git remote returns a
|
||||
// GitHub URL but `gh --version` fails so hasGh is false.
|
||||
//
|
||||
// Spread+flag pattern: the previous bare `mock.module(...)` here leaked
|
||||
// a stub child_process to every later test file in the same `bun test`
|
||||
// run (mock.module is process-global, last-write-wins). Now we register
|
||||
// a flag-gated mock that delegates to real child_process by default, and
|
||||
// only flips on for THIS test's body.
|
||||
mock.module('node:child_process', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:child_process') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
execFile: ((...args: unknown[]) => {
|
||||
if (useIssueLongBodyCpStubs) {
|
||||
const cb = args[3] as
|
||||
| ((e: Error | null, s: string, e2: string) => void)
|
||||
| undefined
|
||||
if (cb) cb(new Error('ENOENT'), '', '')
|
||||
return
|
||||
}
|
||||
return (real.execFile as (...a: unknown[]) => unknown)(...args)
|
||||
}) as typeof real.execFile,
|
||||
execFileSync: ((...args: unknown[]) => {
|
||||
if (useIssueLongBodyCpStubs) {
|
||||
const cmd = args[0] as string
|
||||
if (cmd === 'git')
|
||||
return Buffer.from('https://github.com/owner/repo.git\n')
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return (real.execFileSync as (...a: unknown[]) => unknown)(...args)
|
||||
}) as typeof real.execFileSync,
|
||||
}
|
||||
})
|
||||
useIssueLongBodyCpStubs = true
|
||||
Array.prototype.slice = function (
|
||||
this: unknown[],
|
||||
start?: number,
|
||||
end?: number,
|
||||
): unknown[] {
|
||||
// For `summaryParts.slice(-10)` and `errors.slice(-3)` (negative
|
||||
// start, no end) return the full array so summaryParts.length
|
||||
// determines the body size.
|
||||
if (typeof start === 'number' && start < 0 && end === undefined) {
|
||||
return Array.from(this)
|
||||
}
|
||||
return origSlice.call(this, start, end) as unknown[]
|
||||
} as typeof Array.prototype.slice
|
||||
try {
|
||||
setOriginalCwd(tmpDir)
|
||||
const call = await getCallFn()
|
||||
const result = await call('Long body for draft save')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
// Draft path is reported when body > 4096 chars (line 393 branch).
|
||||
expect(result.value).toContain('Full issue body saved to')
|
||||
}
|
||||
} finally {
|
||||
Array.prototype.slice = origSlice
|
||||
setOriginalCwd(origCwd)
|
||||
useIssueLongBodyCpStubs = false
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,9 +7,30 @@ import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// No keychain mock here — the real store falls back to encrypted file when
|
||||
// @napi-rs/keyring is not installed (which it is not in this environment).
|
||||
// This exercises the full file-fallback path without cross-test module pollution.
|
||||
// Re-register ../keychain.js to override pollution from store.test.ts (which
|
||||
// mocks keychain as always-throwing) and keychain.test.ts (which mocks it with
|
||||
// an in-memory MockEntry). Force KeychainUnavailableError so the store always
|
||||
// uses the encrypted-file fallback path.
|
||||
class KeychainUnavailableError extends Error {
|
||||
override name = 'KeychainUnavailableError'
|
||||
}
|
||||
|
||||
const keychainUnavailable = async (): Promise<never> => {
|
||||
throw new KeychainUnavailableError('test: keychain mocked as unavailable')
|
||||
}
|
||||
|
||||
mock.module('../../../services/localVault/keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain: {
|
||||
set: keychainUnavailable,
|
||||
get: keychainUnavailable,
|
||||
delete: keychainUnavailable,
|
||||
list: keychainUnavailable,
|
||||
_addToIndex: keychainUnavailable,
|
||||
_removeFromIndex: keychainUnavailable,
|
||||
},
|
||||
_resetKeychainModuleCache: () => {},
|
||||
}))
|
||||
|
||||
let callLocalVault: typeof import('../launchLocalVault.js').callLocalVault
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Tests for AuthPlaneSummary.tsx
|
||||
* Uses staticRender to render Ink components to strings.
|
||||
* Covers all 4 mode combinations + long provider list + key preview masking.
|
||||
*/
|
||||
import { describe, expect, test, mock } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { logMock } from '../../../../tests/mocks/log';
|
||||
import { debugMock } from '../../../../tests/mocks/debug';
|
||||
|
||||
mock.module('src/utils/log.ts', logMock);
|
||||
mock.module('src/utils/debug.ts', debugMock);
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }));
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getCachedOrDefaultSettings: () => ({}),
|
||||
getSettings: () => ({}),
|
||||
}));
|
||||
mock.module('src/utils/config.ts', () => ({
|
||||
isConfigEnabled: () => true,
|
||||
getGlobalConfig: () => ({ workspaceApiKey: undefined }),
|
||||
saveGlobalConfig: (_updater: unknown) => undefined,
|
||||
}));
|
||||
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import type { AuthStatus } from '../getAuthStatus.js';
|
||||
|
||||
// Helper to build minimal AuthStatus fixtures
|
||||
function makeStatus(overrides: Partial<AuthStatus> = {}): AuthStatus {
|
||||
return {
|
||||
subscription: {
|
||||
active: false,
|
||||
plan: null,
|
||||
accountEmail: null,
|
||||
},
|
||||
workspaceKey: {
|
||||
set: false,
|
||||
prefixValid: false,
|
||||
keyPreview: null,
|
||||
source: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthPlaneSummary', () => {
|
||||
test('renders subscription as inactive (☐) when not logged in', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus();
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('Subscription');
|
||||
// Subscription inactive symbol or "not logged in" indicator
|
||||
expect(out.toLowerCase()).toMatch(/not logged in|☐/);
|
||||
});
|
||||
|
||||
test('renders subscription as active (☑) with plan label when subscribed', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('pro');
|
||||
// Active symbol present
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key as set+valid (☑) when prefixValid=true', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: true,
|
||||
keyPreview: 'sk-a...67 (48 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Key preview may be word-wrapped across lines in terminal output
|
||||
expect(out).toContain('sk-a...67');
|
||||
expect(out).toContain('☑');
|
||||
});
|
||||
|
||||
test('renders workspace key warning (⚠) when set but prefix invalid', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
workspaceKey: {
|
||||
set: true,
|
||||
prefixValid: false,
|
||||
keyPreview: 'sk-w...ng (40 chars)',
|
||||
source: 'env',
|
||||
},
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
// Warning indicator present
|
||||
expect(out).toContain('⚠');
|
||||
expect(out.toLowerCase()).toContain('sk-ant-api03-');
|
||||
});
|
||||
|
||||
test('shows workspace key 4-step setup instructions when key not set and subscription active', async () => {
|
||||
const { AuthPlaneSummary } = await import('../AuthPlaneSummary.js');
|
||||
const status = makeStatus({
|
||||
subscription: { active: true, plan: 'pro', accountEmail: null },
|
||||
workspaceKey: { set: false, prefixValid: false, keyPreview: null, source: null },
|
||||
});
|
||||
const out = await renderToString(<AuthPlaneSummary status={status} />);
|
||||
expect(out).toContain('console.anthropic.com');
|
||||
});
|
||||
|
||||
// Third-party provider rendering tests removed 2026-05-06 — that section
|
||||
// was deleted from AuthPlaneSummary to defer to fork's existing /login form
|
||||
// for OpenAI-compat configuration. See AuthPlaneSummary.tsx for the rationale.
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
* - Third-party API key values are NEVER included; only boolean presence flags.
|
||||
*/
|
||||
|
||||
import type { SubscriptionType } from '../../services/oauth/types.js'
|
||||
import { getClaudeAIOAuthTokens } from '../../utils/auth.js'
|
||||
import { getGlobalConfig } from '../../utils/config.js'
|
||||
|
||||
@@ -107,7 +108,10 @@ export function getAuthStatus(): AuthStatus {
|
||||
|
||||
let plan: AuthStatus['subscription']['plan'] = null
|
||||
if (subscriptionActive && oauthTokens) {
|
||||
const raw = oauthTokens.subscriptionType
|
||||
// 本地持久化或历史 token 中可能出现 'free' 等未纳入 SubscriptionType 的字符串
|
||||
const raw = oauthTokens.subscriptionType as
|
||||
| (SubscriptionType | 'free')
|
||||
| null
|
||||
if (
|
||||
raw === 'free' ||
|
||||
raw === 'pro' ||
|
||||
|
||||
@@ -1,331 +1,383 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchMemoryStores.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock memoryStoresApi.js itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Let real memoryStoresApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
}))
|
||||
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ms' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-ms',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
// Spread real teleport/api so any export not explicitly stubbed (like
|
||||
// prepareApiRequest, axiosGetWithRetry, type guards, schemas)
|
||||
// remains available to transitive importers.
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
prepareApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── MemoryStoresView mock ───────────────────────────────────────────────────
|
||||
const memoryStoresViewMock = mock((_props: unknown) => null)
|
||||
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
|
||||
MemoryStoresView: memoryStoresViewMock,
|
||||
}))
|
||||
|
||||
// ── memoryStoresApi mock ──────────────────────────────────────────────────
|
||||
const listStoresMock = mock(async () => [] as unknown)
|
||||
const getStoreMock = mock(async () => ({}) as unknown)
|
||||
const createStoreMock = mock(async () => ({}) as unknown)
|
||||
const archiveStoreMock = mock(async () => ({}) as unknown)
|
||||
const listMemoriesMock = mock(async () => [] as unknown)
|
||||
const createMemoryMock = mock(async () => ({}) as unknown)
|
||||
const getMemoryMock = mock(async () => ({}) as unknown)
|
||||
const updateMemoryMock = mock(async () => ({}) as unknown)
|
||||
const deleteMemoryMock = mock(async () => undefined)
|
||||
const listVersionsMock = mock(async () => [] as unknown)
|
||||
const redactVersionMock = mock(async () => ({}) as unknown)
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosPatchMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({
|
||||
listStores: listStoresMock,
|
||||
getStore: getStoreMock,
|
||||
createStore: createStoreMock,
|
||||
archiveStore: archiveStoreMock,
|
||||
listMemories: listMemoriesMock,
|
||||
createMemory: createMemoryMock,
|
||||
getMemory: getMemoryMock,
|
||||
updateMemory: updateMemoryMock,
|
||||
deleteMemory: deleteMemoryMock,
|
||||
listVersions: listVersionsMock,
|
||||
redactVersion: redactVersionMock,
|
||||
}))
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.patch = axiosPatchMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy imports ─────────────────────────────────────────────────────────────
|
||||
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchMemoryStores.js')
|
||||
callMemoryStores = mod.callMemoryStores
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
const calls: [string | undefined, unknown][] = []
|
||||
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
|
||||
return { onDone, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosPatchMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
listStoresMock.mockClear()
|
||||
getStoreMock.mockClear()
|
||||
createStoreMock.mockClear()
|
||||
archiveStoreMock.mockClear()
|
||||
listMemoriesMock.mockClear()
|
||||
createMemoryMock.mockClear()
|
||||
getMemoryMock.mockClear()
|
||||
updateMemoryMock.mockClear()
|
||||
deleteMemoryMock.mockClear()
|
||||
listVersionsMock.mockClear()
|
||||
redactVersionMock.mockClear()
|
||||
memoryStoresViewMock.mockClear()
|
||||
})
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: invalid args', () => {
|
||||
test('invalid subcommand → onDone with usage + null', async () => {
|
||||
const onDone = makeOnDone()
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callMemoryStores(onDone, {} as never, 'badcmd')
|
||||
expect(result).toBeNull()
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/Usage/i)
|
||||
expect(calls[0]?.[0]).toMatch(/Usage/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: list', () => {
|
||||
test('list returns empty stores', async () => {
|
||||
listStoresMock.mockResolvedValueOnce([])
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
expect(listStoresMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no memory stores/i)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/no memory stores/i)
|
||||
})
|
||||
|
||||
test('list with stores reports count', async () => {
|
||||
const stores = [
|
||||
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
|
||||
]
|
||||
listStoresMock.mockResolvedValueOnce(stores)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory store/)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory store/)
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listStoresMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memory stores/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memory stores/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get', () => {
|
||||
test('get calls getStore with id', async () => {
|
||||
test('get calls axios.get with id in URL', async () => {
|
||||
const store = { memory_store_id: 'ms_get', name: 'Work Store' }
|
||||
getStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_get')
|
||||
expect(getStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create', () => {
|
||||
test('create calls createStore with name', async () => {
|
||||
test('create calls axios.post with name in body', async () => {
|
||||
const store = { memory_store_id: 'ms_new', name: 'New Store' }
|
||||
createStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create New Store')
|
||||
expect(createStoreMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createStoreMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('New Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory store created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[1]).toEqual({ name: 'New Store' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory store created/i)
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createStoreMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'create My Store')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── archive ───────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: archive', () => {
|
||||
test('archive calls archiveStore with id', async () => {
|
||||
test('archive calls axios.post with id in URL', async () => {
|
||||
const store = {
|
||||
memory_store_id: 'ms_arc',
|
||||
name: 'Old Store',
|
||||
archived_at: '2026-01-01',
|
||||
}
|
||||
archiveStoreMock.mockResolvedValueOnce(store)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_arc')
|
||||
expect(archiveStoreMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/archived/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_arc')
|
||||
expect(postCall[0]).toContain('archive')
|
||||
expect(calls[0]?.[0]).toMatch(/archived/i)
|
||||
})
|
||||
|
||||
test('archive API error → error message', async () => {
|
||||
archiveStoreMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'archive ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to archive memory store/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to archive memory store/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── memories ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: memories', () => {
|
||||
test('memories lists memories in store', async () => {
|
||||
const memories = [
|
||||
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
|
||||
]
|
||||
listMemoriesMock.mockResolvedValueOnce(memories)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: memories },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_1')
|
||||
expect(listMemoriesMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 memory/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 memory/)
|
||||
})
|
||||
|
||||
test('memories API error → error message', async () => {
|
||||
listMemoriesMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'memories ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list memories/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list memories/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: create-memory', () => {
|
||||
test('create-memory calls createMemory with storeId and content', async () => {
|
||||
test('create-memory calls axios.post with storeId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_new',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'hello world',
|
||||
}
|
||||
createMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 hello world',
|
||||
)
|
||||
expect(createMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = createMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('hello world')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/memory created/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('memories')
|
||||
expect(postCall[1]).toEqual({ content: 'hello world' })
|
||||
expect(calls[0]?.[0]).toMatch(/memory created/i)
|
||||
})
|
||||
|
||||
test('create-memory API error → error message', async () => {
|
||||
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create-memory ms_1 test content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to create memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── get-memory ────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: get-memory', () => {
|
||||
test('get-memory calls getMemory', async () => {
|
||||
test('get-memory calls axios.get with storeId and memoryId in URL', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_get',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'Test',
|
||||
}
|
||||
getMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
|
||||
expect(getMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const getCall = axiosGetMock.mock.calls[0] as unknown as [string]
|
||||
expect(getCall[0]).toContain('ms_1')
|
||||
expect(getCall[0]).toContain('mem_get')
|
||||
})
|
||||
|
||||
test('get-memory API error → error message', async () => {
|
||||
getMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to get memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── update-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: update-memory', () => {
|
||||
test('update-memory calls updateMemory with storeId, memoryId, and content', async () => {
|
||||
test('update-memory calls axios.patch with storeId, memoryId in URL and content in body', async () => {
|
||||
const memory = {
|
||||
memory_id: 'mem_upd',
|
||||
memory_store_id: 'ms_1',
|
||||
content: 'new content',
|
||||
}
|
||||
updateMemoryMock.mockResolvedValueOnce(memory)
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockResolvedValueOnce({ data: memory, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_upd new content',
|
||||
)
|
||||
expect(updateMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateMemoryMock.mock.calls as unknown as [
|
||||
expect(axiosPatchMock).toHaveBeenCalledTimes(1)
|
||||
const patchCall = axiosPatchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_upd')
|
||||
expect(calls[0]?.[2]).toBe('new content')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
Record<string, string>,
|
||||
]
|
||||
expect(patchCall[0]).toContain('ms_1')
|
||||
expect(patchCall[0]).toContain('mem_upd')
|
||||
expect(patchCall[1]).toEqual({ content: 'new content' })
|
||||
expect(calls[0]?.[0]).toMatch(/updated/i)
|
||||
})
|
||||
|
||||
test('update-memory API error → error message', async () => {
|
||||
updateMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPatchMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'update-memory ms_1 mem_missing new content',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to update memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to update memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete-memory ─────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: delete-memory', () => {
|
||||
test('delete-memory calls deleteMemory', async () => {
|
||||
deleteMemoryMock.mockResolvedValueOnce(undefined)
|
||||
const onDone = makeOnDone()
|
||||
test('delete-memory calls axios.delete with storeId and memoryId in URL', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
|
||||
expect(deleteMemoryMock).toHaveBeenCalledTimes(1)
|
||||
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('mem_del')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const deleteCall = axiosDeleteMock.mock.calls[0] as unknown as [string]
|
||||
expect(deleteCall[0]).toContain('ms_1')
|
||||
expect(deleteCall[0]).toContain('mem_del')
|
||||
expect(calls[0]?.[0]).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete-memory API error → error message', async () => {
|
||||
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(
|
||||
onDone,
|
||||
{} as never,
|
||||
'delete-memory ms_1 mem_missing',
|
||||
)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to delete memory/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to delete memory/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── versions ──────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: versions', () => {
|
||||
test('versions lists memory versions', async () => {
|
||||
const versions = [
|
||||
@@ -335,46 +387,47 @@ describe('callMemoryStores: versions', () => {
|
||||
created_at: '2026-01-01',
|
||||
},
|
||||
]
|
||||
listVersionsMock.mockResolvedValueOnce(versions)
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: versions },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_1')
|
||||
expect(listVersionsMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 version/)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(calls[0]?.[0]).toMatch(/1 version/)
|
||||
})
|
||||
|
||||
test('versions API error → error message', async () => {
|
||||
listVersionsMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'versions ms_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list versions/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to list versions/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── redact ────────────────────────────────────────────────────────────────────
|
||||
describe('callMemoryStores: redact', () => {
|
||||
test('redact calls redactVersion with storeId and versionId', async () => {
|
||||
test('redact calls axios.post with storeId and versionId in URL', async () => {
|
||||
const version = {
|
||||
version_id: 'ver_red',
|
||||
memory_store_id: 'ms_1',
|
||||
redacted_at: '2026-01-01',
|
||||
}
|
||||
redactVersionMock.mockResolvedValueOnce(version)
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
|
||||
expect(redactVersionMock).toHaveBeenCalledTimes(1)
|
||||
const calls = redactVersionMock.mock.calls as unknown as [string, string][]
|
||||
expect(calls[0]?.[0]).toBe('ms_1')
|
||||
expect(calls[0]?.[1]).toBe('ver_red')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/redacted/i)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const postCall = axiosPostMock.mock.calls[0] as unknown as [string]
|
||||
expect(postCall[0]).toContain('ms_1')
|
||||
expect(postCall[0]).toContain('ver_red')
|
||||
expect(postCall[0]).toContain('redact')
|
||||
expect(calls[0]?.[0]).toMatch(/redacted/i)
|
||||
})
|
||||
|
||||
test('redact API error → error message', async () => {
|
||||
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to redact version/i)
|
||||
expect(calls[0]?.[0]).toMatch(/failed to redact version/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isPluginEnabledAtProjectScope,
|
||||
uninstallPluginOp,
|
||||
updatePluginOp,
|
||||
type InstallableScope,
|
||||
} from '../../services/plugins/pluginOperations.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import type { Tool } from '../../Tool.js';
|
||||
@@ -76,7 +77,7 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js';
|
||||
import { PluginOptionsFlow } from './PluginOptionsFlow.js';
|
||||
import type { ViewState as ParentViewState } from './types.js';
|
||||
import { UnifiedInstalledCell } from './UnifiedInstalledCell.js';
|
||||
import type { UnifiedInstalledItem } from './unifiedTypes.js';
|
||||
import type { UnifiedInstalledItem, UnifiedInstalledScope } from './unifiedTypes.js';
|
||||
import { usePagination } from './usePagination.js';
|
||||
|
||||
type Props = {
|
||||
@@ -103,7 +104,7 @@ type FailedPluginInfo = {
|
||||
name: string;
|
||||
marketplace: string;
|
||||
errors: PluginError[];
|
||||
scope: PersistablePluginScope;
|
||||
scope: UnifiedInstalledScope;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -1253,7 +1254,7 @@ export function ManagePlugins({
|
||||
const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false;
|
||||
const pluginScope = item.scope;
|
||||
const isBuiltin = pluginScope === 'builtin';
|
||||
if (isBuiltin || isInstallableScope(pluginScope)) {
|
||||
if (isBuiltin || isInstallableScope(pluginScope as PersistablePluginScope)) {
|
||||
const newPending = new Map(pendingToggles);
|
||||
// Omit scope — see handleSingleOperation's enable/disable comment.
|
||||
if (currentPending) {
|
||||
@@ -1579,8 +1580,8 @@ export function ManagePlugins({
|
||||
// is a recovery path for a plugin that failed to load — it may
|
||||
// be reinstallable, so don't nuke ${CLAUDE_PLUGIN_DATA} silently.
|
||||
// The normal uninstall path prompts; this one preserves.
|
||||
const result = isInstallableScope(pluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope, false)
|
||||
const result = isInstallableScope(pluginScope as PersistablePluginScope)
|
||||
? await uninstallPluginOp(pluginId, pluginScope as InstallableScope, false)
|
||||
: await uninstallPluginOp(pluginId, 'user', false);
|
||||
let success = result.success;
|
||||
if (!success) {
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ViewState = any
|
||||
export type PluginSettingsProps = any
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js'
|
||||
|
||||
/**
|
||||
* `/plugin` 根视图在子面板之间的导航状态。
|
||||
* 各分支对应不同子界面或从 CLI 参数解析出的初始路由。
|
||||
*/
|
||||
export type ViewState =
|
||||
| { type: 'menu' } // 返回插件功能总菜单
|
||||
| { type: 'help' } // 展示帮助说明
|
||||
| { type: 'validate'; path?: string } // 校验指定路径下的插件包
|
||||
| {
|
||||
type: 'browse-marketplace' // 在指定市场中浏览/安装插件
|
||||
targetMarketplace: string // 目标市场标识
|
||||
targetPlugin?: string // 可选:预选插件名
|
||||
}
|
||||
| { type: 'discover-plugins'; targetPlugin?: string } // 发现页;可预选搜索插件名
|
||||
| {
|
||||
type: 'manage-plugins' // 已安装插件管理(启用/禁用/卸载)
|
||||
targetPlugin?: string // 可选:聚焦某插件
|
||||
targetMarketplace?: string // 可选:与 targetPlugin 联用的市场
|
||||
action?: 'uninstall' | 'enable' | 'disable' // 可选:打开时直接执行的操作
|
||||
}
|
||||
| { type: 'marketplace-list' } // 列出已配置市场
|
||||
| { type: 'marketplace-menu' } // 市场相关子菜单
|
||||
| { type: 'add-marketplace'; initialValue?: string } // 添加市场;可预填 URL/名称
|
||||
| {
|
||||
type: 'manage-marketplaces' // 管理已保存的市场源
|
||||
targetMarketplace?: string // 可选:聚焦某市场
|
||||
action?: 'remove' | 'update' // 可选:移除或刷新该市场
|
||||
}
|
||||
|
||||
/** `/plugin` Ink 命令入口的 props。 */
|
||||
export type PluginSettingsProps = {
|
||||
onComplete: LocalJSXCommandOnDone // 子流程结束回调(可带结果文案与展示方式)
|
||||
args?: string // CLI 透传的子命令参数字符串
|
||||
showMcpRedirectMessage?: boolean // 从 `/mcp` 跳转时展示 MCP 相关提示
|
||||
}
|
||||
|
||||
@@ -1,2 +1,68 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type UnifiedInstalledItem = any
|
||||
import type {
|
||||
ConfigScope,
|
||||
MCPServerConnection,
|
||||
} from '../../services/mcp/types.js'
|
||||
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
|
||||
|
||||
import type { PersistablePluginScope } from '../../utils/plugins/pluginIdentifier.js'
|
||||
|
||||
/** 列表项作用域:含 MCP 的 `builtin` 与已下架插件的 `flagged`。 */
|
||||
export type UnifiedInstalledScope = ConfigScope | 'builtin' | 'flagged'
|
||||
|
||||
/** 插件管理列表中 MCP 连接行的连接状态摘要。 */
|
||||
export type McpRowStatus =
|
||||
| 'connected' // 已连接且可用
|
||||
| 'disabled' // 用户或策略禁用
|
||||
| 'pending' // 正在连接或重连
|
||||
| 'needs-auth' // 需 OAuth 等鉴权
|
||||
| 'failed' // 连接或握手失败
|
||||
|
||||
/**
|
||||
* 「已安装」统一列表中的一行:插件、失败占位、下架标记或 MCP 服务器。
|
||||
* 用于分页与键盘导航的同一数据源。
|
||||
*/
|
||||
export type UnifiedInstalledItem =
|
||||
| {
|
||||
type: 'plugin' // 正常加载的插件
|
||||
id: string // `name@marketplace` 唯一键
|
||||
name: string // 插件短名
|
||||
description: string | undefined // manifest 描述
|
||||
marketplace: string // 所属市场
|
||||
scope: PersistablePluginScope | 'builtin' // 安装/展示作用域(内置单独标)
|
||||
isEnabled: boolean // 是否在 merged settings 中启用
|
||||
errorCount: number // 与该插件关联的错误条数
|
||||
errors: PluginError[] // 结构化错误列表
|
||||
plugin: LoadedPlugin // 已解析的 manifest 与路径等
|
||||
pendingEnable?: boolean // UI:等待启用完成
|
||||
pendingUpdate?: boolean // UI:等待更新完成
|
||||
pendingToggle?: 'will-enable' | 'will-disable' // 用户已选、尚未落盘的启用切换
|
||||
}
|
||||
| {
|
||||
type: 'failed-plugin' // 未能加载的插件占位行
|
||||
id: string // 与错误 source 对齐的 id
|
||||
name: string // 展示用名称
|
||||
marketplace: string // 推断或 unknown
|
||||
scope: UnifiedInstalledScope // 推断的安装作用域
|
||||
errorCount: number
|
||||
errors: PluginError[]
|
||||
}
|
||||
| {
|
||||
type: 'flagged-plugin' // 市场已下架但仍出现在设置中的插件
|
||||
id: string
|
||||
name: string
|
||||
marketplace: string
|
||||
scope: 'flagged' // 固定为下架分组
|
||||
reason: string // 下架原因码(如 delisted)
|
||||
text: string // 面向用户的说明文案
|
||||
flaggedAt: string // 标记时间(ISO 等)
|
||||
}
|
||||
| {
|
||||
type: 'mcp' // 独立 MCP 或插件子 MCP 行
|
||||
id: string // 列表稳定 id(如 mcp:name)
|
||||
name: string // 展示名(子 MCP 可为 server 段)
|
||||
description: string | undefined // 可选副标题
|
||||
scope: UnifiedInstalledScope // 来自 server config 或父插件推导
|
||||
status: McpRowStatus // 连接态摘要
|
||||
client: MCPServerConnection // 底层连接对象(供详情/工具视图)
|
||||
indented?: boolean // true 表示挂在某插件下的子 MCP
|
||||
}
|
||||
|
||||
@@ -78,9 +78,6 @@ axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
||||
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
|
||||
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
|
||||
// own beforeAll re-registers the real implementation under that same key.
|
||||
let listTriggers: typeof import('../triggersApi.js').listTriggers
|
||||
let getTrigger: typeof import('../triggersApi.js').getTrigger
|
||||
let createTrigger: typeof import('../triggersApi.js').createTrigger
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
/**
|
||||
* Tests for launchSchedule.ts
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock triggersApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Mock auth dependencies so real triggersApi functions can build headers
|
||||
* - Let real triggersApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
@@ -12,8 +31,6 @@ mock.module('src/services/analytics/index.js', () => ({
|
||||
}))
|
||||
|
||||
// ── Cron utility mock ───────────────────────────────────────────────────────
|
||||
// parseCronExpression: returns null if any field is non-numeric/non-wildcard
|
||||
// to simulate real validation; specifically reject expressions with word fields.
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
parseCronExpression: (cron: string) => {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
@@ -38,43 +55,76 @@ mock.module('src/commands/schedule/ScheduleView.js', () => ({
|
||||
ScheduleView: scheduleViewMock,
|
||||
}))
|
||||
|
||||
// ── triggersApi mock ──────────────────────────────────────────────────────
|
||||
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS
|
||||
const listTriggersMock = mock(async () => [] as unknown)
|
||||
const getTriggerMock = mock(async () => ({}) as unknown)
|
||||
const createTriggerMock = mock(async () => ({}) as unknown)
|
||||
const updateTriggerMock = mock(async () => ({}) as unknown)
|
||||
const deleteTriggerMock = mock(async () => undefined)
|
||||
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown)
|
||||
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
getTrigger: getTriggerMock,
|
||||
createTrigger: createTriggerMock,
|
||||
updateTrigger: updateTriggerMock,
|
||||
deleteTrigger: deleteTriggerMock,
|
||||
runTrigger: runTriggerMock,
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-schedule' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid-schedule',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
getOAuthHeaders: (token: string) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
prepareApiRequest: async () => ({
|
||||
accessToken: 'test-token-schedule',
|
||||
orgUUID: 'org-uuid-schedule',
|
||||
}),
|
||||
prepareWorkspaceApiRequest: async () => ({
|
||||
apiKey: 'test-workspace-key',
|
||||
}),
|
||||
}))
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertSubscriptionBaseUrl: () => {},
|
||||
assertWorkspaceHost: () => {},
|
||||
assertNoAnthropicEnvForOpenAI: () => {},
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import ─────────────────────────────────────────────────────────────
|
||||
let callSchedule: typeof import('../launchSchedule.js').callSchedule
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchSchedule.js')
|
||||
callSchedule = mod.callSchedule
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listTriggersMock.mockClear()
|
||||
getTriggerMock.mockClear()
|
||||
createTriggerMock.mockClear()
|
||||
updateTriggerMock.mockClear()
|
||||
deleteTriggerMock.mockClear()
|
||||
runTriggerMock.mockClear()
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
scheduleViewMock.mockClear()
|
||||
})
|
||||
|
||||
@@ -91,10 +141,10 @@ describe('callSchedule: invalid args', () => {
|
||||
|
||||
describe('callSchedule: list', () => {
|
||||
test('list returns empty triggers', async () => {
|
||||
listTriggersMock.mockResolvedValueOnce([])
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
expect(listTriggersMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no scheduled triggers/i)
|
||||
})
|
||||
@@ -108,7 +158,10 @@ describe('callSchedule: list', () => {
|
||||
prompt: 'daily',
|
||||
},
|
||||
]
|
||||
listTriggersMock.mockResolvedValueOnce(triggers)
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: triggers },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -116,7 +169,7 @@ describe('callSchedule: list', () => {
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listTriggersMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -132,16 +185,16 @@ describe('callSchedule: get', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
getTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_get')
|
||||
expect(getTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getTriggerMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('trg_get')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toContain('trg_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosGetMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -157,10 +210,10 @@ describe('callSchedule: create', () => {
|
||||
enabled: true,
|
||||
prompt: 'daily report',
|
||||
}
|
||||
createTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report')
|
||||
expect(createTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/trigger created/i)
|
||||
})
|
||||
@@ -169,12 +222,12 @@ describe('callSchedule: create', () => {
|
||||
const onDone = makeOnDone()
|
||||
// 4 fields only — invalid
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
|
||||
// createTrigger should not be called
|
||||
expect(createTriggerMock).not.toHaveBeenCalled()
|
||||
// axios.post should not be called
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -190,14 +243,16 @@ describe('callSchedule: update', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
|
||||
expect(updateTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[0]).toContain('trg_upd')
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
@@ -206,7 +261,7 @@ describe('callSchedule: update', () => {
|
||||
test('update with unknown field → error without API call', async () => {
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar')
|
||||
expect(updateTriggerMock).not.toHaveBeenCalled()
|
||||
expect(axiosPostMock).not.toHaveBeenCalled()
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/unknown field/i)
|
||||
})
|
||||
@@ -214,16 +269,16 @@ describe('callSchedule: update', () => {
|
||||
|
||||
describe('callSchedule: delete', () => {
|
||||
test('delete calls deleteTrigger', async () => {
|
||||
deleteTriggerMock.mockResolvedValueOnce(undefined)
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_del')
|
||||
expect(deleteTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete API error → error message', async () => {
|
||||
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosDeleteMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -233,16 +288,21 @@ describe('callSchedule: delete', () => {
|
||||
|
||||
describe('callSchedule: run', () => {
|
||||
test('run fires trigger and returns run_id', async () => {
|
||||
runTriggerMock.mockResolvedValueOnce({ run_id: 'run_xyz' })
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_xyz' },
|
||||
status: 200,
|
||||
})
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
expect(runTriggerMock).toHaveBeenCalledTimes(1)
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosPostMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0] as string).toMatch(/\/run$/)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/run_xyz/)
|
||||
})
|
||||
|
||||
test('run API error → error message', async () => {
|
||||
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -258,12 +318,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_en')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: true })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -277,12 +338,13 @@ describe('callSchedule: enable / disable', () => {
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_dis')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -290,7 +352,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('enable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
@@ -298,7 +360,7 @@ describe('callSchedule: enable / disable', () => {
|
||||
})
|
||||
|
||||
test('disable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
axiosPostMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type FeedbackSurveyResponse = any
|
||||
export type FeedbackSurveyType = any
|
||||
/** 会话内满意度调查的选项(与数字键 0–3 映射一致)。 */
|
||||
export type FeedbackSurveyResponse =
|
||||
| 'dismissed' // 0:关闭不反馈
|
||||
| 'bad' // 1:不满意
|
||||
| 'fine' // 2:一般
|
||||
| 'good' // 3:满意
|
||||
|
||||
/** 调查场景;当前仅实现会话级提示。 */
|
||||
export type FeedbackSurveyType = 'session' // 主会话 Spinner/流程内触发
|
||||
|
||||
@@ -798,7 +798,9 @@ const MessagesImpl = ({
|
||||
|
||||
// Collapse diffs for messages beyond the latest N messages.
|
||||
// verbose (ctrl+o) overrides and always shows full diffs.
|
||||
const DIFF_COLLAPSE_DISTANCE = 0;
|
||||
// 0 was too aggressive — tool results are never the last message (assistant
|
||||
// text follows), so diffs were always collapsed. 3 keeps recent edits visible.
|
||||
const DIFF_COLLAPSE_DISTANCE = 3;
|
||||
const shouldCollapseDiffs = renderableMessages.length - 1 - index > DIFF_COLLAPSE_DISTANCE;
|
||||
|
||||
const k = messageKey(msg);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js';
|
||||
import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js';
|
||||
import { isLocalAgentTask } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||
import { isBackgroundTask } from '../tasks/types.js';
|
||||
import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
||||
import { getEffortSuffix } from '../utils/effort.js';
|
||||
@@ -209,15 +210,22 @@ function SpinnerWithVerbInner({
|
||||
const hasRunningTeammates = runningTeammates.length > 0;
|
||||
const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle);
|
||||
|
||||
// Gather aggregate token stats from all running swarm teammates
|
||||
// In spinner-tree mode, skip aggregation (teammates have their own lines in the tree)
|
||||
// Gather aggregate token stats from all running agents.
|
||||
// In spinner-tree mode, skip in-process teammates (they have their own
|
||||
// per-teammate lines in the tree) but still count local-agent tasks
|
||||
// (background agents) which have no dedicated tree rows.
|
||||
let teammateTokens = 0;
|
||||
if (!showSpinnerTree) {
|
||||
for (const task of Object.values(tasks)) {
|
||||
if (isInProcessTeammateTask(task) && task.status === 'running') {
|
||||
if (task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
for (const task of Object.values(tasks)) {
|
||||
if (task.status !== 'running') continue;
|
||||
if (isInProcessTeammateTask(task)) {
|
||||
if (!showSpinnerTree && task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isLocalAgentTask(task)) {
|
||||
if (task.progress?.tokenCount) {
|
||||
teammateTokens += task.progress.tokenCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user