diff --git a/app/index.test.js b/app/index.test.js new file mode 100644 index 0000000..e609483 --- /dev/null +++ b/app/index.test.js @@ -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('摘要內容')); +}); diff --git a/app/lib/git.test.js b/app/lib/git.test.js new file mode 100644 index 0000000..4b5e127 --- /dev/null +++ b/app/lib/git.test.js @@ -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 }); + } +}); diff --git a/app/lib/gitea.test.js b/app/lib/gitea.test.js new file mode 100644 index 0000000..9823e76 --- /dev/null +++ b/app/lib/gitea.test.js @@ -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); +}); diff --git a/app/lib/opencode.test.js b/app/lib/opencode.test.js new file mode 100644 index 0000000..7ead244 --- /dev/null +++ b/app/lib/opencode.test.js @@ -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: '' }); +}); diff --git a/app/lib/util.test.js b/app/lib/util.test.js new file mode 100644 index 0000000..13d2bc2 --- /dev/null +++ b/app/lib/util.test.js @@ -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); +}); diff --git a/app/package.json b/app/package.json index 136121a..48c5e84 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node index.js" + "start": "node index.js", + "test": "node --test" }, "engines": { "node": ">=18"