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 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('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, ); }); it('retries every root and AI game creator npm clean install 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'); } for (const jobName of ['frontend-tests', 'native-shell-tests'] as const) { const install = stepSection( jobName, 'Install AI game creator dependencies', ); expect(install).toContain( 'bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell', ); } 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 and verifies image caches against both AI game creator locks', () => { const npmManifest = 'apps/ai-game-creator-shell/package.json'; const npmLock = 'apps/ai-game-creator-shell/package-lock.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 [path, expectedCount] of [ [npmManifest, 2], [npmLock, 3], [rustManifest, 2], [rustLock, 3], ] as const) { expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1); expect(imageDockerignore).toContain(`!${path}`); } expect(imageBuildScript).toContain( '--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}"', ); expect(imageBuildScript).toContain( '--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"', ); expect(imageDockerfile).toContain('ARG AGC_NPM_LOCK_SHA256'); expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256'); expect(imageDockerfile).toContain( '--prefix /usr/local/share/genarrative-ci/agc-npm', ); 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_NPM_LOCK_SHA256=${AGC_NPM_LOCK_SHA256}', ); expect(imageDockerfile).toContain( 'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}', ); expect(imageCheckScript).toContain(npmLock); expect(imageCheckScript).toContain(rustLock); expect(imageCheckScript).toContain( '${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}', ); expect(imageCheckScript).toContain( '${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}', ); expect(imageCheckScript).toContain( '::warning title=CI dependency cache is partial::', ); }); 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'); }); });