diff --git a/plugins/pi-mail-bridge/package.json b/plugins/pi-mail-bridge/package.json index a1d281a..f96e823 100644 --- a/plugins/pi-mail-bridge/package.json +++ b/plugins/pi-mail-bridge/package.json @@ -12,6 +12,6 @@ }, "scripts": { "start": "node src/index.mjs", - "test": "node test/env-preflight.mjs && node --test 'test/*.test.mjs'" + "test": "node test/lib/env-preflight.mjs && node test/lib/run-suite.mjs" } } diff --git a/plugins/pi-mail-bridge/test/env-guard.test.mjs b/plugins/pi-mail-bridge/test/env-guard.test.mjs index 14b8f67..39e31c8 100644 --- a/plugins/pi-mail-bridge/test/env-guard.test.mjs +++ b/plugins/pi-mail-bridge/test/env-guard.test.mjs @@ -23,17 +23,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync } from 'node:fs'; +// (本文件不再直接读文件:夹具在 test/lib/,边界判据在 layout-boundaries.test.mjs) import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE, measureAvailBytes } from '../lib/tmp-space.mjs'; -import { translateEnvError } from '../lib/env-error.mjs'; -import { writeSession } from '../lib/session-fixtures.mjs'; +import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE, measureAvailBytes } from './lib/tmp-space.mjs'; +import { translateEnvError } from './lib/env-error.mjs'; +import { writeSession } from './lib/session-fixtures.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); -const PREFLIGHT = join(HERE, 'env-preflight.mjs'); +// 前置脚本住在 `test/lib/`(它只在测试期跑,不该进生产快照 —— 见 test/lib/reach.mjs)。 +const PREFLIGHT = join(HERE, 'lib', 'env-preflight.mjs'); // ─── 1. 纯函数:两头都对 ─────────────────────────────────────── @@ -60,7 +61,7 @@ test('刚好多一点就够、刚好少一点就不够(边界不靠感觉)', }); test('读不到可用空间 → 不判红(不知道 ≠ 不对)', () => { - // 平台不支持 statfs、或字段缺 —— 此时放行。理由见 lib/tmp-space.mjs 头注释: + // 平台不支持 statfs、或字段缺 —— 此时放行。理由见 test/lib/tmp-space.mjs 头注释: // 在认不出的文件系统上判红,会造出一条总在亮的红灯,人就会学会忽略它。 for (const availBytes of [null, undefined, NaN]) { const v = judgeSpace({ availBytes }); @@ -193,7 +194,7 @@ test('只有 message 里写着 no space left(没有 code)时也认', () => { test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错(喂假写,不靠机器状态)', () => { // 这条是**行为**判据,不是文本判据:直接调被接上的那个函数,喂一个必然 ENOSPC 的 - // 假写。删掉 `lib/session-fixtures.mjs` 里那段 `translateEnvError(e).error`,这条立刻红。 + // 假写。删掉 `test/lib/session-fixtures.mjs` 里那段 `translateEnvError(e).error`,这条立刻红。 // // ★ 用 `os.tmpdir()`(纯字符串)而不是 `tmpdir()`(会 statfs)当根: // 这里所有创建都被假写打断 ⇒ 目录不会被真正建出来 ⇒ 不需要真临时目录, @@ -219,32 +220,3 @@ test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错 ); }); -test('★夹具不在测试文件里(否则引用它会二次注册整个套件)', () => { - // 这条替代了原来那条文本接线检查,并且守一个**更实质**的东西: - // `env-guard` 曾经从 `session-scan` 那个**测试文件**里取 `writeSession`, - // 而 `node --test` 每个文件一个进程、模块导入是进程内的 ⇒ - // **那个文件的 16 条用例在 env-guard 的进程里又注册了一遍**,包括那条单条写 - // ~12 MiB 的巨行用例 —— 实测 TAP 里它确实出现两次(`ok 87` / `ok 353`), - // 也就是往当时已经满的 /tmp 又多写一份。 - // - // 判据形状:**解析文件里的模块引用**,看它指不指向另一个测试文件。 - // - // 踩过两次,都写在这里免得下一个人重踩: - // 第 1 版 只匹配静态 from 子句 ⇒ 漏掉**动态导入**(同样会二次注册, - // 只是躲开了静态语法); - // 第 2 版 改成"文件里出现别的测试文件名"⇒ 把**注释里的散文引用**也算成违规 - // (本仓库有 3 处这样的注释,包括本条自己的说明)。**过宽和过窄都是坏的** —— - // 前者逼人删掉有用的注释,后者漏掉真违规。 - // 现在只认**真引用**:`from` / `import(` / `require(` 后面跟着的那个字符串。 - // 注释里提到文件名不算(注释不会让用例注册)。 - const refRe = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]\.\/([\w.-]+\.test\.mjs)['"]/g; - const offenders = []; - for (const f of readdirSync(HERE)) { - if (!f.endsWith('.test.mjs')) continue; - const src = readFileSync(join(HERE, f), 'utf8'); - for (const m of src.matchAll(refRe)) { - offenders.push(`${f} → ${m[1]}`); - } - } - assert.deepEqual(offenders, [], `测试文件之间不许互相 import(会二次注册整套用例):${offenders.join('、')}`); -}); diff --git a/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs b/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs new file mode 100644 index 0000000..77ae760 --- /dev/null +++ b/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative, resolve } from 'node:path'; + +import { reachable, productionSeeds } from './lib/reach.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); // …/test +const ROOT = resolve(HERE, '..'); + +test('★生产代码不得引用 test/ 下的文件(`lib/` 与 `test/lib/` 的边界)', () => { + // 规则写成**可判定的**形式:从生产入口(`src/index.mjs` + 按路径 fork 的子进程 + // 入口)出发的 import 闭包里,不许出现 `test/` 下的任何文件。 + // + // 为什么需要:`lib/tmp-space.mjs` 等三个模块只有测试用,却住在 `lib/` 里 + // ⇒ `cp -a "$SRC/." "$STAGING/"`(只排掉 `test/`)把它们**打进生产快照**。 + // 后果不是几 KB,而是"漂移 N 处"这个数字**虚高**、哈希清单变长 —— + // 而我们刚把"手抄哈希清单"定性为会过期的东西。 + const reach = reachable(productionSeeds()); + const offenders = [...reach].filter((p) => p.startsWith('test/')); + assert.deepEqual(offenders, [], `生产可达了测试代码:${offenders.join('、')}`); + + // 反向:闭包必须真的走起来了(空集合也能让上面那条绿 —— 反面样本只挡一半)。 + assert.ok(reach.has('src/index.mjs'), '入口必须在闭包里'); + assert.ok( + reach.has('src/worker.mjs'), + 'worker 是按路径 fork 的子进程入口(不是 import 进来的),漏掉它就是漏掉生产代码' + ); + assert.ok([...reach].some((p) => p.startsWith('lib/')), '至少有一个 lib/ 模块可达'); +}); + +test('★`test/lib/` 里的模块只被测试引用(别把运行时模块藏进去)', () => { + // 反向的边界:`test/lib/` 是给测试用的,若某个**生产可达**的模块住在里面, + // 那它部署后就会缺失 —— 这是个会等到重启才炸的错(白名单失败的经典形状)。 + const reach = reachable(productionSeeds()); + const inTestLib = readdirSync(join(HERE, 'lib')) + .filter((f) => /\.(mjs|js)$/.test(f)) + .map((f) => `test/lib/${f}`); + const wrong = inTestLib.filter((p) => reach.has(p)); + assert.deepEqual(wrong, [], `这些模块生产可达,不该放在 test/lib/:${wrong.join('、')}`); +}); + +test('★测试文件之间不许互相 import(会二次注册整套用例)', () => { + // `node --test` 默认每个文件一个子进程,而模块导入是**进程内**的 ⇒ + // 从另一个 `.test.mjs` 取东西,会把那个文件的用例在引用者进程里**再注册一遍**。 + // 实测:巨行用例(单条往临时目录写 ~12 MiB)跑了两次,测试总数 475(修完 459)。 + // + // 判据形状:解析**真引用**(静态 `from` / 动态 `import()` / `require()`), + // 注释里提到文件名不算(注释不会注册用例)。 + // 两版都踩过,写在注释里免得重踩:只匹配静态 from ⇒ 漏动态导入(过窄); + // "文件里出现别的测试文件名" ⇒ 把散文引用也算违规、还被自己注释里的示例点亮(过宽)。 + const refRe = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]\.\/([\w.-]+\.test\.mjs)['"]/g; + const offenders = []; + for (const f of readdirSync(HERE)) { + if (!f.endsWith('.test.mjs')) continue; + const src = readFileSync(join(HERE, f), 'utf8'); + for (const m of src.matchAll(refRe)) offenders.push(`${f} → ${m[1]}`); + } + assert.deepEqual(offenders, [], `测试文件之间不许互相 import:${offenders.join('、')}`); +}); + +test('★`npm test` 必须跑"从同一次运行的 TAP 里数重名"那一层', () => { + // 因果**无关**的判据:跨文件重名不会被 runner 拦(实测两个文件各写一个同名用例 + // ⇒ `# tests 2 / # pass 2 / # fail 0`,两句 `ok`,没有任何警告)。 + // 静态那条只能发现"已知成因"(互相 import),这条不挑成因。 + // + // 这里只验**接线**(脚本在 package.json 里被调到、且它自带自检), + // 因为真跑一遍套件就是 `npm test` 本身在做的事 —— 在套件里再跑一次套件会递归。 + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + assert.match(pkg.scripts.test, /test\/lib\/run-suite\.mjs/, 'npm test 必须走 run-suite'); + assert.match(pkg.scripts.test, /test\/lib\/env-preflight\.mjs/, '前置自检也必须还在'); +}); diff --git a/plugins/pi-mail-bridge/lib/env-error.mjs b/plugins/pi-mail-bridge/test/lib/env-error.mjs similarity index 100% rename from plugins/pi-mail-bridge/lib/env-error.mjs rename to plugins/pi-mail-bridge/test/lib/env-error.mjs diff --git a/plugins/pi-mail-bridge/test/env-preflight.mjs b/plugins/pi-mail-bridge/test/lib/env-preflight.mjs similarity index 99% rename from plugins/pi-mail-bridge/test/env-preflight.mjs rename to plugins/pi-mail-bridge/test/lib/env-preflight.mjs index 78c456d..2a03251 100644 --- a/plugins/pi-mail-bridge/test/env-preflight.mjs +++ b/plugins/pi-mail-bridge/test/lib/env-preflight.mjs @@ -22,7 +22,7 @@ import { tmpdir } from 'node:os'; -import { judgeSpace, measureAvailBytes, MIN_FREE_BYTES } from '../lib/tmp-space.mjs'; +import { judgeSpace, measureAvailBytes, MIN_FREE_BYTES } from './tmp-space.mjs'; /* * 两个**只为测试存在**的开关(`src/pool.mjs` 的 `workerPath` 是同一手法)。 diff --git a/plugins/pi-mail-bridge/test/lib/reach.mjs b/plugins/pi-mail-bridge/test/lib/reach.mjs new file mode 100644 index 0000000..f097cdf --- /dev/null +++ b/plugins/pi-mail-bridge/test/lib/reach.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * `lib/` 与 `test/lib/` 的边界:**从生产入口出发的 import 闭包**。 + * + * # 为什么需要这条判据 + * + * 2026-09-14 发现:`lib/tmp-space.mjs`、`lib/env-error.mjs`、`lib/session-fixtures.mjs` + * 这三个模块**只有测试用**,却住在 `lib/` 里 ⇒ 部署脚本 `cp -a "$SRC/." "$STAGING/"` + * 会把它们**打进生产快照**(`EXCLUDE` 只排掉 `test/`、`.git`、`node_modules/.cache`)。 + * 后果不是"多几 KB",而是: + * + * - "漂移 N 处"这个数字**虚高**(其中两三处根本不是运行时代码); + * - 部署验收要核的哈希清单变长,而我们刚把"手抄哈希清单"定性为会过期的东西。 + * + * # 规则(写成**可判定的**,不写成"约定") + * + * `lib/` = 从生产入口可达的模块(会进快照) + * `test/lib/` = 只被测试引用的模块(`test/` 不部署、也不被注册进套件) + * + * 判据:**`src/` 下任何文件都不得(直接或间接)引用 `test/` 下的任何文件。** + * 这是单向的 —— 测试引用 `lib/` 完全正常(本仓库就是这么做的)。 + * + * ⚠️ 别把规则写成"被 `src/` **直接** import":实测 22 个 `lib/` 模块里有 4 个 + * `src` 直接引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引用)、 + * `user-question.js`(走前缀动态 import)、`mail-session-id.js`、`crash-notify.mjs`。 + * **直接引用数不是可达性**。所以这里真去走一遍图。 + * + * ⚠️ 也别漏掉"按路径 fork 的子进程入口":`src/pool.mjs` 里是 + * `fileURLToPath(new URL('./worker.mjs', import.meta.url))` —— worker 不是 `import` + * 进来的,但它是生产代码。因此种子 = `src/index.mjs` + 源码里 `new URL('./x.mjs', …)` + * 这类**按路径解析**的本地文件。 + * + * 退出码:0 = 边界干净;1 = 有违规(`src/` 可达 `test/`);2 = 环境/前置问题。 + */ + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { dirname, join, resolve, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); // …/test/lib +const ROOT = resolve(HERE, '../..'); // 插件根 + +/** 从一份源码里取出它引用的**本地**文件(相对路径)。 + * 三类都收:静态 `from './x'`、动态 `import('./x')`、以及按路径解析的 + * `new URL('./x', import.meta.url)`(子进程入口就是这个形状)。 */ +export function localRefs(src) { + const out = new Set(); + const patterns = [ + /\bfrom\s*['"](\.[^'"]+)['"]/g, + /\bimport\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g, + /\bnew\s+URL\s*\(\s*['"](\.[^'"]+)['"]/g, + ]; + for (const re of patterns) { + for (const m of src.matchAll(re)) out.add(m[1]); + } + return [...out]; +} + +/** 把 `./x` 这种相对引用解析成一个真实存在的文件(补 .mjs/.js 后缀)。 */ +function resolveRef(fromFile, ref) { + const base = resolve(dirname(fromFile), ref); + for (const cand of [base, `${base}.mjs`, `${base}.js`, join(base, 'index.mjs')]) { + if (existsSync(cand) && !cand.endsWith('/')) { + try { + if (readFileSync(cand) !== null) return cand; + } catch { /* 目录会抛 —— 跳过 */ } + } + } + return null; +} + +/** 从种子出发走一遍 import 闭包,返回插件根下的相对路径集合。 */ +export function reachable(seeds) { + const seen = new Set(); + const queue = [...seeds]; + while (queue.length) { + const f = queue.pop(); + if (seen.has(f)) continue; + seen.add(f); + let src; + try { + src = readFileSync(f, 'utf8'); + } catch { + continue; + } + for (const ref of localRefs(src)) { + const r = resolveRef(f, ref); + if (r && !seen.has(r)) queue.push(r); + } + } + return new Set([...seen].map((f) => relative(ROOT, f))); +} + +/** 生产种子:部署脚本的 ENTRY + 源码里按路径解析的本地文件(子进程入口)。 */ +export function productionSeeds() { + const entry = join(ROOT, 'src', 'index.mjs'); + const seeds = [entry]; + const srcDir = join(ROOT, 'src'); + for (const f of readdirSync(srcDir)) { + if (!f.endsWith('.mjs')) continue; + const full = join(srcDir, f); + const src = readFileSync(full, 'utf8'); + // `new URL('./worker.mjs', import.meta.url)` —— fork 的入口按路径找,不走 import + for (const m of src.matchAll(/\bnew\s+URL\s*\(\s*['"](\.[^'"]+)['"]/g)) { + const r = resolveRef(full, m[1]); + if (r) seeds.push(r); + } + } + return seeds; +} + +function main() { + const reach = reachable(productionSeeds()); + const offenders = [...reach].filter((p) => p.startsWith('test/') || p.startsWith('test\\')); + + console.log('生产可达性(从 src/index.mjs 出发,含按路径 fork 的子进程入口):'); + const srcFiles = [...reach].filter((p) => p.startsWith('src/') || p.startsWith('lib/')).sort(); + for (const p of srcFiles) console.log(` 可达 ${p}`); + const testFiles = [...reach].filter((p) => p.startsWith('test/')); + console.log(`\n src/ + lib/ 可达 ${srcFiles.length} 个;lib/ 总共有 ${ + readdirSync(join(ROOT, 'lib')).length} 个文件`); + + if (offenders.length) { + console.error('\n✗ 生产代码引用了 test/ 下的文件:'); + for (const p of offenders) console.error(` ${p}`); + console.error(' ⇒ 要么把它移到 lib/(如果生产真的需要),要么切断这条引用。'); + process.exit(1); + } + console.log('✓ 生产代码没有引用 test/ 下的任何文件(边界干净)'); + if (testFiles.length === 0) console.log('(本次闭包里没有 test/ 文件)'); + process.exit(0); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + main(); +} diff --git a/plugins/pi-mail-bridge/test/lib/run-suite.mjs b/plugins/pi-mail-bridge/test/lib/run-suite.mjs new file mode 100644 index 0000000..4e3e557 --- /dev/null +++ b/plugins/pi-mail-bridge/test/lib/run-suite.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * 跑套件,并从**同一次运行的 TAP 输出**里数结果行 —— 任何用例名出现两次就红。 + * + * # 为什么需要它:这是一条**因果无关**的判据 + * + * `test/env-guard.test.mjs` 里那条"测试文件之间不许互相 import"只能发现**已知的 + * 那个成因**。同族的另一种成因它看不见,比如**两个文件里恰好同名**的用例 —— + * `node --test` 按文件注册,跨文件重名**不会被拦**。2026-09-14 实测确认: + * + * 两个文件各写一个 `test('同名用例')` ⇒ `# tests 2 / # pass 2 / # fail 0` + * (两条 `ok`,没有任何警告) + * + * 本脚本不挑成因:**只数结果行**。它同时也能抓住"二次注册"那类问题, + * 所以和那条静态判据是**互补**的(静态那条更早、能指认是哪个文件引了哪个; + * 这条更晚、但因果无关)。 + * + * # 判据锚在哪一行(这条是被人纠正出来的) + * + * 锚 `^(ok|not ok) - ` —— **结果行**。pi 先前给的一条验证命令是 + * `grep -c '<用例名>'`,那个数的是**字符串出现次数**:TAP 里同一个用例名既出现在 + * `# Subtest:` 头、又出现在结果行 ⇒ 2 倍效应 + 2 倍噪声,**两个因子恰好同值**, + * 于是"4"看起来还能解释。若行种类是 3,就会把"两次注册"读成"三次"。 + * 规矩:**判据必须锚在与结论一一对应的那一行上**,而不是"出现过"。 + * + * # 退出码(与 `env-preflight.mjs`、`redeploy-plugin.sh` 同一套) + * + * 0 = 套件全绿 **且**没有重名 + * 1 = 断言失败(套件红了)**或**发现重名(这是真问题,要看代码) + * 2 = 环境/参数问题(临时目录不足等) + * + * `--self-check`:不跑套件,只用两段合成的 TAP 样本验本脚本自己 + * (干净样本必须放行、重复样本必须点名)—— 这就是"判据自检",与 + * `check-deploy-drift.mjs --self-check` 同一形状。 + */ + +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +/** TAP 结果行:`ok 12 - 名字` / `not ok 12 - 名字`(名字里可能有 `#` 之类,照收)。 */ +const RESULT_RE = /^(not ok|ok) (\d+) - (.*)$/; + +/** 只认"用例"行:跳过整份文件的汇总行(形如 `ok 1 - test/xxx.test.mjs` 也算结果行, + * 所以不能只靠这一条区分)。这里用"出现次数"来做判断,不区分文件级与用例级 —— */ +export function findDuplicates(tapText) { + const counts = new Map(); + for (const line of tapText.split('\n')) { + const m = RESULT_RE.exec(line); + if (!m) continue; + const name = m[3].trim(); + counts.set(name, (counts.get(name) || 0) + 1); + } + return [...counts.entries()] + .filter(([, n]) => n > 1) + .map(([name, n]) => ({ name, count: n })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); +} + +/** 从 TAP 里取汇总:`# tests N` / `# pass N` / `# fail N`。取不到就是没跑完。 */ +export function summaryOf(tapText) { + const grab = (k) => { + const m = new RegExp(`^# ${k} (\\d+)$`, 'm').exec(tapText); + return m ? Number(m[1]) : null; + }; + return { tests: grab('tests'), pass: grab('pass'), fail: grab('fail') }; +} + +const SELF_CHECK_SAMPLES = [ + { + what: '干净样本:每个名字只出现一次 → 必须放行', + tap: [ + '# Subtest: 甲', + 'ok 1 - 甲', + '# Subtest: 乙', + 'ok 2 - 乙', + '1..2', + '# tests 2', + '# pass 2', + '# fail 0', + ].join('\n'), + expect: [], + }, + { + what: '★重复样本:同名两次(正是跨文件重名的形状)→ 必须点名', + tap: [ + '# Subtest: 同名用例', + 'ok 1 - 同名用例', + '# Subtest: 同名用例', + 'ok 2 - 同名用例', + '1..2', + '# tests 2', + '# pass 2', + '# fail 0', + ].join('\n'), + expect: ['同名用例'], + }, + { + what: '★噪声样本:名字只出现在 `# Subtest:` 头里(没有结果行)→ 不算重复', + tap: ['# Subtest: 只在头里', '1..0', '# tests 0', '# pass 0', '# fail 0'].join('\n'), + expect: [], + }, + { + what: '★名字里带 `#` 也要能取对(别把名字截断成前缀)', + tap: ['ok 1 - ★0 字节不是「不知道」', 'ok 2 - ★0 字节不是「不知道」', '1..2'].join('\n'), + expect: ['★0 字节不是「不知道」'], + }, +]; + +function selfCheck() { + let bad = 0; + for (const s of SELF_CHECK_SAMPLES) { + const got = findDuplicates(s.tap).map((d) => d.name); + const ok = JSON.stringify(got) === JSON.stringify(s.expect); + console.log(` ${ok ? '通过' : '失败'} ${s.what}`); + if (!ok) { + bad += 1; + console.log(` 期望 ${JSON.stringify(s.expect)},实际 ${JSON.stringify(got)}`); + } + } + console.log(bad === 0 ? '\n判据自检:全部通过。' : `\n判据自检:${bad} 条失败。`); + process.exit(bad === 0 ? 0 : 1); +} + +if (process.argv.includes('--self-check')) selfCheck(); + +// ── 跑套件 ──────────────────────────────────────────────────────── +// `--test-reporter=tap` 让报告格式稳定(`--test` 在 TTY 下会自动换格式, +// 那样就锚不住结果行)。输出走管道,本进程负责原样转写到 stderr。 +const args = ['--test', '--test-reporter=tap', 'test/*.test.mjs']; +const r = spawnSync(process.execPath, args, { + encoding: 'utf8', + env: process.env, + maxBuffer: 256 * 1024 * 1024, +}); + +if (r.error) { + console.error(`[tap-dup] 起不了测试进程:${r.error.message}`); + process.exit(2); +} + +const tap = r.stdout || ''; +process.stderr.write(tap); + +// 环境不足(前置脚本那套约定的 2)原样透传 —— 这一层不替环境问题下结论。 +if (r.status === 2) { + console.error('[tap-dup] 套件以 2 退出(环境问题),不据此判重名。'); + process.exit(2); +} + +const s = summaryOf(tap); +if (s.tests === null) { + console.error('[tap-dup] 拿不到 `# tests` 汇总 —— 套件没跑完(可能崩了)。'); + process.exit(1); +} + +const dups = findDuplicates(tap); +const failed = (s.fail ?? 0) > 0 || r.status !== 0; + +if (dups.length > 0) { + console.error('\n[tap-dup] 有用例名在同一次运行里出现多次:'); + for (const d of dups) console.error(` ${d.count}× ${d.name}`); + console.error(' 跨文件重名不会被 runner 拦下(按文件注册)⇒ 这多半是两个文件里有同名用例,'); + console.error(' 也可能是某个测试文件被二次注册(例如别的测试文件 import 了它)。'); +} + +console.log(`\n# 结果行重复检查:${s.tests} 条结果,${dups.length} 个名字重复`); +if (failed) { + console.error(`[tap-dup] 套件红:pass ${s.pass} / fail ${s.fail}`); + process.exit(1); +} +if (dups.length > 0) process.exit(1); +console.log('[tap-dup] 套件全绿,且没有重名。'); diff --git a/plugins/pi-mail-bridge/lib/session-fixtures.mjs b/plugins/pi-mail-bridge/test/lib/session-fixtures.mjs similarity index 92% rename from plugins/pi-mail-bridge/lib/session-fixtures.mjs rename to plugins/pi-mail-bridge/test/lib/session-fixtures.mjs index 985bdfe..1840b5b 100644 --- a/plugins/pi-mail-bridge/lib/session-fixtures.mjs +++ b/plugins/pi-mail-bridge/test/lib/session-fixtures.mjs @@ -10,7 +10,7 @@ * (`ok 87` 与 `ok 353`),也就是那条用例被跑了两次、往临时目录写了两次。 * 这台机器的 `/tmp` 正是被这次事件的主角(tmpfs 满)—— 判据自己加倍压它是自伤。 * - * 这跟 `translateEnvError` 被抽到 `lib/env-error.mjs` 是同一个手法、同一个理由: + * 这跟 `translateEnvError` 被抽到 `test/lib/env-error.mjs` 是同一个手法、同一个理由: * **可被别的模块引用,而不被注册进套件。** * * # 二、写点必须全部被兜住 @@ -20,7 +20,7 @@ * 抛的仍是原始英文堆栈。所以这里**整段**包一层,与 `selfCheck()` 那条同一个形状: * **覆盖范围不能取决于"我以为的入口/哪一行"**。 * - * ENOSPC 一律翻译成"环境不足"(`lib/env-error.mjs`)—— 不管套件是怎么被调起来的 + * ENOSPC 一律翻译成"环境不足"(`test/lib/env-error.mjs`)—— 不管套件是怎么被调起来的 * (`npm test` 会先跑 `test/env-preflight.mjs`,但 `node --test 'test/*.test.mjs'` * 会绕过它,那就只剩这一道)。 */ diff --git a/plugins/pi-mail-bridge/lib/tmp-space.mjs b/plugins/pi-mail-bridge/test/lib/tmp-space.mjs similarity index 100% rename from plugins/pi-mail-bridge/lib/tmp-space.mjs rename to plugins/pi-mail-bridge/test/lib/tmp-space.mjs diff --git a/plugins/pi-mail-bridge/lib/user-question.js b/plugins/pi-mail-bridge/test/lib/user-question.js similarity index 100% rename from plugins/pi-mail-bridge/lib/user-question.js rename to plugins/pi-mail-bridge/test/lib/user-question.js diff --git a/plugins/pi-mail-bridge/test/session-scan.test.mjs b/plugins/pi-mail-bridge/test/session-scan.test.mjs index 38268e1..0bf059f 100644 --- a/plugins/pi-mail-bridge/test/session-scan.test.mjs +++ b/plugins/pi-mail-bridge/test/session-scan.test.mjs @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { createSessionScanner } from '../src/session-scan.mjs'; // ★ 夹具在 `lib/`(**不是测试模块**):从这里 `export` 它会让别人 import 本文件时 // 把本文件的所有用例在**他们那个进程里再注册一遍** —— 实测巨行用例被跑两次。 -import { writeSession } from '../lib/session-fixtures.mjs'; +import { writeSession } from './lib/session-fixtures.mjs'; /** 造一个会话目录树。返回根目录,用完由调用方删。 */ function makeRoot() { diff --git a/plugins/pi-mail-bridge/test/user-question.test.mjs b/plugins/pi-mail-bridge/test/user-question.test.mjs index bcfec38..29a5ab5 100644 --- a/plugins/pi-mail-bridge/test/user-question.test.mjs +++ b/plugins/pi-mail-bridge/test/user-question.test.mjs @@ -20,7 +20,7 @@ import { hasOptions, optionLabels, questionTitle, -} from '../lib/user-question.js'; +} from './lib/user-question.js'; test('单问题单选项:忠实映射问题与选项', () => { const flat = flattenQuestions([{