test(app): 新增 node:test 單元與整合測試並加入 test 指令

This commit is contained in:
Jeffery
2026-06-26 15:27:30 +08:00
parent 20b0387b4d
commit 264be4de30
6 changed files with 351 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
truncateDiff,
fallbackSummary,
buildResolveBranchName,
buildResolveBody,
} from './index.js';
test('truncateDiff: 內容在上限內不截斷', () => {
const { diff, truncated } = truncateDiff('abc', 100);
assert.equal(diff, 'abc');
assert.equal(truncated, false);
});
test('truncateDiff: null/空字串回傳空字串且不截斷', () => {
assert.deepEqual(truncateDiff(null, 100), { diff: '', truncated: false });
assert.deepEqual(truncateDiff('', 100), { diff: '', truncated: false });
});
test('truncateDiff: 超過上限時截斷並標示', () => {
const big = 'x'.repeat(50);
const { diff, truncated } = truncateDiff(big, 10);
assert.equal(truncated, true);
assert.ok(diff.startsWith('xxxxxxxxxx'));
assert.ok(diff.includes('已截斷'));
});
test('fallbackSummary: 取首個 commit 當標題', () => {
const s = fallbackSummary({
source: 'feature',
target: 'develop',
commitMessages: '- feat: 新增功能\n- fix: 修正',
diffStat: ' a.js | 2 +-',
});
assert.equal(s.title, 'feat: 新增功能');
assert.ok(s.description.includes('## 變更摘要'));
assert.ok(s.description.includes('a.js'));
});
test('fallbackSummary: 無 commit 時退回 Merge 標題且描述非空', () => {
const s = fallbackSummary({
source: 'feature',
target: 'develop',
commitMessages: '',
diffStat: '',
});
assert.equal(s.title, 'Merge feature into develop');
assert.ok(s.description.includes('(無)'));
assert.ok(s.description.length > 0);
});
test('buildResolveBranchName: 含前綴並淨化非法字元', () => {
const name = buildResolveBranchName('develop', 'feature/x y');
assert.ok(name.startsWith('resolve-conflict/'));
assert.ok(name.includes('-into-'));
// 空白等非法字元被替換為 -
assert.ok(!/\s/.test(name));
});
test('buildResolveBranchName: 過長 target/source 仍遠低於 255', () => {
const long = 'a'.repeat(500);
const name = buildResolveBranchName(long, long);
assert.ok(name.length < 255);
});
test('buildResolveBody: 含衝突檔案清單與人工檢查清單', () => {
const body = buildResolveBody({
source: 'feature',
target: 'develop',
resolveBranch: 'resolve-conflict/develop-into-feature',
files: ['a.js', 'b.js'],
summary: { title: 't', description: '摘要內容' },
});
assert.ok(body.includes('- `a.js`'));
assert.ok(body.includes('- `b.js`'));
assert.ok(body.includes('合併前人工檢查清單'));
assert.ok(body.includes('摘要內容'));
});
+114
View File
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { Git } from './git.js';
function g(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' });
}
/** 建立 bare remote + working clone,於 target/source 製造會衝突的變更。 */
function setupRepo() {
const root = mkdtempSync(join(tmpdir(), 'git-test-'));
const bare = join(root, 'remote.git');
const work = join(root, 'work');
execFileSync('git', ['init', '-q', '--bare', bare]);
execFileSync('git', ['clone', '-q', bare, work]);
g(work, ['config', 'user.email', 'test@example.com']);
g(work, ['config', 'user.name', 'tester']);
g(work, ['config', 'commit.gpgsign', 'false']);
writeFileSync(join(work, 'file.txt'), 'base\n');
g(work, ['add', '-A']);
g(work, ['commit', '-q', '-m', 'base']);
const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
g(work, ['checkout', '-q', '-b', 'target']);
writeFileSync(join(work, 'file.txt'), 'target change\n');
g(work, ['commit', '-qam', 'target change']);
g(work, ['checkout', '-q', def]);
g(work, ['checkout', '-q', '-b', 'source']);
writeFileSync(join(work, 'file.txt'), 'source change\n');
g(work, ['commit', '-qam', 'source change']);
g(work, ['push', '-q', 'origin', 'target', 'source', def]);
return { root, bare, work };
}
test('detectConflict: 偵測到衝突並回傳衝突檔,事後還原暫存分支', () => {
const { root, bare, work } = setupRepo();
try {
const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' });
git.configure();
git.fetchBranches(['target', 'source']);
assert.ok(git.countAheadCommits('target', 'source') >= 1);
const det = git.detectConflict('target', 'source');
assert.equal(det.hasConflict, true);
assert.ok(det.files.includes('file.txt'));
// 暫存分支應已清除
assert.ok(!g(work, ['branch']).includes('__conflict_check'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('createResolveBranch: 推送含衝突標記的解衝突分支到 remote', () => {
const { root, bare, work } = setupRepo();
try {
const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' });
git.configure();
git.fetchBranches(['target', 'source']);
const res = git.createResolveBranch({ target: 'target', source: 'source', resolveBranch: 'resolve-x' });
assert.ok(res.files.includes('file.txt'));
// remote 應有 resolve-x 分支
assert.ok(execFileSync('git', ['--git-dir', bare, 'branch'], { encoding: 'utf8' }).includes('resolve-x'));
// 已 commit 的檔案應保留衝突標記
assert.ok(readFileSync(join(work, 'file.txt'), 'utf8').includes('<<<<<<<'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('detectConflict: 可順利合併時回報無衝突', () => {
const root = mkdtempSync(join(tmpdir(), 'git-test-'));
try {
const bare = join(root, 'remote.git');
const work = join(root, 'work');
execFileSync('git', ['init', '-q', '--bare', bare]);
execFileSync('git', ['clone', '-q', bare, work]);
g(work, ['config', 'user.email', 'test@example.com']);
g(work, ['config', 'user.name', 'tester']);
g(work, ['config', 'commit.gpgsign', 'false']);
writeFileSync(join(work, 'a.txt'), 'base\n');
g(work, ['add', '-A']);
g(work, ['commit', '-qm', 'base']);
const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
g(work, ['checkout', '-q', '-b', 'target']); // target 不動
g(work, ['checkout', '-q', def]);
g(work, ['checkout', '-q', '-b', 'source']);
writeFileSync(join(work, 'b.txt'), 'new file\n'); // 改不同檔,無衝突
g(work, ['add', '-A']);
g(work, ['commit', '-qm', 'add b']);
g(work, ['push', '-q', 'origin', 'target', 'source', def]);
const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' });
git.configure();
git.fetchBranches(['target', 'source']);
const det = git.detectConflict('target', 'source');
assert.equal(det.hasConflict, false);
assert.deepEqual(det.files, []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+78
View File
@@ -0,0 +1,78 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { GiteaClient } from './gitea.js';
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; });
function makeClient() {
return new GiteaClient({
serverUrl: 'https://gitea.example',
owner: 'o',
repo: 'r',
token: 't',
});
}
/** 建立假的 fetch,依序回傳給定的回應。 */
function stubFetch(responses) {
const calls = [];
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
const r = responses.shift();
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
text: async () => (r.body == null ? '' : JSON.stringify(r.body)),
};
};
return calls;
}
test('createPull: 成功建立回傳 created=true', async () => {
stubFetch([{ status: 201, body: { number: 7, html_url: 'u' } }]);
const { pull, created } = await makeClient().createPull({
head: 'feature', base: 'develop', title: 't', body: 'b',
});
assert.equal(created, true);
assert.equal(pull.number, 7);
});
test('createPull: 422 已存在時回查既有 PRcreated=false', async () => {
stubFetch([
{ status: 422, body: { message: 'already exists' } },
{ status: 200, body: [
{ number: 3, head: { ref: 'feature' }, base: { ref: 'develop' } },
] },
]);
const { pull, created } = await makeClient().createPull({
head: 'feature', base: 'develop', title: 't', body: 'b',
});
assert.equal(created, false);
assert.equal(pull.number, 3);
});
test('createPull: 422 但查無對應 PR 時丟出錯誤', async () => {
stubFetch([
{ status: 422, body: { message: 'bad' } },
{ status: 200, body: [] },
]);
await assert.rejects(
() => makeClient().createPull({ head: 'feature', base: 'develop', title: 't', body: 'b' }),
/建立 PR 失敗/,
);
});
test('createPull: 其他錯誤狀態碼直接丟出', async () => {
stubFetch([{ status: 500, body: { message: '伺服器錯誤' } }]);
await assert.rejects(
() => makeClient().createPull({ head: 'feature', base: 'develop', title: 't', body: 'b' }),
/建立 PR 失敗 \(500\)/,
);
});
test('findOpenPull: 非陣列回應回傳 null', async () => {
stubFetch([{ status: 200, body: { unexpected: true } }]);
const r = await makeClient().findOpenPull('feature', 'develop');
assert.equal(r, null);
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractResult } from './opencode.js';
test('extractResult: 解析乾淨的 JSON', () => {
const r = extractResult('{"title":"標題","description":"描述"}');
assert.deepEqual(r, { title: '標題', description: '描述' });
});
test('extractResult: 忽略 JSON 前後的雜訊文字', () => {
const r = extractResult('以下是結果:\n{"title":"T","description":"D"}\n完成');
assert.deepEqual(r, { title: 'T', description: 'D' });
});
test('extractResult: 修正字串值內未跳脫的換行', () => {
// LLM 常輸出字串內含真實換行的無效 JSON
const r = extractResult('{"title":"T","description":"第一行\n第二行"}');
assert.equal(r.title, 'T');
assert.equal(r.description, '第一行\n第二行');
});
test('extractResult: 去除 ANSI 控制碼後解析', () => {
const r = extractResult('\x1b[32m{"title":"T","description":"D"}\x1b[0m');
assert.deepEqual(r, { title: 'T', description: 'D' });
});
test('extractResult: 挑出第一個含 title 的物件', () => {
const r = extractResult('{"foo":1}\n{"title":"對的","description":"D"}');
assert.equal(r.title, '對的');
});
test('extractResult: 無有效物件回傳 null', () => {
assert.equal(extractResult('沒有任何 JSON'), null);
assert.equal(extractResult(''), null);
assert.equal(extractResult('{"description":"缺少 title"}'), null);
});
test('extractResult: description 缺漏時以空字串補上', () => {
const r = extractResult('{"title":"只有標題"}');
assert.deepEqual(r, { title: '只有標題', description: '' });
});
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { maskSecrets, run } from './util.js';
test('maskSecrets: 遮蔽出現的祕密字串', () => {
const out = maskSecrets('token is abcd1234 here', ['abcd1234']);
assert.equal(out, 'token is *** here');
});
test('maskSecrets: 遮蔽 URL 內嵌的 token', () => {
const out = maskSecrets('https://oauth2:abcd1234@host/repo.git', ['abcd1234']);
assert.ok(!out.includes('abcd1234'));
});
test('maskSecrets: 太短(<4)的祕密不遮蔽以免誤傷', () => {
assert.equal(maskSecrets('abc here', ['abc']), 'abc here');
});
test('maskSecrets: 多個祕密與空值都安全處理', () => {
const out = maskSecrets('aaaa bbbb', ['aaaa', '', undefined, 'bbbb']);
assert.equal(out, '*** ***');
});
test('maskSecrets: 非字串輸入回傳空字串', () => {
assert.equal(maskSecrets(null), '');
});
test('run: 非零結束碼不丟例外並回傳 status', () => {
const r = run('node', ['-e', 'process.exit(3)']);
assert.equal(r.status, 3);
});
test('run: 指令不存在時回傳 status=1 與錯誤訊息', () => {
const r = run('a-command-that-does-not-exist-xyz', []);
assert.equal(r.status, 1);
assert.ok(r.stderr.length > 0);
});
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js"
"start": "node index.js",
"test": "node --test"
},
"engines": {
"node": ">=18"