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'); }); });