import { AppRuntime } from '../common/enums'; /** * Tests for build service: * • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes * • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic * * NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic * locally instead of importing BuildService — the service pulls in the ESM * `@kubernetes/client-node`, which this project's Jest config does not transform. * Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts. */ /** * Tests for the WordPress build flow — specifically: * 1. Helper pod PVC race condition (must wait for termination) * 2. WordPress Dockerfile generation correctness * 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution */ describe('WordPress Dockerfile generation', () => { // Reproduce the wordpressDockerfile logic from build.service.ts function wordpressDockerfile(app: { runtimeVersion?: string; phpVersion?: string; codePath?: string; port?: number; }): string { const wpVersion = app.runtimeVersion || '6.7'; const phpVersion = app.phpVersion || '8.3'; const hasUploadedCode = !!app.codePath; return `FROM wordpress:${wpVersion}-php${phpVersion}-apache RUN docker-php-ext-install opcache RUN a2enmod rewrite RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini ${hasUploadedCode ? `COPY . /tmp/user-content RUN mkdir -p /usr/src/wordpress-user ENTRYPOINT ["cloudhost-entrypoint.sh"] CMD []` : `CMD ["apache2-foreground"]`} EXPOSE 80 `; } it('should use ENTRYPOINT (not CMD) when user uploaded code', () => { const df = wordpressDockerfile({ codePath: '/some/path/source.zip' }); expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]'); expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]'); }); it('should use CMD apache2-foreground for fresh install (no code)', () => { const df = wordpressDockerfile({}); expect(df).toContain('CMD ["apache2-foreground"]'); expect(df).not.toContain('ENTRYPOINT'); }); it('should use correct WordPress and PHP versions', () => { const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' }); expect(df).toContain('FROM wordpress:6.4-php8.2-apache'); }); it('should default to WP 6.7 and PHP 8.3', () => { const df = wordpressDockerfile({}); expect(df).toContain('FROM wordpress:6.7-php8.3-apache'); }); it('should COPY user content when codePath exists', () => { const df = wordpressDockerfile({ codePath: '/tmp/source.zip' }); expect(df).toContain('COPY . /tmp/user-content'); }); it('should NOT copy user content for fresh install', () => { const df = wordpressDockerfile({}); expect(df).not.toContain('COPY . /tmp/user-content'); }); }); describe('Helper pod PVC race condition', () => { it('should wait for pod deletion (not just fire-and-forget)', () => { // Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404 const deletionSteps = [ { exists: true }, // pod still terminating { exists: true }, // still terminating { exists: false }, // gone (404) ]; let pollCount = 0; let fullyTerminated = false; for (const step of deletionSteps) { pollCount++; if (!step.exists) { fullyTerminated = true; break; } } expect(fullyTerminated).toBe(true); expect(pollCount).toBe(3); }); it('should time out if pod never terminates', () => { const maxPolls = 30; // e.g. 60s / 2s interval let pollCount = 0; let timedOut = false; while (pollCount < maxPolls) { pollCount++; // Pod always exists (simulating stuck termination) const exists = true; if (!exists) break; } if (pollCount >= maxPolls) { timedOut = true; } expect(timedOut).toBe(true); }); }); describe('WordPress entrypoint script', () => { const entrypointScript = `#!/bin/bash set -e # Merge user wp-content into PVC if [ -d /usr/src/wordpress-user/wp-content ]; then mkdir -p /var/www/html/wp-content cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/ chown -R www-data:www-data /var/www/html/wp-content fi exec docker-entrypoint.sh apache2-foreground`; it('should call docker-entrypoint.sh exactly once (via exec)', () => { const matches = entrypointScript.match(/docker-entrypoint\.sh/g); expect(matches).toHaveLength(1); }); it('should use exec to replace process', () => { expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground'); }); it('should merge wp-content on every start when staged content exists', () => { expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content'); expect(entrypointScript).not.toContain('.user-content-merged'); }); it('should not copy user wp-config.php (credentials come from env vars)', () => { expect(entrypointScript).not.toContain('wp-config.php'); }); it('should set proper ownership after merging wp-content', () => { expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content'); }); }); describe('WordPress zip structure handling', () => { // The unzip init container handles single-subfolder flattening it('should flatten single subfolder (public_html/) to root', () => { // Simulate: zip contains only public_html/ const extractedItems = ['public_html']; const count = extractedItems.length; const firstItem = extractedItems[0]; let flattenedToRoot = false; if (count === 1 && firstItem === 'public_html') { // cp -a /tmp/extract/public_html/. /workspace-out/source/ flattenedToRoot = true; } expect(flattenedToRoot).toBe(true); }); it('should copy as-is when multiple items exist', () => { // Simulate: zip contains multiple items at root const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php']; const count = extractedItems.length; let copiedAsIs = false; if (count !== 1) { copiedAsIs = true; } expect(copiedAsIs).toBe(true); }); }); describe('Nixpacks build preparation', () => { // Local copies of the pure logic in build.service.ts (see NOTE at top of file). function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] { const env: { name: string; value: string }[] = []; if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) { env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) }); } if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) { env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) }); } return env; } function nixpacksPrepareInitContainer( app: { runtime: AppRuntime; runtimeVersion?: string }, config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {}, ): any { const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest'; const buildEnv = config.nixpacksBuildEnv || []; const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' '); const planEnv = nixpacksPlanEnv(app); return { name: 'nixpacks-prepare', image, env: planEnv.length ? planEnv : undefined, command: [ 'sh', '-c', `if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` + `else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`, ], volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], }; } it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => { const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string; expect(script).toContain('if [ -f source/Dockerfile ]'); expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile'); expect(script).toContain('nixpacks build source --out source'); expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile'); }); it('uses the configured Nixpacks image (default when unset)', () => { expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest'); expect( nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image, ).toBe('registry.local/nixpacks:1.2.3'); }); it('bakes build-time mirror env into the build via --env flags', () => { const script = nixpacksPrepareInitContainer( { runtime: AppRuntime.NODEJS }, { nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] }, ).command[2] as string; expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`); }); it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => { const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' }); expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' }); }); it('shellQuote escapes embedded single quotes safely', () => { expect(shellQuote("a'b")).toBe("'a'\\''b'"); }); });