test(app): 新增 node:test 單元與整合測試並加入 test 指令
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user