mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478091567d | ||
|
|
b4e52d0c9e | ||
|
|
d11b35e023 | ||
|
|
8570b6ba01 | ||
|
|
db606b5589 | ||
|
|
27a01113e4 | ||
|
|
4a39fd74b1 | ||
|
|
5486d3c02c | ||
|
|
aaabf0c168 | ||
|
|
43c20a43c2 | ||
|
|
17c06690d8 | ||
|
|
89800137b6 |
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
|
||||
|
||||
|
||||
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.2 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.3",
|
||||
"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>",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
端到端验证所有功能是否正确实现。
|
||||
@@ -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(),
|
||||
|
||||
@@ -202,9 +202,9 @@ function createTemplateInCwd(files: Record<string, string>): string {
|
||||
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
|
||||
// Track the ISSUE_TEMPLATE dir for cleanup — never delete the whole .github/
|
||||
// as it may contain workflows, settings, or other project config.
|
||||
createdTemplatePath = templateDir
|
||||
return templateDir
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { Suspense, use, useMemo } from 'react';
|
||||
import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { isENOENT } from 'src/utils/errors.js';
|
||||
import { decodeBuffer } from 'src/utils/encoding.js';
|
||||
import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js';
|
||||
import { getFsImplementation } from 'src/utils/fsOperations.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
@@ -34,10 +33,9 @@ export function SedEditPermissionRequest({ sedInfo, ...props }: SedEditPermissio
|
||||
// render correctly. This matches what readFileSync did before the
|
||||
// async conversion.
|
||||
const encoding = detectEncodingForResolvedPath(filePath);
|
||||
const rawBuffer = await getFsImplementation().readFileBytes(filePath);
|
||||
const raw = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n');
|
||||
const raw = await getFsImplementation().readFile(filePath, { encoding });
|
||||
return {
|
||||
oldContent: raw,
|
||||
oldContent: raw.replaceAll('\r\n', '\n'),
|
||||
fileExists: true,
|
||||
};
|
||||
})().catch((e: unknown): FileReadResult => {
|
||||
|
||||
@@ -1396,7 +1396,7 @@ async function* queryModel(
|
||||
messagesForAPI = [
|
||||
...messagesForAPI,
|
||||
createUserMessage({
|
||||
content: `<system-reminder>\n<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nTo invoke any tool listed above, use ExecuteExtraTool with {"tool_name": "<name>", "params": {...}}. This is the ONLY way to call deferred tools — do not read source code or analyze implementation, just call ExecuteExtraTool directly.\n</system-reminder>`,
|
||||
content: `<system-reminder>\n<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>\nIMPORTANT: These tools are deferred-loading. You MUST first discover a tool via SearchExtraTools before invoking it with ExecuteExtraTool. Do NOT call ExecuteExtraTool directly — it will fail if the tool has not been discovered.\n\nSteps:\n1. SearchExtraTools("select:<tool_name>") — discover the tool and its schema\n2. ExecuteExtraTool({"tool_name": "<name>", "params": {...}}) — invoke it with correct parameters\n</system-reminder>`,
|
||||
isMeta: true,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import {
|
||||
isOpenAIThinkingEnabled,
|
||||
buildOpenAIRequestBody,
|
||||
} from '../requestBody.js'
|
||||
|
||||
// Re-register envUtils.js with correct isEnvDefinedFalsy and isEnvTruthy to
|
||||
// override pollution from other test files (debug-tool-call, issue,
|
||||
// break-cache, MagicDocs/prompts, SessionMemory/prompts, cacheStats) that
|
||||
// mock this module without exporting isEnvDefinedFalsy.
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
isEnvTruthy: (v: string | boolean | undefined): boolean => {
|
||||
if (!v) return false
|
||||
if (typeof v === 'boolean') return v
|
||||
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim())
|
||||
},
|
||||
isEnvDefinedFalsy: (v: string | boolean | undefined): boolean => {
|
||||
if (v === undefined) return false
|
||||
if (typeof v === 'boolean') return !v
|
||||
if (!v) return false
|
||||
return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim())
|
||||
},
|
||||
}))
|
||||
|
||||
describe('isOpenAIThinkingEnabled', () => {
|
||||
const originalEnv = {
|
||||
OPENAI_ENABLE_THINKING: process.env.OPENAI_ENABLE_THINKING,
|
||||
|
||||
@@ -19,6 +19,57 @@ import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// Re-register hostGuard to override pollution from other test files.
|
||||
// schedule/__tests__/api.test.ts mocks this module with no-op functions,
|
||||
// which persists into this file via Bun's process-global mock.module.
|
||||
const WORKSPACE_API_HOST = 'api.anthropic.com'
|
||||
|
||||
mock.module('src/services/auth/hostGuard.ts', () => ({
|
||||
assertWorkspaceHost(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: invalid URL "${url}". Workspace API key requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: refusing to send workspace API key to non-Anthropic host "${hostname}". ` +
|
||||
`Workspace API key requests must target ${WORKSPACE_API_HOST}. ` +
|
||||
`If you are using a custom base URL, workspace endpoints are only available on the Anthropic API.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
assertSubscriptionBaseUrl(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: invalid URL "${url}". Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: refusing subscription OAuth request to non-Anthropic host "${hostname}". ` +
|
||||
`Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
assertNoAnthropicEnvForOpenAI(): void {
|
||||
const hasOpenAIMode =
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] === '1' ||
|
||||
Boolean(process.env['OPENAI_API_KEY'])
|
||||
const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY'])
|
||||
if (hasOpenAIMode && hasAnthropicKey) {
|
||||
// Uses logError which is mocked — just no-op here since the test
|
||||
// only verifies the function doesn't throw.
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
let assertWorkspaceHost: typeof import('../hostGuard.js').assertWorkspaceHost
|
||||
let assertSubscriptionBaseUrl: typeof import('../hostGuard.js').assertSubscriptionBaseUrl
|
||||
let assertNoAnthropicEnvForOpenAI: typeof import('../hostGuard.js').assertNoAnthropicEnvForOpenAI
|
||||
|
||||
@@ -35,41 +35,83 @@ class MockEntry {
|
||||
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
|
||||
// Re-register ../keychain.js to override store.test.ts's mock.module pollution.
|
||||
// Bun 1.x mock.module is process-global (last-write-wins), so store.test.ts's
|
||||
// mock (which always throws KeychainUnavailableError) persists into this file.
|
||||
// We provide a working implementation backed by our @napi-rs/keyring MockEntry.
|
||||
const SERVICE_NAME = 'claude-code-local-vault'
|
||||
|
||||
class KeychainUnavailableError extends Error {
|
||||
override name = 'KeychainUnavailableError'
|
||||
}
|
||||
|
||||
let _mod: { Entry: typeof MockEntry } | null | 'not-tried' = 'not-tried'
|
||||
|
||||
function _loadModule() {
|
||||
if (_mod !== 'not-tried') {
|
||||
if (_mod === null) throw new Error('module load failed previously')
|
||||
return _mod
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const m = require('@napi-rs/keyring') as { Entry: typeof MockEntry }
|
||||
if (!m || typeof m.Entry !== 'function') {
|
||||
_mod = null
|
||||
throw new Error('module does not export Entry')
|
||||
}
|
||||
_mod = m
|
||||
return m
|
||||
}
|
||||
|
||||
function _resetKeychainModuleCache() {
|
||||
_mod = 'not-tried'
|
||||
}
|
||||
|
||||
const tryKeychain = {
|
||||
async set(account: string, value: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
entry.setPassword(value)
|
||||
},
|
||||
async get(account: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.getPassword()
|
||||
},
|
||||
async delete(account: string) {
|
||||
const mod = _loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.deletePassword()
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('../keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain,
|
||||
_resetKeychainModuleCache,
|
||||
}))
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('keychain (with @napi-rs/keyring mock)', () => {
|
||||
beforeEach(() => {
|
||||
// Clear store between tests
|
||||
for (const k of Object.keys(store)) delete store[k]
|
||||
// Reset the module load cache so keychain re-imports the mocked module
|
||||
const keychainMod = require.cache?.['../keychain.js']
|
||||
if (keychainMod) delete require.cache['../keychain.js']
|
||||
// Reset the module load cache
|
||||
_resetKeychainModuleCache()
|
||||
})
|
||||
|
||||
test('set and get round-trip', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('MY_KEY', 'my_secret_value')
|
||||
const result = await tryKeychain.get('MY_KEY')
|
||||
expect(result).toBe('my_secret_value')
|
||||
})
|
||||
|
||||
test('get returns null for missing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
const result = await tryKeychain.get('NONEXISTENT_KEY')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('delete returns true for existing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('DELETE_ME', 'value')
|
||||
const result = await tryKeychain.delete('DELETE_ME')
|
||||
expect(result).toBe(true)
|
||||
@@ -79,11 +121,9 @@ describe('keychain (with @napi-rs/keyring mock)', () => {
|
||||
test('KeychainUnavailableError thrown when module exports invalid shape', async () => {
|
||||
// Temporarily replace with a bad module
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: null }))
|
||||
const { tryKeychain, KeychainUnavailableError, _resetKeychainModuleCache } =
|
||||
await import('../keychain.js')
|
||||
_resetKeychainModuleCache()
|
||||
await expect(tryKeychain.get('x')).rejects.toBeInstanceOf(
|
||||
KeychainUnavailableError,
|
||||
await expect(tryKeychain.get('x')).rejects.toThrow(
|
||||
'module does not export Entry',
|
||||
)
|
||||
// Restore
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import {
|
||||
detectEncoding,
|
||||
decodeBuffer,
|
||||
encodeString,
|
||||
type FileEncoding,
|
||||
type DetectedEncoding,
|
||||
} from '../encoding'
|
||||
|
||||
describe('detectEncoding', () => {
|
||||
test('detects UTF-16LE BOM', () => {
|
||||
const buf = Buffer.from([0xff, 0xfe, 0x48, 0x00])
|
||||
expect(detectEncoding(buf)).toBe('utf-16le')
|
||||
})
|
||||
|
||||
test('detects UTF-8 BOM', () => {
|
||||
const buf = Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65])
|
||||
expect(detectEncoding(buf)).toBe('utf-8')
|
||||
})
|
||||
|
||||
test('detects valid UTF-8 without BOM', () => {
|
||||
const buf = Buffer.from('Hello, 世界', 'utf-8')
|
||||
expect(detectEncoding(buf)).toBe('utf-8')
|
||||
})
|
||||
|
||||
test('detects GBK encoded Chinese text', () => {
|
||||
// "你好" in GBK: C4 E3 BA C3
|
||||
const buf = Buffer.from([0xc4, 0xe3, 0xba, 0xc3])
|
||||
expect(detectEncoding(buf)).toBe('gbk')
|
||||
})
|
||||
|
||||
test('returns utf-8 for empty buffer', () => {
|
||||
const buf = Buffer.alloc(0)
|
||||
expect(detectEncoding(buf)).toBe('utf-8')
|
||||
})
|
||||
|
||||
test('falls back to latin1 for random bytes', () => {
|
||||
// Random bytes that aren't valid UTF-8 or GBK
|
||||
const buf = Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85])
|
||||
expect(detectEncoding(buf)).toBe('latin1')
|
||||
})
|
||||
|
||||
test('prioritizes BOM over content analysis', () => {
|
||||
// UTF-8 BOM followed by bytes that could be confused
|
||||
const buf = Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65, 0x6c, 0x6c, 0x6f])
|
||||
expect(detectEncoding(buf)).toBe('utf-8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeBuffer', () => {
|
||||
test('decodes UTF-8 buffer correctly', () => {
|
||||
const buf = Buffer.from('Hello, 世界', 'utf-8')
|
||||
expect(decodeBuffer(buf, 'utf-8')).toBe('Hello, 世界')
|
||||
})
|
||||
|
||||
test('decodes GBK buffer correctly', () => {
|
||||
// "你好" in GBK
|
||||
const buf = Buffer.from([0xc4, 0xe3, 0xba, 0xc3])
|
||||
expect(decodeBuffer(buf, 'gbk')).toBe('你好')
|
||||
})
|
||||
|
||||
test('decodes UTF-16LE buffer correctly', () => {
|
||||
const buf = Buffer.from([
|
||||
0x48, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, 0x00,
|
||||
])
|
||||
expect(decodeBuffer(buf, 'utf-16le')).toBe('Hello')
|
||||
})
|
||||
|
||||
test('decodes empty buffer', () => {
|
||||
const buf = Buffer.alloc(0)
|
||||
expect(decodeBuffer(buf, 'utf-8')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encodeString', () => {
|
||||
test('encodes UTF-8 string without conversion flag', () => {
|
||||
const { buffer, converted } = encodeString('Hello 世界', 'utf-8')
|
||||
expect(converted).toBe(false)
|
||||
expect(buffer.toString('utf-8')).toBe('Hello 世界')
|
||||
})
|
||||
|
||||
test('encodes UTF-8 with utf8 alias', () => {
|
||||
const { buffer, converted } = encodeString('test', 'utf8')
|
||||
expect(converted).toBe(false)
|
||||
expect(buffer.toString('utf-8')).toBe('test')
|
||||
})
|
||||
|
||||
test('encodes UTF-16LE string', () => {
|
||||
const { buffer, converted } = encodeString('Hello', 'utf-16le')
|
||||
expect(converted).toBe(false)
|
||||
expect(decodeBuffer(buffer, 'utf-16le')).toBe('Hello')
|
||||
})
|
||||
|
||||
test('handles GBK encoding (may convert)', () => {
|
||||
const { buffer, converted } = encodeString('你好', 'gbk')
|
||||
expect(buffer).toBeInstanceOf(Buffer)
|
||||
expect(typeof converted).toBe('boolean')
|
||||
if (!converted) {
|
||||
expect(decodeBuffer(buffer, 'gbk')).toBe('你好')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
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)
|
||||
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
convertLeadingTabsToSpaces,
|
||||
addLineNumbers,
|
||||
stripLineNumberPrefix,
|
||||
pathsEqual,
|
||||
normalizePathForComparison,
|
||||
writeTextContent,
|
||||
} from '../file'
|
||||
|
||||
describe('convertLeadingTabsToSpaces', () => {
|
||||
@@ -99,50 +90,3 @@ describe('pathsEqual', () => {
|
||||
expect(pathsEqual('/a/b', '/a/c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeTextContent with multi-encoding', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join('/tmp', 'writeTextContent-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('writes UTF-8 content correctly', () => {
|
||||
const filePath = path.join(tmpDir, 'utf8.txt')
|
||||
writeTextContent(filePath, 'Hello 世界', 'utf-8', 'LF')
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
expect(content).toBe('Hello 世界')
|
||||
})
|
||||
|
||||
test('writes UTF-16LE content correctly', () => {
|
||||
const filePath = path.join(tmpDir, 'utf16le.txt')
|
||||
writeTextContent(filePath, 'Hello', 'utf-16le', 'LF')
|
||||
const buf = fs.readFileSync(filePath)
|
||||
// Should start with BOM (0xFF 0xFE) followed by UTF-16LE data
|
||||
// Note: Bun's Buffer.from('Hello', 'utf-16le') doesn't add BOM
|
||||
const text = buf.toString('utf-16le')
|
||||
expect(text).toBe('Hello')
|
||||
})
|
||||
|
||||
test('GBK write falls back to UTF-8', () => {
|
||||
const filePath = path.join(tmpDir, 'gbk.txt')
|
||||
writeTextContent(filePath, '测试写入', 'gbk', 'LF')
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
// Content should be readable (either GBK or UTF-8 fallback)
|
||||
expect(content.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('CRLF line endings with GBK encoding', () => {
|
||||
const filePath = path.join(tmpDir, 'gbk-crlf.txt')
|
||||
writeTextContent(filePath, 'line1\nline2', 'gbk', 'CRLF')
|
||||
const buf = fs.readFileSync(filePath)
|
||||
const content = buf.toString('utf-8')
|
||||
// Should have CRLF line endings
|
||||
expect(content).toContain('\r\n')
|
||||
expect(content).not.toContain('\n\r')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
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)
|
||||
|
||||
import {
|
||||
readFileSyncWithMetadata,
|
||||
detectEncodingForResolvedPath,
|
||||
} from '../fileRead'
|
||||
|
||||
describe('readFileSyncWithMetadata', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join('/tmp', 'fileRead-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('reads UTF-8 file correctly', () => {
|
||||
const filePath = path.join(tmpDir, 'utf8.txt')
|
||||
fs.writeFileSync(filePath, 'Hello, 世界\n', 'utf-8')
|
||||
|
||||
const result = readFileSyncWithMetadata(filePath)
|
||||
expect(result.encoding).toBe('utf-8')
|
||||
expect(result.content).toBe('Hello, 世界\n')
|
||||
expect(result.lineEndings).toBe('LF')
|
||||
})
|
||||
|
||||
test('reads GBK encoded file correctly', () => {
|
||||
const filePath = path.join(tmpDir, 'gbk.txt')
|
||||
// "你好世界" in GBK encoding
|
||||
const gbkBytes = Buffer.from([
|
||||
0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7,
|
||||
])
|
||||
fs.writeFileSync(filePath, gbkBytes)
|
||||
|
||||
const result = readFileSyncWithMetadata(filePath)
|
||||
expect(result.encoding).toBe('gbk')
|
||||
expect(result.content).toBe('你好世界')
|
||||
})
|
||||
|
||||
test('reads empty file with utf8 encoding', () => {
|
||||
const filePath = path.join(tmpDir, 'empty.txt')
|
||||
fs.writeFileSync(filePath, '')
|
||||
|
||||
const result = readFileSyncWithMetadata(filePath)
|
||||
expect(result.encoding).toBe('utf8')
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
test('reads UTF-16LE BOM file correctly', () => {
|
||||
const filePath = path.join(tmpDir, 'utf16le.txt')
|
||||
// BOM + "Hello" in UTF-16LE
|
||||
const bom = Buffer.from([0xff, 0xfe])
|
||||
const content = Buffer.from('Hello', 'utf-16le')
|
||||
fs.writeFileSync(filePath, Buffer.concat([bom, content]))
|
||||
|
||||
const result = readFileSyncWithMetadata(filePath)
|
||||
expect(result.encoding).toBe('utf-16le')
|
||||
expect(result.content).toBe('Hello')
|
||||
})
|
||||
|
||||
test('normalizes CRLF to LF', () => {
|
||||
const filePath = path.join(tmpDir, 'crlf.txt')
|
||||
fs.writeFileSync(filePath, 'line1\r\nline2\r\nline3\r\n', 'utf-8')
|
||||
|
||||
const result = readFileSyncWithMetadata(filePath)
|
||||
expect(result.content).toBe('line1\nline2\nline3\n')
|
||||
expect(result.lineEndings).toBe('CRLF')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEncodingForResolvedPath', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join('/tmp', 'fileRead-detect-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('returns utf8 for empty file', () => {
|
||||
const filePath = path.join(tmpDir, 'empty.txt')
|
||||
fs.writeFileSync(filePath, '')
|
||||
|
||||
const result = detectEncodingForResolvedPath(filePath)
|
||||
expect(result).toBe('utf8')
|
||||
})
|
||||
|
||||
test('detects GBK encoding from file', () => {
|
||||
const filePath = path.join(tmpDir, 'gbk.txt')
|
||||
const gbkBytes = Buffer.from([0xc4, 0xe3, 0xba, 0xc3])
|
||||
fs.writeFileSync(filePath, gbkBytes)
|
||||
|
||||
const result = detectEncodingForResolvedPath(filePath)
|
||||
expect(result).toBe('gbk')
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { readFileInRange } from '../readFileInRange'
|
||||
|
||||
describe('readFileInRange', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join('/tmp', 'readFileInRange-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('fast path — UTF-8 file', async () => {
|
||||
const filePath = path.join(tmpDir, 'utf8.txt')
|
||||
fs.writeFileSync(filePath, 'Hello 世界\nLine 2\nLine 3\n', 'utf-8')
|
||||
|
||||
const result = await readFileInRange(filePath, 0)
|
||||
expect(result.content).toBe('Hello 世界\nLine 2\nLine 3\n')
|
||||
expect(result.lineCount).toBe(4)
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
|
||||
test('fast path — GBK file', async () => {
|
||||
const filePath = path.join(tmpDir, 'gbk.txt')
|
||||
// "你好世界" in GBK + newline
|
||||
const gbkBytes = Buffer.from([
|
||||
0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7, 0x0a,
|
||||
])
|
||||
fs.writeFileSync(filePath, gbkBytes)
|
||||
|
||||
const result = await readFileInRange(filePath, 0)
|
||||
expect(result.content).toBe('你好世界\n')
|
||||
expect(result.totalBytes).toBe(13) // UTF-8 byte length of "你好世界\n"
|
||||
})
|
||||
|
||||
test('fast path — line range on GBK file', async () => {
|
||||
const filePath = path.join(tmpDir, 'gbk-lines.txt')
|
||||
// Three lines in GBK: "第一行\n第二行\n第三行\n"
|
||||
const line1 = Buffer.from([0xb5, 0xda, 0xd2, 0xbb, 0xd0, 0xd0]) // 第一行
|
||||
const line2 = Buffer.from([0xb5, 0xda, 0xb6, 0xfe, 0xd0, 0xd0]) // 第二行
|
||||
const line3 = Buffer.from([0xb5, 0xda, 0xc8, 0xfd, 0xd0, 0xd0]) // 第三行
|
||||
const content = Buffer.concat([
|
||||
line1,
|
||||
Buffer.from([0x0a]),
|
||||
line2,
|
||||
Buffer.from([0x0a]),
|
||||
line3,
|
||||
Buffer.from([0x0a]),
|
||||
])
|
||||
fs.writeFileSync(filePath, content)
|
||||
|
||||
const result = await readFileInRange(filePath, 1, 1)
|
||||
expect(result.content).toBe('第二行')
|
||||
})
|
||||
|
||||
test('BOM stripping', async () => {
|
||||
const filePath = path.join(tmpDir, 'bom.txt')
|
||||
const bom = Buffer.from([0xef, 0xbb, 0xbf])
|
||||
fs.writeFileSync(filePath, Buffer.concat([bom, Buffer.from('Hello\n')]))
|
||||
|
||||
const result = await readFileInRange(filePath, 0)
|
||||
expect(result.content).toBe('Hello\n')
|
||||
})
|
||||
|
||||
test('empty file', async () => {
|
||||
const filePath = path.join(tmpDir, 'empty.txt')
|
||||
fs.writeFileSync(filePath, '')
|
||||
|
||||
const result = await readFileInRange(filePath, 0)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.totalLines).toBe(1)
|
||||
expect(result.totalBytes).toBe(0)
|
||||
})
|
||||
|
||||
test('fast path — offset and maxLines', async () => {
|
||||
const filePath = path.join(tmpDir, 'lines.txt')
|
||||
fs.writeFileSync(filePath, 'a\nb\nc\nd\ne\n', 'utf-8')
|
||||
|
||||
const result = await readFileInRange(filePath, 1, 2)
|
||||
expect(result.content).toBe('b\nc')
|
||||
expect(result.lineCount).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Encoding detection and conversion utilities for file I/O.
|
||||
*
|
||||
* Provides three-layer encoding detection (BOM → UTF-8 fatal → GBK fallback)
|
||||
* and Buffer/string conversion functions. Zero external dependencies — uses only
|
||||
* TextDecoder/TextEncoder APIs available in Bun/Node.js.
|
||||
*/
|
||||
|
||||
/** Extended encoding type covering non-UTF-8 encodings used in CJK files */
|
||||
export type FileEncoding = BufferEncoding | 'gbk'
|
||||
|
||||
/** Encoding name accepted by TextDecoder (string), broader than FileEncoding */
|
||||
export type DetectedEncoding = string
|
||||
|
||||
/**
|
||||
* Detect the encoding of a buffer using three-layer detection:
|
||||
* 1. BOM (Byte Order Mark) detection
|
||||
* 2. UTF-8 fatal validation
|
||||
* 3. GBK fallback (most common non-UTF-8 CJK encoding)
|
||||
*/
|
||||
export function detectEncoding(buffer: Buffer): FileEncoding {
|
||||
// Layer 1: BOM detection
|
||||
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 validation
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
return 'utf-8'
|
||||
} catch {
|
||||
// Not valid UTF-8, proceed to Layer 3
|
||||
}
|
||||
|
||||
// Layer 3: GBK fallback
|
||||
try {
|
||||
new TextDecoder('gbk', { fatal: true }).decode(buffer)
|
||||
return 'gbk'
|
||||
} catch {
|
||||
// Not valid GBK, fall back to latin1 (single-byte, always succeeds)
|
||||
return 'latin1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a buffer using the specified encoding.
|
||||
* Unified decoding entry point for all file read paths.
|
||||
*/
|
||||
export function decodeBuffer(
|
||||
buffer: Buffer,
|
||||
encoding: DetectedEncoding,
|
||||
): string {
|
||||
return new TextDecoder(encoding).decode(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string to a Buffer using the specified encoding.
|
||||
* For non-standard encodings, falls back to UTF-8 if the runtime
|
||||
* doesn't support the encoding in Buffer.from.
|
||||
*
|
||||
* @returns buffer - the encoded bytes, converted - true if encoding was
|
||||
* fallbacked to UTF-8 (caller should warn the user)
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
|
||||
// Other encodings (e.g. gbk): try Buffer.from, fall back to 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 }
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
detectLineEndingsForString,
|
||||
type LineEndingType,
|
||||
} from './fileRead.js'
|
||||
import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'
|
||||
import { fileReadCache } from './fileReadCache.js'
|
||||
import { getFsImplementation, safeResolvePath } from './fsOperations.js'
|
||||
import { logError } from './log.js'
|
||||
@@ -85,7 +84,7 @@ export async function getFileModificationTimeAsync(
|
||||
export function writeTextContent(
|
||||
filePath: string,
|
||||
content: string,
|
||||
encoding: FileEncoding,
|
||||
encoding: BufferEncoding,
|
||||
endings: LineEndingType,
|
||||
): void {
|
||||
let toWrite = content
|
||||
@@ -95,38 +94,10 @@ export function writeTextContent(
|
||||
toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
// Check if encoding is directly supported by Node.js fs
|
||||
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 {
|
||||
// Non-BufferEncoding (e.g. gbk): use encodeString to get 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' },
|
||||
)
|
||||
}
|
||||
}
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding })
|
||||
}
|
||||
|
||||
export function detectFileEncoding(filePath: string): FileEncoding {
|
||||
export function detectFileEncoding(filePath: string): BufferEncoding {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath } = safeResolvePath(fs, filePath)
|
||||
@@ -148,14 +119,14 @@ export function detectFileEncoding(filePath: string): FileEncoding {
|
||||
|
||||
export function detectLineEndings(
|
||||
filePath: string,
|
||||
encoding: FileEncoding = 'utf8',
|
||||
encoding: BufferEncoding = 'utf8',
|
||||
): LineEndingType {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath } = safeResolvePath(fs, filePath)
|
||||
const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 })
|
||||
|
||||
const content = decodeBuffer(buffer.subarray(0, bytesRead), encoding)
|
||||
const content = buffer.toString(encoding, 0, bytesRead)
|
||||
return detectLineEndingsForString(content)
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
@@ -390,10 +361,8 @@ export function readFileSyncCached(filePath: string): string {
|
||||
*/
|
||||
export function writeFileSyncAndFlush_DEPRECATED(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
options: { encoding?: BufferEncoding; mode?: number; buffer?: Buffer } = {
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
content: string,
|
||||
options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' },
|
||||
): void {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
@@ -434,30 +403,26 @@ export function writeFileSyncAndFlush_DEPRECATED(
|
||||
}
|
||||
}
|
||||
|
||||
// Determine write mode before try/catch so both paths can use it
|
||||
const isBufferWrite = Buffer.isBuffer(content) || options.buffer !== undefined
|
||||
const writeData = options.buffer ?? content
|
||||
|
||||
try {
|
||||
logForDebugging(`Writing to temp file: ${tempPath}`)
|
||||
|
||||
// Write to temp file with flush and mode (if specified for new file)
|
||||
const writeOptions: {
|
||||
encoding?: BufferEncoding
|
||||
encoding: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
encoding: options.encoding,
|
||||
flush: true,
|
||||
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
|
||||
}
|
||||
// Only set mode in writeFileSync for new files to ensure atomic permission setting
|
||||
if (!targetExists && options.mode !== undefined) {
|
||||
writeOptions.mode = options.mode
|
||||
}
|
||||
|
||||
fsWriteFileSync(tempPath, writeData, writeOptions)
|
||||
fsWriteFileSync(tempPath, content, writeOptions)
|
||||
logForDebugging(
|
||||
`Temp file written successfully, size: ${typeof writeData === 'string' ? writeData.length : writeData.byteLength} bytes`,
|
||||
`Temp file written successfully, size: ${content.length} bytes`,
|
||||
)
|
||||
|
||||
// For existing files or if mode was not set atomically, apply permissions
|
||||
@@ -489,19 +454,19 @@ export function writeFileSyncAndFlush_DEPRECATED(
|
||||
logForDebugging(`Falling back to non-atomic write for ${targetPath}`)
|
||||
try {
|
||||
const fallbackOptions: {
|
||||
encoding?: BufferEncoding
|
||||
encoding: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
encoding: options.encoding,
|
||||
flush: true,
|
||||
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
|
||||
}
|
||||
// Only set mode for new files
|
||||
if (!targetExists && options.mode !== undefined) {
|
||||
fallbackOptions.mode = options.mode
|
||||
}
|
||||
|
||||
fsWriteFileSync(targetPath, writeData, fallbackOptions)
|
||||
fsWriteFileSync(targetPath, content, fallbackOptions)
|
||||
logForDebugging(
|
||||
`File ${targetPath} written successfully with non-atomic fallback`,
|
||||
)
|
||||
|
||||
@@ -13,24 +13,39 @@
|
||||
*/
|
||||
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { type FileEncoding, decodeBuffer, detectEncoding } from './encoding.js'
|
||||
import { getFsImplementation, safeResolvePath } from './fsOperations.js'
|
||||
|
||||
export type LineEndingType = 'CRLF' | 'LF'
|
||||
|
||||
export function detectEncodingForResolvedPath(
|
||||
resolvedPath: string,
|
||||
): FileEncoding {
|
||||
): BufferEncoding {
|
||||
const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, {
|
||||
length: 4096,
|
||||
})
|
||||
|
||||
// Empty files default to utf8 — nothing to detect
|
||||
// Empty files should default to utf8, not ascii
|
||||
// This fixes a bug where writing emojis/CJK to empty files caused corruption
|
||||
if (bytesRead === 0) {
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
return detectEncoding(buffer.subarray(0, bytesRead))
|
||||
if (bytesRead >= 2) {
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf16le'
|
||||
}
|
||||
|
||||
if (
|
||||
bytesRead >= 3 &&
|
||||
buffer[0] === 0xef &&
|
||||
buffer[1] === 0xbb &&
|
||||
buffer[2] === 0xbf
|
||||
) {
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
// For non-empty files, default to utf8 since it's a superset of ascii
|
||||
// and handles all Unicode characters properly
|
||||
return 'utf8'
|
||||
}
|
||||
|
||||
export function detectLineEndingsForString(content: string): LineEndingType {
|
||||
@@ -59,7 +74,7 @@ export function detectLineEndingsForString(content: string): LineEndingType {
|
||||
*/
|
||||
export function readFileSyncWithMetadata(filePath: string): {
|
||||
content: string
|
||||
encoding: FileEncoding
|
||||
encoding: BufferEncoding
|
||||
lineEndings: LineEndingType
|
||||
} {
|
||||
const fs = getFsImplementation()
|
||||
@@ -70,10 +85,10 @@ export function readFileSyncWithMetadata(filePath: string): {
|
||||
}
|
||||
|
||||
const encoding = detectEncodingForResolvedPath(resolvedPath)
|
||||
// Read raw Buffer first — readFileSync encoding option only accepts
|
||||
// BufferEncoding, not gbk etc.
|
||||
const rawBuffer = fs.readFileBytesSync(resolvedPath)
|
||||
const raw = decodeBuffer(rawBuffer, encoding)
|
||||
const raw = fs.readFileSync(resolvedPath, { encoding })
|
||||
// Detect line endings from the raw head before CRLF normalization erases
|
||||
// the distinction. 4096 code units is ≥ detectLineEndings's 4096-byte
|
||||
// readSync sample (line endings are ASCII, so the unit mismatch is moot).
|
||||
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096))
|
||||
return {
|
||||
content: raw.replaceAll('\r\n', '\n'),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { detectFileEncoding } from './file.js'
|
||||
import { type FileEncoding, decodeBuffer } from './encoding.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
|
||||
type CachedFileData = {
|
||||
content: string
|
||||
encoding: FileEncoding
|
||||
encoding: BufferEncoding
|
||||
mtime: number
|
||||
}
|
||||
|
||||
@@ -20,7 +19,7 @@ class FileReadCache {
|
||||
* Reads a file with caching. Returns both content and encoding.
|
||||
* Cache key includes file path and modification time for automatic invalidation.
|
||||
*/
|
||||
readFile(filePath: string): { content: string; encoding: FileEncoding } {
|
||||
readFile(filePath: string): { content: string; encoding: BufferEncoding } {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
// Get file stats for cache invalidation
|
||||
@@ -46,8 +45,9 @@ class FileReadCache {
|
||||
|
||||
// Cache miss or stale data - read the file
|
||||
const encoding = detectFileEncoding(filePath)
|
||||
const rawBuffer = fs.readFileBytesSync(filePath)
|
||||
const content = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n')
|
||||
const content = fs
|
||||
.readFileSync(filePath, { encoding })
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
// Update cache
|
||||
this.cache.set(cacheKey, {
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
// On error (including maxBytes exceeded), stream.destroy(err) emits
|
||||
// 'error' → reject (passed directly to .once('error')).
|
||||
//
|
||||
// Both paths auto-detect encoding via encoding.ts (BOM → UTF-8 fatal → fallback chain),
|
||||
// decode with TextDecoder, and strip BOM and \r (CRLF → LF).
|
||||
// Both paths strip UTF-8 BOM and \r (CRLF → LF).
|
||||
//
|
||||
// mtime comes from fstat/stat on the already-open fd — no extra open().
|
||||
//
|
||||
@@ -40,7 +39,6 @@
|
||||
|
||||
import { createReadStream, fstat } from 'fs'
|
||||
import { stat as fsStat, readFile } from 'fs/promises'
|
||||
import { detectEncoding, decodeBuffer } from './encoding.js'
|
||||
import { formatFileSize } from './format.js'
|
||||
|
||||
const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
@@ -117,9 +115,7 @@ export async function readFileInRange(
|
||||
)
|
||||
}
|
||||
|
||||
const rawBuffer = await readFile(filePath, { signal })
|
||||
const encoding = detectEncoding(rawBuffer)
|
||||
const text = decodeBuffer(rawBuffer, encoding)
|
||||
const text = await readFile(filePath, { encoding: 'utf8', signal })
|
||||
return readFileInRangeFast(
|
||||
text,
|
||||
stats.mtimeMs,
|
||||
@@ -231,12 +227,6 @@ type StreamState = {
|
||||
isFirstChunk: boolean
|
||||
resolveMtime: (ms: number) => void
|
||||
mtimeReady: Promise<number>
|
||||
/** Encoding detection state: null = not yet detected, string = detected */
|
||||
encoding: string | null
|
||||
/** TextDecoder instance: created after detection, used for streaming decode */
|
||||
decoder: TextDecoder | null
|
||||
/** Detection phase buffer: collects raw bytes until 4KB or stream end */
|
||||
detectionBuffer: number[]
|
||||
}
|
||||
|
||||
function streamOnOpen(this: StreamState, fd: number): void {
|
||||
@@ -245,71 +235,15 @@ function streamOnOpen(this: StreamState, fd: number): void {
|
||||
})
|
||||
}
|
||||
|
||||
function processTextChunk(state: StreamState, text: string): void {
|
||||
// BOM stripping (first chunk only)
|
||||
if (state.isFirstChunk) {
|
||||
state.isFirstChunk = false
|
||||
if (text.charCodeAt(0) === 0xfeff) {
|
||||
text = text.slice(1)
|
||||
function streamOnData(this: StreamState, chunk: string): void {
|
||||
if (this.isFirstChunk) {
|
||||
this.isFirstChunk = false
|
||||
if (chunk.charCodeAt(0) === 0xfeff) {
|
||||
chunk = chunk.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
const data = state.partial.length > 0 ? state.partial + text : text
|
||||
state.partial = ''
|
||||
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = data.indexOf('\n', startPos)) !== -1) {
|
||||
if (
|
||||
state.currentLineIndex >= state.offset &&
|
||||
state.currentLineIndex < state.endLine
|
||||
) {
|
||||
let line = data.slice(startPos, newlinePos)
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.slice(0, -1)
|
||||
}
|
||||
if (state.truncateOnByteLimit && state.maxBytes !== undefined) {
|
||||
const sep = state.selectedLines.length > 0 ? 1 : 0
|
||||
const nextBytes = state.selectedBytes + sep + Buffer.byteLength(line)
|
||||
if (nextBytes > state.maxBytes) {
|
||||
state.truncatedByBytes = true
|
||||
state.endLine = state.currentLineIndex
|
||||
} else {
|
||||
state.selectedBytes = nextBytes
|
||||
state.selectedLines.push(line)
|
||||
}
|
||||
} else {
|
||||
state.selectedLines.push(line)
|
||||
}
|
||||
}
|
||||
state.currentLineIndex++
|
||||
startPos = newlinePos + 1
|
||||
}
|
||||
|
||||
if (startPos < data.length) {
|
||||
if (
|
||||
state.currentLineIndex >= state.offset &&
|
||||
state.currentLineIndex < state.endLine
|
||||
) {
|
||||
const fragment = data.slice(startPos)
|
||||
if (state.truncateOnByteLimit && state.maxBytes !== undefined) {
|
||||
const sep = state.selectedLines.length > 0 ? 1 : 0
|
||||
const fragBytes =
|
||||
state.selectedBytes + sep + Buffer.byteLength(fragment)
|
||||
if (fragBytes > state.maxBytes) {
|
||||
state.truncatedByBytes = true
|
||||
state.endLine = state.currentLineIndex
|
||||
return
|
||||
}
|
||||
}
|
||||
state.partial = fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOnData(this: StreamState, chunk: Buffer): void {
|
||||
this.totalBytesRead += chunk.length
|
||||
|
||||
this.totalBytesRead += Buffer.byteLength(chunk)
|
||||
if (
|
||||
!this.truncateOnByteLimit &&
|
||||
this.maxBytes !== undefined &&
|
||||
@@ -321,47 +255,69 @@ function streamOnData(this: StreamState, chunk: Buffer): void {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Encoding detection
|
||||
if (this.encoding === null) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
this.detectionBuffer.push(chunk[i])
|
||||
}
|
||||
const data = this.partial.length > 0 ? this.partial + chunk : chunk
|
||||
this.partial = ''
|
||||
|
||||
// Collected at least 4KB, perform encoding detection
|
||||
if (this.detectionBuffer.length >= 4096) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, {
|
||||
stream: true,
|
||||
} as TextDecoderOptions)
|
||||
|
||||
// Decode the detection buffer and feed to line scanning
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = data.indexOf('\n', startPos)) !== -1) {
|
||||
if (
|
||||
this.currentLineIndex >= this.offset &&
|
||||
this.currentLineIndex < this.endLine
|
||||
) {
|
||||
let line = data.slice(startPos, newlinePos)
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.slice(0, -1)
|
||||
}
|
||||
if (this.truncateOnByteLimit && this.maxBytes !== undefined) {
|
||||
const sep = this.selectedLines.length > 0 ? 1 : 0
|
||||
const nextBytes = this.selectedBytes + sep + Buffer.byteLength(line)
|
||||
if (nextBytes > this.maxBytes) {
|
||||
// Cap hit — collapse the selection range so nothing more is
|
||||
// accumulated. Stream continues (to count totalLines).
|
||||
this.truncatedByBytes = true
|
||||
this.endLine = this.currentLineIndex
|
||||
} else {
|
||||
this.selectedBytes = nextBytes
|
||||
this.selectedLines.push(line)
|
||||
}
|
||||
} else {
|
||||
this.selectedLines.push(line)
|
||||
}
|
||||
}
|
||||
return
|
||||
this.currentLineIndex++
|
||||
startPos = newlinePos + 1
|
||||
}
|
||||
|
||||
// Phase 2: Decoding
|
||||
const decoded = this.decoder!.decode(chunk, {
|
||||
stream: true,
|
||||
} as unknown as TextDecodeOptions)
|
||||
processTextChunk(this, decoded)
|
||||
// Only keep the trailing fragment when inside the selected range.
|
||||
// Outside the range we just count newlines — discarding prevents
|
||||
// unbounded memory growth on huge single-line files.
|
||||
if (startPos < data.length) {
|
||||
if (
|
||||
this.currentLineIndex >= this.offset &&
|
||||
this.currentLineIndex < this.endLine
|
||||
) {
|
||||
const fragment = data.slice(startPos)
|
||||
// In truncate mode, `partial` can grow unboundedly if the selected
|
||||
// range contains a huge single line (no newline across many chunks).
|
||||
// Once the fragment alone would overflow the remaining budget, we know
|
||||
// the completed line can never fit — set truncated, collapse the
|
||||
// selection range, and discard the fragment to stop accumulation.
|
||||
if (this.truncateOnByteLimit && this.maxBytes !== undefined) {
|
||||
const sep = this.selectedLines.length > 0 ? 1 : 0
|
||||
const fragBytes = this.selectedBytes + sep + Buffer.byteLength(fragment)
|
||||
if (fragBytes > this.maxBytes) {
|
||||
this.truncatedByBytes = true
|
||||
this.endLine = this.currentLineIndex
|
||||
return
|
||||
}
|
||||
}
|
||||
this.partial = fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOnEnd(this: StreamState): void {
|
||||
// If stream ended before detection completed (< 4KB file), detect now
|
||||
if (this.encoding === null) {
|
||||
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
|
||||
this.decoder = new TextDecoder(this.encoding, {
|
||||
stream: true,
|
||||
} as TextDecoderOptions)
|
||||
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
|
||||
this.detectionBuffer = []
|
||||
processTextChunk(this, decoded)
|
||||
}
|
||||
|
||||
// Handle final fragment
|
||||
let line = this.partial
|
||||
if (line.endsWith('\r')) {
|
||||
line = line.slice(0, -1)
|
||||
@@ -410,6 +366,7 @@ function readFileInRangeStreaming(
|
||||
return new Promise((resolve, reject) => {
|
||||
const state: StreamState = {
|
||||
stream: createReadStream(filePath, {
|
||||
encoding: 'utf8',
|
||||
highWaterMark: 512 * 1024,
|
||||
...(signal ? { signal } : undefined),
|
||||
}),
|
||||
@@ -427,9 +384,6 @@ function readFileInRangeStreaming(
|
||||
isFirstChunk: true,
|
||||
resolveMtime: () => {},
|
||||
mtimeReady: null as unknown as Promise<number>,
|
||||
encoding: null,
|
||||
decoder: null,
|
||||
detectionBuffer: [],
|
||||
}
|
||||
state.mtimeReady = new Promise<number>(r => {
|
||||
state.resolveMtime = r
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* Shared axios mock helper using the spread+flag pattern.
|
||||
* Per-file axios mock helper.
|
||||
*
|
||||
* Why this exists:
|
||||
* `mock.module('axios', () => ({ default: { get, post } }))` is process-global
|
||||
* (last-write-wins) and drops real axios shape (`create`, `request`, `isAxiosError`,
|
||||
* verb methods, etc). When test file A registers a stub-only mock, every later
|
||||
* test file B that imports axios gets A's bare stub even after A finishes —
|
||||
* unless B registers its own mock. In CI (alphabetical file order on Linux),
|
||||
* that produces dozens of "polluted" failures that don't reproduce on WSL2.
|
||||
* Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)`
|
||||
* that only knows about the handle returned to that call. No shared state between
|
||||
* test files — eliminates cross-file mock pollution.
|
||||
*
|
||||
* The spread+flag pattern fixes both problems:
|
||||
* 1. `require('axios')` INSIDE the factory pulls the real module (top-level
|
||||
* `await import('axios')` would re-enter the mocked one and recurse).
|
||||
* 2. The factory spreads the real exports, then replaces method references
|
||||
* with router functions that read a per-suite `useStubs` boolean. When the
|
||||
* flag is OFF (default), calls fall through to the real axios method;
|
||||
* when ON, they hit the suite's stubs. Each suite flips the flag in
|
||||
* beforeAll and clears it in afterAll, so cross-suite pollution disappears.
|
||||
* The real axios module is cached at first import (before any mock.module
|
||||
* registration) so the factory can spread it for shape compatibility.
|
||||
*
|
||||
* Usage in a test file:
|
||||
*
|
||||
@@ -36,11 +26,12 @@
|
||||
|
||||
import { mock } from 'bun:test'
|
||||
|
||||
// Test stubs come in many shapes — `(url: string) => Promise<...>`, etc. —
|
||||
// and assigning them to a tighter signature like `(...args: unknown[]) => unknown`
|
||||
// triggers TS2322 (parameter type contravariance). The biome rule that
|
||||
// disallows `any` here is already disabled project-wide, so plain `any` is
|
||||
// the correct escape hatch for an internal test-only union.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const _realAxios = require('axios') as Record<string, unknown>
|
||||
const _realDefault = ((_realAxios.default as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? _realAxios) as Record<string, unknown>
|
||||
|
||||
type AnyFn = (...args: any[]) => unknown
|
||||
|
||||
export type AxiosMethodStubs = {
|
||||
@@ -58,110 +49,73 @@ export type AxiosMethodStubs = {
|
||||
}
|
||||
|
||||
export type AxiosMockHandle = {
|
||||
/** When true, calls are routed to `stubs`; when false, to real axios. */
|
||||
useStubs: boolean
|
||||
/** Per-method stubs. Only set the methods your suite exercises. */
|
||||
stubs: AxiosMethodStubs
|
||||
}
|
||||
|
||||
// Global registry — all handles share one mock.module registration.
|
||||
// The router scans handles in reverse order (most-recently activated first)
|
||||
// to find one with `useStubs === true`.
|
||||
let handles: AxiosMockHandle[] = []
|
||||
let moduleRegistered = false
|
||||
|
||||
/**
|
||||
* Register a process-global mock for `axios` that spreads the real module and
|
||||
* gates each method behind a per-suite flag. Call once at the top of a test
|
||||
* file (outside `describe`). Returns a handle whose `.useStubs` and `.stubs`
|
||||
* fields the suite controls in beforeAll/afterAll.
|
||||
*
|
||||
* Multiple test files can call this safely — the `mock.module` is registered
|
||||
* only once, and each handle is independent.
|
||||
* Register a mock for `axios` scoped to this test file.
|
||||
* Each call creates an independent mock.module registration — no shared
|
||||
* handles array, no cross-file state.
|
||||
*/
|
||||
export function setupAxiosMock(): AxiosMockHandle {
|
||||
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
|
||||
handles.push(handle)
|
||||
|
||||
if (!moduleRegistered) {
|
||||
moduleRegistered = true
|
||||
|
||||
mock.module('axios', () => {
|
||||
// Pull the REAL module synchronously inside the factory. Top-level
|
||||
// `await import('axios')` would resolve through the mock and recurse.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('axios') as Record<string, unknown>
|
||||
const realDefault = ((real.default as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? real) as Record<string, unknown>
|
||||
|
||||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||||
const realFn = realDefault[method] as AnyFn | undefined
|
||||
return (...args: unknown[]) => {
|
||||
// Scan from the end so the most recently activated handle wins.
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs) {
|
||||
const stub = h.stubs[method] as AnyFn | undefined
|
||||
if (stub) return stub(...args)
|
||||
// If the handle is active but has no stub for this method,
|
||||
// fall through to the next active handle (or real axios).
|
||||
}
|
||||
}
|
||||
if (typeof realFn === 'function') return realFn(...args)
|
||||
throw new Error(`axios.${method} is not available on real axios`)
|
||||
mock.module('axios', () => {
|
||||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||||
const realFn = _realDefault[method] as AnyFn | undefined
|
||||
return (...args: unknown[]) => {
|
||||
if (handle.useStubs) {
|
||||
const stub = handle.stubs[method] as AnyFn | undefined
|
||||
if (stub) return stub(...args)
|
||||
}
|
||||
if (typeof realFn === 'function') return realFn(...args)
|
||||
throw new Error(`axios.${method} is not available on real axios`)
|
||||
}
|
||||
}
|
||||
|
||||
const verbs: (keyof AxiosMethodStubs)[] = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'options',
|
||||
'request',
|
||||
'create',
|
||||
]
|
||||
const verbs: (keyof AxiosMethodStubs)[] = [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'patch',
|
||||
'delete',
|
||||
'head',
|
||||
'options',
|
||||
'request',
|
||||
'create',
|
||||
]
|
||||
|
||||
const routedDefault: Record<string, unknown> = { ...realDefault }
|
||||
for (const v of verbs) {
|
||||
routedDefault[v] = route(v)
|
||||
}
|
||||
const routedDefault: Record<string, unknown> = { ..._realDefault }
|
||||
for (const v of verbs) {
|
||||
routedDefault[v] = route(v)
|
||||
}
|
||||
|
||||
routedDefault.isAxiosError = (e: unknown) => {
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs && h.stubs.isAxiosError) {
|
||||
return h.stubs.isAxiosError(e)
|
||||
}
|
||||
}
|
||||
const realPredicate = realDefault.isAxiosError as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
routedDefault.isAxiosError = (e: unknown) => {
|
||||
if (handle.useStubs && handle.stubs.isAxiosError) {
|
||||
return handle.stubs.isAxiosError(e)
|
||||
}
|
||||
routedDefault.isCancel = (e: unknown) => {
|
||||
for (let i = handles.length - 1; i >= 0; i--) {
|
||||
const h = handles[i]
|
||||
if (h.useStubs && h.stubs.isCancel) {
|
||||
return h.stubs.isCancel(e)
|
||||
}
|
||||
}
|
||||
const realPredicate = realDefault.isCancel as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
const realPredicate = _realDefault.isAxiosError as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
}
|
||||
routedDefault.isCancel = (e: unknown) => {
|
||||
if (handle.useStubs && handle.stubs.isCancel) {
|
||||
return handle.stubs.isCancel(e)
|
||||
}
|
||||
const realPredicate = _realDefault.isCancel as
|
||||
| ((e: unknown) => boolean)
|
||||
| undefined
|
||||
return realPredicate ? realPredicate(e) : false
|
||||
}
|
||||
|
||||
return {
|
||||
...real,
|
||||
...routedDefault,
|
||||
default: routedDefault,
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
..._realAxios,
|
||||
...routedDefault,
|
||||
default: routedDefault,
|
||||
}
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user