/** * Recurrence gate for celilo#699 — ordinary stdout carries no decoration, and * diagnostics do not share the stream with results. * * The CLI used to render every successful message through `@clack/prompts`, * which prefixed each line with `│ ` and coloured it. Two costs followed, and * this file pins both shut: * * 1. `line.startsWith(' ')` over `celilo module list` matched * nothing, because the line actually began `│ caddy (v2.2.0) - …`. In * celilo#695 that read as a MISSING MODULE rather than as a parse failure, * and cost a full e2e run to find. `e2e/tests/module-pause.test.ts` asserts * the same property against the real fleet topology; this is its fast * equivalent, so a regression is caught in seconds rather than in Docker. * * 2. clack wrote errors to stdout, so no caller could tell a result from a * complaint about producing one — `src/test-utils/cli.ts` carried a comment * saying exactly that, twice, and merged both streams to cope. * * Spawns the real CLI rather than using `CLIContext`: that harness runs the CLI * in `CLI_SERVER_MODE`, which returns `result.message` over a protocol and * never reaches the stdout writer under test here. */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { type IntegrationTestContext, setupIntegrationTest } from '@/test-utils/integration'; let ctx: IntegrationTestContext; function celilo(command: string): { stdout: string; stderr: string; status: number | null } { const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], { encoding: 'utf8', env: { ...process.env, CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, CELILO_SUPPRESS_DEPRECATION: '1', }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000, }); return { stdout: result.stdout, stderr: result.stderr, status: result.status }; } /** The `│` clack used to open every rendered line with. */ const BOX_DRAWING = /[│┌└├─]/; const ANSI = /\x1b\[[0-9;]*m/; describe('celilo#699 — stdout is undecorated', () => { beforeAll(async () => { ctx = await setupIntegrationTest(); // A module has to exist for `module list` to print a roster line at all — // an empty roster would pass every assertion below without testing them. const imported = celilo('module import ../../modules/celilo-mgmt'); expect(imported.status, `module import failed:\n${imported.stderr}`).toBe(0); // The spawn above is budgeted 60s, but bun's DEFAULT hook timeout is 5s, so // the hook killed the import at 5001ms and the failure surfaced as // `status: null` with an empty stderr — which reads as "module import is // broken" rather than "this hook is not allowed to take as long as the work // inside it". `module import` runs ansible-galaxy, measured at ~7s here; CI // is under 5s, so the suite is green there and red on a slower machine. }, 90_000); afterAll(async () => { await ctx.cleanup(); }); test('module list lines start with the module id, with no preprocessing', () => { const { stdout, status, stderr } = celilo('module list'); expect(status, `module list failed:\n${stderr}`).toBe(0); // The exact shape celilo#695 tried and failed to match. No ANSI stripping, // no prefix trimming — if this needs either, the bug is back. const line = stdout.split('\n').find((l) => l.startsWith('celilo-mgmt ')); expect( line, `No line began with "celilo-mgmt ". stdout was:\n${JSON.stringify(stdout)}`, ).toBeDefined(); }); test('module list stdout carries no box-drawing or ANSI', () => { const { stdout } = celilo('module list'); expect(BOX_DRAWING.test(stdout), `box-drawing in stdout:\n${JSON.stringify(stdout)}`).toBe( false, ); expect(ANSI.test(stdout), `ANSI in stdout:\n${JSON.stringify(stdout)}`).toBe(false); }); test('a failing command writes its diagnostic to stderr, not stdout', () => { const { stdout, stderr, status } = celilo('module where no-such-module-exists'); expect(status).not.toBe(0); expect(stderr).toContain('Error'); // The whole point: stdout stays empty so a caller parsing it is not handed // an error message where a result belongs. expect(stdout.trim(), `error text leaked to stdout: ${JSON.stringify(stdout)}`).toBe(''); }); });