修复AGC跨平台资源构建目标

让Linux no-bundle smoke不再误用Windows随包资源目标。

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

同步随包资源staging技术方案中的实际构建目标约定。
This commit is contained in:
2026-09-30 20:24:23 +08:00
parent 1dbbdcf329
commit 0bfc5768cc
6 changed files with 720 additions and 80 deletions
@@ -11,6 +11,7 @@ import {
} from './agc-global-version.mjs';
import {
defaultEditorFeatures,
readCargoTarget,
resolveEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
@@ -20,7 +21,10 @@ import {
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
import {
prepareBundledResources,
supportedHostTarget,
} from './prepare-bundled-resources.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -42,26 +46,7 @@ function defaultTarget() {
}
function explicitBuildTarget(args) {
let target;
const separator = args.indexOf('--');
const options = separator < 0 ? args : args.slice(0, separator);
for (let index = 0; index < options.length; index += 1) {
const argument = options[index];
let value;
if (argument === '--target' || argument === '-t') {
value = options[++index];
} else if (argument.startsWith('--target=')) {
value = argument.slice('--target='.length);
} else {
continue;
}
if (!value?.trim() || value.startsWith('-')) {
throw new Error('--target 缺少有效目标');
}
if (target !== undefined) throw new Error('不能重复指定 --target');
target = value.trim();
}
return target;
return readCargoTarget(args);
}
function validateReleaseTarget(target) {
@@ -494,6 +479,8 @@ export function runTauriBuild(
spawn = spawnSync,
stageRuntime = stageNodeRuntime,
stageBundled = stageBundledResources,
platform = process.platform,
arch = process.arch,
} = {},
) {
if (
@@ -502,19 +489,35 @@ export function runTauriBuild(
) {
throw new Error('构建参数与发布上下文目标不一致');
}
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
const features = resolveEditorFeatures({ argv: args, target });
// --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。
if (!args.includes('--no-bundle')) {
stageRuntime(target);
}
stageBundled(target, { features });
const configPath = writeChannelConfigFile(
channel,
target,
!args.includes('--no-bundle'),
const tauriArguments = buildTauriBuildArguments(
args,
context.target,
platform,
);
const bundling = !args.includes('--no-bundle');
// 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。
const resourceTarget =
explicitBuildTarget(args) ||
(bundling ? context.target : supportedHostTarget(platform, arch));
const features = resolveEditorFeatures({
argv: tauriArguments,
target: resourceTarget ?? platform,
env: {},
});
const { channel } = context;
if (bundling) {
stageRuntime(resourceTarget);
}
if (resourceTarget) {
stageBundled(resourceTarget, { features });
} else {
console.log(
'[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备',
);
}
// Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。
const target = resourceTarget ?? context.target;
const configPath = writeChannelConfigFile(channel, target, bundling);
console.log(
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
);
@@ -38,6 +38,7 @@ import {
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';
@@ -1032,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for
);
});
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: '修复与改进' }, () => {
@@ -1,69 +1,134 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
const separator = argv.indexOf('--');
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
if (
!features.length ||
cargoArgs.some(
(value) =>
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value),
)
) {
// 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的
// 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。
if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。
function tauriOptions(argv) {
const separator = argv.indexOf('--');
return separator < 0 ? argv : argv.slice(0, separator);
}
/**
* 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是
* runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。
* dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布
* 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。
*/
function cargoArguments(argv) {
const separator = argv.indexOf('--');
if (separator < 0) return argv;
const applicationStart = argv.indexOf('--', separator + 1);
return applicationStart < 0 ? argv : argv.slice(0, applicationStart);
}
/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。
function hasExplicitFeatures(value) {
return (
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value)
);
}
function parseFeatureNames(raw) {
return String(raw)
.split(',')
.map((name) => name.trim())
.filter(Boolean);
}
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute']
: [];
}
/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。
/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。
/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值,
/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。
/**
* 显式 Cargo 目标解析:`--target <triple>` / `--target=<triple>` / `-t <triple>`。
* 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、
* 取到另一个选项或重复指定都失败关闭。
* dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标
* 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。
*/
export function readCargoTarget(argv = []) {
const options = cargoArguments(argv);
let target;
for (let index = 0; index < options.length; index += 1) {
const argument = String(options[index]);
let value;
if (argument === '--target' || argument === '-t') {
value = options[index + 1];
index += 1;
} else if (argument.startsWith('--target=')) {
value = argument.slice('--target='.length);
} else if (argument.startsWith('-t') && argument.length > 2) {
value = argument.slice(2).replace(/^=/u, '');
} else {
continue;
}
if (!value?.trim() || String(value).startsWith('-')) {
throw new Error('--target 缺少有效目标');
}
if (target !== undefined) throw new Error('不能重复指定 --target');
target = String(value).trim();
}
return target;
}
/**
* 单一声明式解析:本次构建实际生效的编辑器 feature 集。
* 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖)
* > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature)
* > 目标平台默认值。
* 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源
* 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。
*/
export function resolveEditorFeatures({
argv = [],
target,
env = process.env,
} = {}) {
// 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。
const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim();
if (fromEnv) {
return fromEnv
.split(',')
.map((name) => name.trim())
.filter(Boolean);
}
const fromArgv = featuresFromArgv(argv);
if (fromArgv) {
return fromArgv;
}
// 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。
if (env.AGC_DEV_CARGO_FEATURES !== undefined) {
return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES);
}
return defaultEditorFeatures(target);
}
/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。
/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并);
/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。
export function featuresFromArgv(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const value = String(argv[index]);
const options = cargoArguments(argv);
const features = [];
let found = false;
for (let index = 0; index < options.length; index += 1) {
const value = String(options[index]);
if (value.startsWith('--features=')) {
return value
.slice('--features='.length)
.split(',')
.map((name) => name.trim())
.filter(Boolean);
}
if (value === '--features' && argv[index + 1] !== undefined) {
return String(argv[index + 1])
.split(',')
.map((name) => name.trim())
.filter(Boolean);
found = true;
features.push(...parseFeatureNames(value.slice('--features='.length)));
} else if (value === '--features' || value === '-f') {
found = true;
index += 1;
const next = options[index];
// 变参 flag:后面不是选项时才是它的取值。
if (next !== undefined && !String(next).startsWith('-')) {
features.push(...parseFeatureNames(next));
}
} else if (value.startsWith('-f') && value.length > 2) {
found = true;
features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, '')));
}
}
return null;
return found ? features : null;
}
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
import {
readCargoTarget,
resolveEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
@@ -56,9 +57,14 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
}
// 开发和发行构建使用同一平台编辑器 feature 集合。
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭;
// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。
// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。
function readDevCargoFeatures(argv = []) {
return resolveEditorFeatures({ argv, target: process.platform });
return resolveEditorFeatures({
argv,
target: readCargoTarget(argv) ?? process.platform,
});
}
function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) {
@@ -67,11 +73,19 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) {
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源:
/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过,
/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。
function prepareBundledResourcesBeforeTauri(
features = readDevCargoFeatures(),
{ prepare = prepareBundledResources, log = console.log } = {},
features,
argv = [],
{
prepare = prepareBundledResources,
log = console.log,
hostTarget = supportedHostTarget(),
} = {},
) {
const target = supportedHostTarget();
const target = readCargoTarget(argv) ?? hostTarget;
if (!target) {
log(
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
@@ -80,7 +94,7 @@ function prepareBundledResourcesBeforeTauri(
}
const summaries = prepare({
target,
features: new Set(features),
features: new Set(features ?? readDevCargoFeatures(argv)),
log: (line) => log(`[ai-game-creator-shell] ${line}`),
});
for (const summary of summaries) {
@@ -152,8 +166,11 @@ async function runTauriDev(
}
try {
const devFeatures = readDevCargoFeatures();
prepareResources(devFeatures);
// 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数,
// 不能参与解析;注入默认 feature 也不改变这份解析结果。
const devTauriArguments = buildTauriArguments(argv, endpoint.url);
const devFeatures = readDevCargoFeatures(devTauriArguments);
prepareResources(devFeatures, devTauriArguments);
// Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。
const preparation = prepareFrontend(endpoint, {
signal: preparationAbort.signal,
@@ -5,6 +5,10 @@ import { join } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import {
readCargoTarget,
resolveEditorFeatures,
} from '../scripts/cargo-features.mjs';
import {
isProcessGroupAlive,
spawnChild,
@@ -13,6 +17,7 @@ import {
import {
buildTauriArguments,
buildTauriDevProcessEnv,
prepareBundledResourcesBeforeTauri,
runTauriDev as runTauriDevImpl,
withDevCargoFeatures,
} from '../scripts/start-tauri-dev.mjs';
@@ -363,3 +368,260 @@ describe('AI 游戏创作 Tauri dev 进程环境', () => {
}
});
});
describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => {
const windowsFeatures = [
'cocos-editor-execute',
'unity-editor-execute',
'godot-editor-execute',
];
// buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。
const devConfig =
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}';
test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => {
const calls: [string, string[]][] = [];
const hostTarget = 'aarch64-apple-darwin';
const prepare = (options: {
target: string;
features: Iterable<string>;
}) => {
calls.push([options.target, [...options.features]]);
return [];
};
for (const argv of [
['dev', '--config', devConfig],
['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig],
['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig],
// 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。
[
'dev',
'--config',
devConfig,
'--',
'--',
'--target=x86_64-pc-windows-msvc',
],
]) {
prepareBundledResourcesBeforeTauri(windowsFeatures, argv, {
hostTarget,
prepare,
log: () => {},
});
}
expect(calls.map(([target]) => target)).toEqual([
hostTarget,
'x86_64-apple-darwin',
'x86_64-pc-windows-msvc',
hostTarget,
]);
expect(calls[0][1]).toEqual(windowsFeatures);
});
test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => {
const prepare = vi.fn();
const log = vi.fn();
prepareBundledResourcesBeforeTauri(
windowsFeatures,
['dev', '--config', devConfig],
{ hostTarget: null, prepare, log },
);
expect(prepare).not.toHaveBeenCalled();
expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备');
});
async function runCapturingDev(argv: string[]) {
const prepared: unknown[][] = [];
const spawned: string[][] = [];
const child = Object.assign(new EventEmitter(), {
pid: 1234,
exitCode: 0,
signalCode: null,
kill: vi.fn(),
});
const result = await runTauriDev(argv, {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
prepareResources: (...args: unknown[]) => {
prepared.push(args);
},
spawnCli: (args: string[]) => {
spawned.push(args);
return child;
},
waitForCli: async () => ({ type: 'exit', code: 0, signal: null }),
terminateTree: async () => ({ stopped: true, forced: false }),
});
return { result, prepared, spawned };
}
test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
try {
const { result, prepared, spawned } = await runCapturingDev([
'-f',
'custom-feature',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([
['custom-feature'],
]);
expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']);
expect(spawned[0]).not.toContain(
`--features=${windowsFeatures.join(',')}`,
);
// 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。
expect(prepared[0][1]).toEqual(spawned[0]);
} finally {
vi.unstubAllEnvs();
}
});
test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([
'--target',
'x86_64-apple-darwin',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(prepared[0][1]).toEqual([
'dev',
'--target',
'x86_64-apple-darwin',
'--config',
devConfig,
]);
expect(spawned[0]).toEqual(prepared[0][1]);
} finally {
vi.unstubAllEnvs();
}
});
test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([
'--',
'--features=app-tool',
]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(spawned[0]).toEqual([
'dev',
'--config',
devConfig,
'--',
'--',
'--features=app-tool',
]);
expect(prepared[0][1]).toEqual(spawned[0]);
} finally {
vi.unstubAllEnvs();
}
});
test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
try {
const { result, prepared, spawned } = await runCapturingDev([]);
expect(result).toBe(0);
expect(prepared.map(([features]) => features)).toEqual([[]]);
expect(
spawned[0].some((argument) => argument.startsWith('--features')),
).toBe(false);
} finally {
vi.unstubAllEnvs();
}
});
test('环境变量只在不与显式 CLI 冲突时生效', () => {
vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
try {
expect(
resolveEditorFeatures({
argv: ['dev', '--config', devConfig],
target: 'win32',
}),
).toEqual(['template-library-fixtures']);
expect(
resolveEditorFeatures({
argv: ['dev', '-f', 'cli-feature', '--config', devConfig],
target: 'win32',
}),
).toEqual(['cli-feature']);
} finally {
vi.unstubAllEnvs();
}
});
});
describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => {
test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => {
expect(readCargoTarget([])).toBeUndefined();
expect(readCargoTarget(['--no-watch'])).toBeUndefined();
expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe(
'x86_64-apple-darwin',
);
expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe(
'x86_64-apple-darwin',
);
// runner 参数由 Tauri 追加给 cargo:仍然算构建目标。
expect(
readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']),
).toBe('aarch64-apple-darwin');
// 第二个 `--` 之后是应用参数。
expect(
readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']),
).toBeUndefined();
expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标');
expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow(
'缺少有效目标',
);
expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow(
'不能重复指定',
);
});
test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => {
const cases: [string[], string[]][] = [
[
['-f', 'a,b'],
['a', 'b'],
],
[['-fa'], ['a']],
[['--features=explicit'], ['explicit']],
// Cargo 会合并多个 feature flag:staging 必须拿到同一集合。
[
['--features', 'a', '-f', 'b'],
['a', 'b'],
],
// 显式给空集合即关闭默认,而不是回落默认值。
[['--features='], []],
// 第二个 `--` 之后是应用参数。
[
['dev', '--config', '{}', '--', '--', '-f', 'app-feature'],
[
'cocos-editor-execute',
'unity-editor-execute',
'godot-editor-execute',
],
],
];
for (const [argv, expected] of cases) {
expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual(
expected,
);
}
});
});
@@ -102,6 +102,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要
6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。
7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。
8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。
9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。
### 4.4 build.rs 退化后的职责