diff --git a/package.json b/package.json index 72286b2..862506f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "AI 多角色 code review:攻擊方找問題、防守方裁決誤報,結果留言到 PR 並保存 findings", "main": "src/index.js", "scripts": { - "build": "ncc build src/index.js -o dist" + "build": "ncc build src/index.js -o dist", + "test": "node --test" }, "author": "Jeffery", "license": "MIT" diff --git a/test/gitea.test.js b/test/gitea.test.js new file mode 100644 index 0000000..f5f6469 --- /dev/null +++ b/test/gitea.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const gitea = require('../src/lib/gitea'); + +function withFetchStub(handler, fn) { + const originalFetch = global.fetch; + const calls = []; + global.fetch = async (url, options = {}) => { + calls.push({ url, options }); + return handler(url, options); + }; + return Promise.resolve() + .then(() => fn(calls)) + .finally(() => { + global.fetch = originalFetch; + }); +} + +function jsonResponse(data, ok = true, status = 200) { + return { + ok, + status, + async text() { + return JSON.stringify(data); + }, + }; +} + +test('addIssueDependency 使用正確 endpoint、method 與 IssueMeta body', async () => { + const ctx = { + apiBase: 'https://gitea.example.test/api/v1', + token: 'hidden', + owner: 'owner', + repo: 'repo', + }; + + await withFetchStub(() => jsonResponse({ ok: true }), async (calls) => { + await gitea.addIssueDependency(ctx, 12, 34); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://gitea.example.test/api/v1/repos/owner/repo/issues/12/dependencies'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.headers.Authorization, 'token hidden'); + assert.deepEqual(JSON.parse(calls[0].options.body), { + index: 34, + owner: 'owner', + repo: 'repo', + }); + }); +}); + +test('createIssue 空 labels 不送出 labels 欄位', async () => { + const ctx = { + apiBase: 'https://gitea.example.test/api/v1', + token: 'hidden', + owner: 'owner', + repo: 'repo', + }; + + await withFetchStub(() => jsonResponse({ number: 5 }), async (calls) => { + await gitea.createIssue(ctx, { title: 'title', body: 'body', labels: [] }); + + assert.equal(calls[0].url, 'https://gitea.example.test/api/v1/repos/owner/repo/issues'); + assert.equal(calls[0].options.method, 'POST'); + assert.deepEqual(JSON.parse(calls[0].options.body), { + title: 'title', + body: 'body', + }); + }); +}); diff --git a/test/gitrepo.test.js b/test/gitrepo.test.js new file mode 100644 index 0000000..baa5895 --- /dev/null +++ b/test/gitrepo.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const gitrepo = require('../src/lib/gitrepo'); + +test('assertSafeBranchRef 接受一般分支名稱', () => { + assert.equal(gitrepo.__test.assertSafeBranchRef('feature/review-123', 'baseRef'), 'feature/review-123'); +}); + +test('assertSafeBranchRef 拒絕路徑穿越分支名稱', () => { + assert.throws( + () => gitrepo.__test.assertSafeBranchRef('../../hooks/pre-push', 'baseRef'), + /不是安全的分支名稱/, + ); +}); + +test('resolveMergeBase 會在 git fetch 前拒絕不安全 baseRef', () => { + assert.throws( + () => gitrepo.resolveMergeBase(process.cwd(), '../../hooks/pre-push'), + /不是安全的分支名稱/, + ); +}); diff --git a/test/review.test.js b/test/review.test.js new file mode 100644 index 0000000..8d10353 --- /dev/null +++ b/test/review.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const review = require('../src/lib/review'); + +test('agentFailureDetail 預設不輸出 stderr/stdout 片段', () => { + const oldDebug = process.env.ACTIONS_STEP_DEBUG; + delete process.env.ACTIONS_STEP_DEBUG; + try { + const detail = review.__test.agentFailureDetail({ + ok: false, + error: Object.assign(new Error('boom'), { code: 1 }), + stderr: 'token=super-secret-value', + output: 'stdout with password=hidden', + }); + assert.match(detail, /exit 1/); + assert.doesNotMatch(detail, /super-secret-value|password|stdout|stderr/); + } finally { + if (oldDebug === undefined) delete process.env.ACTIONS_STEP_DEBUG; + else process.env.ACTIONS_STEP_DEBUG = oldDebug; + } +}); + +test('agentFailureDetail 在 debug 模式輸出遮罩後片段', () => { + const oldDebug = process.env.ACTIONS_STEP_DEBUG; + process.env.ACTIONS_STEP_DEBUG = 'true'; + try { + const detail = review.__test.agentFailureDetail({ + ok: false, + error: Object.assign(new Error('boom'), { code: 2 }), + stderr: 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890', + output: 'token=abcdefghijklmnopqrstuvwxyz1234567890TOKEN', + }); + assert.match(detail, /exit 2/); + assert.match(detail, /stderr:Authorization: \*\*\*/); + assert.match(detail, /stdout:token=\*\*\*/); + assert.doesNotMatch(detail, /abcdefghijklmnopqrstuvwxyz/); + } finally { + if (oldDebug === undefined) delete process.env.ACTIONS_STEP_DEBUG; + else process.env.ACTIONS_STEP_DEBUG = oldDebug; + } +}); + +test('postOthersToIssue 批次送出 issue 留言', async () => { + const calls = []; + let active = 0; + let maxActive = 0; + const fakeGitea = { + async createCommentOnIssue(ctx, issueNumber, body) { + active += 1; + maxActive = Math.max(maxActive, active); + calls.push({ ctx, issueNumber, body }); + await new Promise((resolve) => setTimeout(resolve, 20)); + active -= 1; + return { id: calls.length }; + }, + }; + + await review.postOthersToIssue({ + ctx: { token: 'hidden' }, + gitea: fakeGitea, + issueNumber: 7, + others: [ + { severity: '警告', reviewer: 'Maya', file: 'a.js', startLine: 1, endLine: 1, problem: 'p1', suggestion: 's1' }, + { severity: '建議', reviewer: 'Bard', file: 'b.js', startLine: 2, endLine: 2, problem: 'p2', suggestion: 's2' }, + ], + }); + + assert.equal(calls.length, 2); + assert.equal(calls[0].issueNumber, 7); + assert.ok(maxActive > 1); +});