@mirralabs/sdk
v0.7.0
Published
Run real third-party APIs in test sessions — Resend, Twilio, Stripe — with first-class assertions. Per-test isolation, no mocks.
Readme
@mirralabs/sdk
Run real third-party APIs in test sessions — Resend, Twilio, Stripe — with first-class assertions. Per-test isolation, no mocks.
import { test } from 'vitest';
import { MirraClient } from '@mirralabs/sdk';
const mirra = new MirraClient(); // reads MIRRA_API_KEY + MIRRA_API_URL
test('signup sends a welcome email', async () => {
await using session = await mirra.sessions.create({
projectId: process.env.MIRRA_PROJECT!,
mirrors: ['resend'],
mode: 'ephemeral',
});
await session.ready();
process.env.RESEND_API_KEY = 'any';
process.env.RESEND_BASE_URL = session.mirror('resend').url;
await myApp.signup({ email: '[email protected]' });
await session.mirror('resend').expect.email.sent({
to: '[email protected]',
});
});Install
pnpm add @mirralabs/sdkRequires Node 24 or later. Ships ESM + CJS dual output with bundled TypeScript declarations.
Configuration
Environment variables
| Variable | Purpose | Default |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| MIRRA_API_KEY | Project API key. Format mirra_live_<hex>.<hex>. Mint one in the dashboard under Project Settings → API keys. | — (required) |
| MIRRA_API_URL | Control plane URL. Override for self-hosted dev. | http://localhost:4400 |
| MIRRA_WEB_URL | Dashboard URL — used to build session.dashboardUrl. | https://app.mirra.run |
| MIRRA_PROJECT | Convention used in examples. The SDK does NOT read this directly; pass projectId to sessions.create. | — |
Constructor options
import { MirraClient } from '@mirralabs/sdk';
const mirra = new MirraClient({
apiUrl: 'https://api.mirra.run', // override env / default
apiKey: process.env.MIRRA_API_KEY,
timeoutMs: 30_000, // per-request HTTP timeout
userAgent: 'my-tests/1.0', // appended to default UA
webPublicUrl: 'https://app.mirra.run',
});All options are optional. Env vars are read at new MirraClient() time if the corresponding option isn't passed.
Concepts
Sessions are isolation
A session is the unit of test isolation. Every session has its own database, its own webhooks, its own request history. Sessions running in parallel never see each other's state.
await using session = await mirra.sessions.create({ projectId, mirrors: ['resend'], mode: 'ephemeral' });
await session.ready();
// ... test body ...
// `await using` cancels the session on scope exit. No teardown code needed.await using requires Node 20.18+ (or TypeScript 5.2+ with downlevel). If you can't use it, call await session.cancel() in afterEach.
Mirrors are vendor stand-ins
A mirror is a real HTTP service running real database state, real fixture data, and emitting real webhooks. Point your vendor SDK at session.mirror(slug).url and it works as if it were the real vendor — except it lives entirely inside your test session.
import { Resend } from 'resend';
const resend = new Resend('any-key', {
baseUrl: session.mirror('resend').url,
});
await resend.emails.send({ from, to, subject, html });Mirrors are not mocks. They have schemas, run migrations, persist between requests within a session, and fire webhooks back to your app.
Assertions are async and return matched data
session.mirror('resend').expect.email.sent({ to }) polls the session's traffic until a matching event lands, then returns the matched record so you can inspect it further.
const req = await session.mirror('resend').expect.email.sent({
to: '[email protected]',
});
expect(req.requestBody).toMatchObject({ subject: 'Welcome!' });Assertions throw MirraTimeoutError if the deadline (default 5s) passes without a match.
Sessions
mirra.sessions.*
mirra.sessions.create(input: CreateSessionInput): Promise<SessionHandle>
mirra.sessions.get(id: string): Promise<SessionHandle>
mirra.sessions.cancel(id: string): Promise<Session>
mirra.sessions.list(projectId, opts?): Paginator<Session>CreateSessionInput:
{
projectId: string;
mirrors: string[]; // e.g. ['resend', 'twilio']
mode: 'ephemeral' | 'persistent';
name?: string; // human label; null → auto-numbered sequence
environmentId?: string; // pin to a specific env; defaults to project's first
}SessionHandle
session.id // 'ses_01ARZ3...'
session.status // 'provisioning' | 'ready' | 'active' | 'ended' | 'failed' | ...
session.mirrorInstances // attached mirrors
session.urls // { resend: 'https://resend-abc.mirra.run', ... }
session.dashboardUrl // safe to print to CI logs
await session.refresh(); // re-fetch state
await session.ready({ timeoutMs: 60_000 });
await session.cancel();
session.mirror('resend'); // → MirrorHandle, see below
session.listRequests({ method: 'POST' }); // → Paginator<RequestRow>
session.listWebhookDeliveries({ eventType: 'email.delivered' });
await session.waitFor(
async () => /* predicate */,
{ timeoutMs: 5_000, pollMs: 200 },
);
await session[Symbol.asyncDispose](); // implicit via `await using`waitFor is the polling primitive every expect.* assertion is built on. Returns whatever the predicate returns truthy; throws MirraTimeoutError on deadline.
Mirror handles
const resend = session.mirror('resend');
resend.url // point your vendor SDK here
resend.id // mirror_instance row id ('mir_…')
resend.status // 'ready' | 'provisioning' | 'failed' | 'destroyed'
resend.port // mirror engine's internal port
await resend.state<ResendState>(); // raw state read
await resend.reset(); // clear DB + re-run migrations
await resend.simulate('email.bounced', { /* payload */ });
const blob = await resend.snapshot(); // binary SQLite snapshot
await resend.restore(blob);
resend.expect.email.sent({ to: '[email protected]' });
// .expect is per-mirror — see "Resend assertions" below.session.mirror<S>(slug) is typed via a registry. Built-in mirrors register themselves on import. Pass an unknown slug and .expect resolves to Record<string, never> so typos fail to compile.
Resend assertions
session.mirror('resend').expect.* exposes domain-level assertions for the Resend mirror. Each one polls the session's traffic and returns the matched record.
await resend.expect.email.sent(match, opts?) // → RequestRow (POST /emails)
await resend.expect.email.delivered(match, opts?) // → WebhookDelivery (email.delivered)
await resend.expect.email.bounced(match, opts?) // → WebhookDelivery (email.bounced)
await resend.expect.email.complained(match, opts?) // → WebhookDelivery (email.complained)EmailMatch:
{
to?: string | string[];
from?: string;
subject?: string;
subjectIncludes?: string;
}opts: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal }. Defaults: timeoutMs: 5_000, pollMs: 200.
Worked example
import { test, expect } from 'vitest';
import { MirraClient } from '@mirralabs/sdk';
import { Resend } from 'resend';
const mirra = new MirraClient();
test('welcome email lands, then bounces if address is invalid', async () => {
await using session = await mirra.sessions.create({
projectId: process.env.MIRRA_PROJECT!,
mirrors: ['resend'],
mode: 'ephemeral',
});
await session.ready();
const resend = session.mirror('resend');
const client = new Resend('any', { baseUrl: resend.url });
await client.emails.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome!',
html: '<p>hi</p>',
});
const req = await resend.expect.email.sent({ to: '[email protected]' });
expect(req.requestBody).toMatchObject({ subject: 'Welcome!' });
// Trigger a bounce webhook against the email we just sent
await resend.simulate('email.bounced', { to: '[email protected]' });
const bounce = await resend.expect.email.bounced({ to: '[email protected]' });
expect(bounce.eventType).toBe('email.bounced');
});.sent reads the session's requests traffic (the POST /emails the user's app made).
.delivered, .bounced, .complained read the session's webhook deliveries (events the mirror fired back to the user's app).
Scenarios
A scenario is a markdown file describing what the system under test should do. The SDK runs it inside a session, and you assert against the result.
Scenario file format
# Welcome email
## Setup
A new user signs up with a valid email address.
## Expected Behavior
The app sends a welcome email immediately.
## Criteria
- email sent within 5 seconds of signup
- subject contains "Welcome"
- body addresses the user by name
## Config
mirrors: resend
runs: 1
judgeModel: claude-haiku-4-5
failBelow: 0.8Each bullet under ## Criteria is a single criterion. Some are deterministic checks (the executor reduces them to assertions); others escalate to an LLM judge. The ## Config block tunes the run — runs > 1 runs the scenario multiple times for flake-resilience.
Running a scenario
session.runScenario(content: string, opts?): Promise<ScenarioHandle>
session.runScenarioFile(path: string, opts?): Promise<ScenarioHandle>opts: { judgeModel?: string }.
import { test, expect } from 'vitest';
import { MirraClient } from '@mirralabs/sdk';
const mirra = new MirraClient();
test('signup matches the welcome scenario', async () => {
await using session = await mirra.sessions.create({
projectId: process.env.MIRRA_PROJECT!,
mirrors: ['resend'],
mode: 'ephemeral',
});
await session.ready();
process.env.RESEND_BASE_URL = session.mirror('resend').url;
await myApp.signup({ email: '[email protected]', name: 'Alice' });
const scenario = await session.runScenarioFile('./scenarios/welcome.md');
await scenario.expect.toPass();
});ScenarioHandle
scenario.id // 'run_…' (single) or 'grp_…' (group)
scenario.isGroup // true when scenario.runs > 1
scenario.satisfactionScore // single's score, or group's mean
scenario.runs // ScenarioRunResult[]
scenario.criteria // unified per-criterion view
scenario.scenario // the parsed scenario file
scenario.raw // raw API response
await scenario.wait(); // resolve a multi-run group
await scenario.refresh(); // re-fetch group snapshot
await scenario.cancel(); // group only — single-run throwsFor multi-run groups, raw getters (satisfactionScore, criteria, etc.) throw MirraNotReadyError until wait() (or any expect.* call) resolves the group.
Assertions
await scenario.expect.toPass(opts?) // → ScenarioHandle
await scenario.expect.allRunsPass() // → ScenarioHandle
const view = await scenario.expect.criterion(textOrIndex).toPass(opts?);
const view = await scenario.expect.criterion(textOrIndex).toFail(opts?);toPass({ minSatisfaction? }) — passes when satisfaction is at or above the threshold. Threshold precedence: explicit opts.minSatisfaction → scenario file's failBelow → default 1.0 (every criterion must pass). For groups, compares the mean across runs; for single runs, compares the run's score.
allRunsPass() — strict mode. Every run must pass every criterion. Throws on the first per-criterion passRate < 1.0. Use when you don't want to tolerate any flake.
criterion(textOrIndex) — sync lookup by exact text or 0-based index. Throws ScenarioCriterionNotFound (with .available[] listing every criterion) on miss.
criterion().toPass({ minPassRate? }) — for groups, defaults minPassRate: 1.0 (every run passed). For single runs, passes when criteria[i].result === 'pass'.
criterion().toFail({ maxPassRate? }) — inverse. Defaults maxPassRate: 0 (no run passed it). Useful for negative-test scenarios.
Single-run vs group
When the scenario file has runs: 1, runScenario returns a ScenarioHandle wrapping a ScenarioRunResult — already resolved, raw fields work immediately.
When runs > 1, the API queues a group and the handle wraps a ScenarioRunGroupHandle. Raw fields throw until wait() (or expect.*) resolves the snapshot. The same expect.* API works for both — assertion code doesn't branch.
Errors
All SDK errors extend MirraError. Narrow with instanceof.
| Error | Fires when | What to do |
| --------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| MirraAuthError | 401/403 — bad / missing API key, or key not authorized for the project | Mint a fresh key in the dashboard. |
| MirraValidationError | 4xx (incl. 422 with errors: ParseError[] for scenario parse failures) | Read err.message. For parse failures, inspect err.errors. |
| MirraServerError | 5xx | Retry with backoff or check status. |
| MirraNetworkError | DNS / abort / connection failure | Check connectivity to MIRRA_API_URL. |
| MirraNotReadyError | Session still provisioning past timeout, or scenario group not yet resolved | await session.ready({ timeoutMs }) or await scenario.wait(). |
| MirraTimeoutError | waitFor deadline exceeded | Increase timeoutMs or check whether the expected event is firing. |
| ScenarioAssertionError | scenario.expect.* failed | Inspect err.scenario, err.reason, err.details. |
| ScenarioCriterionNotFound | scenario.expect.criterion(text\|index) lookup miss | Check err.available for the full list of criteria. |
Cheat sheet
import {
MirraAuthError,
MirraValidationError,
MirraTimeoutError,
ScenarioAssertionError,
ScenarioCriterionNotFound,
} from '@mirralabs/sdk';
try {
await session.runScenarioFile('./scenarios/welcome.md');
await scenario.expect.criterion('email sent within 5 seconds of signup').toPass();
} catch (err) {
if (err instanceof ScenarioCriterionNotFound) {
console.error('Available criteria:', err.available);
throw err;
}
if (err instanceof ScenarioAssertionError) {
console.error('Failed:', err.reason, err.details);
throw err;
}
if (err instanceof MirraTimeoutError) {
console.error('Polled past deadline; nothing matched.');
throw err;
}
if (err instanceof MirraAuthError) {
console.error('Check MIRRA_API_KEY.');
throw err;
}
if (err instanceof MirraValidationError) {
if (err.errors.length > 0) {
for (const e of err.errors) console.error(`${e.kind}: ${e.message}`);
}
throw err;
}
throw err;
}Other resources
// Scenarios — parse, evaluate, async run-groups
mirra.scenarios.parse(content)
mirra.scenarios.evaluate({ sessionId, content, options? })
mirra.scenarios.runGroup({ projectId, content, sessions, totalRuns?, options? })
mirra.scenarios.group(groupId) // rehydrate handle by id
mirra.scenarios.run(runId) // fetch a single run
// Workspaces / catalog / keys
mirra.workspaces.me() // current user + workspaces
mirra.mirrors.list({ status? }) // catalog
mirra.mirrors.get(slug)
mirra.apiKeys.create(projectId, { name })
mirra.apiKeys.list(projectId)
mirra.apiKeys.revoke(id)
// Webhook traffic
mirra.webhookDeliveries.list(sessionId, { eventType?, finalStatus?, since?, until? })
mirra.webhookDeliveries.get(id)
// Device-flow auth (used by @mirralabs/cli; not for browser apps)
mirra.cliAuth.start({ clientName })
mirra.cliAuth.poll({ code, verifier })Streaming
session.subscribe(handler, opts?) opens a Socket.io connection to the session's event stream.
const sub = session.subscribe(
(event) => console.log(event.type, event.data),
{ prefix: 'email.' }, // optional event-type filter
);
// later
sub.close();Use this for live CLI dashboards or long-running watchers. Don't use it in tests — expect.* assertions poll the same data and are deterministic, which is what tests want.
Requirements
- Node 24 or later (per
engines.node). - ESM and CJS both supported. TypeScript declarations bundled.
- Browser is not supported in 0.2.0 — the SDK uses
undiciandnode:fs.
Support
Email [email protected] for bugs, questions, and feature requests.
Dashboard: https://app.mirra.run.
