3fc3399032
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m41s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Successful in 4m22s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 9m52s
Project CI / Backend tests (pull_request) Successful in 11m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m41s
新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证 统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断 接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发 补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查 记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证
555 lines
22 KiB
TypeScript
555 lines
22 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
const workflow = readFileSync(
|
||
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
|
||
'utf8',
|
||
);
|
||
const imageBuildScript = readFileSync(
|
||
resolve(process.cwd(), 'scripts/gitea-ci-job-image.sh'),
|
||
'utf8',
|
||
);
|
||
const imageCheckScript = readFileSync(
|
||
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
|
||
'utf8',
|
||
);
|
||
const npmCiRetryScript = readFileSync(
|
||
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
|
||
'utf8',
|
||
);
|
||
const imageDockerfile = readFileSync(
|
||
resolve(process.cwd(), 'deploy/container/gitea-ci-job.Dockerfile'),
|
||
'utf8',
|
||
);
|
||
const imageDockerignore = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'deploy/container/gitea-ci-job.Dockerfile.dockerignore',
|
||
),
|
||
'utf8',
|
||
);
|
||
const apiServerDockerfile = readFileSync(
|
||
resolve(process.cwd(), 'deploy/container/api-server.Dockerfile'),
|
||
'utf8',
|
||
);
|
||
|
||
const jobNames = [
|
||
'repository-checks',
|
||
'frontend-tests',
|
||
'backend-tests',
|
||
'native-shell-tests',
|
||
'ai-game-creator-shell-web-tests',
|
||
'ai-game-creator-shell-rust-shard-1',
|
||
'ai-game-creator-shell-rust-shard-2',
|
||
'ai-game-creator-shell-rust-shard-3',
|
||
'ai-game-creator-shell-rust-shard-4',
|
||
'ai-game-creator-shell-rust-smoke',
|
||
'ai-game-creator-shell-rust-crates',
|
||
] as const;
|
||
|
||
// 这几个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与
|
||
// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令,
|
||
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
|
||
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
|
||
const jobsWithoutNpmInstall: readonly string[] = [
|
||
'ai-game-creator-shell-rust-shard-1',
|
||
'ai-game-creator-shell-rust-shard-2',
|
||
'ai-game-creator-shell-rust-shard-3',
|
||
'ai-game-creator-shell-rust-shard-4',
|
||
'ai-game-creator-shell-rust-smoke',
|
||
'ai-game-creator-shell-rust-crates',
|
||
];
|
||
|
||
const rootPackageJson = JSON.parse(
|
||
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
|
||
) as { scripts?: Record<string, string> };
|
||
const nativeShellGateScript = readFileSync(
|
||
resolve(process.cwd(), 'scripts/check-native-shells.mjs'),
|
||
'utf8',
|
||
);
|
||
|
||
// 客户端门禁拆分口径:门禁脚本里的每个分组都由一个根 npm 脚本暴露,并在 workflow
|
||
// 的某个 job 里被恰好调用一次。新增分组时必须同步这三处,否则拆分就会静默漏跑门禁。
|
||
const nativeShellGateGroupScripts = {
|
||
contract: 'npm run check:native-shells:contract',
|
||
shells: 'npm run check:native-shells:shells',
|
||
'agc-web': 'npm run check:native-shells:agc-web',
|
||
'agc-rust-crates': 'npm run check:native-shells:agc-rust-crates',
|
||
'agc-rust-shard-1': 'npm run check:native-shells:agc-rust-shard-1',
|
||
'agc-rust-shard-2': 'npm run check:native-shells:agc-rust-shard-2',
|
||
'agc-rust-shard-3': 'npm run check:native-shells:agc-rust-shard-3',
|
||
'agc-rust-shard-4': 'npm run check:native-shells:agc-rust-shard-4',
|
||
'agc-rust-smoke': 'npm run check:native-shells:agc-rust-smoke',
|
||
release: 'npm run check:native-shells:release',
|
||
} as const;
|
||
|
||
function jobSection(jobName: (typeof jobNames)[number]) {
|
||
const jobStart = workflow.indexOf(` ${jobName}:`);
|
||
expect(jobStart).toBeGreaterThanOrEqual(0);
|
||
|
||
const nextJobOffset = workflow
|
||
.slice(jobStart + 1)
|
||
.search(/^ {2}[a-z][a-z0-9-]+:$/m);
|
||
return workflow.slice(
|
||
jobStart,
|
||
nextJobOffset < 0 ? undefined : jobStart + 1 + nextJobOffset,
|
||
);
|
||
}
|
||
|
||
function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
|
||
const job = jobSection(jobName);
|
||
const stepStart = job.indexOf(` - name: ${stepName}`);
|
||
expect(stepStart).toBeGreaterThanOrEqual(0);
|
||
|
||
const nextStepOffset = job.slice(stepStart + 1).search(/^ {6}- name: /m);
|
||
return job.slice(
|
||
stepStart,
|
||
nextStepOffset < 0 ? undefined : stepStart + 1 + nextStepOffset,
|
||
);
|
||
}
|
||
|
||
function escapeRegExp(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||
}
|
||
|
||
function backendStepIndex(stepName: string) {
|
||
const backendJobStart = workflow.indexOf(' backend-tests:');
|
||
const nativeShellJobStart = workflow.indexOf(' native-shell-tests:');
|
||
expect(backendJobStart).toBeGreaterThanOrEqual(0);
|
||
expect(nativeShellJobStart).toBeGreaterThan(backendJobStart);
|
||
|
||
return workflow
|
||
.slice(backendJobStart, nativeShellJobStart)
|
||
.indexOf(` - name: ${stepName}`);
|
||
}
|
||
|
||
describe('project CI workflow', () => {
|
||
it('runs for master pushes, pull requests, and manual dispatch only', () => {
|
||
expect(workflow).toMatch(
|
||
/on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u,
|
||
);
|
||
expect(workflow).not.toContain('codex/ai-game-creator-app');
|
||
});
|
||
|
||
it('keeps every job on the isolated preinstalled CI image boundary', () => {
|
||
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(
|
||
jobNames.length,
|
||
);
|
||
expect(workflow).not.toContain('actions/checkout');
|
||
expect(workflow).not.toContain('actions/setup-node');
|
||
expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m);
|
||
|
||
for (const jobName of jobNames) {
|
||
const job = jobSection(jobName);
|
||
const checkout = job.indexOf('genarrative-gitea-checkout');
|
||
const validateImage = job.indexOf(
|
||
'GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh',
|
||
);
|
||
const installDependencies = job.indexOf(
|
||
'- name: Install npm dependencies',
|
||
);
|
||
|
||
expect(checkout).toBeGreaterThanOrEqual(0);
|
||
expect(validateImage).toBeGreaterThan(checkout);
|
||
if (jobsWithoutNpmInstall.includes(jobName)) {
|
||
expect(installDependencies).toBe(-1);
|
||
continue;
|
||
}
|
||
expect(installDependencies).toBeGreaterThan(validateImage);
|
||
}
|
||
|
||
expect(imageDockerfile).toMatch(
|
||
/^ARG RUST_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
|
||
);
|
||
expect(imageDockerfile).toMatch(
|
||
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
|
||
);
|
||
expect(imageDockerfile).toContain('ARG NPM_VERSION=10.9.7');
|
||
expect(imageDockerfile).toContain(
|
||
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
|
||
);
|
||
expect(imageDockerfile).toContain(
|
||
'GENARRATIVE_GITEA_CI_NPM_VERSION=${NPM_VERSION}',
|
||
);
|
||
expect(imageCheckScript).toContain(
|
||
'test "$(npm --version)" = "${GENARRATIVE_GITEA_CI_NPM_VERSION}"',
|
||
);
|
||
expect(imageCheckScript).toContain("printf 'npm_version=hit\\n'");
|
||
expect(imageCheckScript).toContain("printf 'npm_version=partial\\n'");
|
||
expect(imageCheckScript).toContain(
|
||
'::warning title=CI npm version metadata is partial::',
|
||
);
|
||
expect(imageCheckScript).toContain('continue with the root npm ci');
|
||
expect(imageCheckScript).not.toContain(
|
||
'test -n "${GENARRATIVE_GITEA_CI_NPM_VERSION:-}"',
|
||
);
|
||
});
|
||
|
||
it('retries the single root workspace clean install in every job that needs node_modules', () => {
|
||
for (const jobName of jobNames) {
|
||
if (jobsWithoutNpmInstall.includes(jobName)) {
|
||
expect(jobSection(jobName)).not.toContain('ci-npm-ci-with-retry.sh');
|
||
continue;
|
||
}
|
||
const install = stepSection(jobName, 'Install npm dependencies');
|
||
expect(install).toContain('bash scripts/ci-npm-ci-with-retry.sh');
|
||
expect(install).not.toContain('--prefix');
|
||
expect(
|
||
jobSection(jobName).match(/ci-npm-ci-with-retry\.sh/gu),
|
||
).toHaveLength(1);
|
||
}
|
||
expect(workflow).not.toContain('Install AI game creator dependencies');
|
||
|
||
expect(npmCiRetryScript).toContain(
|
||
'max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"',
|
||
);
|
||
expect(npmCiRetryScript).toContain(
|
||
'base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"',
|
||
);
|
||
expect(npmCiRetryScript).toContain(
|
||
'for attempt in $(seq 1 "${max_attempts}"); do',
|
||
);
|
||
expect(npmCiRetryScript).toContain('if npm ci "$@"; then');
|
||
expect(npmCiRetryScript).toContain(
|
||
'if [[ "${attempt}" -eq "${max_attempts}" ]]; then',
|
||
);
|
||
});
|
||
|
||
it('builds one npm workspace cache from every manifest and keeps three Cargo lock caches', () => {
|
||
const workspaceManifests = [
|
||
'apps/admin-web/package.json',
|
||
'apps/ai-game-creator-shell/package.json',
|
||
'apps/desktop-shell/package.json',
|
||
'apps/mobile-shell/package.json',
|
||
'apps/preview-deployer-web/package.json',
|
||
'packages/image-canvas-core/package.json',
|
||
'packages/image-canvas-react/package.json',
|
||
'packages/shared/package.json',
|
||
'tools/spine-json-export-validator/package.json',
|
||
];
|
||
const rustManifest = 'apps/ai-game-creator-shell/src-tauri/Cargo.toml';
|
||
const rustLock = 'apps/ai-game-creator-shell/src-tauri/Cargo.lock';
|
||
|
||
for (const workspaceManifest of workspaceManifests) {
|
||
expect(imageBuildScript.split(workspaceManifest)).toHaveLength(3);
|
||
expect(imageDockerignore).toContain(`!${workspaceManifest}`);
|
||
expect(imageDockerfile).toContain(
|
||
`COPY ${workspaceManifest} /usr/local/share/genarrative-ci/npm/${workspaceManifest}`,
|
||
);
|
||
}
|
||
|
||
for (const [path, expectedCount] of [
|
||
[rustManifest, 2],
|
||
[rustLock, 3],
|
||
] as const) {
|
||
expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1);
|
||
expect(imageDockerignore).toContain(`!${path}`);
|
||
}
|
||
|
||
expect(imageBuildScript).toContain(
|
||
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
|
||
);
|
||
expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256');
|
||
expect(imageDockerfile.match(/\bnpm ci\b/gu)).toHaveLength(1);
|
||
expect(imageDockerfile).not.toContain('AGC_NPM_LOCK_SHA256');
|
||
expect(imageDockerfile).not.toContain('/agc-npm');
|
||
expect(imageBuildScript).not.toContain(
|
||
'apps/ai-game-creator-shell/package-lock.json',
|
||
);
|
||
expect(imageDockerignore).not.toContain(
|
||
'!apps/ai-game-creator-shell/package-lock.json',
|
||
);
|
||
expect(
|
||
imageDockerfile.match(
|
||
/--manifest-path \/tmp\/genarrative-cargo-cache\/apps\/ai-game-creator-shell\/src-tauri\/Cargo\.toml/g,
|
||
),
|
||
).toHaveLength(1);
|
||
expect(imageDockerfile).toContain(
|
||
'cargo_fetch_with_retry /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||
);
|
||
expect(imageDockerfile).toContain(
|
||
'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}',
|
||
);
|
||
|
||
expect(imageCheckScript).toContain(
|
||
'npm_lock_path="${repo_root}/package-lock.json"',
|
||
);
|
||
expect(imageCheckScript).not.toContain('agc_npm_lock_path');
|
||
expect(imageCheckScript).toContain(rustLock);
|
||
expect(imageCheckScript).toContain(
|
||
'${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}',
|
||
);
|
||
expect(imageCheckScript).toContain(
|
||
'::warning title=CI dependency cache is partial::',
|
||
);
|
||
});
|
||
|
||
it('copies every workspace manifest before the API image web-builder clean install', () => {
|
||
const npmCiOffset = apiServerDockerfile.indexOf('RUN npm ci');
|
||
expect(npmCiOffset).toBeGreaterThanOrEqual(0);
|
||
expect(apiServerDockerfile.match(/\bRUN npm ci\b/gu)).toHaveLength(1);
|
||
expect(apiServerDockerfile).toContain('ARG NPM_VERSION=10.9.7');
|
||
expect(apiServerDockerfile).toContain(
|
||
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
|
||
);
|
||
const npmVersionCheckOffset = apiServerDockerfile.indexOf(
|
||
'test "$(npm --version)" = "${NPM_VERSION}"',
|
||
);
|
||
expect(npmVersionCheckOffset).toBeGreaterThanOrEqual(0);
|
||
expect(npmVersionCheckOffset).toBeLessThan(npmCiOffset);
|
||
|
||
for (const manifest of [
|
||
'package.json',
|
||
'package-lock.json',
|
||
'apps/admin-web/package.json',
|
||
'apps/ai-game-creator-shell/package.json',
|
||
'apps/desktop-shell/package.json',
|
||
'apps/mobile-shell/package.json',
|
||
'apps/preview-deployer-web/package.json',
|
||
'packages/image-canvas-core/package.json',
|
||
'packages/image-canvas-react/package.json',
|
||
'packages/shared/package.json',
|
||
'tools/spine-json-export-validator/package.json',
|
||
]) {
|
||
const copyLine = apiServerDockerfile
|
||
.split('\n')
|
||
.find(
|
||
(line) =>
|
||
line.startsWith('COPY ') && line.split(/\s+/u).includes(manifest),
|
||
);
|
||
expect(copyLine).toBeDefined();
|
||
const copyOffset = apiServerDockerfile.indexOf(copyLine ?? '');
|
||
expect(copyOffset).toBeGreaterThanOrEqual(0);
|
||
expect(copyOffset).toBeLessThan(npmCiOffset);
|
||
}
|
||
});
|
||
|
||
it('prepares locked server-rs dependencies before the first Cargo build gate', () => {
|
||
const prepareDependencies = backendStepIndex(
|
||
'Prepare server-rs Rust dependencies',
|
||
);
|
||
const checkBoundaries = backendStepIndex('Check server-rs boundaries');
|
||
const runWorkspaceTests = backendStepIndex('Run server-rs workspace tests');
|
||
|
||
expect(prepareDependencies).toBeGreaterThanOrEqual(0);
|
||
expect(checkBoundaries).toBeGreaterThan(prepareDependencies);
|
||
expect(runWorkspaceTests).toBeGreaterThan(checkBoundaries);
|
||
expect(workflow).toContain('cargo fetch --locked');
|
||
expect(workflow).toContain('for attempt in $(seq 1 5); do');
|
||
expect(workflow).toContain(
|
||
'cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml',
|
||
);
|
||
expect(workflow).toContain(
|
||
'cargo test --locked -p spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml',
|
||
);
|
||
});
|
||
|
||
it('never passes HEAD itself to the schema comparison gate', () => {
|
||
expect(
|
||
workflow.match(
|
||
/if \[\[ "\$\{resolved_base_ref\}" == "\$\{head_ref\}" \]\]; then/g,
|
||
),
|
||
).toHaveLength(2);
|
||
expect(workflow.match(/git rev-parse --verify HEAD\^/g)).toHaveLength(2);
|
||
expect(
|
||
workflow.match(
|
||
/comparison base must resolve to a commit distinct from HEAD\./g,
|
||
),
|
||
).toHaveLength(2);
|
||
});
|
||
|
||
it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => {
|
||
const frontendJob = jobSection('frontend-tests');
|
||
expect(frontendJob).toContain('run: npm run test');
|
||
expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test');
|
||
expect(frontendJob).toContain(
|
||
'run: npm run check:production-health-patrol',
|
||
);
|
||
expect(frontendJob).toContain('run: npm run check:production-api-release');
|
||
expect(frontendJob).toContain('run: npm run check:production-api-deploy');
|
||
|
||
const nativeJob = jobSection('native-shell-tests');
|
||
expect(nativeJob).toContain('run: npm run check:native-shells:contract');
|
||
expect(nativeJob).toContain('run: npm run check:native-shells:shells');
|
||
expect(nativeJob).toContain('run: npm run check:native-shells:release');
|
||
expect(nativeJob).toContain(
|
||
'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock',
|
||
);
|
||
expect(nativeJob).toContain('apps/desktop-shell/src-tauri/Cargo.toml');
|
||
expect(nativeJob).toContain(
|
||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||
);
|
||
expect(nativeJob).toContain('cargo fetch --locked');
|
||
});
|
||
|
||
it('runs every native shell gate group exactly once across the split jobs', () => {
|
||
for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) {
|
||
expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe(
|
||
`node scripts/check-native-shells.mjs --groups=${group}`,
|
||
);
|
||
expect(
|
||
workflow.match(new RegExp(`^ {8}run: ${escapeRegExp(script)}$`, 'mu')),
|
||
).toHaveLength(1);
|
||
}
|
||
|
||
const declaredGroups = [
|
||
...nativeShellGateScript
|
||
.slice(
|
||
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
|
||
nativeShellGateScript.indexOf(
|
||
'];',
|
||
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
|
||
),
|
||
)
|
||
.matchAll(/'([a-z0-9-]+)'/gu),
|
||
].map((match) => match[1]);
|
||
expect(declaredGroups).toEqual(Object.keys(nativeShellGateGroupScripts));
|
||
|
||
// 全量分组脚本只允许本地使用:CI 必须走拆分后的分组脚本,避免整套门禁再被
|
||
// 串行跑一遍。
|
||
expect(workflow).not.toMatch(/^ {8}run: npm run check:native-shells$/mu);
|
||
});
|
||
|
||
it('splits the AI game creator shell gates into web, shell shard, and shared crate jobs', () => {
|
||
const webJob = jobSection('ai-game-creator-shell-web-tests');
|
||
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
|
||
expect(webJob).not.toContain('cargo fetch');
|
||
expect(nativeShellGateScript).toMatch(
|
||
/group: 'agc-web',[\s\S]*?args: \['run', 'agc:plugins:test'\]/u,
|
||
);
|
||
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
|
||
'plugins/agc-unity-editor/src/entry.test.mjs',
|
||
);
|
||
|
||
// 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳
|
||
// 自己那份锁定依赖(server-rs 那份归 crate 级 job)。
|
||
for (const [index, jobName] of [
|
||
[1, 'ai-game-creator-shell-rust-shard-1'],
|
||
[2, 'ai-game-creator-shell-rust-shard-2'],
|
||
[3, 'ai-game-creator-shell-rust-shard-3'],
|
||
[4, 'ai-game-creator-shell-rust-shard-4'],
|
||
] as const) {
|
||
const shardJob = jobSection(jobName);
|
||
expect(shardJob).toContain(
|
||
`run: npm run check:native-shells:agc-rust-shard-${index}`,
|
||
);
|
||
expect(shardJob).toContain(
|
||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||
);
|
||
expect(shardJob).toContain('cargo fetch --locked');
|
||
expect(shardJob).not.toContain('server-rs/Cargo.toml');
|
||
}
|
||
|
||
// 整套用例不能再作为一条命令串行跑完:每个片 job 都必须落到分片运行器的
|
||
// `--shard-index` 上,4 个 index 各一次。
|
||
for (const index of [1, 2, 3, 4]) {
|
||
expect(nativeShellGateScript).toContain(`'--shard-index=${index}'`);
|
||
}
|
||
expect(
|
||
nativeShellGateScript.match(/ai-game-creator-shell:check:rust:shell/gu),
|
||
).toHaveLength(4);
|
||
|
||
const smokeJob = jobSection('ai-game-creator-shell-rust-smoke');
|
||
expect(smokeJob).toContain(
|
||
'run: npm run check:native-shells:agc-rust-smoke',
|
||
);
|
||
expect(smokeJob).toContain('cargo fetch --locked');
|
||
expect(smokeJob).not.toContain('server-rs/Cargo.toml');
|
||
|
||
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
|
||
expect(cratesJob).toContain(
|
||
'run: npm run check:native-shells:agc-rust-crates',
|
||
);
|
||
expect(cratesJob).toContain('server-rs/Cargo.toml');
|
||
expect(cratesJob).toContain('cargo fetch --locked');
|
||
expect(nativeShellGateScript).toMatch(
|
||
/group: 'agc-rust-crates',[\s\S]*?args: \['run', 'agc:plugins:native-test'\]/u,
|
||
);
|
||
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
|
||
'cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||
);
|
||
|
||
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列,
|
||
// rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。
|
||
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check']).toBe(
|
||
'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke',
|
||
);
|
||
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe(
|
||
'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests',
|
||
);
|
||
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe(
|
||
'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell',
|
||
);
|
||
expect(
|
||
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:crates'],
|
||
).toContain(
|
||
'cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app',
|
||
);
|
||
expect(
|
||
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:shell'],
|
||
).toBe(
|
||
'node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4',
|
||
);
|
||
// 整套用例不再作为一条 `cargo test -- --test-threads=1` 命令串行跑:CI 与本地都走
|
||
// 分片运行器,片内串行,片与片之间靠 job 级并发摊开。
|
||
expect(workflow).not.toContain('-- --test-threads=1');
|
||
expect(workflow).not.toMatch(/^ {8}run: .*--test-threads/mu);
|
||
});
|
||
|
||
it('keeps the shell shard runner exhaustive over discovered tests', () => {
|
||
const shardRunner = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs',
|
||
),
|
||
'utf8',
|
||
);
|
||
// 分片必须对 `--list` 的全集做覆盖与互斥校验,否则改分片规则会静默漏跑门禁。
|
||
expect(shardRunner).toContain('function assertShardsCoverEveryTest(');
|
||
expect(shardRunner).toContain('must be exhaustive');
|
||
expect(shardRunner).toContain('must be disjoint');
|
||
expect(shardRunner).toContain("'--test-threads=1'");
|
||
expect(shardRunner).toContain('TMPDIR: shardTmpDirectory');
|
||
expect(shardRunner).toContain("'--exact'");
|
||
// `--shard-index` 是 CI 的「一个 job 一片」入口:只跑指定片,但覆盖自校验照旧
|
||
// 针对 `--list` 全集执行,所以改分片规则仍然会在每个 job 上暴露。
|
||
expect(shardRunner).toContain("case 'shard-index':");
|
||
expect(shardRunner).toContain('must be within --shards');
|
||
});
|
||
|
||
it('prefetches the excluded standalone Rust crates before the shared crate gates', () => {
|
||
const standaloneStep = stepSection(
|
||
'ai-game-creator-shell-rust-crates',
|
||
'Prepare standalone Rust crate dependencies',
|
||
);
|
||
for (const manifest of [
|
||
'server-rs/crates/agent-runtime-core/Cargo.toml',
|
||
'server-rs/crates/agent-runtime-orchestration/Cargo.toml',
|
||
'plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml',
|
||
]) {
|
||
expect(standaloneStep).toContain(manifest);
|
||
}
|
||
// 这些 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch:
|
||
// 带 --locked 会因为缺少锁文件直接失败。
|
||
expect(standaloneStep).toContain('cargo fetch \\');
|
||
expect(standaloneStep).not.toContain('cargo fetch --locked');
|
||
const unityStep = stepSection(
|
||
'ai-game-creator-shell-rust-crates',
|
||
'Prepare Unity plugin Rust dependencies',
|
||
);
|
||
expect(unityStep).toContain('cargo fetch --locked');
|
||
expect(unityStep).toContain(
|
||
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||
);
|
||
|
||
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
|
||
expect(
|
||
cratesJob.indexOf('Prepare standalone Rust crate dependencies'),
|
||
).toBeLessThan(
|
||
cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'),
|
||
);
|
||
});
|
||
});
|