import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate, reevalScopeGate } from '../src/executor/checks.js'; import type { Project } from '../src/model/types.js'; function withTmpData(cb: (dir: string) => Promise | void): Promise | void { const dir = mkdtempSync(join(tmpdir(), 'maestro-checks-')); const prev = process.env.MAESTRO_DATA_DIR; process.env.MAESTRO_DATA_DIR = dir; const done = () => { if (prev === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prev; rmSync(dir, { recursive: true, force: true }); }; try { const r = cb(dir); if (r instanceof Promise) return r.finally(done); done(); } catch (e) { done(); throw e; } } function proj(checks: string | null): Project { return { checks, repoPath: '/tmp/x', defaultBranch: 'main' } as Project; } test('parseChecks:合法 JSON 取 lint/typecheck/build,非法/空 → {}', () => { assert.deepEqual(parseChecks('{"lint":"npm run lint","build":"npm run build"}'), { lint: 'npm run lint', build: 'npm run build' }); assert.deepEqual(parseChecks('{"other":"x","typecheck":" tsc "}'), { typecheck: 'tsc' }); assert.deepEqual(parseChecks(null), {}); assert.deepEqual(parseChecks('not json'), {}); assert.deepEqual(parseChecks('{"lint":""}'), {}); // 空命令跳过 }); test('runChecks:无 checks → 通过', async () => { await withTmpData(async (dir) => { const r = await runChecks(proj(null), dir, 'run_a'); assert.equal(r.ok, true); assert.equal(r.logRef, null); }); }); test('runChecks:各项 exit0 → 通过;任一非0 → 失败并报哪项挂', async () => { await withTmpData(async (dir) => { const pass = await runChecks(proj('{"lint":"true","typecheck":"true"}'), dir, 'run_b'); assert.equal(pass.ok, true); assert.equal(pass.failed, null); const fail = await runChecks(proj('{"lint":"true","typecheck":"false","build":"true"}'), dir, 'run_c'); assert.equal(fail.ok, false); assert.equal(fail.failed, 'typecheck'); assert.match(fail.error!, /typecheck 检查失败/); }); }); test('diffSizeGate:超文件/行阈值 → 拦截;阈值内 → 通过', async () => { const repo = mkdtempSync(join(tmpdir(), 'maestro-gitdiff-')); const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString(); g(['init', '-q', '-b', 'main']); g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']); writeFileSync(join(repo, 'base.txt'), 'base\n'); g(['add', '-A']); g(['commit', '-qm', 'base']); g(['checkout', '-q', '-b', 'feature']); // 改动:3 个文件、若干行 for (const f of ['a.txt', 'b.txt', 'c.txt']) writeFileSync(join(repo, f), 'x\ny\nz\n'); g(['add', '-A']); g(['commit', '-qm', 'feat']); const prevF = process.env.MAESTRO_DIFF_MAX_FILES; const prevL = process.env.MAESTRO_DIFF_MAX_LINES; try { process.env.MAESTRO_DIFF_MAX_FILES = '2'; // 3 文件 > 2 → 拦截 process.env.MAESTRO_DIFF_MAX_LINES = '0'; const over = await diffSizeGate(repo, repo, 'feature', 'main'); assert.equal(over.ok, false); assert.equal(over.files, 3); assert.match(over.error!, /文件数 3 > 2/); process.env.MAESTRO_DIFF_MAX_FILES = '0'; // 0=不限 process.env.MAESTRO_DIFF_MAX_LINES = '0'; const under = await diffSizeGate(repo, repo, 'feature', 'main'); assert.equal(under.ok, true); assert.equal(under.files, 3); } finally { if (prevF === undefined) delete process.env.MAESTRO_DIFF_MAX_FILES; else process.env.MAESTRO_DIFF_MAX_FILES = prevF; if (prevL === undefined) delete process.env.MAESTRO_DIFF_MAX_LINES; else process.env.MAESTRO_DIFF_MAX_LINES = prevL; rmSync(repo, { recursive: true, force: true }); } }); test('diffSizeGate:git 出错 → 不拦截(测不出体量)', async () => { const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b'); assert.equal(r.ok, true); }); test('globToRegExp / matchesAnyGlob:* 单层、** 跨层、? 单字符、目录式', () => { assert.ok(globToRegExp('src/*.ts').test('src/a.ts')); assert.ok(!globToRegExp('src/*.ts').test('src/sub/a.ts')); // * 不跨 / assert.ok(globToRegExp('src/**').test('src/sub/deep/a.ts')); // ** 跨 / assert.ok(globToRegExp('src/').test('src/anything/x.ts')); // 目录式 → 其下全部 assert.ok(globToRegExp('a?.ts').test('ab.ts')); assert.ok(!globToRegExp('a?.ts').test('abc.ts')); assert.ok(globToRegExp('file.name.js').test('file.name.js')); // . 字面量 assert.ok(!globToRegExp('file.name.js').test('fileXname.js')); assert.ok(matchesAnyGlob('lib/x.ts', ['src/**', 'lib/*.ts'])); assert.ok(!matchesAnyGlob('test/x.ts', ['src/**', 'lib/*.ts'])); }); test('scopeFileGate:改动越界文件 → 拦截;范围内 → 通过;空声明 → 跳过', async () => { const repo = mkdtempSync(join(tmpdir(), 'maestro-scope-')); const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString(); g(['init', '-q', '-b', 'main']); g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']); writeFileSync(join(repo, 'base.txt'), 'base\n'); g(['add', '-A']); g(['commit', '-qm', 'base']); g(['checkout', '-q', '-b', 'feature']); execFileSync('mkdir', ['-p', join(repo, 'src')]); writeFileSync(join(repo, 'src', 'a.ts'), 'a\n'); writeFileSync(join(repo, 'other.txt'), 'o\n'); // 越界文件 g(['add', '-A']); g(['commit', '-qm', 'feat']); try { // 声明只允许 src/**,但改了 other.txt → 拦截 const over = await scopeFileGate(repo, 'feature', 'main', ['src/**']); assert.equal(over.ok, false); assert.deepEqual(over.outside, ['other.txt']); assert.match(over.error!, /声明范围外/); // 声明覆盖全部改动 → 通过 const ok = await scopeFileGate(repo, 'feature', 'main', ['src/**', 'other.txt']); assert.equal(ok.ok, true); assert.deepEqual(ok.outside, []); // 空声明 → 跳过(不限范围) const skip = await scopeFileGate(repo, 'feature', 'main', []); assert.equal(skip.ok, true); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('scopeFileGate:git 出错 → 不拦截', async () => { const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']); assert.equal(r.ok, true); }); test('reevalScopeGate(规则13):main 动了声明范围内文件 → overlap 非空;范围外 → 空;空声明 → 空', async () => { const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval-')); const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString(); g(['init', '-q', '-b', 'main']); g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']); execFileSync('mkdir', ['-p', join(repo, 'src')]); writeFileSync(join(repo, 'src', 'a.ts'), 'a\n'); writeFileSync(join(repo, 'docs.md'), 'd\n'); g(['add', '-A']); g(['commit', '-qm', 'base']); // 任务分支从此刻 main 切出(尚无任务提交,模拟 sync 前的 worktree HEAD) g(['checkout', '-q', '-b', 'feature']); // main 在任务排队期间前移:改了 src/a.ts(声明范围内) g(['checkout', '-q', 'main']); writeFileSync(join(repo, 'src', 'a.ts'), 'a changed by main\n'); g(['add', '-A']); g(['commit', '-qm', 'main moved']); g(['checkout', '-q', 'feature']); // worktree HEAD 回到分支点 try { // 声明 src/**,main 动了 src/a.ts → 撞上,触发重评估 const hit = await reevalScopeGate(repo, 'main', ['src/**']); assert.deepEqual(hit.overlap, ['src/a.ts']); // 声明 docs.md,main 只动了 src/a.ts → 不撞,不触发 const miss = await reevalScopeGate(repo, 'main', ['docs.md']); assert.deepEqual(miss.overlap, []); // 空声明 → 不评估 const skip = await reevalScopeGate(repo, 'main', []); assert.deepEqual(skip.overlap, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('reevalScopeGate(规则13):main 无前移 → 空 overlap;git 出错 → 空', async () => { const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval2-')); const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString(); g(['init', '-q', '-b', 'main']); g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']); writeFileSync(join(repo, 'src.ts'), 'x\n'); g(['add', '-A']); g(['commit', '-qm', 'base']); g(['checkout', '-q', '-b', 'feature']); // 分支点 = main tip,main 未前移 try { const r = await reevalScopeGate(repo, 'main', ['**']); assert.deepEqual(r.overlap, []); } finally { rmSync(repo, { recursive: true, force: true }); } const err = await reevalScopeGate('/nonexistent-repo-xyz', 'main', ['src/**']); assert.deepEqual(err.overlap, []); });