bruno-lifecycle-adapter
v0.1.0
Published
Wraps Bruno CLI and exposes an observable execution lifecycle for external integrations.
Maintainers
Readme
bruno-lifecycle-adapter
A TypeScript package that wraps Bruno CLI and exposes an observable execution lifecycle for external integrations.
It runs Bruno collections through bru run, captures process output, parses generated reports, and emits typed lifecycle events such as run start/end, request execution, test results, assertions, stdout, and stderr.
The adapter is designed to be independent from Bruno's core, with a clean architecture, strong typing, automated tests, and a stable internal contract that can evolve even if Bruno's report format changes over time.
Installation
npm install bruno-lifecycle-adapterBruno CLI must be installed separately (the adapter calls bru run):
npm install -g @usebruno/cliUsage
import { BrunoLifecycleAdapter } from 'bruno-lifecycle-adapter';
const adapter = new BrunoLifecycleAdapter();
adapter.on('run:started', (event) => {
console.log(`Run started (pid ${event.pid}), runId: ${event.runId}`);
});
adapter.on('request:finished', (event) => {
console.log(`${event.requestName} → ${event.status} (${event.responseStatus ?? '?'})`);
});
adapter.on('test:finished', (event) => {
console.log(` test "${event.testName}": ${event.status}`);
});
adapter.on('assertion:result', (event) => {
console.log(` assertion "${event.assertion}": ${event.passed ? 'pass' : 'fail'}`);
});
const summary = await adapter.run({
cwd: process.cwd(),
collectionPath: './my-collection',
env: 'local',
reporterJsonPath: './tmp/bruno-report.json',
});
console.log('Exit code:', summary.exitCode);
console.log('Passed requests:', summary.passedRequests, '/', summary.totalRequests);Configuration
interface AdapterRunConfig {
/** Working directory where `bru run` is executed. */
cwd: string;
/** Path to the Bruno collection directory or specific `.bru` file. */
collectionPath: string;
/** Environment name passed to `--env`. */
env?: string;
/** Path where Bruno writes its JSON report (`--reporter-json`). */
reporterJsonPath?: string;
/** When true, passes `-r` to `bru run` to recurse into sub-folders. */
recursive?: boolean;
/** Extra CLI arguments appended verbatim after the collection path. */
extraArgs?: string[];
/** Path to the `bru` binary. Defaults to `bru` (resolved from PATH). */
bruBin?: string;
/** Timeout in milliseconds for the entire run. `0` means no timeout. */
timeoutMs?: number;
}Event Subscription
// Subscribe and get an unsubscribe handle
const unsubscribe = adapter.on('stdout', (event) => {
process.stdout.write(event.chunk);
});
// Subscribe once – fires only on the first emission
adapter.once('run:finished', (event) => {
console.log('Total duration:', event.durationMs, 'ms');
});
// Unsubscribe manually
adapter.off('stderr', myStderrHandler);
// Or use the returned unsubscribe function
unsubscribe();Lifecycle Events
| Event | Reliability | Description |
|---|---|---|
| run:starting | native | Emitted before spawning the process |
| run:started | native | Emitted once the child process has a PID |
| run:finished | native | Emitted when the process exits with code 0 |
| run:failed | native | Emitted when the process exits with non-zero code, times out, or fails to spawn |
| request:discovered | inferred | Emitted per .bru request file found in the collection directory, fired after the process exits and before report-derived events |
| request:started | derived | Emitted immediately before each request:finished or request:skipped (derived from the JSON report; fires in batch after the run) |
| request:finished | derived | Emitted per non-skipped request after successful report parsing |
| request:skipped | derived | Emitted per skipped request after successful report parsing |
| test:started | derived | Emitted immediately before each test:finished (derived from the JSON report; fires in batch after the run) |
| test:finished | derived | Emitted per test result after successful report parsing |
| assertion:result | derived | Emitted per assertion after successful report parsing |
| stdout | native | Raw stdout chunk from the bru run process |
| stderr | native | Raw stderr chunk from the bru run process |
| report:json:ready | derived | Emitted only after the JSON report is parsed successfully |
Event Reliability
Each event payload includes a reliability field:
native– directly observed from process stdout/stderr or exit codederived– computed from one or more native signals (e.g. parsed report data)inferred– best-effort; could not be confirmed from available signals
Event Payloads
All event payloads extend EventMetadata:
interface EventMetadata {
runId: string; // UUID identifying this run
timestamp: string; // ISO 8601 timestamp
reliability: 'native' | 'derived' | 'inferred';
}Example payloads:
interface RunStartedEvent extends EventMetadata {
event: 'run:started';
cwd: string;
collectionPath: string;
pid: number;
}
interface RequestFinishedEvent extends EventMetadata {
event: 'request:finished';
requestName: string;
requestFile: string;
status: 'discovered' | 'started' | 'finished' | 'skipped' | 'failed';
responseStatus?: number;
durationMs?: number;
error?: { message: string; stack?: string };
}
interface TestFinishedEvent extends EventMetadata {
event: 'test:finished';
requestName: string;
testName: string;
status: 'started' | 'passed' | 'failed' | 'skipped';
error?: { message: string; stack?: string };
}Execution Summary
adapter.run() resolves with an ExecutionSummary:
interface ExecutionSummary {
runId: string;
collectionPath: string;
startedAt: string;
finishedAt: string;
durationMs: number;
exitCode: number;
status: 'starting' | 'running' | 'finished' | 'failed' | 'cancelled';
totalRequests: number;
passedRequests: number;
failedRequests: number;
skippedRequests: number;
totalTests: number;
passedTests: number;
failedTests: number;
totalAssertions: number;
passedAssertions: number;
failedAssertions: number;
requests: RequestResult[];
error?: ErrorDetail;
}Custom Report Parser
You can supply your own report parser to handle custom Bruno report formats:
import { BrunoLifecycleAdapter } from 'bruno-lifecycle-adapter';
import type { ReportParserContract } from 'bruno-lifecycle-adapter';
class MyCustomParser implements ReportParserContract {
async parse(reportPath: string): Promise<ExecutionSummary> {
// read and map your format
}
}
const adapter = new BrunoLifecycleAdapter(new MyCustomParser());Limitations
- No native per-request events from process stdout. Bruno CLI does not emit structured events to stdout; per-request and per-test events are derived from the JSON report. Subscribe to
stdoutfor raw output. - Report events require
reporterJsonPath. Without a JSON report path,request:finished,test:finished,assertion:result, andreport:json:readyare not emitted. - Bruno JSON schema may change. The parser maps known fields and falls back gracefully, but new Bruno versions may change the report schema. Inspect the raw
stdoutevents if you need forward compatibility. - Timeout kills the process with
SIGTERM. The process is sentSIGTERMon timeout; clean shutdown depends on the OS and Bruno's signal handling.
Project Structure
src/
domain/
events.ts # All lifecycle event types and execution models
models.ts # AdapterRunConfig and ReportParserContract interfaces
application/
BrunoLifecycleAdapter.ts # Main adapter (spawns bru, emits events)
infrastructure/
TypedEventBus.ts # Strongly-typed event bus
BrunoJsonReportParser.ts # Bruno JSON report → internal model
shared/
utils.ts # generateRunId, nowIso, elapsedMs
index.ts # Public exports
tests/
unit/
TypedEventBus.test.ts
BrunoJsonReportParser.test.ts
BrunoLifecycleAdapter.test.ts
BruFileScanner.test.ts
integration/
adapter.integration.test.ts # End-to-end integration tests
fixtures/
bruno-project/ # Sample Bruno collection
bruno.json
environments/local.bru
health-check.bru
echo-post.bru
multi-request-flow/
01-list-items.bru
02-get-item-by-id.bru
external-listener/ # External JS consumer package
package.json
index.js
e2e/
app-server.js # Standalone HTTP server for Docker E2E
run.js # Docker E2E test runner entry-pointDevelopment
npm install
npm run build # compile with tsup
npm test # run unit tests with vitest
npm run test:unit # explicit alias for unit tests
npm run test:e2e # run full E2E suite inside Docker Compose
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm run format # prettier --writeIntegration Tests
Integration tests verify the adapter against a real Bruno installation.
Prerequisites
- Build the adapter:
npm run build - Install Bruno CLI:
npm install -g @usebruno/cli - Install external listener dependencies:
npm run test:integration:setup
Running integration tests
npm run test:integrationFull CI-equivalent local run
This command reproduces the exact CI execution sequence:
npm run ci:localIt runs: build → lint → typecheck → unit tests → integration setup → integration tests.
Note: Integration tests fail (they do not skip) when
bruis not found on PATH. This is intentional — a passing run without Bruno would give false confidence. Unit tests (npm test) run without Bruno.
How integration tests work
- A minimal HTTP server is started on port
47891to serve the Bruno collection's requests locally — no external network calls required. - The external listener (
tests/integration/fixtures/external-listener/) is spawned as a child process. It imports the adapter, subscribes to all lifecycle events, and writes structured JSON-lines to stdout for each event. - The integration test captures the listener's stdout and asserts that expected events were logged — confirming that the adapter correctly surfaces lifecycle information to external consumers.
CI
Integration tests run automatically on pull requests targeting main via
the .github/workflows/integration.yml workflow. They do not run on feature
branch pushes to keep CI fast.
E2E Tests
E2E tests verify the adapter end-to-end inside isolated Docker containers — no host-level Bruno installation is required.
Running E2E tests
docker compose up --build --exit-code-from e2e --abort-on-container-exitOr via the npm script:
npm run test:e2eHow E2E tests work
The docker-compose.yml starts two services:
app(node:20-alpine): a minimal HTTP server exposing the same routes as the integration test server (/health,/echo,/items,/items/1). Reachable within the Compose network ashttp://app:47891.e2e(built fromDockerfile): installs Bruno CLI globally, resolves the adapter from the localdist/, installs the external listener, and runstests/e2e/run.js.
The e2e container exits 0 on success, 1 on failure.
docker compose up --exit-code-from e2e propagates that exit code to the host.
CI
E2E tests run automatically on pull requests targeting main via the
.github/workflows/e2e.yml workflow. Two jobs run in sequence:
docker-build— verifies theDockerfilebuilds successfully.e2e— runsdocker compose upwith the full stack.
License
MIT
