/** * Recurrence gate for celilo#698 — a command that emits JSON must emit JSON * that parses, with no preprocessing. * * `cli/index.ts` already implements the rule: a `CommandResult` carrying * `rawOutput: true` is written straight to stdout, while everything else goes * through the decorating renderer that prefixes each line with `│ ` and wraps * it in ANSI colour. A JSON payload that forgets the flag therefore reaches * stdout as something no `JSON.parse` will accept — and the workaround * (`| sed 's/\x1b\[[0-9;]*m//g'`) got written into CLAUDE.md instead of the fix. * * Two gates here, deliberately different in kind: * * 1. `emits parseable JSON` — spawns the REAL CLI and parses its raw stdout. * This is the honest end-to-end check, and it is the one that fails today * without the fix. It cannot use `CLIContext`: that harness drives the CLI * in `CLI_SERVER_MODE`, which returns `result.message` over a protocol and * never exercises the stdout renderer where the bug lives. * * 2. `every JSON CommandResult sets rawOutput` — a static scan, so a NEW * command that forgets the flag fails even though nobody added it to the * table above. * * Lives under `src/` rather than `test-integration/` for one blunt reason: * `test-integration/` is run by no CI workflow (the `validate` job runs * `test:unit`, which is `bun test src/`), so a gate placed there would never * have failed anything. See celilo#703. */ import { Database } from 'bun:sqlite'; import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { type IntegrationTestContext, setupIntegrationTest } from '@/test-utils/integration'; const COMMANDS_DIR = join(import.meta.dir, 'commands'); /** * Commands whose stdout is a JSON document. Each is spawned as a real process * and its stdout handed to `JSON.parse` verbatim. * * `events` verbs share one `jsonResult()` helper, so a few representatives * cover all 16 of its call sites; the rest are the distinct `--json` surfaces. */ const JSON_COMMANDS = [ 'events status', 'events tail --limit 5', 'events list-subscribers', 'module list --json', 'module health --json', 'system audit --json', 'system update --dry-run --json', 'alerts list --json', 'commands --json', ] as const; describe('celilo#698 — JSON commands emit parseable JSON', () => { let ctx: IntegrationTestContext; beforeAll(async () => { ctx = await setupIntegrationTest(); }); afterAll(async () => { await ctx.cleanup(); }); for (const command of JSON_COMMANDS) { test(`celilo ${command} emits parseable JSON`, () => { 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, }); expect( result.status, `celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`, ).toBe(0); // Verbatim: no ANSI stripping, no prefix removal, no line filtering. // If this throws, the payload went through the decorating renderer. expect(() => JSON.parse(result.stdout)).not.toThrow(); }); } }); /** * celilo#1362 — a --json command must stay parseable while its health checks * run. The celilo#698 gates above all fire against an EMPTY fleet, where no * health check ever executes and the pollution path never wakes up: the * gauge silently animated frames onto stdout (plus plain `[module:hook]` * logger lines) for every check, and `system audit --json` redirected to a * file carried ~1 MB of `Testing app` frames before the first `{`. * * This gate seeds a module with a logging, sleeping health_check hook * (`test-fixtures/modules/gauge-pollution-test`) so the checks genuinely * run, then parses each fleet-wide --json command's stdout verbatim. */ describe('celilo#1362 — --json output stays parseable while health checks run', () => { let ctx: IntegrationTestContext; const FIXTURE_PATH = join(import.meta.dir, '../../test-fixtures/modules/gauge-pollution-test'); const MODULE_ID = 'gauge-pollution-test'; const SPAWN_ENV = () => ({ ...process.env, CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, CELILO_SUPPRESS_DEPRECATION: '1', }); beforeAll(async () => { ctx = await setupIntegrationTest(); const imported = spawnSync( 'bun', ['run', 'src/cli/index.ts', 'module', 'import', FIXTURE_PATH], { encoding: 'utf8', env: SPAWN_ENV(), stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 }, ); expect( imported.status, `module import exited ${imported.status}\nstdout: ${imported.stdout}\nstderr: ${imported.stderr}`, ).toBe(0); // runAllHealthChecks only checks INSTALLED/VERIFIED modules. Seeded // the same way capability-abi-mismatch.test.ts seeds providers: the // lifecycle under test is output purity, not the deploy flow. const db = new Database(ctx.dbPath); try { db.run(`UPDATE modules SET state = 'VERIFIED' WHERE id = '${MODULE_ID}'`); } finally { db.close(); } }); afterAll(async () => { await ctx.cleanup(); }); for (const command of [ 'system audit --json', 'system update --dry-run --json', 'module health --json', ]) { test(`celilo ${command} emits parseable JSON with a health-checked module deployed`, () => { const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], { encoding: 'utf8', env: SPAWN_ENV(), stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, }); expect( result.status, `celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`, ).toBe(0); // Verbatim JSON.parse — the gauge frames and plain logger lines both // land here pre-fix, and either one alone breaks the parse. expect(() => JSON.parse(result.stdout)).not.toThrow(); // And the fixture's own log lines must not ride along on stdout. expect(result.stdout).not.toContain('gauge-pollution step'); }); } }); /** * Walk from the index of a `{` to the index just past its matching `}`, * skipping over string literals so a brace inside a string doesn't unbalance * the count. */ function objectEnd(source: string, open: number): number { let depth = 0; for (let i = open; i < source.length; i++) { const char = source[i]; if (char === '{') { depth++; } else if (char === '}') { depth--; if (depth === 0) return i + 1; } else if (char === '"' || char === "'" || char === '`') { const quote = char; i++; while (i < source.length && source[i] !== quote) { if (source[i] === '\\') i++; i++; } } } return -1; } /** Index of the `{` opening the object literal that encloses `index`. */ function enclosingObjectStart(source: string, index: number): number { let depth = 0; for (let i = index; i >= 0; i--) { if (source[i] === '}') { depth++; } else if (source[i] === '{') { if (depth === 0) return i; depth--; } } return -1; } describe('celilo#698 recurrence gate — every JSON CommandResult sets rawOutput', () => { const files = readdirSync(COMMANDS_DIR).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts'), ); test('command files exist to scan', () => { expect(files.length).toBeGreaterThan(0); }); for (const file of files) { test(`${file} sets rawOutput on every JSON message`, () => { const source = readFileSync(join(COMMANDS_DIR, file), 'utf8'); const pattern = /\bmessage:\s*JSON\.stringify\b/g; let match: RegExpExecArray | null = pattern.exec(source); while (match !== null) { const open = enclosingObjectStart(source, match.index); const literal = open < 0 ? '' : source.slice(open, objectEnd(source, open)); const line = source.slice(0, match.index).split('\n').length; expect( /\brawOutput\b/.test(literal), `${file}:${line} returns a JSON message without rawOutput: true. Without the flag the payload goes through the decorating renderer and no longer parses. Add \`rawOutput: true\` to the same result object.`, ).toBe(true); match = pattern.exec(source); } }); } });