Lesson 0006 给了你可观测性——你能看到扩展的决策过程。但每次改完代码,你还是要手动跑 node out/debug.js 再人眼扫输出。本课补上缺失的另一半:自动化验证。你会学会为 getLatestTokenCount() 设计 8 种 fixture 场景,用 Node.js 内置的 node:test(零依赖)写测试,然后以测试为安全网,把 debug.ts 和 extension.ts 之间的代码复制消除为共享模块。
npm test,3 秒出结果"。本课不引入任何 npm 依赖——node:test 是 Node 18+ 内置模块。
debug.ts 和测试解决的是两个正交的问题。一张表说清楚:
| debug.ts | 单元测试 | |
|---|---|---|
| 回答什么问题 | "行为为什么是这样?" | "行为还正确吗?" |
| 触发方式 | 手动运行 + 人眼解读 | 自动运行 + 机器判断 |
| 运行时间 | 秒级(需扫描真实文件) | 毫秒级(fixture 只有几十字节) |
| 覆盖范围 | 真实数据,一次一个快照 | 人造数据,覆盖所有边界 |
| 防回归 | ❌ 不防——没人每次改代码都跑 | ✅ 防——CI 里每次 push 都跑 |
| 最佳场景 | 排查"为什么这个会话没显示" | 确认"改了 A 没有弄坏 B" |
两者互补,不是替代:有测试没 debug.ts → 你知道"有 bug"但不知道"bug 在哪"。有 debug.ts 没测试 → 你能找到 bug 但你不知道"什么时候引入的"。
这个项目的核心逻辑有一个共同特征:不依赖 VS Code API。它们是纯数据变换——读 bytes、解析 JSON、计算数字、返回结构体。这意味着它们可以在任何 Node.js 进程中测试,不需要启动 VS Code:
| 函数 | 输入 | 输出 | 可测性 | 当前测试 |
|---|---|---|---|---|
getLatestTokenCount() | .jsonl 文件路径 | TokenUsage | ⭐⭐⭐⭐⭐ 完全独立 | 无 |
| Supersession 算法 | SessionInfo[] | 过滤后的 SessionInfo[] | ⭐⭐⭐⭐⭐ 纯数组操作 | 无 |
decodeProjectPath() | "C--dev-my-project" | {name, fullPath} | ⭐⭐⭐⭐⭐ 纯字符串 | 无 |
getContextLimitForModel() | "claude-sonnet-4-6-1m" | 1000000 | ⭐⭐⭐⭐⭐ 纯函数 | 无 |
formatTokens() | 144000 | "144K" | ⭐⭐⭐⭐⭐ 纯函数 | 无 |
getShortName() | "my-cool-project" | "MCP" | ⭐⭐⭐⭐⭐ 纯字符串 | 无 |
refreshAllSessions() | (无参数,读全局状态) | 更新状态栏 | ⭐⭐ 需 mock StatusBarItem | 无 |
node:test 就不够了。本课聚焦纯函数测试——不需要 mock。但当你未来需要测试 refreshAllSessions() 这种依赖 VS Code API 的函数时(需要 mock vscode.window.createStatusBarItem()),Vitest 的 vi.mock() 是更好的选择。详见 测试框架对比参考。
getLatestTokenCount()。它是整个扩展中最复杂、最容易出错的函数——90 行双向扫描逻辑、6 种边界情况。测试了它,就覆盖了扩展 60% 的 bug 风险。其他纯函数(decodeProjectPath、formatTokens、getContextLimitForModel)的测试模式完全相同,练习中会涉及。
Fixture(测试夹具)是人造的 .jsonl 文件,每个只包含刚好足够触发一种边界情况的数据。以下是 getLatestTokenCount() 需要覆盖的 8 种场景:
| Fixture 文件 | 场景 | 关键断言 |
|---|---|---|
normal-session.jsonl | 用户消息 → 助手回复(含 usage) | totalTokens = 1500+3000+500 = 5000 firstMessage = "Hello, can you..." wasCleared = false model = "claude-sonnet-4-6" |
empty.jsonl | 0 字节空文件 | totalTokens = 0 wasCleared = false 不抛异常 |
clear-last.jsonl | 会话以 /clear 结束,之后无新消息 | wasCleared = true totalTokens = /clear 前最后的 usage |
clear-then-continue.jsonl | /clear 之后又发了新消息 | wasCleared = false totalTokens = 新消息后的 usage sessionCreated = 新消息的时间戳 |
no-usage.jsonl | 有消息但没有任何 usage 记录(旧格式) | totalTokens = 0 firstMessage 仍然正确解析 |
multiple-usage.jsonl | 多轮对话,每次助手回复都有 usage | totalTokens = 最后一轮的 usage (因为 for 循环中 finalUsage 每次被覆盖) |
malformed-lines.jsonl | 某行 JSON 解析失败 | 不崩溃——catch 后 continue 后续正常行仍然正确解析 |
command-as-first-message.jsonl | 第一条用户消息是斜杠命令 | firstMessage 跳过命令,取下一条非命令消息 |
下面给出 4 个最关键的 fixture 的完整内容——其余 4 个在练习中自行推导:
// ===== normal-session.jsonl(3 行)===== // 行 1:用户消息 {"type":"user","message":{"content":"Hello, can you help me write a function?"},"timestamp":"2026-06-26T10:00:00.000Z"} // 行 2:助手回复,含 usage 和 model {"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1500,"cache_read_input_tokens":3000,"cache_creation_input_tokens":500}},"timestamp":"2026-06-26T10:00:05.000Z"} // 行 3:用户追问 {"type":"user","message":{"content":"Can you add error handling?"},"timestamp":"2026-06-26T10:01:00.000Z"}
// ===== clear-last.jsonl(3 行)===== {"type":"user","message":{"content":"Analyze this code"},"timestamp":"2026-06-26T11:00:00.000Z"} {"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":800,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T11:00:05.000Z"} {"type":"user","message":{"content":"<command-name>/clear</command-name>"},"timestamp":"2026-06-26T11:05:00.000Z"}
clear-last.jsonl 的关键细节:/clear 的那行本身是 type: "user"——它包含在反向扫描中。反向扫描从最后一行开始,遇到它时 userMessagesAfterClear 还是 0(因为是反向扫描且它之后的"之后"在时间线上是之前),所以 wasCleared = true。这个"向前反向扫"的微妙之处是整段逻辑中最容易写错的地方。
// ===== clear-then-continue.jsonl(4 行)===== {"type":"user","message":{"content":"First question"},"timestamp":"2026-06-26T12:00:00.000Z"} {"type":"assistant","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":300,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T12:00:05.000Z"} // ↓ /clear 结束旧会话 {"type":"user","message":{"content":"<command-name>/clear</command-name>"},"timestamp":"2026-06-26T12:10:00.000Z"} // ↓ 新会话:/clear 之后的新消息 {"type":"user","message":{"content":"New topic: how about testing?"},"timestamp":"2026-06-26T12:15:00.000Z"} {"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":600,"cache_read_input_tokens":0,"cache_creation_input_tokens":100}},"timestamp":"2026-06-26T12:15:05.000Z"}
// ===== multiple-usage.jsonl(6 行,3 轮对话)===== // 第 1 轮 {"type":"user","message":{"content":"Round 1"},"timestamp":"2026-06-26T13:00:00.000Z"} {"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":100,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T13:00:05.000Z"} // 第 2 轮 {"type":"user","message":{"content":"Round 2"},"timestamp":"2026-06-26T13:01:00.000Z"} {"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":200,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-26T13:01:05.000Z"} // 第 3 轮 {"type":"user","message":{"content":"Round 3"},"timestamp":"2026-06-26T13:02:00.000Z"} {"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":300,"cache_read_input_tokens":50,"cache_creation_input_tokens":25}},"timestamp":"2026-06-26T13:02:05.000Z"}
input_tokens 改成字符串 "300" 会怎样?由于 u.input_tokens || 0 中,非空字符串是 truthy,"300" || 0 会保留字符串 "300"。然后 totalTokens = "300" + 50 + 25 触发 JavaScript 的字符串拼接,结果是 "3005025" 而非 375。这个测试会失败——而失败本身就是测试的价值。它暴露了 || 的弱类型陷阱:如果 JSONL 文件中 token 值意外是字符串格式,当前代码会静默产生垃圾数据。修复方向:在计算前加 Number(u.input_tokens) || 0,或改用 ?? + 显式类型转换。这个 bug 的发现过程正是"为写测试而读代码"的副产品——详见第 3.3 节。
在设计 fixture 的过程中,你会被迫逐行走读 getLatestTokenCount() 的源码——这种"为了写测试而读代码"的行为本身就能发现 bug:
// extension.ts:381 / debug.ts:111 finalUsage = { inputTokens: u.input_tokens || 0, // ↑ 如果 JSONL 中 input_tokens 恰好是数字 0 // 0 || 0 === 0 ✅ 没问题 // 但如果值是 undefined → 0 ✅ 也没问题 // 但如果值是 ""(空字符串)→ 0 ✅ 被 || 兜底 cacheReadTokens: u.cache_read_input_tokens || 0, // ... };
实际上这里用 ??(nullish coalescing)比 || 更精确——因为 0 是合法的 token 数,而 || 会把 0 和 undefined 同等对待。对数字类型,0 || 0 === 0 恰好正确——但如果 JSONL 中的 token 值意外是字符串(如 "300"),"300" || 0 返回 "300",后续 totalTokens = "300" + 50 + 25 变成字符串拼接 "3005025"。这个 bug 在手工运行 debug.ts 时几乎不可能发现——只有测试的精确断言才会暴露它。修复方向:Number(u.input_tokens) || 0,或改用 ?? + 显式类型转换。详见第 3.2 节 multiple-usage.jsonl 的 callout。
node:test + node:assert
Node.js 18+ 内置了测试框架 node:test,零依赖——这也是本课选它的原因:纯函数 + fixture 文件场景不需要 mock,零依赖就是最优解。其他场景下(需要 mock、快照、coverage),Vitest 是社区目前最推荐的选择(详见 测试框架对比参考)。以下是测试文件的主体结构:
// ===== src/get-latest-token-count.test.ts ===== import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'fs'; import * as path from 'path'; // fixture 目录:test/fixtures/ const FIXTURE_DIR = path.join(__dirname, '..', 'test', 'fixtures'); function fixture(name: string): string { return path.join(FIXTURE_DIR, name); } // 被测试的函数——从 extension.ts 复制过来 // (在提取 shared.ts 之前,先直接复制——和 debug.ts 一样的逻辑) async function getLatestTokenCount(jsonlPath: string): Promise<TokenUsage> { // ... 与 extension.ts 完全相同的 90 行 } describe('getLatestTokenCount', () => { it('正常会话:正确解析 usage、firstMessage、model', async () => { const result = await getLatestTokenCount(fixture('normal-session.jsonl')); assert.equal(result.totalTokens, 5000); assert.match(result.firstMessage || '', /Hello/); assert.equal(result.model, 'claude-sonnet-4-6'); assert.equal(result.wasCleared, false); assert.ok(result.sessionCreated instanceof Date); }); it('空文件:totalTokens=0,wasCleared=false,不抛异常', async () => { const result = await getLatestTokenCount(fixture('empty.jsonl')); assert.equal(result.totalTokens, 0); assert.equal(result.wasCleared, false); assert.equal(result.model, ''); }); it('/clear 结尾的会话:wasCleared=true,usage 来自 /clear 之前', async () => { const result = await getLatestTokenCount(fixture('clear-last.jsonl')); assert.equal(result.wasCleared, true); assert.equal(result.totalTokens, 800); }); it('/clear 后有新消息:wasCleared=false,usage 来自新消息之后', async () => { const result = await getLatestTokenCount(fixture('clear-then-continue.jsonl')); assert.equal(result.wasCleared, false); assert.equal(result.totalTokens, 700); assert.equal(result.model, 'claude-opus-4-8'); }); it('多轮对话:totalTokens 应该是最后一轮的 usage', async () => { const result = await getLatestTokenCount(fixture('multiple-usage.jsonl')); // 第 3 轮: input_tokens="300"(→300) + cache_read=50 + cache_creation=25 = 375 // 注意:不是第 1 轮的 100,也不是累加值 600 (=100+200+300) assert.equal(result.totalTokens, 375); }); it('无效 JSON 行:不崩溃,跳过继续解析', async () => { const result = await getLatestTokenCount(fixture('malformed-lines.jsonl')); // 文件中有 1 行无效 JSON,应该被 catch 跳过 assert.equal(result.totalTokens, 500); assert.ok(!result.wasCleared); }); it('第一条消息是命令:firstMessage 应跳过命令取到下一条', async () => { const result = await getLatestTokenCount(fixture('command-as-first-message.jsonl')); // firstMessage 不应包含 <command-name> if (result.firstMessage) { assert.doesNotMatch(result.firstMessage, /command-name/); } }); it('无 usage 记录:totalTokens=0 但 firstMessage 仍解析', async () => { const result = await getLatestTokenCount(fixture('no-usage.jsonl')); assert.equal(result.totalTokens, 0); assert.match(result.firstMessage || '', /./); // firstMessage 非空 }); });
node --import tsx --test src/get-latest-token-count.test.ts(如果直接用 TypeScript),或先 tsc 编译再用 node --test out/get-latest-token-count.test.js。tsx 是一个零配置的 TypeScript 执行器——如果你不想每次改测试都重新编译,它是更好的选择。
getLatestTokenCount() 只处理单个文件的解析。下一个值得测试的是同项目内多会话的淘汰逻辑——这段在 extension.ts 第 498-539 行。它与文件系统无关,是纯内存操作:
// 输入:同项目的 SessionInfo[](已按 sessionCreated 从新到老排列) const input: SessionInfo[] = [ { sessionId: 'new', sessionCreated: new Date('2026-06-27'), lastUpdated: new Date('2026-06-27'), wasCleared: false }, { sessionId: 'old-a', sessionCreated: new Date('2026-06-25'), lastUpdated: new Date('2026-06-26'), wasCleared: false }, { sessionId: 'old-b', sessionCreated: new Date('2026-06-24'), lastUpdated: new Date('2026-06-27'), wasCleared: false }, ]; // 期望输出: // 'new' ✅ SHOW(最新的,没有更新的覆盖它) // 'old-a' ❌ HIDE(new.created=6/27 > old-a.lastUpdated=6/26 → 被取代) // 'old-b' ✅ SHOW(new.created=6/27 不大于 old-b.lastUpdated=6/27,且 old-b.created=6/24 不大于 old-a.lastUpdated=6/26 → 未被取代)
这个测试不依赖任何文件系统——你直接构造 SessionInfo[] 数组,调用淘汰逻辑,断言结果。它的价值在于:它把 Lesson 0004 第 6 节的文字描述变成了可执行的断言。
Lesson 0006 第 2 节讨论了"为什么不提取共享模块"——核心理由是 ~150 行复制比引入一个新模块边界更简单。这个判断在没有测试时是对的。但有了测试之后,方程变了:
| 没有测试时 | 有测试后 | |
|---|---|---|
| 提取 shared.ts 的风险 | 高——可能引入 import 路径错误、破坏原有行为而不自知 | 低——测试 3 秒告诉你是否弄坏了什么 |
| 修改 getLatestTokenCount() 的恐惧 | 大——改了这里可能影响 extension 和 debug 两处,要手动验证 | 小——改了 shared.ts → 跑测试 → 如果通过了就没事 |
| 最佳策略 | 复制 | 提取为共享模块 |
具体步骤:
# 步骤 1:写测试(覆盖 getLatestTokenCount 的所有边界) node --test out/get-latest-token-count.test.js # 8 tests pass ✅ # 步骤 2:提取 shared.ts——把两个文件中的同名函数替换为 import # shared.ts ← 移入 getLatestTokenCount() + TokenUsage + SessionInfo # extension.ts → import { getLatestTokenCount } from './shared' # debug.ts → import { getLatestTokenCount } from './shared' # 步骤 3:再跑一次测试——如果全绿,提取成功 node --test out/get-latest-token-count.test.js # 8 tests still pass ✅ # 步骤 4:删除两个文件中复制的 90 行——代码去重完成
temp_repo/test/fixtures/ 目录下创建 normal-session.jsonl、empty.jsonl 和 clear-last.jsonl,内容参考第 3.2 节。不要求格式完美——重点是体验"最少数据触发最多边界"的 fixture 设计思维temp_repo/out/ 旁边,写一个最小测试文件 temp_repo/src/minimal.test.ts,只测"正常会话"这一个场景。用 npx tsx --test src/minimal.test.ts 运行——确认你能跑通一个测试再继续shared.ts——把 getLatestTokenCount() 和两个 interface 移到新文件,让 extension.ts 和 debug.ts 都 import 它。跑测试确认没有引入回归。感受"测试驱动重构"的工作流debug.ts 和单元测试解决的是两个正交的问题。以下哪项最准确地描述了这种正交性?
clear-then-continue.jsonl 的 fixture 有 4 行:用户消息 A、助手回复、用户发送 /clear、用户消息 B。以下哪个关于 wasCleared 的断言是正确的?
在 multiple-usage.jsonl 的测试中,期望 totalTokens 是最后一轮的 usage(而非累加值)。这个行为是由什么决定的?
Lesson 0006 认为"~150 行复制比引入共享模块更简单"。本课认为"有了测试后,提取共享模块更安全"。这两个判断矛盾吗?
以下关于 fixture 设计的说法,哪个是错误的?
node --test 直接跑 .ts 文件node:test vs Jest vs Vitest vs Mocha 完整对比、选型建议、API 速查fs.readFileSync 而不需要物理 fixture 文件吗?或者想知道本课发现的 "0 || 0" 问题是否值得提一个 PR?这些问题都可以继续追问。