Files
Genarrative/scripts/project-ci-workflow.test.ts
T
suzmii a6f5ab9d23 修复 Native shell 作业在测试阶段才解析 crates.io index 的缺口
- .gitea/workflows/project-ci.yml:Native shell tests 新增「Prepare standalone Rust crate dependencies」步骤,
  对被 server-rs workspace exclude 的 agent-runtime-core / agent-runtime-orchestration 先做 cargo fetch,
  把它们在测试阶段的 registry 解析与下载提前到依赖准备阶段;两 crate 未提交 Cargo.lock,故不能带 --locked
- scripts/project-ci-workflow.test.ts:新增用例钉住该步骤存在、覆盖两个独立 crate manifest、且命令不带锁标志

根因与影响见 #327:此前这两个 crate 只在 agent-runtime-*:check 中现场 `Updating crates.io index`,
crates.io 一抖动整条 native shell 作业就红(PR #316 run 1950)。
2026-09-11 16:18:43 +08:00

347 lines
13 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',
] 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 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(4);
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);
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 as a bounded whole command', () => {
for (const jobName of jobNames) {
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');
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('server-rs/Cargo.toml');
expect(nativeJob).toContain('cargo fetch --locked');
});
it('prefetches the excluded standalone Rust crates before the native shell gates', () => {
const standaloneStep = stepSection(
'native-shell-tests',
'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 nativeJob = jobSection('native-shell-tests');
expect(
nativeJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(nativeJob.indexOf('run: npm run check:native-shells'));
});
});