Skip to content

netzulo/bruno-lifecycle-adapter

Repository files navigation

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-adapter

Bruno CLI must be installed separately (the adapter calls bru run):

npm install -g @usebruno/cli

Usage

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 code
  • derived – 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 stdout for raw output.
  • Report events require reporterJsonPath. Without a JSON report path, request:finished, test:finished, assertion:result, and report:json:ready are 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 stdout events if you need forward compatibility.
  • Timeout kills the process with SIGTERM. The process is sent SIGTERM on 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-point

Development

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 --write

Integration Tests

Integration tests verify the adapter against a real Bruno installation.

Prerequisites

  1. Build the adapter:
    npm run build
  2. Install Bruno CLI:
    npm install -g @usebruno/cli
  3. Install external listener dependencies:
    npm run test:integration:setup

Running integration tests

npm run test:integration

Full CI-equivalent local run

This command reproduces the exact CI execution sequence:

npm run ci:local

It runs: build → lint → typecheck → unit tests → integration setup → integration tests.

Note: Integration tests fail (they do not skip) when bru is 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

  1. A minimal HTTP server is started on port 47891 to serve the Bruno collection's requests locally — no external network calls required.
  2. 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.
  3. 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-exit

Or via the npm script:

npm run test:e2e

How E2E tests work

The docker-compose.yml starts two services:

  1. 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 as http://app:47891.
  2. e2e (built from Dockerfile): installs Bruno CLI globally, resolves the adapter from the local dist/, installs the external listener, and runs tests/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:

  1. docker-build — verifies the Dockerfile builds successfully.
  2. e2e — runs docker compose up with the full stack.

License

MIT

About

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, stderr. Adapter is designed to be independent from Bruno’s core

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors