import { afterEach, beforeEach, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { gzipSync } from 'node:zlib'; import { assertGzipValid, bakeManagement, packageNetapp, reportBakeChildFailure, stageNetappsFromRegistry, verifyNetapp, verifyStagedNetapps, } from './build'; let dir: string; const realFetch = globalThis.fetch; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'netapps-')); process.env.CELILO_REGISTRY_URL = 'https://example.test/registry/'; }); afterEach(() => { globalThis.fetch = realFetch; rmSync(dir, { recursive: true, force: true }); delete process.env.CELILO_REGISTRY_URL; }); test('fetches each module latest version to .netapp via the download endpoint', async () => { const seen: string[] = []; // Valid gzip payload — the staging step verifies every downloaded .netapp. const netappBytes = gzipSync(Buffer.from('tar-payload')); // @ts-expect-error — minimal fetch stub for the two shapes we call. globalThis.fetch = async (url: string) => { seen.push(url); if (url.includes('/api/v1/modules?')) { return new Response( JSON.stringify({ modules: [ { name: 'caddy', max_version: '1.2.3' }, { name: 'namecheap', max_version: '0.4.0' }, ], }), ); } return new Response(new Uint8Array(netappBytes)); }; await stageNetappsFromRegistry(dir); expect(readdirSync(dir).sort()).toEqual(['caddy.netapp', 'namecheap.netapp']); expect(existsSync(join(dir, 'caddy.netapp'))).toBe(true); expect([...readFileSync(join(dir, 'caddy.netapp'))]).toEqual([...netappBytes]); // Trailing slash stripped; download hits the module's max_version. expect(seen).toContain('https://example.test/registry/api/v1/modules/caddy/1.2.3/download'); expect(seen).toContain('https://example.test/registry/api/v1/modules/namecheap/0.4.0/download'); }); test('refuses a downloaded .netapp that is not a complete gzip stream (celilo#1257)', async () => { const realExit = process.exit; const exitCalls: unknown[] = []; // Stub exit so the test survives the loud failure the corrupt download gets. (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => { exitCalls.push(code); throw new Error(`process.exit(${code})`); }) as typeof process.exit; // @ts-expect-error — minimal fetch stub for the two shapes we call. globalThis.fetch = async (url: string) => { if (url.includes('/api/v1/modules?')) { return new Response( JSON.stringify({ modules: [{ name: 'technitium', max_version: '9.9.9' }] }), ); } // A payload truncated mid-stream, the shape a wedged packaging write leaves. return new Response(new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x99])); }; try { await expect(stageNetappsFromRegistry(dir)).rejects.toThrow('process.exit(1)'); } finally { process.exit = realExit; } expect(exitCalls).toEqual([1]); }); test('verifyStagedNetapps accepts a valid .netapp and refuses a truncated one', () => { const valid = gzipSync(Buffer.from('x'.repeat(2048))); writeFileSync(join(dir, 'good.netapp'), valid); verifyStagedNetapps(dir); verifyNetapp(join(dir, 'good.netapp')); assertGzipValid(join(dir, 'good.netapp')); // Plant a truncated .netapp: valid gzip bytes cut at 60 percent, missing the // trailer. The recurrence gate for celilo#1257. writeFileSync(join(dir, 'technitium.netapp'), valid.subarray(0, Math.floor(valid.length * 0.6))); expect(() => verifyStagedNetapps(dir)).toThrow(/technitium\.netapp.*gzip/s); expect(() => verifyNetapp(join(dir, 'technitium.netapp'))).toThrow(/technitium\.netapp/s); expect(() => assertGzipValid(join(dir, 'technitium.netapp'))).toThrow(/unexpected end of file/s); }); /** * Stub process.exit and console.error around a call expected to terminate. * Returns the exit codes collected and everything written to stderr. */ function captureTerminalExit(run: () => void): { exitCodes: unknown[]; stderr: string } { const realExit = process.exit; const realError = console.error; const exitCodes: unknown[] = []; const stderrLines: string[] = []; (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => { exitCodes.push(code); throw new Error(`process.exit(${code})`); }) as typeof process.exit; console.error = (...args: unknown[]) => { stderrLines.push(args.map(String).join(' ')); }; try { try { run(); } catch (err) { // The stubbed exit throws to unwind; anything else is a real failure. if (!String(err).startsWith('Error: process.exit(')) throw err; } } finally { process.exit = realExit; console.error = realError; } return { exitCodes, stderr: stderrLines.join('\n') }; } // celilo#1302: a lock-free live stack on the builder made the bake child's // startup cleanup refuse (exit 3), and the parent reported "Bake step failed" // with three install.sh causes for a step that never executed. /** * Stub console.log/console.error around `run`, returning what each captured. */ function captureConsole(run: () => void): { stdout: string; stderr: string } { const realLog = console.log; const realError = console.error; const out: string[] = []; const err: string[] = []; console.log = (...args: unknown[]) => { out.push(args.map(String).join(' ')); }; console.error = (...args: unknown[]) => { err.push(args.map(String).join(' ')); }; try { run(); } finally { console.log = realLog; console.error = realError; } return { stdout: out.join('\n'), stderr: err.join('\n') }; } // celilo#1258: build-infra is mandatory after cele2e down, and it used to // repackage all 37 modules from scratch every run (about 5 minutes to reach a // 38 second test) because nothing reused a current .netapp. test('a second build-infra with no source change skips repackaging', () => { const moduleDir = mkdtempSync(join(tmpdir(), 'module-fixture-')); const netappsDir = mkdtempSync(join(tmpdir(), 'netapps-fixture-')); writeFileSync(join(moduleDir, 'main.sh'), 'echo hello'); // First run's output: staged after the source, so it is current. const staged = join(netappsDir, `${basename(moduleDir)}.netapp`); writeFileSync(staged, 'netapp-bytes'); const { stdout, stderr } = captureConsole(() => packageNetapp(moduleDir, netappsDir)); expect(stdout).toContain('current, skipped'); // The skip returns before any CLI lookup: no packaging attempt happened. expect(stderr).not.toContain('celilo CLI not found'); // Recurrence gate: touching a shipped source file flips the answer, and the // same call proceeds toward packaging. In a tmpdir there is no monorepo CLI, // so getting past the skip surfaces as the CLI-not-found report; the point // is that the skip did not fire. const later = new Date(Date.now() + 60_000); const touched = join(moduleDir, 'main.sh'); utimesSync(touched, later, later); const { stdout: stdoutAfterTouch, stderr: stderrAfterTouch } = captureConsole(() => packageNetapp(moduleDir, netappsDir), ); expect(stderrAfterTouch).toContain('celilo CLI not found'); expect(stdoutAfterTouch).not.toContain('current, skipped'); rmSync(moduleDir, { recursive: true, force: true }); rmSync(netappsDir, { recursive: true, force: true }); }); test('a refusal exit (3) from the bake child reports the bake did not run, not a bake failure', () => { const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(3, 0)); expect(exitCodes).toEqual([3]); expect(stderr).toContain('did not run'); expect(stderr).toContain('cele2e down'); // The wrong hint must not appear: these causes are all false when the bake // never started. expect(stderr).not.toContain('Likely causes'); expect(stderr).not.toContain('install.sh regressed'); }); test('a genuine bake failure (exit 1) keeps the bake-failure report and its likely causes', () => { const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(1, 12)); expect(exitCodes).toEqual([1]); expect(stderr).toContain('Bake step failed after 12s'); expect(stderr).toContain('Likely causes'); expect(stderr).not.toContain('did not run'); }); test('bakeManagement classifies a real child exit 3 end to end and exits 3', () => { // A fake bake script that behaves like the real one does when the startup // cleanup refuses: print the refusal to stderr, exit 3. const pkgDir = mkdtempSync(join(tmpdir(), 'bake-child-')); mkdirSync(join(pkgDir, 'bin'), { recursive: true }); writeFileSync( join(pkgDir, 'bin', 'e2e-bake-management'), 'console.error("refusing to clean up"); process.exit(3);\n', ); try { const { exitCodes, stderr } = captureTerminalExit(() => bakeManagement(pkgDir, false)); expect(exitCodes).toEqual([3]); expect(stderr).toContain('did not run'); expect(stderr).not.toContain('Likely causes'); } finally { rmSync(pkgDir, { recursive: true, force: true }); } });