import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview, formatFindingsStats } from './comments.js'; import { FINDINGS_PATH } from './config.js'; describe('saveFindings', () => { const tempDirs = []; const makeTempDir = prefix => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); return dir; }; it('writes findings to workspace and mirror dirs when provided', () => { const workspace = makeTempDir('findings-ws-'); const mirrorDir = makeTempDir('findings-mirror-'); const findings = [{ level: 'warning', role: 'Leo', location: 'file.js:1', suggestion: 'test' }]; saveFindings(workspace, findings, mirrorDir); const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8'); assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n'); assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n'); }); it('writes only to workspace when mirrorDir is omitted', () => { const workspace = makeTempDir('findings-ws-'); const findings = [{ level: 'info', role: 'Maya', location: 'file.js:2', suggestion: 'note' }]; saveFindings(workspace, findings); const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n'); }); it('does not duplicate writes when mirrorDir matches workspace', () => { const workspace = makeTempDir('findings-same-'); const findings = []; const writeCalls = []; const originalWriteFileSync = fs.writeFileSync; fs.writeFileSync = (...args) => { writeCalls.push(args[0]); return originalWriteFileSync(...args); }; try { saveFindings(workspace, findings, workspace); } finally { fs.writeFileSync = originalWriteFileSync; } assert.equal(writeCalls.length, 1); assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH)); }); it('writes an empty JSON array when findings is empty', () => { const workspace = makeTempDir('findings-empty-'); saveFindings(workspace, []); const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); assert.equal(workspaceText, '[]\n'); }); afterEach(() => { while (tempDirs.length > 0) { fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); } }); }); describe('parseLocation', () => { it('parses file and single line', () => { assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 }); }); it('uses the start line for a line range', () => { assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 }); }); it('returns null when there is no line number', () => { assert.equal(parseLocation('app/preflight.test.js'), null); }); it('returns null when multiple files are listed', () => { assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null); }); it('returns null for non-string input', () => { assert.equal(parseLocation(undefined), null); }); }); describe('formatFindingsStats', () => { it('formats old and new findings by severity', () => { const stats = formatFindingsStats([ { level: 'critical', is_new: false }, { level: 'warning', is_new: true }, { level: 'info' }, { level: 'custom', is_new: true }, ]); assert.equal(stats, [ '| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 |', '| --- | --- | --- | --- |', '| 舊問題 | 1 筆 | 0 筆 | 0 筆 |', '| 新問題 | 0 筆 | 1 筆 | 1 筆 |', ].join('\n')); }); }); describe('postNewCriticalComments', () => { const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true }; it('posts an inline review comment annotating file/line with level/role/suggestion', async () => { const inlineCalls = []; const issueCalls = []; await postNewCriticalComments([critical], { postInline: async (args) => { inlineCalls.push(args); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(inlineCalls.length, 1); assert.equal(issueCalls.length, 0); assert.equal(inlineCalls[0].path, 'app/preflight.js'); assert.equal(inlineCalls[0].line, 19); assert.match(inlineCalls[0].body, /等級/); assert.match(inlineCalls[0].body, /審查員.*Rex/s); assert.match(inlineCalls[0].body, /建議.*修這個/s); }); it('falls back to a normal comment when the location has no line number', async () => { const inlineCalls = []; const issueCalls = []; await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], { postInline: async (args) => { inlineCalls.push(args); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(inlineCalls.length, 0); assert.equal(issueCalls.length, 1); assert.match(issueCalls[0], /嚴重問題/); }); it('falls back to a normal comment when the inline post fails', async () => { const issueCalls = []; await postNewCriticalComments([critical], { postInline: async () => { throw new Error('line not in diff'); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(issueCalls.length, 1); assert.match(issueCalls[0], /嚴重問題/); }); it('only posts for new critical findings', async () => { const inlineCalls = []; const issueCalls = []; await postNewCriticalComments([ { ...critical, is_new: false }, { level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true }, ], { postInline: async (args) => { inlineCalls.push(args); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(inlineCalls.length, 0); assert.equal(issueCalls.length, 0); }); it('posts nothing when given an empty findings array', async () => { const inlineCalls = []; const issueCalls = []; await postNewCriticalComments([], { postInline: async (args) => { inlineCalls.push(args); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(inlineCalls.length, 0); assert.equal(issueCalls.length, 0); }); it('handles multiple criticals, posting inline where possible and degrading the rest', async () => { const criticalCommentPattern = /嚴重問題/; const inlineCalls = []; const issueCalls = []; const findings = [ { ...critical, location: 'app/a.js:10', suggestion: 'A' }, // 有行號、inline 成功 { ...critical, location: 'app/b.js', suggestion: 'B' }, // 無行號 → 降級為一般 comment { ...critical, location: 'app/c.js:20', suggestion: 'C' }, // inline 拋錯 → 降級為一般 comment ]; await postNewCriticalComments(findings, { postInline: async (args) => { if (args.path === 'app/c.js') throw new Error('line not in diff'); inlineCalls.push(args); }, postIssue: async (body) => { issueCalls.push(body); }, }); assert.equal(inlineCalls.length, 1); assert.equal(inlineCalls[0].path, 'app/a.js'); assert.equal(inlineCalls[0].line, 10); assert.equal(issueCalls.length, 2); assert.ok(issueCalls.every(b => criticalCommentPattern.test(b))); }); }); describe('postFindingsReview', () => { const REVIEW_SEVERITY_LABELS = ['🔴 嚴重', '🟡 警告', '🔵 建議']; const REVIEW_SEVERITY_PATTERN = new RegExp(`\\*\\*嚴重等級\\*\\*:(${REVIEW_SEVERITY_LABELS.join('|')})(?:\\n|$)`); /** * 從 review comment body 擷取嚴重等級標籤。 * @param {object | null | undefined} comment - 預期包含 body 欄位的 review comment。 * @returns {string | undefined} 嚴重等級標籤;格式不符時回傳 undefined。 */ function reviewSeverityLabel(comment) { return comment?.body?.match(REVIEW_SEVERITY_PATTERN)?.[1]; } it('handles missing review severity bodies gracefully', () => { assert.equal(reviewSeverityLabel(null), undefined); assert.equal(reviewSeverityLabel(undefined), undefined); assert.equal(reviewSeverityLabel({}), undefined); assert.equal(reviewSeverityLabel({ body: null }), undefined); assert.equal(reviewSeverityLabel({ body: undefined }), undefined); }); it('extracts review severity labels only when the format is valid', () => { assert.equal( reviewSeverityLabel({ body: '**嚴重等級**:🔴 嚴重\n**審查員**:Rex' }), '🔴 嚴重', ); assert.equal(reviewSeverityLabel({ body: '**審查員**:Rex' }), undefined); assert.equal(reviewSeverityLabel({ body: '**嚴重等級**:' }), undefined); assert.equal(reviewSeverityLabel({ body: '**嚴重等級**:高風險' }), undefined); }); it('posts one review with statistics and sorted line comments', async () => { const reviewCalls = []; const findings = [ { level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'I', is_new: true }, { level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'C', is_new: false }, { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true }, ]; await postFindingsReview(findings, { summaryFindings: findings, commentFindings: findings, postReview: async (args) => { reviewCalls.push(args); }, }); assert.equal(reviewCalls.length, 1); assert.match(reviewCalls[0].body, /\| 類型 \| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/); assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/); assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/); assert.deepEqual( reviewCalls[0].comments.map(c => c.path), ['app/a.js', 'app/b.js', 'app/c.js'], ); assert.deepEqual( reviewCalls[0].comments.map(reviewSeverityLabel), REVIEW_SEVERITY_LABELS, ); assert.deepEqual( reviewCalls[0].comments.map(c => c.new_position), [10, 20, 30], ); assert.match(reviewCalls[0].comments[0].body, /嚴重等級/); assert.match(reviewCalls[0].comments[0].body, /審查員.*Rex/s); assert.match(reviewCalls[0].comments[0].body, /問題.*未提供問題原因/s); assert.doesNotMatch(reviewCalls[0].comments[0].body, /問題.*app\/a\.js:10/s); assert.match(reviewCalls[0].comments[0].body, /建議.*C/s); }); it('separates old and new findings in default review statistics', async () => { const reviewCalls = []; await postFindingsReview([ { level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old critical', is_new: false }, { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warning', is_new: true }, { level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info' }, ], { postReview: async (args) => { reviewCalls.push(args); }, }); assert.equal(reviewCalls.length, 1); assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/); assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/); assert.equal(reviewCalls[0].comments.length, 3); }); it('only adds comments for findings with parseable file and line', async () => { const reviewCalls = []; await postFindingsReview([ { level: 'critical', role: 'Rex', location: 'app/a.js', suggestion: 'missing line', is_new: true }, { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'line', is_new: true }, ], { postReview: async (args) => { reviewCalls.push(args); }, }); assert.equal(reviewCalls.length, 1); assert.match(reviewCalls[0].body, /\| 新問題 \| 1 筆 \| 1 筆 \| 0 筆 \|/); assert.equal(reviewCalls[0].comments.length, 1); assert.equal(reviewCalls[0].comments[0].path, 'app/b.js'); }); it('uses an explicit problem field when present', async () => { const reviewCalls = []; await postFindingsReview([ { level: 'warning', role: 'Leo', location: 'app/a.js:5', problem: '命名不清楚', suggestion: '改成具體名稱' }, ], { postReview: async (args) => { reviewCalls.push(args); }, }); assert.match(reviewCalls[0].comments[0].body, /問題.*命名不清楚/s); assert.match(reviewCalls[0].comments[0].body, /建議.*改成具體名稱/s); }); it('uses reviewer reason fields as the problem text instead of the location', async () => { const reviewCalls = []; await postFindingsReview([ { level: 'warning', role: 'Leo', location: 'app/a.js:5', description: '這裡缺少空值檢查', suggestion: '先判斷 null 再使用' }, ], { postReview: async (args) => { reviewCalls.push(args); }, }); assert.match(reviewCalls[0].comments[0].body, /問題.*這裡缺少空值檢查/s); assert.doesNotMatch(reviewCalls[0].comments[0].body, /問題.*app\/a\.js:5/s); }); });