Files
Genarrative/scripts/project-ci-workflow.test.ts
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

555 lines
22 KiB
TypeScript
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 { 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'),
);
});
});