86b830cf2d
Project CI / AI game creator shell Rust crates (push) Successful in 3m17s
Project CI / Native shell tests (push) Successful in 6m1s
Project CI / Backend tests (push) Successful in 6m39s
Project CI / Frontend tests (push) Successful in 3m59s
Project CI / Repository checks (push) Successful in 2m37s
Project CI / AI game creator shell web tests (push) Successful in 2m37s
Project CI / AI game creator shell Rust tests (push) Has been cancelled
run 2097 显示 AI game creator shell Rust tests 是客户端 CI 的关键路径(15 分 27 秒): 前置 5 分 30 秒(checkout + npm ci + Cargo fetch)、编译 1 分 39 秒、AGC 壳 bin target 的 2466 条单测一条命令串行 507 秒、agent-run smoke 51 秒。 - 新增 apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs:cargo test --no-run 编译一次拿到测试可执行文件,用 --list 取全部用例名,排序后按 index % shards 切 4 片,每片作为独立进程执行 <bin> --exact <名单> --test-threads=1,并给每片独立 TMPDIR。片并集必须等于 --list 全集且互斥(assertShardsCoverEveryTest),改分片规则 不会静默漏跑。串行口径只从「整套串行」放宽到「片内串行」:当年 libtest 线程并行会 互相干扰的是进程内后台锁与异步终态,进程分片不共享这些状态,因此不必放宽断言。 - package.json:ai-game-creator-shell:check:rust 拆成 :rust:crates(agent-runtime-core / orchestration / platform-llm / shared-contracts)与 :rust:shell(分片运行器),聚合 脚本保持同序,ai-game-creator-shell:check 与本地全量入口语义不变。 - check-native-shells.mjs:分组由五个变六个(新增 agc-rust-crates、agc-rust-shell, 移除 agc-rust);smoke 与壳测试同组以共享 AGC 壳的依赖预热。 - project-ci.yml:新增第 7 个 job AI game creator shell Rust crates(预热 server-rs 与 两个无锁独立 crate 后跑四条 crate 测试);AI game creator shell Rust tests 只保留壳 分片与 agent-run smoke,并只预热 AGC 壳自己那份 manifest(其 Cargo.lock 的 path 依赖 已覆盖 platform-llm / platform-agent / agent-runtime-core / shared-contracts)。 这两个 job 只用 cargo 与 node 内建模块,因此都不再执行 npm ci(各省 1~3 分钟)。 - project-ci-workflow.test.ts:新增纯 cargo job 免 npm ci、分片运行器覆盖校验、crate 级 job 预热顺序等断言;同步运维文档、development-workflow、decision-log、pitfalls。 验证:分片运行器本地以 agent-runtime-core(7 条 → 2/2/2/1)与 platform-llm(146 条 → 49/49/48)验证分片、--exact 与片 TMPDIR 隔离,参数负例 exit 1;vitest scripts/project-ci-workflow.test.ts 12 passed;contract 分组、AGC 与 mobile 壳 check-config、eslint、prettier、check:encoding(13400 文件)、check:doc-index 全绿。 Gitea master 分支保护需补 Project CI / AI game creator shell Rust crates (pull_request)。 Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
427 lines
13 KiB
JavaScript
427 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
// AGC 壳 Rust 套件的分片运行器。
|
||
//
|
||
// 背景:AGC 壳的 Rust 单测集中在 src/main.rs 的 bin target(实测 2466 条),仓库口径用
|
||
// `--test-threads=1` 跑,理由是并行调度会让共享 Agent Runtime 后台锁与异步终态的用例在
|
||
// **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是
|
||
// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。
|
||
//
|
||
// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片、让每片在**独立进程**里并行:
|
||
// 当年线程并行的两个根因(进程内全局锁、异步终态)在多进程下不存在,每片还会拿到自己的
|
||
// TMPDIR,避免 tempfile 目录互相踩。片并集必须等于全集、且不得重复,数量不符即失败,
|
||
// 防止分片规则改动后静默漏跑。
|
||
//
|
||
// 用法:
|
||
// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4]
|
||
// node scripts/run-rust-shell-test-shards.mjs --manifest=<Cargo.toml> --target-kind=lib --no-locked
|
||
//
|
||
// 参数:
|
||
// --shards=<n> 分片数,默认 4
|
||
// --concurrency=<n> 同时运行的片数,默认等于分片数
|
||
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
|
||
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib)
|
||
// --bin=<name> bin target 名,默认 genarrative-ai-game-creator-shell
|
||
// --package=<name> target-kind=lib 时要跑的包名(配合 lib 目标使用)
|
||
// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要)
|
||
// --shard-tmp-root=<path> 片专属 TMPDIR 的父目录,默认 <系统临时目录>/agc-rust-shards
|
||
|
||
import { spawn } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||
const shellRoot = path.resolve(scriptDirectory, '..');
|
||
|
||
function fail(message) {
|
||
console.error(`[rust-shards] ${message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
function parsePositiveInteger(name, rawValue) {
|
||
if (!/^[1-9][0-9]*$/.test(rawValue)) {
|
||
fail(`${name} must be a positive integer, received: ${rawValue}`);
|
||
}
|
||
|
||
return Number(rawValue);
|
||
}
|
||
|
||
const options = {
|
||
shards: 4,
|
||
concurrency: undefined,
|
||
manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'),
|
||
targetKind: 'bin',
|
||
binName: 'genarrative-ai-game-creator-shell',
|
||
packageName: undefined,
|
||
locked: true,
|
||
shardTmpRoot: path.join(os.tmpdir(), 'agc-rust-shards'),
|
||
};
|
||
|
||
for (const rawArgument of process.argv.slice(2)) {
|
||
if (rawArgument === '--no-locked') {
|
||
options.locked = false;
|
||
continue;
|
||
}
|
||
|
||
const separatorIndex = rawArgument.indexOf('=');
|
||
if (!rawArgument.startsWith('--') || separatorIndex === -1) {
|
||
fail(`unexpected argument: ${rawArgument}`);
|
||
}
|
||
|
||
const name = rawArgument.slice(2, separatorIndex);
|
||
const value = rawArgument.slice(separatorIndex + 1);
|
||
switch (name) {
|
||
case 'shards':
|
||
options.shards = parsePositiveInteger('--shards', value);
|
||
break;
|
||
case 'concurrency':
|
||
options.concurrency = parsePositiveInteger('--concurrency', value);
|
||
break;
|
||
case 'manifest':
|
||
options.manifestPath = path.resolve(process.cwd(), value);
|
||
break;
|
||
case 'target-kind':
|
||
if (value !== 'bin' && value !== 'lib') {
|
||
fail(`--target-kind must be bin or lib, received: ${value}`);
|
||
}
|
||
options.targetKind = value;
|
||
break;
|
||
case 'bin':
|
||
options.binName = value;
|
||
break;
|
||
case 'package':
|
||
options.packageName = value;
|
||
break;
|
||
case 'shard-tmp-root':
|
||
options.shardTmpRoot = path.resolve(process.cwd(), value);
|
||
break;
|
||
default:
|
||
fail(`unexpected argument: ${rawArgument}`);
|
||
}
|
||
}
|
||
|
||
if (!fs.existsSync(options.manifestPath)) {
|
||
fail(`manifest does not exist: ${options.manifestPath}`);
|
||
}
|
||
|
||
const concurrency = options.concurrency ?? options.shards;
|
||
const crateRoot = path.dirname(options.manifestPath);
|
||
|
||
function buildCargoArguments(target) {
|
||
const cargoArguments = ['test'];
|
||
if (options.locked) {
|
||
cargoArguments.push('--locked');
|
||
}
|
||
cargoArguments.push('--manifest-path', options.manifestPath);
|
||
if (target.kind === 'lib') {
|
||
if (options.packageName !== undefined) {
|
||
cargoArguments.push('-p', options.packageName);
|
||
}
|
||
cargoArguments.push('--lib');
|
||
return cargoArguments;
|
||
}
|
||
cargoArguments.push('--bin', target.name);
|
||
return cargoArguments;
|
||
}
|
||
|
||
function formatDuration(milliseconds) {
|
||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||
}
|
||
|
||
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
|
||
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
|
||
function resolveTestExecutable() {
|
||
return new Promise((resolve, reject) => {
|
||
const cargoArguments = buildCargoArguments({
|
||
kind: options.targetKind,
|
||
name: options.binName,
|
||
});
|
||
cargoArguments.push('--no-run', '--message-format=json');
|
||
console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`);
|
||
|
||
const child = spawn('cargo', cargoArguments, {
|
||
cwd: crateRoot,
|
||
env: process.env,
|
||
stdio: ['ignore', 'pipe', 'inherit'],
|
||
});
|
||
|
||
let buffered = '';
|
||
const executables = [];
|
||
child.stdout.setEncoding('utf8');
|
||
child.stdout.on('data', (chunk) => {
|
||
buffered += chunk;
|
||
const lines = buffered.split('\n');
|
||
buffered = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
if (!line.startsWith('{')) {
|
||
continue;
|
||
}
|
||
let message;
|
||
try {
|
||
message = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (
|
||
message.reason === 'compiler-artifact' &&
|
||
typeof message.executable === 'string'
|
||
) {
|
||
executables.push(message.executable);
|
||
}
|
||
}
|
||
});
|
||
|
||
child.on('error', (error) => {
|
||
reject(new Error(`unable to start cargo: ${error.message}`));
|
||
});
|
||
child.on('close', (code) => {
|
||
if (code !== 0) {
|
||
reject(
|
||
new Error(
|
||
`cargo ${cargoArguments.join(' ')} failed with exit code ${code}`,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
const uniqueExecutables = [...new Set(executables)];
|
||
if (uniqueExecutables.length !== 1) {
|
||
reject(
|
||
new Error(
|
||
`expected exactly one test executable for the ${options.targetKind} target, found ${uniqueExecutables.length}: ${uniqueExecutables.join(', ')}`,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
resolve(uniqueExecutables[0]);
|
||
});
|
||
});
|
||
}
|
||
|
||
function listTestNames(executable) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(executable, ['--list'], {
|
||
cwd: crateRoot,
|
||
env: process.env,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
child.stdout.on('data', (chunk) => {
|
||
stdout += chunk;
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk;
|
||
});
|
||
child.on('error', (error) => {
|
||
reject(new Error(`unable to list tests: ${error.message}`));
|
||
});
|
||
child.on('close', (code) => {
|
||
if (code !== 0) {
|
||
reject(
|
||
new Error(
|
||
`listing tests failed with exit code ${code}: ${stderr.trim()}`,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
const names = [];
|
||
for (const line of stdout.split('\n')) {
|
||
const match = /^(.*): test$/.exec(line.trim());
|
||
if (match !== null && match[1].length > 0) {
|
||
names.push(match[1]);
|
||
}
|
||
}
|
||
resolve(names);
|
||
});
|
||
});
|
||
}
|
||
|
||
function splitTestNames(testNames, shardCount) {
|
||
const sortedNames = [...testNames].sort();
|
||
const shards = Array.from({ length: shardCount }, () => []);
|
||
sortedNames.forEach((testName, index) => {
|
||
shards[index % shardCount].push(testName);
|
||
});
|
||
return shards;
|
||
}
|
||
|
||
function assertShardsCoverEveryTest(testNames, shards) {
|
||
const flattened = shards.flat();
|
||
if (flattened.length !== testNames.length) {
|
||
fail(
|
||
`shard split covered ${flattened.length} of ${testNames.length} tests; the split rule must be exhaustive`,
|
||
);
|
||
}
|
||
if (new Set(flattened).size !== flattened.length) {
|
||
fail(
|
||
'shard split selected the same test more than once; the split rule must be disjoint',
|
||
);
|
||
}
|
||
const missing = testNames.filter((testName) => !flattened.includes(testName));
|
||
if (missing.length > 0) {
|
||
fail(
|
||
`shard split is missing tests, for example: ${missing.slice(0, 5).join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function runShard(executable, shardIndex, shardCount, shardTestNames) {
|
||
const label = `shard ${shardIndex + 1}/${shardCount}`;
|
||
const shardTmpDirectory = path.join(
|
||
options.shardTmpRoot,
|
||
`shard-${shardIndex + 1}`,
|
||
);
|
||
fs.rmSync(shardTmpDirectory, { recursive: true, force: true });
|
||
fs.mkdirSync(shardTmpDirectory, { recursive: true });
|
||
const startedAt = Date.now();
|
||
|
||
return new Promise((resolve) => {
|
||
const child = spawn(
|
||
executable,
|
||
['--exact', ...shardTestNames, '--test-threads=1'],
|
||
{
|
||
cwd: crateRoot,
|
||
env: {
|
||
...process.env,
|
||
TMPDIR: shardTmpDirectory,
|
||
TMP: shardTmpDirectory,
|
||
TEMP: shardTmpDirectory,
|
||
},
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
},
|
||
);
|
||
|
||
const failureLines = [];
|
||
let inFailureList = false;
|
||
let stderr = '';
|
||
const consumeLine = (rawLine) => {
|
||
const line = rawLine.replace(/\r$/, '');
|
||
if (line.includes('failures:')) {
|
||
inFailureList = true;
|
||
return;
|
||
}
|
||
if (inFailureList) {
|
||
if (line.trim().length === 0) {
|
||
inFailureList = false;
|
||
return;
|
||
}
|
||
failureLines.push(line.trim());
|
||
}
|
||
};
|
||
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
let stdoutBuffer = '';
|
||
child.stdout.on('data', (chunk) => {
|
||
stdoutBuffer += chunk;
|
||
const lines = stdoutBuffer.split('\n');
|
||
stdoutBuffer = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
consumeLine(line);
|
||
}
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk;
|
||
});
|
||
|
||
child.on('error', (error) => {
|
||
resolve({
|
||
label,
|
||
ok: false,
|
||
durationMs: Date.now() - startedAt,
|
||
testCount: shardTestNames.length,
|
||
failures: [`unable to start test binary: ${error.message}`],
|
||
stderr,
|
||
});
|
||
});
|
||
child.on('close', (code) => {
|
||
resolve({
|
||
label,
|
||
ok: code === 0,
|
||
durationMs: Date.now() - startedAt,
|
||
testCount: shardTestNames.length,
|
||
failures: failureLines,
|
||
stderr,
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runWithConcurrency(shards, runner) {
|
||
const results = new Array(shards.length);
|
||
let nextIndex = 0;
|
||
const workers = Array.from(
|
||
{ length: Math.min(concurrency, shards.length) },
|
||
async () => {
|
||
while (nextIndex < shards.length) {
|
||
const index = nextIndex;
|
||
nextIndex += 1;
|
||
results[index] = await runner(shards[index], index);
|
||
}
|
||
},
|
||
);
|
||
await Promise.all(workers);
|
||
return results;
|
||
}
|
||
|
||
async function main() {
|
||
const executable = await resolveTestExecutable();
|
||
const testNames = await listTestNames(executable);
|
||
if (testNames.length === 0) {
|
||
fail(
|
||
`no tests discovered in ${options.manifestPath} (${options.targetKind})`,
|
||
);
|
||
}
|
||
|
||
const shards = splitTestNames(testNames, options.shards);
|
||
assertShardsCoverEveryTest(testNames, shards);
|
||
|
||
console.log(
|
||
`[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`,
|
||
);
|
||
shards.forEach((shardTestNames, index) => {
|
||
console.log(
|
||
`[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`,
|
||
);
|
||
});
|
||
|
||
const results = await runWithConcurrency(shards, (shardTestNames, index) =>
|
||
runShard(executable, index, shards.length, shardTestNames),
|
||
);
|
||
|
||
let failed = false;
|
||
for (const result of results) {
|
||
if (result.ok) {
|
||
console.log(
|
||
`[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
|
||
);
|
||
continue;
|
||
}
|
||
failed = true;
|
||
console.error(
|
||
`[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
|
||
);
|
||
for (const failure of result.failures) {
|
||
console.error(`[rust-shards] ${failure}`);
|
||
}
|
||
if (result.stderr.trim().length > 0) {
|
||
console.error(
|
||
`[rust-shards] stderr: ${result.stderr.trim().split('\n').slice(-20).join('\n[rust-shards] ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (failed) {
|
||
process.exit(1);
|
||
}
|
||
console.log('[rust-shards] OK');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
fail(error instanceof Error ? error.message : String(error));
|
||
});
|