Files
ai-code-review-by-node/src/test/main.test.js
T
Jeffery 5c2c37f9d1
CI / 2. TEST (pull_request) Successful in 9m44s
CI / 3. RESULT (pull_request) Successful in 1s
CI / 1. BUILD (pull_request) Successful in 1s
test(main): 設 AI_REVIEW_SKIP_MAIN 略過自動執行
2026-07-03 17:53:52 +08:00

138 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});