test(app): 新增 node:test 單元與整合測試並加入 test 指令
This commit is contained in:
@@ -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('摘要內容'));
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 已存在時回查既有 PR,created=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);
|
||||
});
|
||||
@@ -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: '' });
|
||||
});
|
||||
@@ -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
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "node index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
Reference in New Issue
Block a user