import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, collectRecentReleaseCommits, collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, formatRecentReleaseNotes, formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, resolvePreviousReleaseCommit, resolveReleaseChannel, resolveRemoteHighWaterVersion, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; function withEnv(overrides, run) { const previous = new Map(); for (const [key, value] of Object.entries(overrides)) { previous.set(key, process.env[key]); if (value === undefined) delete process.env[key]; else process.env[key] = value; } try { return run(); } finally { for (const [key, value] of previous) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } } function withSignedArtifact(fileName, run) { const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-')); try { const artifact = path.join(directory, fileName); writeFileSync(artifact, 'installation package'); writeFileSync(`${artifact}.sig`, 'signature-content\n'); return run(artifact); } finally { rmSync(directory, { recursive: true, force: true }); } } function jsonResponse(body, status = 200) { return { status, ok: status >= 200 && status < 300, json: async () => body, }; } function withStubbedFetch(handler, run) { const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => handler(String(url)); return Promise.resolve(run()).finally(() => { globalThis.fetch = originalFetch; }); } test('selects an explicit release artifact when configured', () => { const artifactPath = fileURLToPath( new URL('../package.json', import.meta.url), ); withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => { assert.equal(selectReleaseArtifact([]), artifactPath); }); }); test('does not select unsupported files', () => { assert.equal( selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']), null, ); }); test('resolves the channel from the target platform and rejects mismatches', () => { assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win'); assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac'); assert.equal( resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget), 'dev-mac', ); assert.throws( () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget), /只能用于 darwin 目标/u, ); assert.throws( () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget), /未知发布渠道/u, ); }); test('channel manifest URL and build-time endpoint follow the channel', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { assert.equal( updateManifestUrl('dev-win'), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev-mac'), { plugins: { updater: { endpoints: [ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', ], }, }, }); }); }); test('universal macOS builds publish one artifact under both platform keys', () => { assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ 'darwin-aarch64', 'darwin-x86_64', ]); assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ 'windows-x86_64', ]); }); test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { const manifest = createUpdateManifest(artifact, { channel: 'dev-win', target: windowsTarget, publishedAt: '2026-09-17T00:00:00.000Z', }); assert.match(manifest.version, /^\d+\.\d+\.\d+$/u); assert.equal(manifest.notes, '修复与改进'); assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); assert.equal( manifest.platforms['windows-x86_64'].signature, 'signature-content', ); assert.match( manifest.platforms['windows-x86_64'].url, new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'), ); }); }); }); test('missing signature fails the channel manifest closed', () => { const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-')); try { const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe'); writeFileSync(artifact, 'installation package'); assert.throws( () => createUpdateManifest(artifact, { channel: 'dev-win', target: windowsTarget, }), /缺少更新包签名/u, ); } finally { rmSync(directory, { recursive: true, force: true }); } }); test('legacy manifest keeps the sha256 contract of published clients', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { const legacy = createLegacyUpdateManifest(artifact, { channel: 'dev-win', }); assert.match(legacy.version, /^\d+\.\d+\.\d+$/u); assert.equal(legacy.sha256.length, 64); assert.equal(legacy.size, 'installation package'.length); assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u); }); }); test('next release version follows the higher local or channel version', () => { assert.equal(compareVersions('0.1.15', '0.1.12'), 1); assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16'); assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19'); assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); test('version high water keeps the legacy pointer during the migration window', async () => { await withStubbedFetch( (url) => url.endsWith('/agc/dev-win/latest.json') ? jsonResponse({}, 404) : jsonResponse({ version: '0.1.57' }), async () => { assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); }, ); }); test('version high water takes the higher of channel and legacy pointer', async () => { await withStubbedFetch( (url) => url.endsWith('/agc/dev-win/latest.json') ? jsonResponse({ version: '0.1.60' }) : jsonResponse({ version: '0.1.57' }), async () => { assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); }, ); }); test('version high water ignores the windows migration pointer for other channels', async () => { await withStubbedFetch( (url) => { assert.ok( !url.endsWith('/agc/latest.json'), 'non-windows channel must not read the windows migration pointer', ); return jsonResponse({ version: '0.1.12' }); }, async () => { assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); }, ); }); test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => { await withStubbedFetch( () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), async () => { assert.equal( await resolvePreviousReleaseCommit('dev-win', { override: '6017d46088c04199e99cf89f347b12d67591475e', }), '6017d46088c04199e99cf89f347b12d67591475e', ); // 覆盖值非法时忽略,继续用清单里的 commit。 assert.equal( await resolvePreviousReleaseCommit('dev-win', { override: 'not-a-sha', }), 'abcdef1234567890', ); assert.equal( await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), 'abcdef1234567890', ); }, ); await withStubbedFetch( () => jsonResponse({ version: '0.1.61' }), async () => { assert.equal( await resolvePreviousReleaseCommit('dev-win', { override: undefined }), null, ); }, ); }); test('release notes anchor degrades to null when the manifest cannot be read', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { throw new Error('fetch failed'); }; try { assert.equal( await resolvePreviousReleaseCommit('dev-win', { override: undefined }), null, ); } finally { globalThis.fetch = originalFetch; } }); test('recent commit fallback marks that entries may repeat the previous release', () => { const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-')); const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); try { git('init', '--quiet'); git('config', 'user.email', 'release@example.test'); git('config', 'user.name', 'release test'); mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { recursive: true, }); for (const name of ['one', 'two']) { writeFileSync( path.join(directory, `apps/ai-game-creator-shell/${name}.rs`), `fn ${name}() {}\n`, ); git('add', '.'); git('commit', '--quiet', '-m', `客户端:${name}`); } writeFileSync(path.join(directory, 'README.md'), '# 文档\n'); git('add', '.'); git('commit', '--quiet', '-m', '文档:说明'); const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 }); assert.deepEqual( recent.map((entry) => entry.subject), ['客户端:two', '客户端:one'], ); const notes = formatRecentReleaseNotes(recent); assert.match( notes, /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u, ); assert.equal(formatRecentReleaseNotes([]), ''); assert.equal(formatRecentReleaseNotes(null), ''); } finally { rmSync(directory, { recursive: true, force: true }); } }); test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), 'utf8', ); assert.equal( (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length, 4, ); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); }); test('release notes list client commits with short sha and bound their size', () => { const notes = formatReleaseNotes([ { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, { sha: '55af6014', subject: '修'.repeat(120) }, ]); const lines = notes.split('\n'); assert.equal(lines.length, 2); assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); const truncatedSubject = lines[1] .replace(/^- /u, '') .replace(/(55af6014)$/u, ''); assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); assert.match(truncatedSubject, /…$/u); assert.match(lines[1], /(55af6014)$/u); const many = formatReleaseNotes( Array.from({ length: 20 }, (_, index) => ({ sha: `sha${index}`, subject: `改动 ${index}`, })), ); assert.match(many, /- 其余 8 项客户端改动省略$/u); assert.equal(formatReleaseNotes([]), ''); assert.equal(formatReleaseNotes(null), ''); }); test('release commits cover only client paths and skip merge commits', () => { const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-')); const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); try { git('init', '--quiet'); git('config', 'user.email', 'release@example.test'); git('config', 'user.name', 'release test'); git('commit', '--allow-empty', '--quiet', '-m', '基点'); const base = git('rev-parse', 'HEAD').trim(); mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { recursive: true, }); mkdirSync(path.join(directory, 'docs'), { recursive: true }); writeFileSync( path.join(directory, 'apps/ai-game-creator-shell/main.rs'), 'fn main() {}\n', ); git('add', '.'); git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); git('add', '.'); git('commit', '--quiet', '-m', '文档:补充说明'); writeFileSync( path.join(directory, 'apps/ai-game-creator-shell/other.rs'), 'fn other() {}\n', ); git('add', '.'); git('commit', '--quiet', '-m', '客户端:修复版本回退'); git('checkout', '--quiet', '-b', 'side'); writeFileSync( path.join(directory, 'apps/ai-game-creator-shell/side.rs'), 'fn side() {}\n', ); git('add', '.'); git('commit', '--quiet', '-m', '客户端:侧分支改动'); git('checkout', '--quiet', 'master'); git('merge', '--quiet', '--no-ff', '--no-edit', 'side'); const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); assert.ok(commits, '应能在临时仓库里收集提交'); const subjects = commits.map((entry) => entry.subject); // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 assert.deepEqual(subjects, [ '客户端:侧分支改动', '客户端:修复版本回退', '客户端:新增更新插件接入', ]); assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha))); assert.equal( collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }), null, ); assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null); } finally { rmSync(directory, { recursive: true, force: true }); } }); test('scheduler path filter stays in sync with client release paths', () => { const jenkinsfile = readFileSync( new URL( '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', import.meta.url, ), 'utf8', ); const filterLine = jenkinsfile .split('\n') .find((line) => line.includes('apps/ai-game-creator-shell/*|')); assert.ok(filterLine, '调度管线里应存在发布范围过滤模式'); for (const pattern of agcReleasePathPatterns) { const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern; assert.ok( filterLine.includes(bashPattern), `调度管线过滤缺少 ${bashPattern}`, ); } }); test('scheduler skips the full build only for non-deploy paths', () => { const jenkinsfile = readFileSync( new URL( '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', import.meta.url, ), 'utf8', ); const skipLine = jenkinsfile .split('\n') .find((line) => line.includes('docs/*|.codex/*|jenkins/*')); assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式'); for (const pattern of [ 'docs/*', '.codex/*', 'jenkins/*', 'apps/ai-game-creator-shell/*', 'apps/mobile-shell/*', 'apps/desktop-shell/*', 'apps/preview-deployer-web/*', 'tools/*', '*.md', ]) { assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); } });