import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; /* * 「读不出来」不是「不存在」—— 这一行曾把 dsh 的邮件通道**整条弄断**。 * * 事故(2026-09-19,DSH 升到 0.1.5-rc.2 后):`persistedCwd()` 的 * `catch { return undefined }` 把三种抛出情形压成同一个「磁盘上没有」,而调用方 * 只把 `undefined` 读作"可以 create"。于是格式迁移拒绝 → 当成不存在 → 走 create * → 磁盘上**确实有**那个 id ⇒ `session "…" already exists` ⇒ 该会话的邮件 * 全部投不进去。40 个 `mail-*` 会话全部命中,且日志里只有 create 的错, * **真正的读失败被那个 catch 吃掉了**。 * * 为什么这个形状值得单独钉:它在"不报错的错"这一族里最典型 —— * 读失败被降级成"没有",bug 就从"报一句错"变成"静默走错分支"。 * 本仓已经为同一形状付过几次学费(判据自匹配、`read_thread` 会话作用域、 * 「取链里第一张证书」),所以这里用判据把区别固定下来。 * * 判据钉的是**区分本身**(哪些 code 允许 create、哪些必须抛), * 而不是"有没有 try/catch"——后者在错误修法下照样能通过。 */ const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8'); /** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文) */ const codeOnly = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); /** 取某个函数/分支的正文(按花括号配对,不看窗口) */ function blockAt(src, needle) { const at = src.indexOf(needle); if (at < 0) return ''; const open = src.indexOf('{', at); let depth = 0; for (let i = open; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); } } return ''; } const FN = 'async function persistedCwd('; /** dsh-session-query 的分类里,「确实不存在」是**唯一**允许 create 的那个 */ const ABSENT = 'SESSION_QUERY_SESSION_NOT_FOUND'; test('★ persistedCwd 必须区分「不存在」与「读不出来」(只有前者能 create)', () => { assert.ok(SRC.indexOf(FN) >= 0, '找不到 persistedCwd —— 结构变了,判据要跟着改'); const body = codeOnly(blockAt(SRC, FN)); assert.ok(body.length > 50, 'persistedCwd 正文太短,八成是结构变了'); // ① 存在性判据:必须**只**对"确实不存在"这个 code 返回 undefined assert.match(body, new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`), `只有 ${ABSENT} 才允许当成"磁盘上没有"。` + '少了这个 code 判断,就是把"读失败"和"不存在"又压回同一个返回值 —— ' + '那正是让 40 个会话的邮件全投不进去的那一行。'); assert.match(body, /return undefined/, '「不存在」这一支仍要返回 undefined(它是调用方 create 的唯一依据)'); // ② 非存在性:读失败必须**抛出**,不许静默吞掉 assert.match(body, /throw e\b/, '读失败必须抛出(让原文错误浮到调用方)—— 降级成 undefined 会让 bug ' + '从"报一句错"变成"静默走错分支",而日志里只剩 create 那句误导人的错。'); // ③ 顺序/可达性:throw 不能被无条件提前(否则"不存在"也走不到 create) const codeAt = body.indexOf(ABSENT); const throwAt = body.indexOf('throw e'); assert.ok(codeAt >= 0 && throwAt > codeAt, '`throw e` 必须在 code 判断**之后** —— 反过来的话,连"确实不存在"也会抛,' + 'create 分支永远不可达(邮件会话再也建不出来)。'); // ④ 残余风险:catch 里不许出现无条件的 `return undefined` // (写法上最容易的错:加了 code 判断,却把 return 留在最前面) const catchBlock = body.slice(body.indexOf('catch')); assert.doesNotMatch(catchBlock, /catch\s*\([^)]*\)\s*\{\s*return undefined\s*;?\s*\}/, 'catch 里不许无条件 `return undefined` —— 那等于把这次的修复又改回去了。'); }); /* * 变异自检:判据本身要能红。 * * 不复用被测源码,而是喂给同一套断言"变异后的文本"—— 判据写松了(例如只找 * `return undefined` 就算了)这里会立刻暴露。 */ test('★ 判据自检:把修复回退成事故版本,本文件必须能发现', () => { // 只在 persistedCwd **这个函数内部**换掉 catch —— 用全局正则会命中文件里 // 第一个无关的 `catch (e: any)`(本仓实测踩过:变异没落在目标上, // 自检于是变成恒真的假判据)。 const at = SRC.indexOf(FN); const body = blockAt(SRC, FN); const bodyStart = SRC.indexOf('{', at) + 1; const mutated = SRC.slice(0, bodyStart) + body.replace(/catch \(e: any\) \{[\s\S]*\n \}/, 'catch {\n return undefined;\n }') + SRC.slice(bodyStart + body.length); assert.notEqual(mutated, SRC, '变异必须真的落下去(否则下面那条自检恒真,等于没写)'); const mutatedBody = codeOnly(blockAt(mutated, FN)); const distinguishes = body => new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`).test(body) && /throw e\b/.test(body); assert.equal(distinguishes(mutatedBody), false, '把 catch 改回无条件的 `return undefined`(事故版本)后,"区分"条件必须为假 —— ' + '若这里仍为真,说明判据松到发现不了这个回归。'); // 反向:原样源码必须为真(否则这条自检恒假,等于没写) assert.ok(distinguishes(codeOnly(blockAt(SRC, FN))), '未变异的源码必须满足"区分"条件(否则这条自检恒假,等于没写)'); });