import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readdirSync, 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, assertArtifactVersionMatches, buildRelease, buildTauriBuildArguments, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, generateUpdateManifest, nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, resolveReleaseContext, resolveReleasePartition, resolveRemoteHighWaterVersion, runTauriBuild, selectFirstInstallArtifact, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; import { AGC_APP_IDENTIFIER, AGC_MAIN_BINARY_NAME, AGC_PRODUCT_NAME, resolveChannelInstallIdentity, resolveChannelMainBinaryName, } from './channel-identity.mjs'; import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; const packageVersion = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ).version; function createDmgFixture(root, target, version = packageVersion) { const architecture = target.startsWith('aarch64') ? 'aarch64' : target === universalTarget ? 'universal' : 'x64'; const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`); writeFileSync(dmg, 'first installation disk image'); return dmg; } test('native sidecar builds accept universal and each macOS architecture', () => { for (const target of [ universalTarget, 'aarch64-apple-darwin', 'x86_64-apple-darwin', ]) { assert.deepEqual(buildTauriBuildArguments([], target), [ 'build', '--target', target, ]); } }); 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('channels are independent of platform and accept release and custom names', () => { assert.equal(resolveReleaseChannel({}), 'dev'); for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) { assert.equal( resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), channel, ); assert.equal( resolveReleasePartition(channel, windowsTarget), `${channel}-win`, ); assert.equal( resolveReleasePartition(channel, 'aarch64-apple-darwin'), `${channel}-mac`, ); } assert.equal( resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }), 'release', ); for (const channel of [ '', ' ', 'win', 'mac', 'windows', 'macos', 'darwin', 'linux', 'dev-win', 'dev-mac', 'Release', '../dev', 'a/b', 'a_b', '-beta', 'beta-', '1beta', 'a'.repeat(33), ]) { assert.throws( () => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }), /发布渠道无效/u, ); } }); test('channel manifest URL and build-time endpoint follow the channel', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { assert.equal( updateManifestUrl('dev', windowsTarget), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, mainBinaryName: AGC_MAIN_BINARY_NAME, app: { windows: [ { label: 'client', title: `${AGC_PRODUCT_NAME}开发版`, url: 'index.html', width: 1280, height: 800, decorations: false, minWidth: 1280, minHeight: 720, }, ], }, plugins: { updater: { endpoints: [ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', ], }, }, }); assert.equal( updateManifestUrl('release', windowsTarget), 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json', ); assert.equal( createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater .endpoints[0], 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json', ); }); }); test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 assert.deepEqual(resolveChannelInstallIdentity('dev'), { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, }); assert.deepEqual(resolveChannelInstallIdentity('release'), { productName: AGC_PRODUCT_NAME, identifier: `${AGC_APP_IDENTIFIER}.release`, }); assert.deepEqual(resolveChannelInstallIdentity('beta-2'), { productName: '陶泥儿 Beta-2', identifier: `${AGC_APP_IDENTIFIER}.beta-2`, }); assert.equal(resolveChannelMainBinaryName('dev'), AGC_MAIN_BINARY_NAME); assert.equal( resolveChannelMainBinaryName('release'), `${AGC_MAIN_BINARY_NAME}-release`, ); assert.equal( resolveChannelMainBinaryName('beta-2'), `${AGC_MAIN_BINARY_NAME}-beta-2`, ); // release 与其它非默认渠道通过独立 identifier 隔离运行身份与数据目录; // 自定义渠道另外保留可区分的产品名。 for (const channel of ['beta-2', 'a'.repeat(32)]) { const identity = resolveChannelInstallIdentity(channel); assert.notEqual(identity.productName, AGC_PRODUCT_NAME); assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER); assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)); } assert.equal( resolveChannelInstallIdentity('release').identifier, `${AGC_APP_IDENTIFIER}.release`, ); for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) { assert.notEqual( resolveChannelInstallIdentity(channel).identifier, AGC_APP_IDENTIFIER, ); } for (const channel of ['dev-win', 'Release', 'win', 'beta-']) { assert.throws( () => resolveChannelInstallIdentity(channel), /发布渠道无效/u, ); } }); test('channel install identity is baked into the same build-time config as the endpoint', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { const config = createChannelConfig('release', windowsTarget); assert.equal(config.productName, AGC_PRODUCT_NAME); assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); assert.equal(config.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`); assert.match( config.plugins.updater.endpoints[0], /\/release-win\/latest\.json$/u, ); }); }); /** * dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径 * (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。 */ test('渠道 Windows 包注入各自迁移钩子,且主进程文件名按渠道隔离', () => { withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { const devWindows = createChannelConfig('dev', windowsTarget); const hooksPath = devWindows.bundle.windows.nsis.installerHooks; assert.equal(existsSync(hooksPath), true); assert.match(hooksPath, /src-tauri[\\/]windows[\\/]installer-hooks\.nsh$/u); const hooksSource = readFileSync(hooksPath, 'utf8'); assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u); // 改名史必须逐条留在钩子里:漏掉一条,那批机器升级后旧快捷方式就继续指向旧安装。 for (const legacyIdentity of ['陶泥儿', 'Genarrative AI Game Creator']) { assert.ok( hooksSource.includes(legacyIdentity), `迁移钩子缺少旧身份:${legacyIdentity}`, ); } assert.match( hooksSource, /AGC_RELEASE_MAIN_BINARY\s+"genarrative-ai-game-creator-shell-release"/u, ); const releaseWindows = createChannelConfig('release', windowsTarget); const releaseHooksPath = releaseWindows.bundle.windows.nsis.installerHooks; assert.ok(releaseHooksPath); assert.equal(existsSync(releaseHooksPath), true); assert.match(readFileSync(releaseHooksPath, 'utf8'), /陶泥儿 Release/u); assert.equal( releaseWindows.mainBinaryName, `${AGC_MAIN_BINARY_NAME}-release`, ); assert.notEqual( devWindows.mainBinaryName, releaseWindows.mainBinaryName, 'dev 与 release 的 NSIS 进程匹配名必须不同', ); assert.equal( createChannelConfig('beta-2', windowsTarget).bundle, undefined, ); // macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。 assert.equal( createChannelConfig('dev', 'aarch64-apple-darwin').bundle, undefined, ); // 渠道配置与发行资源一起走 JSON Merge Patch:加 node runtime 资源不能顶掉钩子。 const base = readBaseTauriConfig(); const merged = applyJsonMergePatch(base, { ...devWindows, version: base.version, bundle: { ...devWindows.bundle, resources: { 'resources/node-runtime': 'game-runtime/node' }, }, }); assert.equal(merged.bundle.windows.nsis.installerHooks, hooksPath); }); }); /** * Tauri 2.11 的 NSIS 模板在 `installer.nsi` 第 28 行 include 钩子文件,位置**早于** * `!define INSTALLMODE` / `!define MAINBINARYNAME` 等模板常量,也早于 * `!addplugindir "${ADDITIONALPLUGINSPATH}"`(nsis_tauri_utils 插件目录)。 * 所以顶层只能放声明:顶层 Function 会在 include 处就编译(模板常量还是未定义的, * makensis 静默替换成空文本),顶层调 `nsis_tauri_utils::*` 会直接 `Plugin not found` * 并中断整个打包(2026-09-29 Jenkins 打包失败的形态)。可执行逻辑必须待在 !macro 里, * 等模板的 `!insertmacro NSIS_HOOK_*`(Section Install,addplugindir 之后)展开。 */ test('安装钩子的可执行逻辑必须全在 !macro 内,顶层不得引用模板常量或插件', () => { const templateConstants = [ 'MAINBINARYNAME', 'PRODUCTNAME', 'MANUFACTURER', 'INSTALLMODE', 'BUNDLEID', 'ADDITIONALPLUGINSPATH', 'VERSION', 'OUTFILE', ]; const hooksPath = createChannelConfig('dev', windowsTarget).bundle.windows .nsis.installerHooks; const offenders = []; let macroDepth = 0; for (const [index, rawLine] of readFileSync(hooksPath, 'utf8') .split('\n') .entries()) { const line = rawLine.trim(); if (line === '' || line.startsWith(';')) continue; if (/^!macroend\b/u.test(line)) { macroDepth = Math.max(0, macroDepth - 1); continue; } if (/^!macro\b/u.test(line)) { macroDepth += 1; continue; } if (macroDepth > 0) continue; const where = `第 ${index + 1} 行`; if (/^Function(?:End)?\b/u.test(line)) { offenders.push(`${where} 顶层定义 Function:函数体在 include 处就编译`); continue; } if (line.includes('::')) { offenders.push(`${where} 顶层调用插件:此时 addplugindir 还没执行`); continue; } const usedConstant = templateConstants.find((name) => line.includes(`\${${name}}`), ); if (usedConstant) { offenders.push(`${where} 顶层引用模板常量 \${${usedConstant}}`); continue; } if ( !/^(?:!define\b|!macro\b|!macroend\b|!ifn?def\b|!if\b|!else\b|!endif\b|!undef\b|Var\b)/u.test( line, ) ) { offenders.push(`${where} 顶层出现可执行语句:${line}`); } } assert.deepEqual( offenders, [], `installer-hooks.nsh 顶层只能放 !define / Var / !macro;` + `模板在 installer.nsi 第 28 行 include 它,早于模板常量与 addplugindir,` + `可执行逻辑一律写进 !macro,等 !insertmacro NSIS_HOOK_* 时再展开:\n` + offenders.join('\n'), ); }); test('release 安装钩子迁移旧 Release 身份且保持编译期位置纪律', () => { const hooksPath = createChannelConfig('release', windowsTarget).bundle.windows .nsis.installerHooks; const hooksSource = readFileSync(hooksPath, 'utf8'); assert.deepEqual( readFileSync(hooksPath).subarray(0, 3), Buffer.from([0xef, 0xbb, 0xbf]), 'NSIS 中文钩子必须保留 UTF-8 BOM', ); assert.match(hooksSource, /陶泥儿 Release/u); assert.match(hooksSource, /AGC_RELEASE_LEGACY_BINARY/u); assert.match(hooksSource, /NSIS_HOOK_PREINSTALL/u); assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u); let macroDepth = 0; const offenders = []; for (const [index, rawLine] of hooksSource.split('\n').entries()) { const line = rawLine.trim(); if (line === '' || line.startsWith(';')) continue; if (/^!macroend\b/u.test(line)) { macroDepth = Math.max(0, macroDepth - 1); continue; } if (/^!macro\b/u.test(line)) { macroDepth += 1; continue; } if (macroDepth > 0) continue; if ( line.includes('::') || /^(?:Function(?:End)?|Return|Delete|ReadRegStr|StrCpy|CreateShortCut)\b/u.test( line, ) ) offenders.push(`第 ${index + 1} 行出现顶层可执行语句:${line}`); } assert.deepEqual(offenders, []); }); /** * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 */ function applyJsonMergePatch(base, patch) { if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) { return patch; } const merged = typeof base === 'object' && base !== null && !Array.isArray(base) ? { ...base } : {}; for (const [key, value] of Object.entries(patch)) { if (value === null) delete merged[key]; else merged[key] = applyJsonMergePatch(merged[key], value); } return merged; } function readBaseTauriConfig() { return JSON.parse( readFileSync( new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf8', ), ); } test('channel config keeps the client window contract across the Tauri config merge', () => { const base = readBaseTauriConfig(); const merged = applyJsonMergePatch(base, { ...createChannelConfig('release', windowsTarget), version: base.version, }); const [clientWindow] = merged.app.windows; assert.deepEqual(clientWindow, { ...base.app.windows[0], title: AGC_PRODUCT_NAME, }); // 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效, // 并让按 label 绑定的 capability(剪贴板 / 原生对话框 / opener / updater 权限)不再命中。 assert.equal(clientWindow.label, 'client'); assert.equal(clientWindow.decorations, false); assert.equal(clientWindow.width, 1280); assert.equal(clientWindow.height, 800); assert.equal(clientWindow.minWidth, 1280); assert.equal(clientWindow.minHeight, 720); const capabilitiesDirectory = new URL( '../src-tauri/capabilities/', import.meta.url, ); const capabilities = readdirSync(capabilitiesDirectory) .filter((name) => name.endsWith('.json')) .map((name) => JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')), ); const clientCapability = capabilities.find((capability) => (capability.windows ?? []).includes(clientWindow.label), ); assert.ok( clientCapability, `必须存在绑定 ${clientWindow.label} 窗口的 capability(剪贴板 / 原生对话框 / opener / updater 权限)`, ); // 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧, // 因此任何 capability 都不应再授予 http:default(历史上用于渲染层直传素材)。 const httpPermissionHolders = capabilities.filter((capability) => (capability.permissions ?? []).some( (permission) => permission === 'http:default' || (typeof permission === 'object' && permission?.identifier === 'http:default'), ), ); assert.deepEqual( httpPermissionHolders, [], '渲染层不得再被授予 http:default;平台与 OSS 的网络 IO 由 Rust facade 承担', ); }); test('channel products keep first-install selection working under the channel product name', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); try { const { productName } = resolveChannelInstallIdentity('release'); const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`); writeFileSync(dmg, 'channel first installation disk image'); writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); assert.equal( selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], { target: 'aarch64-apple-darwin', version: packageVersion, }), dmg, ); } finally { rmSync(root, { recursive: true, force: true }); } }); test('packaged renderer receives the same channel as the updater manifest', () => { const context = resolveReleaseContext([], { AGC_BUILD_TARGET: windowsTarget, AGC_UPDATE_CHANNEL: 'release', }); let spawnOptions; runTauriBuild([], context, { // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 stageRuntime: () => {}, // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。 stageBundled: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; }, }); assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release'); assert.equal(spawnOptions?.env?.VITE_AGC_PRODUCT_NAME, AGC_PRODUCT_NAME); }); test('macOS manifests advertise exactly the architectures actually built', () => { assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ 'darwin-aarch64', 'darwin-x86_64', ]); assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ 'darwin-aarch64', ]); assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [ 'darwin-x86_64', ]); assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ 'windows-x86_64', ]); }); test('release context resolves explicit targets before environment/default and fails closed', () => { for (const args of [ ['--target', 'aarch64-apple-darwin'], ['--target=aarch64-apple-darwin'], ['-t', 'aarch64-apple-darwin'], ]) { for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { const context = resolveReleaseContext(args, env); assert.equal(context.target, 'aarch64-apple-darwin'); assert.equal(context.channel, 'dev'); assert.match( context.bundleRoot.replaceAll('\\', '/'), /target\/aarch64-apple-darwin\/release\/bundle$/, ); assert.ok(Object.isFrozen(context)); } assert.throws( () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), /发布渠道无效/, ); } assert.equal(resolveReleaseContext([], {}).target, windowsTarget); assert.equal( resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) .channel, 'dev', ); for (const args of [ ['--target'], ['--target='], ['--target', '--no-bundle'], ['--target', windowsTarget, '--target=aarch64-apple-darwin'], ['--target', 'unknown'], ]) assert.throws(() => resolveReleaseContext(args, {})); }); test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => { const calls = []; const seenContexts = []; await withStubbedFetch( (url) => { calls.push(url); assert.match(url, /\/dev-mac\/latest\.json$/); return jsonResponse({ version: '0.1.67' }); }, () => withEnv( { AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined }, () => buildRelease(['--target', 'aarch64-apple-darwin'], { prepareVersion: async (context) => { seenContexts.push(context); assert.equal( await resolveRemoteHighWaterVersion( context.channel, context.target, ), '0.1.67', ); }, build: (args, context) => { seenContexts.push(context); runTauriBuild(args, context, { stageRuntime: () => {}, // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 stageBundled: () => {}, spawn: (_binary, command) => { const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( readFileSync(command[configIndex + 1], 'utf8'), ); assert.match( config.plugins.updater.endpoints[0], /\/dev-mac\/latest\.json$/, ); assert.ok(command.includes('aarch64-apple-darwin')); assert.ok( !command.includes('--features=cocos-editor-execute'), ); return { status: 0 }; }, }); }, generateManifest: (context) => { seenContexts.push(context); withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { assert.equal( selectReleaseArtifact( ['/tmp/win.exe', artifact, '/tmp/mac.dmg'], context.target, ), artifact, ); const manifest = createUpdateManifest(artifact, { ...context, downloadArtifact: createDmgFixture( path.dirname(artifact), context.target, ), }); assert.deepEqual(Object.keys(manifest.platforms), [ 'darwin-aarch64', ]); assert.match( manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//, ); }); }, }), ), ); assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针'); assert.equal(seenContexts.length, 3); assert.ok(seenContexts.every((context) => context === seenContexts[0])); }); for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); try { const artifact = path.join(root, '陶泥儿.app.tar.gz'); writeFileSync(artifact, 'mac package'); writeFileSync(`${artifact}.sig`, 'mac signature'); writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); const downloadArtifact = createDmgFixture(root, target); const context = { ...resolveReleaseContext([`--target=${target}`], {}), bundleRoot: root, }; const result = await withStubbedFetch( (url) => { assert.match(url, /\/dev-mac\/latest\.json$/); return jsonResponse({}, 404); }, () => generateUpdateManifest(context), ); assert.equal(result.artifact, artifact); assert.equal(result.downloadArtifact, downloadArtifact); assert.equal(result.manifestPath, path.join(root, 'latest.json')); assert.equal(result.legacyManifestPath, null); const key = target.startsWith('aarch64') ? 'darwin-aarch64' : 'darwin-x86_64'; assert.deepEqual(Object.keys(result.manifest.platforms), [key]); assert.deepEqual(Object.keys(result.manifest.downloads), [key]); assert.match( result.manifest.platforms[key].url, /\/dev-mac\/.*\.app\.tar\.gz$/, ); assert.equal( decodeURIComponent( new URL(result.manifest.downloads[key].url).pathname, ), `/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`, ); assert.deepEqual( JSON.parse(readFileSync(result.manifestPath, 'utf8')), result.manifest, ); } finally { rmSync(root, { recursive: true, force: true }); } }); } test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-')); try { const target = 'aarch64-apple-darwin'; const options = { target, version: '2.3.4', artifact: path.join(root, '陶泥儿.app.tar.gz'), }; const oldVersion = createDmgFixture(root, target, '2.3.3'); const wrongArchitecture = createDmgFixture( root, 'x86_64-apple-darwin', '2.3.4', ); assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u); assert.throws( () => selectFirstInstallArtifact([oldVersion, wrongArchitecture], options), /找到 0 个/u, ); const current = createDmgFixture(root, target, '2.3.4'); assert.equal( selectFirstInstallArtifact( [oldVersion, wrongArchitecture, current], options, ), current, ); writeFileSync(current, ''); assert.throws( () => selectFirstInstallArtifact([current], options), /不存在或为空/u, ); writeFileSync(current, 'valid dmg'); const second = path.join(root, '另一包_2.3.4_aarch64.dmg'); writeFileSync(second, 'ambiguous dmg'); assert.throws( () => selectFirstInstallArtifact([current, second], options), /找到 2 个/u, ); } finally { rmSync(root, { recursive: true, force: true }); } }); test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-')); try { const artifact = path.join(root, '陶泥儿.app.tar.gz'); writeFileSync(artifact, 'updater archive'); writeFileSync(`${artifact}.sig`, 'signature'); const context = { ...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}), bundleRoot: root, }; await assert.rejects( () => generateUpdateManifest(context), /首装 DMG 必须唯一匹配/u, ); assert.throws(() => readFileSync(path.join(root, 'latest.json')), { code: 'ENOENT', }); } finally { rmSync(root, { recursive: true, force: true }); } }); test('invalid target or platform used as channel fails before any release side effect', async () => { let touched = false; const sideEffects = { prepareVersion: () => { touched = true; }, build: () => { touched = true; }, generateManifest: () => { touched = true; }, }; await assert.rejects( () => buildRelease(['--target', 'unknown'], sideEffects), /不支持的发布目标/, ); await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), /发布渠道无效/, ), ); assert.equal(touched, false); }); test('universal uses the Mac channel and the same signed artifact for both architectures', () => { const context = resolveReleaseContext(['--target', universalTarget], { AGC_BUILD_TARGET: windowsTarget, }); // 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。 assert.equal(context.channel, 'dev'); assert.equal( resolveReleasePartition(context.channel, context.target), 'dev-mac', ); assert.ok(context.bundleRoot.includes(universalTarget)); withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { const manifest = createUpdateManifest(artifact, { ...context, downloadArtifact: createDmgFixture( path.dirname(artifact), universalTarget, ), }); assert.deepEqual(Object.keys(manifest.platforms), [ 'darwin-aarch64', 'darwin-x86_64', ]); assert.deepEqual( manifest.platforms['darwin-aarch64'], manifest.platforms['darwin-x86_64'], ); assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//); // 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。 assert.deepEqual( manifest.downloads['darwin-aarch64'].url, manifest.downloads['darwin-x86_64'].url, ); assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u); }); }); test('Windows remains the default and explicit Windows overrides macOS environment', () => { const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; for (const context of [ resolveReleaseContext([], {}), resolveReleaseContext(['--target', windowsTarget], { AGC_BUILD_TARGET: 'aarch64-apple-darwin', }), ]) { assert.equal(context.channel, 'dev'); assert.equal( selectReleaseArtifact(files, context.target), '/tmp/windows.exe', ); runTauriBuild( ['--target', windowsTarget, '--config', 'user-config.json'], context, { stageRuntime: () => {}, stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( '--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute', ), ); assert.ok(command.includes('user-config.json')); const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( readFileSync(command[configIndex + 1], 'utf8'), ); assert.match( config.plugins.updater.endpoints[0], /\/dev-win\/latest\.json$/, ); return { status: 0 }; }, }, ); } }); test('no-bundle smoke skips version writes and manifest generation', async () => { const steps = []; await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () => buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { prepareVersion: () => { steps.push('version'); }, build: (_args, context) => { steps.push(context.channel); }, generateManifest: () => { steps.push('manifest'); }, }), ); assert.deepEqual(steps, ['dev']); }); test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => { const events = []; await buildRelease(['--target', windowsTarget], { prepareVersion: () => { events.push('version'); }, prepareToolset: (context, options) => { events.push(`toolset:${context.target}:${options.bundling}`); }, build: () => { events.push('build'); }, generateManifest: () => { events.push('manifest'); }, }); assert.deepEqual(events, [ 'version', `toolset:${windowsTarget}:true`, 'build', 'manifest', ]); }); test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => { const events = []; await assert.rejects( buildRelease(['--target', windowsTarget], { prepareVersion: () => { events.push('version'); }, prepareToolset: () => { throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败'); }, build: () => { events.push('build'); }, generateManifest: () => { events.push('manifest'); }, }), /NSIS 工具链预置失败/u, ); assert.deepEqual(events, ['version']); }); test('--no-bundle 不预置 NSIS 工具链', async () => { const steps = []; await buildRelease(['--no-bundle', '--target', windowsTarget], { prepareVersion: () => { steps.push('version'); }, prepareToolset: () => { steps.push('toolset'); }, build: () => { steps.push('build'); }, generateManifest: () => { steps.push('manifest'); }, }); assert.deepEqual(steps, ['build']); }); test('release stages Node before Tauri and injects its resource mapping only for bundles', () => { // Jenkins release lanes set AGC_UPDATE_CHANNEL=release; this fixture also checks the // dev-only Windows NSIS hook, so keep the channel explicit instead of inheriting the host. const context = resolveReleaseContext(['--target', windowsTarget], { AGC_UPDATE_CHANNEL: 'dev', }); const events = []; runTauriBuild(['--target', windowsTarget], context, { stageRuntime(target) { assert.equal(target, windowsTarget); events.push('stage'); }, stageBundled(target) { assert.equal(target, windowsTarget); events.push('bundled'); }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); assert.deepEqual(config.bundle.resources, { 'resources/node-runtime': 'game-runtime/node', }); assert.match( config.bundle.windows.nsis.installerHooks, /installer-hooks\.nsh$/u, ); return { status: 0 }; }, }); assert.deepEqual(events, ['stage', 'bundled', 'build']); events.length = 0; runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { assert.fail('no-bundle must not stage node runtime'); }, stageBundled(target, options) { // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging, // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。 assert.equal(target, windowsTarget); assert.ok( options?.features?.length > 0, 'bundled staging 必须收到 feature 集', ); events.push('bundled'); }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); // --no-bundle 只是不加发行资源;渠道安装身份(dev 改名钩子)不属于资源映射。 assert.equal(config.bundle.resources, undefined); assert.match( config.bundle.windows.nsis.installerHooks, /installer-hooks\.nsh$/u, ); return { status: 0 }; }, }); assert.deepEqual(events, ['bundled', 'build']); assert.throws( () => runTauriBuild(['--target', windowsTarget], context, { stageRuntime() { throw new Error('missing runtime'); }, stageBundled() { assert.fail('invalid runtime must prevent bundled staging'); }, spawn() { assert.fail('invalid runtime must prevent build'); }, }), /missing runtime/, ); }); const windowsEditorFeatures = [ 'cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute', ]; function uniqueSorted(values) { return [...new Set(values ?? [])].sort(); } function splitFeatureList(value) { return String(value ?? '') .split(',') .map((name) => name.trim()) .filter(Boolean); } /// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` /// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, /// 随包资源准备必须拿到同一集合。 function cargoFeaturesFromCommand(command) { const argv = command.slice(command.indexOf('--') + 1); const features = []; for (let index = 0; index < argv.length; index += 1) { const argument = String(argv[index]); if (argument.startsWith('--features=')) { features.push(...splitFeatureList(argument.slice('--features='.length))); } else if (argument === '--features' || argument === '-f') { features.push(...splitFeatureList(argv[index + 1])); index += 1; } else if (/^-f.+/u.test(argument)) { features.push(...splitFeatureList(argument.slice(2))); } } return uniqueSorted(features); } /** * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), * 还会出现「cargo 没开 feature、staging 开了」的错配。 */ test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { const context = resolveReleaseContext(['--no-bundle'], {}); assert.equal(context.target, windowsTarget); let command; withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { runTauriBuild(['--no-bundle'], context, { platform: 'linux', arch: 'x64', stageRuntime() { assert.fail('--no-bundle 不得准备 Node 运行时'); }, stageBundled(target) { assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); }, spawn(_binary, args) { command = args; return { status: 0 }; }, }); }); assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); assert.deepEqual( cargoFeaturesFromCommand(command), [], 'Linux 宿主不得注入 Windows 编辑器 feature', ); }); test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { const context = resolveReleaseContext(['--no-bundle'], {}); for (const [platform, arch, hostTarget, expectedFeatures] of [ ['darwin', 'arm64', 'aarch64-apple-darwin', []], ['darwin', 'x64', 'x86_64-apple-darwin', []], ['win32', 'x64', windowsTarget, windowsEditorFeatures], ]) { const label = `${platform}/${arch}`; let staged; let command; withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { runTauriBuild(['--no-bundle'], context, { platform, arch, stageRuntime() { assert.fail('--no-bundle 不得准备 Node 运行时'); }, stageBundled(target, options) { staged = { target, features: options?.features }; }, spawn(_binary, args) { command = args; return { status: 0 }; }, }); }); assert.equal( staged?.target, hostTarget, `${label} 必须按宿主三元组 staging`, ); assert.deepEqual( uniqueSorted(staged?.features), uniqueSorted(expectedFeatures), `${label} staging feature 与宿主不一致`, ); assert.deepEqual( cargoFeaturesFromCommand(command), uniqueSorted(expectedFeatures), `${label} Cargo feature 与 staging 不一致`, ); assert.ok( !command.includes('--target'), `${label} 宿主构建不得打显式 target`, ); } }); test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { for (const [platform, arch, target, expectedFeatures] of [ ['linux', 'x64', windowsTarget, windowsEditorFeatures], ['darwin', 'arm64', 'x86_64-apple-darwin', []], ]) { const args = ['--no-bundle', '--target', target]; const context = resolveReleaseContext(args, {}); const label = `${platform}/${arch} → ${target}`; let staged; let command; withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { runTauriBuild(args, context, { platform, arch, stageRuntime() { assert.fail('--no-bundle 不得准备 Node 运行时'); }, stageBundled(stageTarget, options) { staged = { target: stageTarget, features: options?.features }; }, spawn(_binary, argv) { command = argv; return { status: 0 }; }, }); }); assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); assert.ok( command.includes(target), `${label} 必须把显式 target 传给 Tauri`, ); assert.deepEqual( uniqueSorted(staged?.features), uniqueSorted(expectedFeatures), `${label} staging feature 与预期不一致`, ); assert.deepEqual( cargoFeaturesFromCommand(command), uniqueSorted(expectedFeatures), `${label} Cargo feature 与 staging 不一致`, ); } }); test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { const crossArch = 'x86_64-apple-darwin'; const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); assert.equal(context.target, crossArch); let runtimeTarget; let staged; let command; withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { runTauriBuild([], context, { platform: 'darwin', arch: 'arm64', stageRuntime(target) { runtimeTarget = target; }, stageBundled(target, options) { staged = { target, features: options?.features }; }, spawn(_binary, args) { command = args; return { status: 0 }; }, }); }); assert.equal(runtimeTarget, crossArch); assert.equal( staged?.target, crossArch, '打包构建必须按发布上下文目标 staging', ); assert.deepEqual(uniqueSorted(staged?.features), []); assert.ok( command.includes(crossArch), '打包构建必须把发布上下文目标传给 Tauri', ); assert.deepEqual(cargoFeaturesFromCommand(command), []); }); test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { const context = resolveReleaseContext(['--no-bundle'], {}); const cases = [ { args: ['--no-bundle', '--features', 'custom-editor-feature'], expected: ['custom-editor-feature'], }, { args: ['--no-bundle', '--features=custom-a,custom-b'], expected: ['custom-a', 'custom-b'], }, { args: ['--no-bundle', '-f', 'custom-editor-feature'], expected: ['custom-editor-feature'], }, { args: ['--no-bundle', '-fcustom-editor-feature'], expected: ['custom-editor-feature'], }, // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 // (`tauri build --help`:Command line arguments passed to the runner), // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 { args: ['--no-bundle', '--', '--features=app-runner'], expected: [...windowsEditorFeatures, 'app-runner'], }, // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 { args: ['--no-bundle'], env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, }, ]; for (const entry of cases) { const { args, expected } = entry; const label = args.join(' '); let staged; let command; withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { runTauriBuild(args, context, { platform: 'win32', arch: 'x64', stageRuntime() { assert.fail(`${label} 不得准备 Node 运行时`); }, stageBundled(target, options) { staged = { target, features: options?.features }; }, spawn(_binary, argv) { command = argv; return { status: 0 }; }, }); }); assert.equal(staged?.target, windowsTarget, label); const cargoFeatures = cargoFeaturesFromCommand(command); assert.deepEqual( uniqueSorted(staged?.features), cargoFeatures, `${label}:staging feature 必须等于 Cargo 收到的 feature`, ); if (expected) { assert.deepEqual( cargoFeatures, uniqueSorted(expected), `${label}:Cargo feature 与预期不一致`, ); } } }); test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { const hostTarget = supportedHostTarget(process.platform, process.arch); const context = resolveReleaseContext(['--no-bundle'], {}); const events = []; withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { runTauriBuild(['--no-bundle'], context, { stageRuntime() { assert.fail('--no-bundle 不得准备 Node 运行时'); }, stageBundled(target) { events.push(target); }, spawn() { return { status: 0 }; }, }); }); assert.deepEqual(events, hostTarget ? [hostTarget] : []); }); 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', 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.deepEqual(manifest.downloads, { 'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url }, }); 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', 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', }); 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'); }); for (const channel of ['release', 'beta-2']) { for (const target of [windowsTarget, 'aarch64-apple-darwin']) { test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-')); try { const windows = target === windowsTarget; const partition = `${channel}-${windows ? 'win' : 'mac'}`; const artifact = path.join( root, windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz', ); writeFileSync(artifact, 'updater package'); writeFileSync(`${artifact}.sig`, 'updater signature'); if (!windows) createDmgFixture(root, target); const context = { ...resolveReleaseContext([`--target=${target}`], { AGC_UPDATE_CHANNEL: channel, }), bundleRoot: root, }; const requests = []; const result = await withStubbedFetch( (url) => { requests.push(url); assert.ok(url.endsWith(`/agc/${partition}/latest.json`)); return jsonResponse({ version: '2.3.4', commit: 'abcdef1234567890', }); }, async () => { assert.equal( await resolveRemoteHighWaterVersion( context.channel, context.target, ), '2.3.4', ); runTauriBuild([`--target=${target}`], context, { stageRuntime: () => {}, // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 stageBundled: () => {}, spawn: (_binary, command) => { const config = JSON.parse( readFileSync( command[command.lastIndexOf('--config') + 1], 'utf8', ), ); assert.ok( config.plugins.updater.endpoints[0].endsWith( `/agc/${partition}/latest.json`, ), ); return { status: 0 }; }, }); return generateUpdateManifest(context); }, ); assert.equal(result.channel, channel); assert.equal(result.target, target); assert.equal(result.manifest.version, packageVersion); assert.equal(result.notes, ''); assert.equal(result.manifest.notes, undefined); assert.equal( readFileSync(result.notesPath, 'utf8'), '(本次没有可用的更新摘要)\n', ); assert.equal(result.legacyManifestPath, null); assert.equal(result.legacyManifest, null); assert.equal(requests.length, 1); for (const entry of [ ...Object.values(result.manifest.platforms), ...Object.values(result.manifest.downloads), ]) { assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`)); } assert.throws( () => createLegacyUpdateManifest(artifact, { channel, target }), /只属于 dev 渠道/u, ); } finally { rmSync(root, { recursive: true, force: true }); } }); } } 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', windowsTarget), '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', windowsTarget), '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 () => { for (const [channel, target] of [ ['dev', 'aarch64-apple-darwin'], ['release', windowsTarget], ['beta-2', windowsTarget], ]) { assert.equal( await resolveRemoteHighWaterVersion(channel, target), '0.1.12', ); } }, ); }); test('release entry forwards the built artifacts and dry-run mode to the uploader', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), 'utf8', ); assert.match( source, /const release = await buildRelease\(process\.argv\.slice\(2\)\)/u, ); assert.match(source, /uploadReleaseArtifacts\(release, \{/u); assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); assert.ok(source.includes('\n dryRun,\n')); }); 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}`); } }); test('rejects a stale installer picked up from the build directory', () => { // generateUpdateManifest() 是按目录扫描挑产物:残留的旧版本安装包会被挑中, // 必须在这里失败关闭,而不是把「清单新版本 + 对象旧版本」发出去。 assert.throws( () => assertArtifactVersionMatches( '/bundle/nsis/陶泥儿开发版_0.1.153_x64-setup.exe', '0.1.154', ), /发布产物版本与本次发布不一致:产物 0\.1\.153,本次 0\.1\.154/u, ); assert.equal( assertArtifactVersionMatches( '/bundle/nsis/陶泥儿开发版_0.1.154_x64-setup.exe', '0.1.154', ), '0.1.154', ); // macOS 更新包名里没有版本号,交给各入口的身份断言处理。 assert.equal( assertArtifactVersionMatches( '/bundle/macos/陶泥儿开发版.app.tar.gz', '0.1.154', ), null, ); });