f6dad1950f
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序 - build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging - 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads - 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测 - 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除 - 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程 - 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed - 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { describe, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
isProcessGroupAlive,
|
|
spawnChild,
|
|
terminateChildTree,
|
|
} from '../scripts/start-dev-stack.mjs';
|
|
import {
|
|
buildTauriArguments,
|
|
buildTauriDevProcessEnv,
|
|
runTauriDev as runTauriDevImpl,
|
|
withDevCargoFeatures,
|
|
} from '../scripts/start-tauri-dev.mjs';
|
|
|
|
const testEndpoint = {
|
|
host: '127.0.0.1',
|
|
port: 10005,
|
|
url: 'http://127.0.0.1:10005/',
|
|
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
|
|
portRange: { start: 10000, end: 10099, label: '10000-10099' },
|
|
};
|
|
const resolveTestEndpoint = async () => testEndpoint;
|
|
const runTauriDev = (
|
|
argv: string[],
|
|
options: Parameters<typeof runTauriDevImpl>[1],
|
|
) =>
|
|
runTauriDevImpl(argv, {
|
|
prepareFrontend: async () => {},
|
|
// 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。
|
|
prepareResources: () => {},
|
|
...options,
|
|
});
|
|
|
|
async function waitForFile(path: string, timeoutMs = 5000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (existsSync(path)) {
|
|
return;
|
|
}
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
}
|
|
throw new Error(`等待测试进程标记超时: ${path}`);
|
|
}
|
|
|
|
describe('AI 游戏创作 Tauri dev 启动参数', () => {
|
|
test('开发构建默认带上 Cocos 编辑器 feature', () => {
|
|
expect(
|
|
withDevCargoFeatures(['--no-watch'], ['cocos-editor-execute']),
|
|
).toEqual(['--features=cocos-editor-execute', '--no-watch']);
|
|
expect(withDevCargoFeatures(['--no-watch'], [])).toEqual(['--no-watch']);
|
|
expect(
|
|
withDevCargoFeatures(
|
|
['--features', 'custom-feature'],
|
|
['cocos-editor-execute'],
|
|
),
|
|
).toEqual(['--features', 'custom-feature']);
|
|
});
|
|
|
|
test('普通 dev 参数原样交给 Tauri CLI', () => {
|
|
expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([
|
|
'dev',
|
|
'--no-watch',
|
|
'--config',
|
|
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
|
]);
|
|
});
|
|
|
|
test('动态 devUrl 配置位于用户 Tauri 配置之后且不越过参数分隔符', () => {
|
|
expect(
|
|
buildTauriArguments(
|
|
['--config', 'custom.json', '--', '--', '--example-app-arg'],
|
|
testEndpoint.url,
|
|
),
|
|
).toEqual([
|
|
'dev',
|
|
'--config',
|
|
'custom.json',
|
|
'--config',
|
|
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
|
'--',
|
|
'--',
|
|
'--example-app-arg',
|
|
]);
|
|
});
|
|
|
|
test('单个启动器分隔符后的参数进入应用而不是 Cargo', () => {
|
|
expect(
|
|
buildTauriArguments(
|
|
['--', '--config-dir', 'C:\\temp\\agc-dev-config'],
|
|
testEndpoint.url,
|
|
),
|
|
).toEqual([
|
|
'dev',
|
|
'--config',
|
|
'{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}',
|
|
'--',
|
|
'--',
|
|
'--config-dir',
|
|
'C:\\temp\\agc-dev-config',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
|
test('前端就绪前不启动 Tauri,准备失败时清理自有服务', async () => {
|
|
const spawnCli = vi.fn();
|
|
const frontend = { pid: 1234 };
|
|
const terminateTree = vi.fn(async () => ({ stopped: true }));
|
|
await expect(
|
|
runTauriDev([], {
|
|
resolveDevEndpoint: resolveTestEndpoint,
|
|
preflight: async () => {},
|
|
prepareFrontend: async (_, { onChild }) => {
|
|
onChild(frontend);
|
|
expect(spawnCli).not.toHaveBeenCalled();
|
|
throw new Error('backend failed');
|
|
},
|
|
spawnCli,
|
|
terminateTree,
|
|
}),
|
|
).rejects.toThrow('backend failed');
|
|
expect(spawnCli).not.toHaveBeenCalled();
|
|
expect(terminateTree).toHaveBeenCalledWith(frontend);
|
|
});
|
|
test('动态端口预检失败时不启动 Tauri CLI', async () => {
|
|
const spawnCli = vi.fn();
|
|
|
|
await expect(
|
|
runTauriDev([], {
|
|
resolveDevEndpoint: resolveTestEndpoint,
|
|
preflight: async () => {
|
|
throw new Error('stale AGC Vite');
|
|
},
|
|
spawnCli,
|
|
}),
|
|
).rejects.toThrow('stale AGC Vite');
|
|
|
|
expect(spawnCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => {
|
|
const order: string[] = [];
|
|
const child = Object.assign(new EventEmitter(), {
|
|
pid: 1234,
|
|
exitCode: 1,
|
|
signalCode: null,
|
|
kill: vi.fn(),
|
|
});
|
|
const result = await runTauriDev([], {
|
|
resolveDevEndpoint: resolveTestEndpoint,
|
|
preflight: async () => {
|
|
order.push('preflight');
|
|
},
|
|
prepareFrontend: async () => {
|
|
order.push('frontend-ready');
|
|
},
|
|
prepareResources: (features) => {
|
|
expect(Array.isArray(features)).toBe(true);
|
|
order.push('resources');
|
|
},
|
|
spawnCli: () => {
|
|
order.push('spawn');
|
|
return child;
|
|
},
|
|
waitForCli: async () => {
|
|
order.push('exit');
|
|
return { type: 'exit', code: 1, signal: null };
|
|
},
|
|
terminateTree: async (receivedChild) => {
|
|
expect(receivedChild).toBe(child);
|
|
order.push('cleanup');
|
|
return { stopped: true, forced: false };
|
|
},
|
|
});
|
|
|
|
expect(result).toBe(1);
|
|
expect(order).toEqual([
|
|
'preflight',
|
|
'frontend-ready',
|
|
'resources',
|
|
'spawn',
|
|
'exit',
|
|
'cleanup',
|
|
]);
|
|
});
|
|
|
|
const posixTest = process.platform === 'win32' ? test.skip : test;
|
|
|
|
posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-'));
|
|
const readyPath = join(tempDir, 'client-ready');
|
|
const stoppedPath = join(tempDir, 'client-stopped');
|
|
const descendantSource = `
|
|
const { writeFileSync } = require('node:fs');
|
|
const [readyPath, stoppedPath] = process.argv.slice(1);
|
|
process.on('SIGTERM', () => {
|
|
writeFileSync(stoppedPath, 'stopped');
|
|
process.exit(0);
|
|
});
|
|
writeFileSync(readyPath, 'ready');
|
|
setInterval(() => {}, 1000);
|
|
`;
|
|
const leaderSource = `
|
|
const { existsSync } = require('node:fs');
|
|
const { spawn } = require('node:child_process');
|
|
const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1);
|
|
const descendant = spawn(
|
|
process.execPath,
|
|
['-e', descendantSource, readyPath, stoppedPath],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
descendant.unref();
|
|
const timer = setInterval(() => {
|
|
if (existsSync(readyPath)) {
|
|
clearInterval(timer);
|
|
process.exit(42);
|
|
}
|
|
}, 10);
|
|
`;
|
|
let cliChild;
|
|
try {
|
|
const result = await runTauriDev([], {
|
|
resolveDevEndpoint: resolveTestEndpoint,
|
|
preflight: async () => {},
|
|
spawnCli: () => {
|
|
cliChild = spawnChild(
|
|
process.execPath,
|
|
['-e', leaderSource, readyPath, stoppedPath, descendantSource],
|
|
{ cwd: process.cwd() },
|
|
);
|
|
return cliChild;
|
|
},
|
|
});
|
|
|
|
expect(result).toBe(42);
|
|
await waitForFile(stoppedPath);
|
|
} finally {
|
|
if (Number.isInteger(cliChild?.pid)) {
|
|
try {
|
|
process.kill(-cliChild.pid, 'SIGKILL');
|
|
} catch {
|
|
// 进程组已经由启动器收束。
|
|
}
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-'));
|
|
const readyPath = join(tempDir, 'client-ready');
|
|
const descendantSource = `
|
|
const { writeFileSync } = require('node:fs');
|
|
const [readyPath] = process.argv.slice(1);
|
|
process.on('SIGTERM', () => {});
|
|
writeFileSync(readyPath, 'ready');
|
|
setInterval(() => {}, 1000);
|
|
`;
|
|
const leaderSource = `
|
|
const { existsSync } = require('node:fs');
|
|
const { spawn } = require('node:child_process');
|
|
const [readyPath, descendantSource] = process.argv.slice(1);
|
|
const descendant = spawn(
|
|
process.execPath,
|
|
['-e', descendantSource, readyPath],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
descendant.unref();
|
|
const timer = setInterval(() => {
|
|
if (existsSync(readyPath)) {
|
|
clearInterval(timer);
|
|
process.exit(42);
|
|
}
|
|
}, 10);
|
|
`;
|
|
let cliChild;
|
|
try {
|
|
const result = await runTauriDev([], {
|
|
resolveDevEndpoint: resolveTestEndpoint,
|
|
preflight: async () => {},
|
|
spawnCli: () => {
|
|
cliChild = spawnChild(
|
|
process.execPath,
|
|
['-e', leaderSource, readyPath, descendantSource],
|
|
{ cwd: process.cwd() },
|
|
);
|
|
return cliChild;
|
|
},
|
|
terminateTree: (child) =>
|
|
terminateChildTree(child, {
|
|
gracefulTimeoutMs: 50,
|
|
forceTimeoutMs: 2000,
|
|
}),
|
|
});
|
|
|
|
expect(result).toBe(42);
|
|
expect(isProcessGroupAlive(cliChild.pid)).toBe(false);
|
|
} finally {
|
|
if (Number.isInteger(cliChild?.pid)) {
|
|
try {
|
|
process.kill(-cliChild.pid, 'SIGKILL');
|
|
} catch {
|
|
// 进程组已经由启动器强制收束。
|
|
}
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('AI 游戏创作 Tauri dev 进程环境', () => {
|
|
const posixTest = process.platform === 'win32' ? test.skip : test;
|
|
|
|
// 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量
|
|
// 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。
|
|
posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const env = buildTauriDevProcessEnv(testEndpoint, {
|
|
RUSTC_WRAPPER: 'sccache',
|
|
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
|
});
|
|
|
|
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
|
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env');
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
posixTest('未显式配置 wrapper 时清空两个变量', () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const env = buildTauriDevProcessEnv(testEndpoint, {
|
|
CARGO_TERM_COLOR: 'never',
|
|
});
|
|
|
|
expect(env.RUSTC_WRAPPER).toBe('');
|
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
|
|
expect(env.CARGO_TERM_COLOR).toBe('never');
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' };
|
|
const env = buildTauriDevProcessEnv(testEndpoint, input);
|
|
|
|
expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
|
|
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
|
|
expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port));
|
|
expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' });
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|