// tests/unit/main/lint/no-fixed-temp-dir.test.ts // // 守「测试的临时目录必须每次跑测唯一」这条约定(r209)。 // // ── 被守卫的缺陷(不是风格问题,是并发跑测时的假红)──────────────────── // 约定:临时目录用 `fs.mkdtemp(join(tmpdir(), 'prefix-'))` —— 名字带 // 随机后缀,每次跑测唯一。仓库里 34 处测试都这么写。 // // 偏离:少数几个测试文件用**固定**目录名 + 递归删: // const TMP = join(tmpdir(), 'rkms-r205'); // beforeEach(async () => { await fs.rm(TMP, { recursive: true, force: true }); … }) // // 后果:两次**并发**的跑测(CI 分片 / watch 模式重跑 / 两个 checkout 同时 // 跑 / 一条命令跑全量 + 另一条命令单跑某个文件)会用**同一个**目录, // 于是一个进程的 `fs.rm -rf` 把另一个进程**正在用**的目录树删掉 ⇒ // 一片 ENOENT / ENOTEMPTY 假红,而两个测试**单跑都是绿的**。 // // 实测(2026-10-20,真机):一条命令跑全量(81 文件)、另一条并行单跑 // rkms-r205 那个文件 ⇒ 该文件 4 例红: // ENOENT: chmod '…\rkms-r205\default\notes' (fs.cp 刚建好的目录没了) // ENOENT: scandir '…\rkms-r205\current' // ENOENT: mkdir '…\rkms-r205\current' // ENOTEMPTY: rmdir '…\rkms-r205\default' // 两条命令**各自单跑**都 7/7 绿 ⇒ 竞争,不是被测代码的缺陷。 // 这类假红最贵:它看起来像「代码被改坏了」,会让人去改**没错**的产品代码。 // // ── 判定方式 ─────────────────────────────────────────────────────── // 静态扫 tests/**:凡是 `join(tmpdir(), '<字面量>')` 形式的**固定**路径, // 必须紧跟在 `mkdtemp(` 之后(即由 mkdtemp 生成唯一名)。 // - `mkdtemp(join(tmpdir(), 'rkms-x-'))` → 放行 // - `join(tmpdir(), 'rkms-x')` → 违规(固定名) // // 覆盖边界(诚实声明):静态判定只认**字面量**路径。写成 // `join(tmpdir(), \`rkms-${Date.now()}\`)` 的动态名**放行**(它每次都不同), // 但那种写法不是原子唯一的 —— 两个进程在同一毫秒起跑仍可能撞名, // 所以约定仍然是 mkdtemp。 import { readdirSync, readFileSync, statSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; const ROOT = process.cwd(); const TESTS_DIR = join(ROOT, 'tests'); /** * 本守卫自己 exempt:它的源码里必然出现「固定 tmpdir 路径」的字面写法 * (注释与正则),扫到自己会误报。 */ const SELF = 'tests/unit/main/lint/no-fixed-temp-dir.test.ts'; function walk(dir: string): string[] { const out: string[] = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) out.push(...walk(full)); else if (/\.tsx?$/.test(entry)) out.push(full); } return out; } /** `join(tmpdir(), '…')` / `join(os.tmpdir(), '…')` 里的字面量参数 */ const FIXED_TMP_PATH_RE = /join\(\s*(?:os\.)?tmpdir\(\)\s*,\s*(['"`])([^'"`]*)\1\s*\)/g; interface Violation { file: string; line: number; snippet: string; } function scanFile(abs: string): Violation[] { const src = readFileSync(abs, 'utf-8'); const rel = abs.slice(ROOT.length + 1).split('\\').join('/'); if (rel === SELF) return []; const out: Violation[] = []; for (const m of src.matchAll(FIXED_TMP_PATH_RE)) { const literal = m[2] ?? ''; // 动态名(带插值)每次都不同 —— 放行(但约定上仍该用 mkdtemp) if (literal.includes('${')) continue; const before = src.slice(0, m.index); // 紧跟在 mkdtemp( 之后 ⇒ 由 mkdtemp 生成唯一名 ⇒ 放行 if (/mkdtemp\(\s*$/.test(before)) continue; const line = src.slice(0, m.index).split('\n').length; out.push({ file: rel, line, snippet: m[0].replace(/\s+/g, ' ') }); } return out; } describe('临时目录必须每次跑测唯一(静态守卫)', () => { it('tests/ 下不得出现固定的 tmpdir 路径(必须是 mkdtemp)', () => { const violations = walk(TESTS_DIR).flatMap(scanFile); expect( violations, violations.length > 0 ? `固定临时目录名会让并发的两轮跑测互删对方的目录树(实测假红):\n` + violations.map((v) => ` ${v.file}:${v.line} ${v.snippet}`).join('\n') + `\n改法:const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-xxx-'));` : '', ).toEqual([]); }); it('守卫本身是有效的(拿一个已知的违规写法做对照)', () => { const fake = [ "const TMP = join(tmpdir(), 'rkms-fake');", "const OK = await fs.mkdtemp(join(tmpdir(), 'rkms-fake-'));", "const ALSO_OK = join(tmpdir(), `rkms-${Date.now()}`);", ].join('\n'); const src = fake; const hits: string[] = []; for (const m of src.matchAll(FIXED_TMP_PATH_RE)) { const literal = m[2] ?? ''; if (literal.includes('${')) continue; const before = src.slice(0, m.index); if (/mkdtemp\(\s*$/.test(before)) continue; hits.push(m[0]); } // 第一条(固定名)被抓到,后两条(mkdtemp / 动态名)放行 expect(hits).toHaveLength(1); expect(hits[0]).toContain('rkms-fake'); }); });