import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; // main() 是整個 action 的流程總管(Step1~Step11),本測試用 node:test 的 module mock // 把所有相依模組換成可控 stub,逐一驗證關鍵分支的 exit code 與退出時機。 // 需要 `--experimental-test-module-mocks`(見 package.json test script)。 // 略過 main.js 被 import 時的自動執行;本測試改為手動呼叫 export 的 main()。 process.env.AI_REVIEW_SKIP_MAIN = '1'; const U = (rel) => new URL(rel, import.meta.url).href; // 每個相依模組的預設 stub(快樂路徑:無 critical、diff 非空、角色分析成功)。 // 各測試以 overrides 覆寫要驗證的分支。 function baseStubs() { return { config: { GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop', FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json', getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }), }, roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' }, gitea: { getPRDiff: async () => 'diff --git a/x b/x\n+code', postComment: async () => ({ id: 1 }), getCommitMessageBySha: async () => 'normal commit', getBotReviewOutcome: () => null, shouldSkipBotCommit: async () => false, }, findings: { analyzeWithRole: async () => [], loadOldFindings: () => [], mergeFindings: (a, b) => [...a, ...b], sortByLevel: (a) => a, deduplicateWithAI: async (a) => a, loadExclusions: () => [], applyExclusions: (a) => a, filterFalsePositivesWithAI: async (a) => a, appendExclusions: () => null, resolveMissingLineNumbers: async (a) => a, }, resolve: { reconcileConversations: async () => ({ resolvedFindings: [], excludedFindings: [], carriedFindings: [], resolvedCount: 0, falsePositiveCount: 0, openCount: 0, closedCount: 0 }), dropResolvedFindings: (a) => a, addCarriedFindings: (a) => a, }, comments: { saveFindings: () => {}, postFindingsReview: async () => {}, formatFindingsStatsLine: () => '' }, usage: { getRunUsage: () => ({}), getRateLimit: () => ({}), fetchAccountQuota: async () => ({}), formatUsageStats: () => '', formatUsageStatsLine: () => '' }, git: { cloneRepo: () => '/repo', commitAndPush: async () => {}, getRepoState: () => ({ branch: 'feat', shortSha: 'abc' }) }, json: { validateJSONArrayFile: async () => ({ exists: true }), ensureJSONArrayFileExists: () => {} }, preflight: { runPreflight: async () => true }, llm: { mapWithConcurrency: async (items, _l, fn) => Promise.all(items.map(fn)), LLM_CONCURRENCY: 0 }, log: { section() {}, step() {}, line() {}, input() {}, output() {}, result() {}, warn() {}, error() {} }, }; } // 套用 mock 並載入一份全新的 main(以 query 破快取),回傳 main()。 let importSeq = 0; async function loadMain(overrides = {}) { const s = baseStubs(); for (const k of Object.keys(overrides)) s[k] = { ...s[k], ...overrides[k] }; mock.module(U('../config.js'), { namedExports: s.config }); mock.module(U('../roles.js'), { namedExports: s.roles }); mock.module(U('../gitea.js'), { namedExports: s.gitea }); mock.module(U('../findings.js'), { namedExports: s.findings }); mock.module(U('../resolve.js'), { namedExports: s.resolve }); mock.module(U('../comments.js'), { namedExports: s.comments }); mock.module(U('../usage.js'), { namedExports: s.usage }); mock.module(U('../git.js'), { namedExports: s.git }); mock.module(U('../json.js'), { namedExports: s.json }); mock.module(U('../preflight.js'), { namedExports: s.preflight }); mock.module(U('../llm.js'), { namedExports: s.llm }); mock.module(U('../log.js'), { namedExports: s.log }); const mod = await import(`../main.js?seq=${importSeq++}`); return mod.main; } // 執行 main(),攔截 process.exit 並回傳 exit code(正常結束回 0)。 async function runMain(overrides) { const main = await loadMain(overrides); mock.method(process, 'exit', (code) => { throw Object.assign(new Error('__exit__'), { __code: code ?? 0 }); }); try { await main(); return 0; } catch (e) { if (e && e.__code !== undefined) return e.__code; throw e; } } describe('main pipeline', () => { beforeEach(() => { process.env.PR_HEAD_SHA = 'deadbeef'; }); afterEach(() => { mock.restoreAll(); delete process.env.PR_HEAD_SHA; }); it('前置驗證失敗 → exit 1', async () => { assert.equal(await runMain({ preflight: { runPreflight: async () => false } }), 1); }); it('偵測到 [ai-review-bot][failure] → exit 1', async () => { assert.equal(await runMain({ gitea: { getCommitMessageBySha: async () => 'chore: update [ai-review-bot][failure]', getBotReviewOutcome: () => 'failure' }, }), 1); }); it('本次為 bot 自動提交 → exit 0(跳過審查)', async () => { assert.equal(await runMain({ gitea: { shouldSkipBotCommit: async () => true } }), 0); }); it('未偵測到 LLM provider → exit 1', async () => { assert.equal(await runMain({ config: { getLLMConfig: () => ({ provider: null, apiKeys: [], baseURL: null, model: null, command: null }) } }), 1); }); it('diff 為空 → exit 0', async () => { assert.equal(await runMain({ gitea: { getPRDiff: async () => ' ' } }), 0); }); it('所有角色分析皆失敗 → exit 1', async () => { assert.equal(await runMain({ findings: { analyzeWithRole: async () => { throw new Error('boom'); } } }), 1); }); it('JSON 格式驗證失敗 → exit 1', async () => { assert.equal(await runMain({ json: { validateJSONArrayFile: async () => { throw new Error('bad json'); } } }), 1); }); it('產生 critical finding → exit 1', async () => { const crit = [{ level: 'critical', role: 'Mage', location: 'a.js:1', suggestion: 'fix' }]; assert.equal(await runMain({ findings: { analyzeWithRole: async () => crit, filterFalsePositivesWithAI: async () => crit } }), 1); }); it('無 critical → 正常走完(exit 0)', async () => { assert.equal(await runMain(), 0); }); it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => { assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0); }); });