Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.test.mjs
T
suzmii 0bfc5768cc 修复AGC跨平台资源构建目标
让Linux no-bundle smoke不再误用Windows随包资源目标。

统一dev与release的Cargo目标和feature解析,补充跨平台回归测试。

同步随包资源staging技术方案中的实际构建目标约定。
2026-09-30 20:24:23 +08:00

1625 lines
53 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 {
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_PRODUCT_NAME,
resolveChannelInstallIdentity,
} 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,
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`,
});
// 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.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
);
});
});
/**
* dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径
* (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。
*/
test('dev 渠道的 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}`,
);
}
// 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。
assert.equal(
createChannelConfig('release', 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'),
);
});
/**
* 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,
);
});