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 } 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('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', () => { 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, { postReview: async (args) => { reviewCalls.push(args); }, }); assert.equal(reviewCalls.length, 1); assert.match(reviewCalls[0].body, /\| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/); assert.match(reviewCalls[0].body, /\| 1 筆 \| 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(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('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); }); });