397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
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, formatFindingsStatsLine } 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', () => {
|
||
const statsFindings = [
|
||
{ level: 'critical', is_new: false },
|
||
{ level: 'warning', is_new: true },
|
||
{ level: 'info' },
|
||
{ level: 'custom', is_new: true },
|
||
];
|
||
|
||
it('formats old and new findings by severity with an unclassified column', () => {
|
||
const stats = formatFindingsStats(statsFindings);
|
||
|
||
assert.equal(stats, [
|
||
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
|
||
'| --- | --- | --- | --- | --- |',
|
||
'| 新問題 | 0 筆 | 1 筆 | 1 筆 | 1 筆 |',
|
||
'| 舊問題 | 1 筆 | 0 筆 | 0 筆 | 0 筆 |',
|
||
].join('\n'));
|
||
});
|
||
|
||
it('formats compact one-line stats for action logs', () => {
|
||
assert.equal(
|
||
formatFindingsStatsLine(statsFindings),
|
||
'新: 嚴重0 / 警告1 / 建議1 / 無法標示1;舊: 嚴重1 / 警告0 / 建議0 / 無法標示0',
|
||
);
|
||
});
|
||
});
|
||
|
||
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 inline comments only for new findings, not old ones', 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 筆 \|/);
|
||
// 舊問題 app/a.js(is_new:false)不應被行內標註,僅新問題依嚴重等級排序後標註
|
||
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
|
||
assert.deepEqual(
|
||
reviewCalls[0].comments.map(c => c.path),
|
||
['app/b.js', 'app/c.js'],
|
||
);
|
||
assert.deepEqual(
|
||
reviewCalls[0].comments.map(reviewSeverityLabel),
|
||
['🟡 警告', '🔵 建議'],
|
||
);
|
||
assert.deepEqual(
|
||
reviewCalls[0].comments.map(c => c.new_position),
|
||
[20, 30],
|
||
);
|
||
assert.match(reviewCalls[0].comments[0].body, /嚴重等級/);
|
||
assert.match(reviewCalls[0].comments[0].body, /審查員.*Leo/s);
|
||
assert.match(reviewCalls[0].comments[0].body, /建議.*W/s);
|
||
});
|
||
|
||
it('appends the usage section to the review body when provided', async () => {
|
||
const reviewCalls = [];
|
||
await postFindingsReview([
|
||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
|
||
], {
|
||
postReview: async (args) => { reviewCalls.push(args); },
|
||
usageSection: '## 🤖 AI 助理使用量\n\n本次:120 token',
|
||
});
|
||
|
||
assert.match(reviewCalls[0].body, /## AI Code Review 統計/);
|
||
assert.match(reviewCalls[0].body, /## 🤖 AI 助理使用量\n\n本次:120 token$/);
|
||
});
|
||
|
||
it('omits the usage section when not provided', async () => {
|
||
const reviewCalls = [];
|
||
await postFindingsReview([
|
||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
|
||
], {
|
||
postReview: async (args) => { reviewCalls.push(args); },
|
||
});
|
||
|
||
assert.doesNotMatch(reviewCalls[0].body, /AI 助理使用量/);
|
||
// usageSection 省略時,body 不應殘留多餘的尾端空白/換行
|
||
assert.equal(reviewCalls[0].body, reviewCalls[0].body.trimEnd());
|
||
});
|
||
|
||
it('appends usageSection verbatim after the stats block without altering structure', async () => {
|
||
const reviewCalls = [];
|
||
const usageSection = '## 🤖 AI 助理使用量\n\n| x | y |\n| - | - |\n| 1 | 2 |';
|
||
await postFindingsReview([
|
||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
|
||
], { postReview: async (args) => { reviewCalls.push(args); }, usageSection });
|
||
|
||
const body = reviewCalls[0].body;
|
||
// 統計區塊在前、usageSection 原樣接在後(中間一個空行);不交錯、不被竄改
|
||
assert.ok(body.startsWith('## AI Code Review 統計'));
|
||
assert.ok(body.endsWith(usageSection));
|
||
assert.match(body, /## AI Code Review 統計[\s\S]*\n\n## 🤖 AI 助理使用量/);
|
||
});
|
||
|
||
it('counts both new and old findings in the summary but only inline-comments new ones', async () => {
|
||
const reviewCalls = [];
|
||
await postFindingsReview([
|
||
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old crit', is_new: false },
|
||
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warn', is_new: true },
|
||
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info', is_new: true },
|
||
], { postReview: async (a) => { reviewCalls.push(a); } });
|
||
|
||
const body = reviewCalls[0].body;
|
||
assert.match(body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
|
||
assert.match(body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \| 0 筆 \|/);
|
||
// 舊問題 app/a.js 不產生行內 comment;只有新問題被標註
|
||
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
|
||
assert.deepEqual(reviewCalls[0].comments.map(c => c.path), ['app/b.js', 'app/c.js']);
|
||
});
|
||
|
||
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 筆 \|/);
|
||
// 統計含新舊(舊問題仍計入本文),但行內 comment 只給新問題(舊 critical 不標註)
|
||
assert.equal(reviewCalls[0].comments.length, 2);
|
||
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|