Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.test.mjs
T
kdletters c6ea0f0e34
Project CI / AI game creator shell Rust crates (push) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
给共享产物选择器补版本守卫,避免旧安装包被发出去
- build-release:新增导出的 assertArtifactVersionMatches,generateUpdateManifest 选中的产物若文件名带 _x.y.z_ 版本段就必须等于本轮版本,否则失败关闭(macOS 无版本段的 .app.tar.gz 交给身份断言)
- build-release.test:新增残留旧安装包被拒绝、同版本通过、无版本段返回 null 三条用例
- decision-log 与 pitfalls 补记该通用守卫与完整测试批次(apps/ai-game-creator-shell/scripts/*.test.mjs 110 passed)
2026-09-28 22:13:41 +08:00

1165 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import {
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_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.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,
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: '陶泥儿 Release',
identifier: `${AGC_APP_IDENTIFIER}.release`,
});
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
productName: '陶泥儿 Beta-2',
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
});
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
for (const channel of ['release', '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}.`));
}
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, '陶泥儿 Release');
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
assert.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
);
});
});
/**
* 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: '陶泥儿 Release',
});
// 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效,
// 并让按 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: () => {},
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} Release`,
);
});
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: () => {},
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: () => {},
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', () => {
const context = resolveReleaseContext(['--target', windowsTarget]);
const events = [];
runTauriBuild(['--target', windowsTarget], context, {
stageRuntime(target) {
assert.equal(target, windowsTarget);
events.push('stage');
},
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',
});
return { status: 0 };
},
});
assert.deepEqual(events, ['stage', 'build']);
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
assert.fail('no-bundle must not stage resources');
},
spawn(_binary, args) {
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
assert.equal(config.bundle, undefined);
return { status: 0 };
},
});
assert.throws(
() =>
runTauriBuild(['--target', windowsTarget], context, {
stageRuntime() {
throw new Error('missing runtime');
},
spawn() {
assert.fail('invalid runtime must prevent build');
},
}),
/missing runtime/,
);
});
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: () => {},
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,
);
});