Files
Genarrative/scripts/project-ci-workflow.test.ts
T
kdletters 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
AGC 壳 Rust 套件分片并行,客户端 Rust 关键路径压到 7 分钟以内
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>
2026-09-14 17:02:34 +08:00

493 lines
19 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-tests',
'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-tests',
'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-shell': 'npm run check:native-shells:agc-rust-shell',
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-z-]+)'/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');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(rustJob).toContain(
'run: npm run check:native-shells:agc-rust-shell',
);
expect(rustJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(rustJob).toContain('cargo fetch --locked');
// 壳 job 只预热 AGC 壳自己那份锁定依赖:server-rs 那份归 crate 级 job。
expect(rustJob).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');
// 拆开的 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 与本地都走
// 分片运行器,片内串行、片间并行。
expect(workflow).not.toContain('-- --test-threads=1');
});
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'");
});
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',
]) {
expect(standaloneStep).toContain(manifest);
}
// 这两个 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch
// 带 --locked 会因为缺少锁文件直接失败。
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
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'),
);
});
});