release: 將 AI 程式碼審查 action 發布到 master #2
@@ -0,0 +1,134 @@
|
|||||||
|
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)。
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user