import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { parseBotReviewComment, groupConversations, codeWindow, judgeConversationsResolved, reconcileConversations, dropResolvedFindings, addCarriedFindings, } from './resolve.js'; const reviewBody = (level, role, problem, suggestion) => `**嚴重等級**:${level}\n**審查員**:${role}\n**問題**:${problem}\n**建議**:${suggestion}`; describe('parseBotReviewComment', () => { it('parses a standard review comment back into a finding', () => { const f = parseBotReviewComment(reviewBody('🔴 嚴重', 'Assassin', '可能空指標', '加上 null 檢查')); assert.deepEqual(f, { level: 'critical', role: 'Assassin', problem: '可能空指標', suggestion: '加上 null 檢查' }); }); it('maps 警告/建議 labels to warning/info', () => { assert.equal(parseBotReviewComment(reviewBody('🟡 警告', 'Mage', 'p', 's')).level, 'warning'); assert.equal(parseBotReviewComment(reviewBody('🔵 建議', 'Bard', 'p', 's')).level, 'info'); }); it('parses inline critical comment format (等級/審查員/建議, no 問題)', () => { const body = '**等級**:🔴 嚴重\n**審查員**:Rogue\n**建議**:移除硬編碼密鑰'; const f = parseBotReviewComment(body); assert.equal(f.level, 'critical'); assert.equal(f.role, 'Rogue'); assert.equal(f.suggestion, '移除硬編碼密鑰'); }); it('falls back to 問題 content when 建議 is absent', () => { const body = '**審查員**:Maya\n**問題**:缺少邊界測試'; const f = parseBotReviewComment(body); assert.equal(f.problem, '缺少邊界測試'); assert.equal(f.suggestion, '缺少邊界測試'); }); it('defaults level to warning when 嚴重等級/等級 is missing', () => { const body = '**審查員**:Maya\n**問題**:p\n**建議**:s'; assert.equal(parseBotReviewComment(body).level, 'warning'); }); it('returns null for free-form human comments', () => { assert.equal(parseBotReviewComment('我覺得這段可以再想想'), null); assert.equal(parseBotReviewComment(''), null); assert.equal(parseBotReviewComment(null), null); }); }); describe('groupConversations', () => { it('groups by path+line, detects resolved, and extracts bot finding', () => { const comments = [ { id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 's1') }, { id: 2, path: 'a.js', position: 10, body: '補充:請看這裡', resolver: { login: 'dev' } }, { id: 3, path: 'b.js', position: 5, body: reviewBody('🟡 警告', 'Mage', 'p2', 's2') }, ]; const convos = groupConversations(comments); assert.equal(convos.length, 2); const a = convos.find(c => c.path === 'a.js'); assert.equal(a.resolved, true); assert.deepEqual(a.commentIds, [1, 2]); assert.equal(a.botFinding.level, 'critical'); assert.equal(a.botFinding.location, 'a.js:10'); const b = convos.find(c => c.path === 'b.js'); assert.equal(b.resolved, false); assert.equal(b.botFinding.suggestion, 's2'); }); it('falls back to original_position when position is missing', () => { const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]); assert.equal(convos[0].line, 7); }); }); describe('codeWindow', () => { it('returns a numbered window around the target line', () => { const content = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n'); const win = codeWindow(content, 25, 2); assert.equal(win, '23: line23\n24: line24\n25: line25\n26: line26\n27: line27'); }); it('returns empty string for empty content', () => { assert.equal(codeWindow('', 10), ''); }); }); describe('judgeConversationsResolved', () => { it('aligns verdicts by idx and defaults missing/non-true to false', async () => { const items = [{ idx: 0 }, { idx: 1 }, { idx: 2 }]; const chatFn = async () => [{ idx: 0, resolved: true }, { idx: 1, resolved: false }]; const verdicts = await judgeConversationsResolved(items, chatFn); assert.deepEqual(verdicts, [ { idx: 0, resolved: true }, { idx: 1, resolved: false }, { idx: 2, resolved: false }, ]); }); it('treats non-array AI output as all unresolved', async () => { const verdicts = await judgeConversationsResolved([{ idx: 0 }], async () => ({})); assert.deepEqual(verdicts, [{ idx: 0, resolved: false }]); }); it('filters out AI results missing idx or resolved and keeps valid ones', async () => { const items = [{ idx: 0 }, { idx: 1 }]; const chatFn = async () => [{ resolved: true }, { idx: 1, resolved: true }, { idx: 0 }]; const verdicts = await judgeConversationsResolved(items, chatFn); assert.deepEqual(verdicts, [ { idx: 0, resolved: false }, // {idx:0} 缺 resolved → 視為 false;缺 idx 的整筆被過濾 { idx: 1, resolved: true }, ]); }); it('propagates errors thrown by chatFn to the caller', async () => { await assert.rejects( () => judgeConversationsResolved([{ idx: 0 }], async () => { throw new Error('LLM down'); }), /LLM down/, ); }); it('returns [] for no items', async () => { assert.deepEqual(await judgeConversationsResolved([]), []); }); }); describe('reconcileConversations', () => { const baseDeps = () => ({ listComments: async () => [ { id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 'fix one') }, { id: 2, path: 'b.js', position: 20, body: reviewBody('🟡 警告', 'Mage', 'p2', 'fix two') }, { id: 3, path: 'c.js', position: 30, body: reviewBody('🔵 建議', 'Bard', 'p3', 'fix three'), resolver: { login: 'dev' } }, ], getFileContent: async () => 'some code', judge: async (items) => items.map(it => ({ idx: it.idx, resolved: it.path === 'a.js' })), resolveComment: async () => ({ ok: true }), }); it('resolves AI-confirmed conversations and carries unresolved ones back', async () => { const resolvedIds = []; const deps = baseDeps(); deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; }; const result = await reconcileConversations(deps); assert.deepEqual(resolvedIds, [1]); // a.js resolved, c.js already resolved (skipped) assert.equal(result.resolvedCount, 1); assert.equal(result.unresolvedCount, 1); // only b.js (c.js was skipped as already resolved) assert.deepEqual(result.resolvedFindings.map(f => f.location), ['a.js:10']); assert.deepEqual(result.carriedFindings.map(f => f.suggestion), ['fix two']); }); it('carries the conversation back when the resolve API call fails', async () => { const deps = baseDeps(); deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: true })); deps.resolveComment = async () => { throw new Error('403'); }; const result = await reconcileConversations(deps); assert.equal(result.resolvedCount, 0); assert.equal(result.carriedFindings.length, 2); }); it('treats all conversations as unresolved when the judge throws', async () => { const resolvedIds = []; const deps = baseDeps(); deps.judge = async () => { throw new Error('judge boom'); }; deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; }; const result = await reconcileConversations(deps); assert.deepEqual(resolvedIds, []); // 無任何對話被 resolve assert.equal(result.resolvedCount, 0); assert.equal(result.carriedFindings.length, 2); // a.js + b.js 皆加回(c.js 已解決略過) assert.deepEqual(result.carriedFindings.map(f => f.suggestion).sort(), ['fix one', 'fix two']); }); it('treats a file as empty and continues when getFileContent throws', async () => { const deps = baseDeps(); deps.getFileContent = async (path) => { if (path === 'a.js') throw new Error('404'); return 'some code'; }; // judge 收到的 a.js code 應為空字串,仍照常判斷、不丟例外 let seenCode; deps.judge = async (items) => { seenCode = items.find(it => it.path === 'a.js')?.code; return items.map(it => ({ idx: it.idx, resolved: false })); }; const result = await reconcileConversations(deps); assert.equal(seenCode, ''); assert.equal(result.resolvedCount, 0); assert.equal(result.carriedFindings.length, 2); // a.js + b.js 加回(c.js 已解決略過) }); it('skips path-traversal file paths without calling getFileContent', async () => { const requested = []; const deps = baseDeps(); deps.listComments = async () => [ { id: 1, path: '../../etc/passwd', position: 1, body: reviewBody('🔴 嚴重', 'Assassin', 'p', 's') }, { id: 2, path: 'b.js', position: 20, body: reviewBody('🟡 警告', 'Mage', 'p2', 'fix two') }, ]; deps.getFileContent = async (path) => { requested.push(path); return 'code'; }; deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: false })); await reconcileConversations(deps); assert.deepEqual(requested, ['b.js']); // 不安全路徑未被請求 }); it('returns empty result and does not throw when listing comments fails', async () => { const result = await reconcileConversations({ listComments: async () => { throw new Error('boom'); } }); assert.deepEqual(result, { resolvedFindings: [], carriedFindings: [], resolvedCount: 0, unresolvedCount: 0 }); }); it('returns empty result when there are no open conversations', async () => { const result = await reconcileConversations({ listComments: async () => [{ id: 1, path: 'a.js', position: 1, body: 'x', resolver: { login: 'd' } }], }); assert.equal(result.resolvedCount, 0); assert.equal(result.carriedFindings.length, 0); }); }); describe('dropResolvedFindings', () => { it('removes findings matching resolved ones by file + suggestion, ignoring line drift', () => { const findings = [ { role: 'Assassin', location: 'a.js:19', suggestion: '加上 null 檢查' }, { role: 'Mage', location: 'b.js:5', suggestion: '保留這個' }, ]; const resolved = [{ location: 'a.js:42', suggestion: '加上 null 檢查!' }]; const result = dropResolvedFindings(findings, resolved); assert.equal(result.length, 1); assert.equal(result[0].location, 'b.js:5'); }); it('returns input unchanged when no resolved findings', () => { const findings = [{ location: 'a.js:1', suggestion: 's' }]; assert.equal(dropResolvedFindings(findings, []), findings); }); }); describe('addCarriedFindings', () => { it('adds carried findings missing from the list, deduping by file + suggestion', () => { const findings = [{ role: 'Mage', location: 'b.js:5', suggestion: '保留' }]; const carried = [ { role: 'Mage', location: 'b.js:9', suggestion: '保留', is_new: false }, // dup -> skip { role: 'Assassin', location: 'a.js:10', suggestion: '加回我', is_new: false }, // new -> add ]; const result = addCarriedFindings(findings, carried); assert.equal(result.length, 2); assert.equal(result[1].suggestion, '加回我'); }); it('returns input unchanged when no carried findings', () => { const findings = [{ location: 'a.js:1', suggestion: 's' }]; assert.equal(addCarriedFindings(findings, []), findings); }); });