Inject controlled chaos into web applications to test frontend resilience. Works with Playwright, Cypress, WebdriverIO, and Puppeteer with no backend changes.
- Streaming chaos for
fetch(...).body: a newfetchStreamconfig slice wraps everyResponse.bodyconsumer so chunk-level chaos applies to AI chat SDKs (Vercel AI SDK, OpenAI SDK, LangChain, anything that reaches forResponse.body.getReader()). Drop, delay, corrupt, duplicate, or truncate individual chunks by zero-based index or by probability. See AI streaming concept. - Per-chunk phase markers on SSE + WebSocket + fetch-stream: every streaming event now carries
detail.phase(ai:first-chunk,ai:stream-paused,ai:stream-resumed,ai:stream-truncated,ai:chunk-duplicated) and a stabledetail.connectionId, so report consumers can reconstruct a chunk-level timeline without rerunning matchers. aiconfig shorthand: a singleai: { firstChunkDelayMs, pauseAfterChunk, truncateAfterChunk, duplicateChunkProbability, transport }block compiles into transport rule arrays at engine init so the same scenario fires across fetch-stream, SSE, and WebSocket without per-transport duplication. See the AI chat fetch-stream recipe and AI chat SSE recipe.'duplicate'corruption strategy on fetch-stream: emission-level chunk duplication; consumers see the same chunk one additional time. Use for testing client-side idempotency under retry storms or replayed deltas.- Stream replay and mutation: capture a stream to a versioned JSON fixture and replay it deterministically with no live backend, across fetch-stream, SSE, and WebSocket. Six chunk mutations (
delay,truncate,duplicate,split,coalesce,inject-malformed) rewrite the stream by original chunk index with no RNG. NewloadStreamFixture/recordStreamFixtureadapter helpers. See the stream replay concept. - Human interaction chaos: a
userInteractionconfig namespace simulates the user side of streaming failures on a fixed, seed-independent schedule:cancelStreamAfterMsaborts in-flight streams (realAbortErroron fetch streams, close on SSE/WS),retryStormhammers a retry button,tabHidden/blurWindowflip visibility and focus,promptEditDuringResponsetypes into the prompt mid-generation,navigateAwayleaves the page. Triggers emitui:*events tagged withuser:*phases that render in the streaming timeline. See the human interaction concept.
Full release notes in CHANGELOG.md.
npm install @chaos-maker/core @chaos-maker/playwright
npm install @chaos-maker/core @chaos-maker/cypress
npm install @chaos-maker/core @chaos-maker/webdriverio
npm install @chaos-maker/core @chaos-maker/puppeteerDrop a named scenario into the config - flaky backend, mobile network, checkout instability - and run. Layer multiple presets for compound scenarios.
import { test, expect } from '@playwright/test';
import { injectChaos } from '@chaos-maker/playwright';
test('checkout works under degraded mobile network', async ({ page }) => {
await injectChaos(page, { presets: ['mobile-3g', 'checkout-degraded'], seed: 42 });
await page.goto('/checkout');
await expect(page.locator('[data-testid="checkout-form"]')).toBeVisible();
});For AI chat and assistant interfaces, seven streaming presets reproduce the incidents those UIs hit in production: ai-slow-first-chunk, ai-stream-paused, ai-stream-truncated, ai-tool-call-fails, ai-retry-loop, ai-reconnect-after-drop, and ai-mobile-interrupt.
await injectChaos(page, { presets: ['ai-slow-first-chunk'], seed: 42 });See the full catalog in the Presets docs. When a failure only appears under a generated seed, follow the replay recipe.
When several tests should share the same named scenario, wrap it into a profile. Chaos Maker ships exactly one built-in mobileCheckout demo profile (a wiring proof, not an open catalog) - define your own scenarios via customProfiles. Pass profileOverrides alongside profile to tune one parameter at the call site without forking the profile.
await injectChaos(page, {
profile: 'mobile-checkout',
profileOverrides: {
network: { latencies: [{ urlPattern: '/api/extra', delayMs: 999, probability: 1 }] },
},
seed: 42,
});See the Scenario profiles concept for the resolution rules and runtime override precedence.
Every network, WebSocket, and SSE rule accepts hostname, query parameter, and (network-only) request header / resource-type matchers alongside urlPattern and methods. A separate matchers registry holds reusable named matchers so one matcher can target network, WebSocket, and SSE without per-transport duplication.
await injectChaos(page, {
matchers: {
customers: {
hostname: 'api.example.com',
urlPattern: '/api/customers',
methods: ['GET'],
requestHeaders: { authorization: /^Bearer / },
},
},
network: {
failures: [{ matcher: 'customers', statusCode: 503, probability: 1 }],
latencies: [{ matcher: 'customers', delayMs: 500, probability: 1 }],
},
});See the Advanced matchers concept for the full surface, the four validation issue codes (matcher_not_found, matcher_collision, matcher_inline_conflict, matcher_cycle), and the matcher attribution on debug events.
Three matchers ship built in, so the most common targets need no matchers entry:
await injectChaos(page, {
network: {
latencies: [{ matcher: 'graphql', delayMs: 1200, probability: 1 }],
},
});graphql (/graphql), apiRequests (/api), and authRequests (any request with an Authorization header) resolve by name and behave exactly like a matcher you define. A matchers entry of the same name overrides one. authRequests is meaningful for network rules only: it matches on a request header, which WebSocket and SSE rules cannot target, so a stream rule referencing it matches every connection.
After a chaos run, turn the event log into a structured ChaosReport and serialize it as JSON, Markdown, or a self-contained HTML timeline. The core package returns strings; your test writes them to disk or attaches them as CI artifacts.
import { test } from '@playwright/test';
import {
buildChaosReport,
formatReportHtml,
getChaosLog,
getChaosSeed,
injectChaos,
} from '@chaos-maker/playwright';
test('attach a chaos report on every run', async ({ page }, testInfo) => {
await injectChaos(page, { debug: true, network: { failures: [/* … */] }, seed: 42 });
// run scenario …
const events = await getChaosLog(page);
const seed = await getChaosSeed(page);
const report = buildChaosReport(events, { seed, title: testInfo.title });
await testInfo.attach('chaos-report.html', {
body: formatReportHtml(report),
contentType: 'text/html',
});
});The HTML output is fully self-contained (inline CSS, no <script>, no external URLs). For PR comments, swap formatReportHtml for formatReportMarkdown. See the Timeline and reporting concept for the full report shape, determinism guarantees, and per-rule attribution requirements.
Streaming runs additionally produce per-connection lifecycle timelines (first-chunk latency, pauses and whether they resolved, truncation and replay markers with mutation attribution) plus a streamingReadiness scorecard that counts how many streamed connections completed without interruption. Reports for non-streaming runs are unchanged.
When a preset is too coarse, drop down to explicit rules:
npm install @chaos-maker/core @chaos-maker/playwrightimport { test, expect } from '@playwright/test';
import { injectChaos, getChaosLog } from '@chaos-maker/playwright';
test('shows error state when payment API fails', async ({ page }) => {
await injectChaos(page, {
seed: 42,
network: {
failures: [{ urlPattern: '/api/payments', statusCode: 503, probability: 1.0 }],
},
});
await page.goto('/checkout');
await page.click('#pay-now');
await expect(page.locator('[data-testid="error-banner"]')).toBeVisible();
const log = await getChaosLog(page);
expect(log.some(e => e.type === 'network:failure' && e.applied)).toBe(true);
});| Surface | Playwright | Cypress | WebdriverIO | Puppeteer |
|---|---|---|---|---|
| Network fetch/XHR | Yes | Yes | Yes | Yes |
| UI assaults | Yes | Yes | Yes | Yes |
| WebSocket | Yes | Yes | Yes | Yes |
| Service Worker fetch | Yes | Yes | Yes | Yes |
| Server-Sent Events | Yes | Yes | Yes | Yes |
| GraphQL operation matcher | Yes | Yes | Yes | Yes |
| Rule Groups | Yes | Yes | Yes | Yes |
PWAs and offline-first apps serve fetches from a Service Worker. Those bypass page-side chaos, so add one line to your SW and chaos applies there too:
// classic sw.js
importScripts('/chaos-maker-sw.js');Page-side: injectSWChaos / removeSWChaos / getSWChaosLog in each adapter. See adapter READMEs.
Group related rules so a test can turn a whole failure scenario on or off at runtime without restarting chaos.
import { ChaosConfigBuilder } from '@chaos-maker/core';
const chaos = new ChaosConfigBuilder()
.inGroup("payments")
.failRequests("/api/pay", 503, 1)
.build();Rules without .inGroup() stay in the default group and continue to work as before.
The examples below use page as a generic adapter handle. See each adapter README for exact syntax.
await page.enableGroup("payments");
await page.disableGroup("payments");Browser-side toggles affect rules injected into the page with injectChaos.
await page.enableSWGroup("payments");
await page.disableSWGroup("payments");Service Worker toggles affect rules injected into the active Service Worker with injectSWChaos. Browser-side and SW-side toggles are separate because they run in different JavaScript contexts. If a group has rules in both places, toggle both explicitly.
import { ChaosConfigBuilder } from '@chaos-maker/core';
const chaos = new ChaosConfigBuilder()
.inGroup("payments")
.failRequests("/api/pay", 503, 1)
.inGroup("auth")
.failRequests("/api/session", 401, 1)
.inGroup("analytics")
.addLatency("/api/events", 750, 1)
.build();
await injectChaos(page, chaos);
await page.disableGroup("payments");
await page.enableGroup("auth");
await page.enableGroup("analytics");In this state, payment failures are skipped, auth failures run, and analytics latency runs.
- Group not working: confirm the rule was created with
.inGroup("name")orgroup: "name", and confirm you awaited the toggle before triggering the request. - Group name errors: group names must be strings after trimming. Empty strings, whitespace-only strings, and
nullthrow. - SW toggling issues: call
injectSWChaosafter the page has an active Service Worker controller, and useenableSWGroupordisableSWGroupfor SW rules. Page-sideenableGroupdoes not toggle SW rules.
await injectChaos(page, {
sse: {
drops: [{ urlPattern: '/events', eventType: 'token', probability: 0.1 }],
},
network: {
failures: [{
urlPattern: '/graphql',
graphqlOperation: 'GetUser',
statusCode: 503,
probability: 1,
}],
},
});Streaming UIs read responses chunk by chunk (fetch(...).body.getReader(),
EventSource, WebSocket.onmessage). The fetchStream slice wraps every
Response.body so chunk-level chaos applies to AI SDKs that read the stream
through the SDK rather than the user's Response. The ai shorthand
compiles into the matching transport rules for fetch-stream, SSE, AND
WebSocket so one scenario covers whichever transport the SDK picked.
await injectChaos(page, {
ai: {
firstChunkDelayMs: 800,
pauseAfterChunk: 4,
pauseDurationMs: 2000,
truncateAfterChunk: 12,
duplicateChunkProbability: 0.05,
transport: 'auto', // 'fetch-stream' | 'sse' | 'websocket' | 'auto' (default)
},
});Every emitted streaming event carries detail.phase (ai:first-chunk,
ai:stream-paused, ai:stream-resumed, ai:stream-truncated,
ai:chunk-duplicated) and a stable detail.connectionId, so report
consumers can reconstruct a chunk-level timeline. See the AI streaming
concept
and the recipes for fetch-stream
and SSE.
Capture a stream to a plain JSON fixture and replay it deterministically with
no live backend, then mutate chunks to reproduce broken responses. Load a
fixture on the Node side with loadStreamFixture (Playwright, Puppeteer,
WebdriverIO; Cypress uses cy.readFile) and pass it to ai.replay:
import { injectChaos, loadStreamFixture } from '@chaos-maker/playwright';
await injectChaos(page, {
ai: {
replay: {
data: loadStreamFixture('fixtures/chat-stream.json'),
mutations: [
{ type: 'split', chunkIndex: 7, at: 32 }, // break a chunk in two
{ type: 'truncate', afterChunk: 12 }, // cut the stream short
],
},
},
});Replay works across fetch-stream (block or substitute mode), SSE, and WebSocket, and is fully deterministic (no RNG). See the stream replay concept and the broken-markdown recipe.
Streams also break when the HUMAN acts mid-generation. The userInteraction
namespace fires deterministic triggers on a fixed schedule: cancel the
in-flight stream, storm the retry button, background the tab, edit the prompt
while chunks arrive, or navigate away.
await injectChaos(page, {
userInteraction: {
cancelStreamAfterMs: 4000,
tabHidden: { afterMs: 1000, durationMs: 3000 },
retryStorm: { count: 5, intervalMs: 200 },
},
});Cancelled fetch streams reject with a real AbortError; SSE sources close and
dispatch error; WebSockets close. Every trigger emits ui:* events tagged
with user:* phases (user:cancel, user:tab-hidden, ...) that render in the
timeline report alongside transport chaos. See the human interaction
concept
and the cancel-mid-stream recipe.
Getting started | Concepts | Recipes | API
bun install # install all workspace dependencies
bun run build # build all packages
bun run test # unit tests
bun run lint # eslint
bun run dev:docs # local docs dev server
bun run build:docs # build docs for productionPull requests are welcome. See CONTRIBUTING.md for guidelines.
Run the full check before submitting:
bun run lint && bun run test && bun run build
bun run test:playwright -- --project=chromium