From 2f294845c881e65c90fc12b1740373d24a21d080 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 3 Jul 2026 17:47:48 +0800 Subject: [PATCH] =?UTF-8?q?test(main):=20=E6=96=B0=E5=A2=9E=20main=20pipel?= =?UTF-8?q?ine=20=E5=90=84=E5=88=86=E6=94=AF=E6=95=B4=E5=90=88=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/main.test.js | 134 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/test/main.test.js diff --git a/src/test/main.test.js b/src/test/main.test.js new file mode 100644 index 0000000..854e3cc --- /dev/null +++ b/src/test/main.test.js @@ -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); + }); +});