calculate-version:多階段容器化、log 格式統一與 AI review 修復 #9

Merged
admin merged 7 commits from develop into master 2026-06-30 05:32:33 +00:00
7 changed files with 104 additions and 270 deletions
Showing only changes of commit 04e1ada80a - Show all commits
+42 -4
View File
@@ -36,6 +36,7 @@ test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => {
assert.equal(normalizeBetaFlag('yes'), false); assert.equal(normalizeBetaFlag('yes'), false);
assert.equal(normalizeBetaFlag(undefined), false); assert.equal(normalizeBetaFlag(undefined), false);
assert.equal(normalizeBetaFlag('null'), false); assert.equal(normalizeBetaFlag('null'), false);
assert.equal(normalizeBetaFlag('TRUE'), false);
}); });
test('loadConfig 從環境變數組出設定', () => { test('loadConfig 從環境變數組出設定', () => {
@@ -53,16 +54,53 @@ test('loadConfig 從環境變數組出設定', () => {
}); });
}); });
test('loadConfig 將未提供的 token 正規化為 nullbeta 預設 false', () => { test('loadConfig 將未提供或字面 "null" 的 token 正規化為 nullbeta 預設 false', () => {
const config = loadConfig({ const omitted = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo', GITEA_REPOSITORY: 'owner/repo',
}); });
assert.equal(config.token, null); assert.equal(omitted.token, null);
assert.equal(config.isBeta, false); assert.equal(omitted.isBeta, false);
const literalNull = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
GITEA_TOKEN: 'null',
});
assert.equal(literalNull.token, null);
}); });
test('loadConfig 在缺少必填環境變數時丟出錯誤', () => { test('loadConfig 在缺少必填環境變數時丟出錯誤', () => {
assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/);
assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/); assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/);
}); });
test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'not-a-url',
GITEA_REPOSITORY: 'owner/repo',
}), /GITEA_SERVER_URL 格式錯誤/);
});
test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'ftp://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
}), /必須使用 http 或 https/);
});
test('loadConfig 於 GITEA_REPOSITORY 非 owner/repo 形式或含路徑穿越時拋錯', () => {
const base = { GITEA_SERVER_URL: 'https://gitea.example.com' };
assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'no-slash' }), /GITEA_REPOSITORY 格式錯誤/);
assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/repo/extra' }), /GITEA_REPOSITORY 格式錯誤/);
assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: '../owner/repo' }), /GITEA_REPOSITORY 格式錯誤/);
assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/..' }), /GITEA_REPOSITORY 格式錯誤/);
});
test('loadConfig 接受含點號的合法 owner/repo', () => {
const config = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'my.org/my.repo',
});
assert.equal(config.repository, 'my.org/my.repo');
});
@@ -3,7 +3,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { main } = require('../app/index'); const { main } = require('../index');
// 安靜的 log 記錄器,避免測試輸出雜訊 // 安靜的 log 記錄器,避免測試輸出雜訊
const silentLog = { section() {}, info() {}, error() {} }; const silentLog = { section() {}, info() {}, error() {} };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const logger = require('../logger');
const realStdout = process.stdout.write;
const realStderr = process.stderr.write;
afterEach(() => {
process.stdout.write = realStdout;
process.stderr.write = realStderr;
});
// 同時攔截 stdout 與 stderr,回傳本次 run() 內各自寫出的字串
function capture(run) {
const out = [];
const err = [];
process.stdout.write = (chunk) => { out.push(String(chunk)); return true; };
process.stderr.write = (chunk) => { err.push(String(chunk)); return true; };
try {
run();
} finally {
process.stdout.write = realStdout;
process.stderr.write = realStderr;
}
return { out: out.join(''), err: err.join('') };
}
const TIME = /\[\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}\]/;
test('section 於 stdout 輸出標題與 50 寬分隔線區塊', () => {
const { out, err } = capture(() => logger.section('參數檢查'));
assert.equal(out, `\n${'='.repeat(50)}\n參數檢查\n${'-'.repeat(50)}\n`);
assert.equal(err, '');
});
test('info 以 [INF][時間]: 格式輸出至 stdout', () => {
const { out, err } = capture(() => logger.info('IS_BETA=false'));
assert.match(out, new RegExp(`^\\[INF\\]${TIME.source}: IS_BETA=false\\n$`));
assert.equal(err, '');
});
test('error 以 [ERR][時間]: 格式輸出至 stderr,且不寫入 stdout', () => {
const { out, err } = capture(() => logger.error('GITEA_SERVER_URL 未設定'));
assert.match(err, new RegExp(`^\\[ERR\\]${TIME.source}: GITEA_SERVER_URL 未設定\\n$`));
assert.equal(out, '');
});
test('info/error 的時間戳為 yyyy/MM/dd HH:mm:ss 格式', () => {
const { out } = capture(() => logger.info('x'));
assert.match(out, TIME);
});
+8
View File
@@ -68,3 +68,11 @@ test('fetchReleases 在 null 回應時視為無更多資料', async () => {
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases');
assert.deepEqual(releases, []); assert.deepEqual(releases, []);
}); });
test('fetchReleases 在回傳無法解析的 JSON 時丟出例外', async () => {
globalThis.fetch = async () => ({ ok: true, text: async () => '{ this is not valid json' });
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'),
/回傳資料無法解析/,
);
});
-75
View File
@@ -1,75 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
isUnset,
requireEnv,
normalizeBetaFlag,
loadConfig,
} = require('../app/config');
test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => {
assert.equal(isUnset(undefined), true);
assert.equal(isUnset(''), true);
assert.equal(isUnset('null'), true);
assert.equal(isUnset('value'), false);
});
test('requireEnv 未設定時拋錯,有值時回傳原值', () => {
assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/);
assert.equal(requireEnv('FOO', 'bar'), 'bar');
});
test('normalizeBetaFlag 僅 "true" 視為啟用', () => {
assert.equal(normalizeBetaFlag('true'), true);
assert.equal(normalizeBetaFlag('false'), false);
assert.equal(normalizeBetaFlag(undefined), false);
assert.equal(normalizeBetaFlag('TRUE'), false);
});
test('loadConfig 解析完整設定', () => {
const config = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
GITEA_TOKEN: 'secret',
IS_BETA: 'true',
});
assert.deepEqual(config, {
serverUrl: 'https://gitea.example.com',
repository: 'owner/repo',
token: 'secret',
isBeta: true,
});
});
test('loadConfig 缺少 token 視為匿名(null', () => {
const config = loadConfig({
GITEA_SERVER_URL: 'https://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
GITEA_TOKEN: 'null',
});
assert.equal(config.token, null);
assert.equal(config.isBeta, false);
});
test('loadConfig 缺少必填項目時拋錯', () => {
assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/);
});
test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'not-a-url',
GITEA_REPOSITORY: 'owner/repo',
}), /GITEA_SERVER_URL 格式錯誤/);
});
test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => {
assert.throws(() => loadConfig({
GITEA_SERVER_URL: 'ftp://gitea.example.com',
GITEA_REPOSITORY: 'owner/repo',
}), /必須使用 http 或 https/);
});
-105
View File
@@ -1,105 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchReleases } = require('../app/releases');
// 以可控的假回應替換全域 fetch,並於結束後還原
function withFetch(handler, run) {
const original = globalThis.fetch;
globalThis.fetch = handler;
return Promise.resolve()
.then(run)
.finally(() => {
globalThis.fetch = original;
});
}
// 建立模擬 Response 物件
const jsonResponse = (body, ok = true) => ({
ok,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
});
test('fetchReleases 單頁(不足一頁即停止)', async () => {
await withFetch(
async () => jsonResponse([{ tag_name: 'v1.0.0' }]),
async () => {
const releases = await fetchReleases('https://gitea.example.com/api');
assert.deepEqual(releases, [{ tag_name: 'v1.0.0' }]);
},
);
});
test('fetchReleases 跨頁合併直到不足一頁', async () => {
const pages = {
1: Array.from({ length: 10 }, (_, i) => ({ tag_name: `v1.0.${i}` })),
2: [{ tag_name: 'v1.1.0' }],
};
const seen = [];
await withFetch(
async (url) => {
const page = Number(new URL(url).searchParams.get('page'));
seen.push(page);
return jsonResponse(pages[page]);
},
async () => {
const releases = await fetchReleases('https://gitea.example.com/api');
assert.equal(releases.length, 11);
assert.deepEqual(seen, [1, 2]);
},
);
});
test('fetchReleases 對非 2xx 回應拋錯', async () => {
await withFetch(
async () => jsonResponse('Not Found', false),
async () => {
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api'),
/release API 請求失敗/,
);
},
);
});
test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => {
await withFetch(
async () => jsonResponse('{ this is not valid json', true),
async () => {
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api'),
/回傳資料無法解析/,
);
},
);
});
test('fetchReleases 對非陣列回應拋錯', async () => {
await withFetch(
async () => jsonResponse({ message: 'oops' }),
async () => {
await assert.rejects(
() => fetchReleases('https://gitea.example.com/api'),
/回傳非陣列資料/,
);
},
);
});
test('fetchReleases 有 token 時帶入授權標頭', async () => {
let captured;
await withFetch(
async (url, options) => {
captured = options;
return jsonResponse([]);
},
async () => {
await fetchReleases('https://gitea.example.com/api', { token: 'secret' });
assert.equal(captured.headers.Authorization, 'token secret');
},
);
});
-85
View File
@@ -1,85 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
compareVersionArrays,
parseStableVersions,
latestStableVersion,
nextReleaseVersion,
nextBetaNumber,
calculateVersion,
} = require('../app/version');
// 便於建構測試資料的小工具
const rel = (tag) => ({ tag_name: tag });
test('compareVersionArrays 逐區段比較且較短者較小', () => {
assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0);
assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0);
assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0);
});
test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => {
const releases = [
rel('v1.2.3'),
rel('2.0.0'),
rel('v1.0.0-beta.1'),
rel('latest'),
{ name: '沒有 tag_name' },
];
assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]);
});
test('latestStableVersion 無資料時回傳 0.0.0', () => {
assert.equal(latestStableVersion([]), '0.0.0');
assert.equal(latestStableVersion(null), '0.0.0');
});
test('latestStableVersion 取出最大穩定版', () => {
const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')];
assert.equal(latestStableVersion(releases), '1.9.9');
});
test('nextReleaseVersion patch 進位', () => {
assert.equal(nextReleaseVersion('0.0.0'), '0.0.1');
assert.equal(nextReleaseVersion('1.2.3'), '1.2.4');
});
test('nextReleaseVersion patch 達 10 進位至 minor', () => {
assert.equal(nextReleaseVersion('1.2.9'), '1.3.0');
});
test('nextReleaseVersion minor 達 10 進位至 major', () => {
assert.equal(nextReleaseVersion('1.9.9'), '2.0.0');
});
test('nextBetaNumber 無對應 beta 時回傳 1', () => {
assert.equal(nextBetaNumber([], '0.0.1'), 1);
assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1);
});
test('nextBetaNumber 取最大流水號加一', () => {
const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')];
assert.equal(nextBetaNumber(releases, '1.3.0'), 4);
});
test('calculateVersion 無 release 的穩定版', () => {
assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' });
});
test('calculateVersion 無 release 的 beta 版', () => {
assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' });
});
test('calculateVersion 既有穩定版推下一個穩定版', () => {
const releases = [rel('v1.2.3'), rel('v1.2.2')];
assert.deepEqual(calculateVersion(releases, false), { latest: '1.2.3', version: '1.2.4' });
});
test('calculateVersion 既有穩定版推下一個 beta 版', () => {
const releases = [rel('v1.2.3'), rel('v1.2.4-beta.1')];
assert.deepEqual(calculateVersion(releases, true), { latest: '1.2.3', version: '1.2.4-beta.2' });
});