/** * cele2e lifecycle events emitted onto the SQLite event bus. * * Motivation: long cele2e runs produce 50+ MB of streaming ANSI output. * Tooling (CI, Claude tool wrappers, dashboards) that needs to know * "did the run pass?" shouldn't have to parse that stream. Instead, * cele2e emits a small structured event at run start, per-test * completion, and at run end. Subscribers wait on the bus for the * payload they care about. * * The events are scoped by `runId` (a fresh uuid per run) so a * subscriber can target one run unambiguously: * * bus.watch(`e2e.run.completed.${runId}`, ...) * * Bus connection is best-effort: if no `EVENT_BUS_DB` is set * (the bus DB path) emits become no-ops. cele2e still runs end-to-end * without the bus, just without observability hooks. */ import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { dirname } from 'node:path'; import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import { z } from 'zod'; const NO_SCHEMAS = defineEvents({}); export interface RunStartedPayload { runId: string; scenario: 'single-test' | 'multi-test'; testNames: string[]; moduleDirs: string[]; startedAt: number; } export interface RunCompletedPayload { runId: string; total: number; passed: number; failed: number; durationMs: number; resultsDir: string; } export interface RunFailedPayload { runId: string; durationMs: number; error: string; } export interface TestStartedPayload { runId: string; name: string; expectedDurationS?: number; } export interface TestCompletedPayload { runId: string; name: string; status: 'pass' | 'fail' | 'suspicious'; durationMs: number; error?: string; logDir: string; } const RunStarted = z.object({ runId: z.string().min(1), scenario: z.enum(['single-test', 'multi-test']), testNames: z.array(z.string()), moduleDirs: z.array(z.string()), startedAt: z.number().int().nonnegative(), }); const RunCompleted = z.object({ runId: z.string().min(1), total: z.number().int().nonnegative(), passed: z.number().int().nonnegative(), failed: z.number().int().nonnegative(), durationMs: z.number().int().nonnegative(), resultsDir: z.string(), }); const RunFailed = z.object({ runId: z.string().min(1), durationMs: z.number().int().nonnegative(), error: z.string(), }); const TestStarted = z.object({ runId: z.string().min(1), name: z.string().min(1), expectedDurationS: z.number().optional(), }); const TestCompleted = z.object({ runId: z.string().min(1), name: z.string().min(1), status: z.enum(['pass', 'fail', 'suspicious']), durationMs: z.number().int().nonnegative(), error: z.string().optional(), logDir: z.string(), }); export function emitRunStarted(p: RunStartedPayload): void { RunStarted.parse(p); emitBest(`e2e.run.started.${p.runId}`, p); } export function emitRunCompleted(p: RunCompletedPayload): void { RunCompleted.parse(p); emitBest(`e2e.run.completed.${p.runId}`, p); } export function emitRunFailed(p: RunFailedPayload): void { RunFailed.parse(p); emitBest(`e2e.run.failed.${p.runId}`, p); } export function emitTestStarted(p: TestStartedPayload): void { TestStarted.parse(p); emitBest(`e2e.test.started.${p.runId}`, p); } export function emitTestCompleted(p: TestCompletedPayload): void { TestCompleted.parse(p); emitBest(`e2e.test.completed.${p.runId}`, p); } /** * Resolve the bus database path. Priority: * 1. EVENT_BUS_DB env (the bus library's native name — set by celilo, * the events daemon, or the operator) * 2. /events.db (matching celilo's default), if the * directory already exists * 3. null — no bus, emits become no-ops * * cele2e doesn't create the celilo data dir on its own; it only emits * if the bus is already provisioned by celilo or by the operator. */ export function resolveBusPath(): string | null { if (process.env.EVENT_BUS_DB) { return process.env.EVENT_BUS_DB; } // Mirror celilo's getDataDir() defaults without taking a celilo dep. const platform = process.platform; let dataDir: string; if (process.env.CELILO_DATA_DIR) { dataDir = process.env.CELILO_DATA_DIR; } else if (process.env.ENVIRONMENT === 'dev') { dataDir = join(process.cwd(), 'celilo-data'); } else if (platform === 'darwin') { dataDir = join(homedir(), 'Library', 'Application Support', 'celilo'); } else { dataDir = '/var/lib/celilo'; } if (!existsSync(dataDir)) return null; return join(dataDir, 'events.db'); } function emitBest(type: string, payload: unknown): void { const dbPath = resolveBusPath(); if (!dbPath) return; // no bus configured → no-op let bus: Bus | undefined; try { // openBus auto-creates the file if missing, but only if its parent // directory exists. resolveBusPath() already gates on dir presence. const parent = dirname(dbPath); if (!existsSync(parent)) mkdirSync(parent, { recursive: true }); bus = openBus({ dbPath, events: NO_SCHEMAS }); bus.emitRaw(type, payload); } catch (err) { // Best-effort: don't let a sick bus wedge a test run. Log to stderr // since stdout is the test output stream. const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`[cele2e bus] failed to emit ${type}: ${msg}\n`); } finally { bus?.close(); } }