Faultsense is a lightweight, zero-dependency browser agent. It evaluates end-to-end assertions against real user sessions in production. You write the assertions as annotations on the UI they check, and Faultsense decides pass or fail in a real browser on the user's own device.
It's the expect() without the page: the assertion lives next to the markup it checks
and runs wherever your app runs.
<button
fs-assert="checkout/submit-order"
fs-trigger="click"
fs-assert-added-success=".order-confirmation"
fs-assert-added-error=".error-message[text-matches=try again]">
Place Order
</button>
When a user clicks Place Order: if the order confirmation appears, the success
condition passes. If an error message appears instead, the error condition passes. If
neither happens before the timeout, Faultsense reports a failure and tells you which
assertion broke, on which release, and what should have happened instead.
You don't need a second codebase to test the first one. Your assertions stop being trapped in a script suite that only runs in CI, against a simulated browser, on a network that never drops. They move onto the UI itself and run in the field.
Contents
- Why Faultsense
- How it works
- Quick start
- Instrumentation reference
- JSON-spec instrumentation
- Framework usage
- Works with
- Conformance
- Configuration
- JavaScript API
- Event payload — bring your own sink
- Performance
- Worked examples
- Package info
- License & links
Why Faultsense
The end-to-end test is splitting in two. AI is taking over the driving half: clicking through flows and filling in forms. What's left is the half that was always the point, the assertion half, where you say what "correct" actually means. That's Faultsense.
You already do this when you write a Playwright or Cypress test. Faultsense lets you lift those checks out of the script and onto the element itself, then run them against real user sessions instead of seeded fixtures in CI. It's the same reasoning you already use, minus the fixtures.
How it works
- Annotate — add
fs-*attributes to the flows that matter, right on the elements they check. - Drive — an AI agent walks your flows in staging. Your real users walk them in production.
- Assert — Faultsense evaluates every annotation as the flow runs and records pass or fail. The UI is the signal, so there's no server-side integration to wire up.
- Report — results stream to the stack you already run: your warehouse, your metrics pipeline, wherever you point them.
Every assertion needs three things:
- A key —
fs-assert="checkout/submit-order"identifies this assertion (stable across releases). - A trigger —
fs-trigger="click"defines when the assertion activates. - An expected outcome —
fs-assert-added=".success"defines what should happen.
Quick start
You init the agent with a collector function: a plain callback that receives every
resolved assertion, so you decide where it goes. Send it to your warehouse, an internal
endpoint, or three sinks at once. There's no API key to manage and no backend you're forced
to run.
Via CDN — load the bundle, then init:
<script defer src="https://cdn.faultsense.com/v0.6.0/faultsense-agent.min.js"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
Faultsense.init({
releaseLabel: '2.4.1',
collectorURL: (result) => {
// send it anywhere — this is your sink
navigator.sendBeacon('/faultsense', JSON.stringify(result));
},
});
});
</script>
/v0.6.0/ is served immutable (Cache-Control: public, max-age=31536000, immutable). Use
/v0/ to float to the latest 0.x in development.
Via npm — the same init call:
npm install @faultsense/agent
import { init } from '@faultsense/agent';
const cleanup = init({
releaseLabel: '2.4.1',
collectorURL: (result) => {
// send it anywhere — this is your sink
navigator.sendBeacon('/faultsense', JSON.stringify(result));
},
});
// call cleanup() on unmount / HMR dispose
The default entry is side-effect-free and SSR-safe: importing it never touches window
or document. collectorURL also accepts a URL string (with apiKey) or one of the
built-in 'console' / 'panel' collectors. See
Event payload.
Instrumentation reference
The fs-* attributes work in any framework that renders to the DOM. An instrumented
element carries an assertion key, a trigger, and one or more expectations.
Triggers
Exactly one fs-trigger per element defines when the assertion activates.
| Trigger | When it fires |
|---|---|
click |
Element is clicked |
dblclick |
Element is double-clicked |
change |
Input value changes |
input |
Input receives input |
blur / focus |
Element loses / gains focus |
hover |
Pointer enters the element |
keydown / keydown:<key> |
Key press (optionally a specific key) |
submit |
Form is submitted |
mount |
Element is added to the DOM |
unmount |
Element is removed from the DOM |
load / error |
Media resource loads or fails |
online / offline |
Connectivity changes |
invariant |
Continuous monitoring — reports violations and recoveries only |
event:<name> |
A CustomEvent fires on document |
Attributes go on the interacted element; clicks on descendants resolve up via closest().
For forms, put submit on the <form> or click on the submit button.
Assertion types
The value is a CSS selector, optionally followed by inline modifiers in brackets.
| Attribute | Resolves when |
|---|---|
fs-assert-added="<selector>" |
A matching element appears in the DOM |
fs-assert-removed="<selector>" |
A matching element is removed from the DOM |
fs-assert-updated="<selector>" |
A matched element or subtree is mutated |
fs-assert-visible="<selector>" |
Element exists and has layout dimensions |
fs-assert-hidden="<selector>" |
Element exists but has no layout dimensions |
fs-assert-loaded="<selector>" |
A media element (img/video/iframe) finishes loading |
fs-assert-stable="<selector>" |
Element is not mutated during the timeout window |
fs-assert-emitted="<event>" |
A matching CustomEvent fires on document |
fs-assert-after="<key>" |
The referenced parent assertion(s) have already passed |
addedvs.updatedis the #1 gotcha.added/removed/updatedresolve only from mutation records; a pre-existing match doesn't count as a pass. Useaddedwhen the element doesn't exist yet; useupdatedwhen it exists and its content changes (a class toggle isupdated).visible/hiddenare point-in-time layout checks and pass immediately if already satisfied.
Conditional assertions
Handle multiple outcomes from a single action with condition keys:
<button fs-assert="auth/login" fs-trigger="click"
fs-assert-added-success=".dashboard"
fs-assert-added-error=".error-msg">
Login
</button>
The first matching condition wins and the rest are dismissed (never sent). The UI is the
signal, so nothing changes server-side. For cross-type conditionals (e.g.
removed-success + added-error), group them with fs-assert-mutex="each".
Inline modifiers
Chained onto the selector with CSS-like bracket syntax:
fs-assert-updated='#count[text-matches=\d+]'
fs-assert-updated='#logo[src=/img/new.png][alt=New Logo]'
fs-assert-updated='.panel[classlist=active:true,hidden:false]'
fs-assert-added='.success[text-matches=Order #\d+]'
| Modifier | Checks |
|---|---|
[text-matches=pattern] |
Text content — regex, partial match |
[value-matches=pattern] |
Form control .value — regex, partial match |
[checked=true|false] |
Checkbox / radio checked state |
[disabled=true|false] |
Disabled state |
[focused=true|false] / [focused-within=…] |
Focus state |
[count=N] / [count-min=N] / [count-max=N] |
Element count |
[classlist=class:true,class:false] |
Class presence |
[detail-matches=key:pattern] |
CustomEvent.detail field (with emitted) |
[attr=value] |
Any other bracket key is an attribute check — full match |
Anchoring rule:
text-matches/value-matchesare partial (unanchored; use^exact$to pin them). Attribute checks are full match (auto-anchored). Omit the selector and provide only modifiers to check the triggering element itself:fs-assert-updated="[text-matches=\d+ remaining]".
Assertion keys
Use / to group related assertions hierarchically. Keys must be stable across releases.
Human-readable labels live on the collector side, not here.
fs-assert="checkout/add-to-cart"
fs-assert="checkout/submit-order"
fs-assert="profile/media/upload-photo"
Element-level attributes
| Attribute | Purpose |
|---|---|
fs-assert-timeout="<ms>" |
SLA timeout — fail if not resolved in time |
fs-assert-mpa="true" |
Persist across full page navigation (MPA) |
fs-assert-mutex="<mode>" |
Group conditionals across types (type / each / conditions) |
fs-assert-oob="<keys>" |
Fire a side-effect check when a parent assertion passes |
fs-assert-oob-fail="<keys>" |
Fire a side-effect check when a parent assertion fails |
JSON-spec instrumentation
Sometimes you can't edit the HTML: a third-party widget, generated markup, a SaaS template
you don't control. Or a tool is generating the instrumentation for you, like a recorder or
an importer. For those cases, declare the same assertions as a JSON spec. Both paths run
through the same pipeline and behave identically; the only difference is where the fs-*
pairs live. HTML and JSON can coexist on the same page.
Each entry mirrors the fs-* attribute names and adds one JSON-only key, fs-target: the
CSS selector the trigger binds to. (In HTML that target is implicitly the element itself.)
import { init } from '@faultsense/agent';
init({
releaseLabel: '2.4.1',
collectorURL: (result) => navigator.sendBeacon('/faultsense', JSON.stringify(result)),
spec: [
{
'fs-target': '#submit-btn', // JSON-only: required CSS selector
'fs-trigger': 'click',
'fs-assert': 'checkout/submit-order',
'fs-assert-added': '.confirmation[text-matches=Order #\\d+]',
},
],
});
Every trigger, assertion type, and modifier documented above applies verbatim. Three keys
are required per entry: fs-target, a trigger (fs-trigger, or fs-assert-oob /
fs-assert-oob-fail for OOB children), and fs-assert.
fs-targetis re-resolved on every event, so elements added after init (SPAs, late-rendered content) get picked up on their own; you never re-register them.- Escape backslashes. JSON string rules apply: a regex modifier is
"fs-assert-updated": "#counter[text-matches=\\d+]"(double backslash). A single backslash silently compiles to the wrong regex. Emit specs withJSON.stringify. - Validate against the schema. The published
spec.schema.json(also athttps://faultsense.com/spec.schema.json) is the only place typos infs-*keys surface loudly; the agent itself ignores unknown keys, same as on the HTML side.
Update the spec at runtime, or run purely from JSON:
Faultsense.setSpec(entries); // replace the active spec (installs/tears down as needed)
Faultsense.addSpec(entries); // append — never removes
const entries = Faultsense.getSpec(); // frozen snapshot
// Drive an existing HTML-instrumented page entirely from JSON without stripping attributes:
init({ /* … */, ignoreHtmlAttrs: true, spec: [/* … */] });
See docs/public/agent/json-spec.md for the full
authoring guide, including selector-stability tips and the HTMLJSON co-existence rules.
Framework usage
Faultsense observes the DOM, not framework internals, so fs-* attributes work anywhere
that renders HTML.
React (JSX)
<button onClick={handleAdd}
fs-assert="cart/add-item" fs-trigger="click"
fs-assert-updated="#cart-count">
Add to Cart
</button>
Vue (SFC)
<template>
<button @click="handleAdd"
fs-assert="cart/add-item" fs-trigger="click"
fs-assert-updated="#cart-count">
Add to Cart
</button>
</template>
Svelte
<button on:click={handleAdd}
fs-assert="cart/add-item" fs-trigger="click"
fs-assert-updated="#cart-count">
Add to Cart
</button>
Framework traps: React drops bare boolean attributes, so always use explicit string values (
fs-assert-mutex="each", notfs-assert-mutex). Custom components must forwardfs-*props to the root DOM element. Server-swap frameworks force theaddedvs.updatedchoice: morphdom-style patching preserves element identity (useupdated);outerHTMLreplacement creates a new node (useadded).
Works with
Faultsense is framework-agnostic. The matrix below is verified end-to-end against real framework dev servers via Playwright on every release.
| Framework | Runtime | Coverage |
|---|---|---|
| React 19 + Vite | conformance/react/ |
10/10 scenarios |
| Vue 3 + Vite | conformance/vue3/ |
10/10 scenarios |
| Svelte 5 (runes) + Vite | conformance/svelte/ |
10/10 scenarios |
| Solid 1.9 + Vite | conformance/solid/ |
10/10 scenarios |
| Alpine.js 3 | conformance/alpine/ |
10/10 scenarios |
| Astro 6 (SSR + React island) | conformance/astro/ |
11/11 scenarios |
| Hotwire (Rails 8 + Turbo 8) | conformance/hotwire/ (Docker) |
8/8 scenarios |
| HTMX 2 + Express | conformance/htmx/ |
7/7 scenarios |
| Livewire 3 (Laravel 11) | conformance/livewire/ (Docker) |
8/8 scenarios |
| Phoenix LiveView 1.0 | conformance/liveview/ (Docker) |
8/8 scenarios |
Docker is required only for the Hotwire, Livewire, and LiveView harnesses (each boots its
own Rails / Laravel / Phoenix runtime in a container). The other seven run directly in
Node. See conformance/README.md for setup.
Conformance
Support is enforced through two layers:
- Layer 1 — DOM mutation-pattern suite. An exhaustive jsdom suite
(
tests/conformance/) that locks in every raw DOM mutation shape the agent handles. This is the source of truth: any framework that produces a locked-in shape is supported by transitivity. - Layer 2 — Per-framework harnesses. Minimal real apps under
conformance/run in real browsers as empirical confirmation, and feed newly-discovered patterns back into Layer 1.
The mutation-pattern catalog:
| ID | Pattern | Status | Seen in |
|---|---|---|---|
| PAT-01 | Pre-existing target | supported | SSR React/Vue, Turbo/HTMX server lists |
| PAT-02 | Delayed-commit mutation | supported | HTMX mid-swap classes, React Suspense, transitions |
| PAT-03 | outerHTML replacement |
supported | HTMX hx-swap="outerHTML", Turbo Stream replace |
| PAT-04 | morphdom preserved-identity | supported | Livewire, Turbo 8 morphing, Alpine x-html.morph |
| PAT-05 | Detach-reattach | supported | React keyed reorder / StrictMode, Vue Teleport |
| PAT-06 | Text-only mutation | supported | Solid, Svelte, Vue 3 reactive text, Lit |
| PAT-07 | Microtask batching | supported | React 18 auto-batching, Vue nextTick, Preact signals |
| PAT-08 | Cascading mutations | supported | Redux/Zustand, Turbo Stream broadcasts, hx-swap-oob |
| PAT-09 | Hydration upgrade | supported | Next.js App Router, Remix, Astro, SvelteKit, Nuxt |
| PAT-10 | Shadow-DOM traversal | gap | Lit, Stencil, Salesforce LWC |
Known limitation — Shadow DOM (PAT-10). The
MutationObserveris rooted atdocument.bodyand does not cross shadow-root boundaries, so mutations inside shadow trees are invisible to the agent today. Tracked as a future feature.
Configuration
Passed to Faultsense.init(config) (or as data-* attributes on the script tag).
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
releaseLabel |
string | Yes | — | App version or commit hash |
collectorURL |
string | function | 'console' | 'panel' |
Yes | — | Where results go — see below |
apiKey |
string | If URL | — | API key (required when collectorURL is a URL) |
debug |
boolean | No | false |
Enable console logging |
userContext |
Record<string, any> |
No | — | Arbitrary context attached to every payload (e.g. userId, plan tier) |
userCohorts |
Record<string, string> |
No | — | Low-cardinality cohort dimensions for per-cohort assertion health |
gcInterval |
number (ms) | No | 5000 |
Background sweep interval for stale assertions |
unloadGracePeriod |
number (ms) | No | 2000 |
On page unload, assertions older than this fail; fresher ones are dropped |
spec |
SpecEntry[] |
No | — | JSON-spec instrumentation — peer of fs-* attributes |
ignoreHtmlAttrs |
boolean | No | false |
Ignore all fs-* HTML attributes and run purely from spec |
Assertions have no default per-assertion timer; they resolve naturally when the DOM
changes. Add a per-element SLA with fs-assert-timeout when you want one.
Script-tag data-* attributes map one-to-one: data-release-label, data-collector-url,
data-api-key, data-debug, data-gc-interval, data-unload-grace-period,
data-user-context (JSON), data-user-cohorts (JSON).
CSP: script-src 'self' https://cdn.faultsense.com; and
connect-src 'self' https://collector.example.com;. No inline scripts, no eval.
JavaScript API
| Method | Description |
|---|---|
Faultsense.init(config) |
Initialize the agent. Returns a cleanup function. |
Faultsense.cleanup() |
Tear down the agent — remove all listeners and observers. |
Faultsense.registerCleanupHook(fn) |
Register a function to run during cleanup. |
Faultsense.setUserContext(context) |
Replace the current user context (does not merge). |
Faultsense.setUserCohorts(cohorts) |
Replace the current user cohorts (low-cardinality strings). |
Faultsense.setSpec(entries) |
Replace the active JSON spec. |
Faultsense.addSpec(entries) |
Append entries to the active JSON spec (never removes). |
Faultsense.getSpec() |
Return a frozen snapshot of the active JSON spec. |
Faultsense.version |
The agent's semver string. |
// At init
Faultsense.init({
releaseLabel: '2.4.1',
collectorURL: (result) => navigator.sendBeacon('/faultsense', JSON.stringify(result)),
userContext: { plan: 'pro' },
userCohorts: { plan: 'pro', region: 'us-east' },
});
// After login — pass the complete context each time (no merge)
Faultsense.setUserContext({ userId: 'u_123', plan: 'pro' });
Faultsense.setUserCohorts({ plan: 'pro', region: 'us-east' });
Event payload — bring your own sink
Faultsense sends results wherever you point collectorURL. It never mandates a backend;
bring your own sink. collectorURL accepts four shapes:
- A URL string — one
POSTper resolved assertion vianavigator.sendBeacon(with afetchfallback),Content-Type: application/json, fire-and-forget. RequiresapiKey. This is the contract below. - A function
(payload) => void— an in-process sink (proxy, queue, logger, tests). NoapiKeyneeded; one call per event. 'console'— logs each payload withconsole.log(via@faultsense/console-collector).'panel'— renders results in a Shadow-DOM overlay (via@faultsense/panel-collector), handy in staging.
Only passed and failed are sent (internal dismissed results are dropped), and only on
a status change. Each event has this shape:
interface EventPayload {
api_key: string;
assertion_key: string;
assertion_trigger: string;
assertion_type:
| "added" | "removed" | "updated" | "visible"
| "hidden" | "loaded" | "stable" | "emitted" | "after";
assertion_type_value: string;
assertion_type_modifiers: Record<string, string>;
attempts: number[]; // re-trigger timestamps (rage-click signal)
condition_key: string;
element_snapshot: string; // outerHTML of the instrumented element
release_label: string;
status: "passed" | "failed";
timestamp: string; // trigger (creation) time, ISO 8601
user_context?: Record<string, any>;
user_cohorts?: Record<string, string>;
agent_version: string;
error_context?: { // first uncaught error during the session, if any
message: string;
stack?: string;
source?: string;
lineno?: number;
colno?: number;
};
}
Example body:
{
"api_key": "your-api-key",
"assertion_key": "checkout/submit-order",
"assertion_trigger": "click",
"assertion_type": "added",
"assertion_type_value": ".success-message",
"assertion_type_modifiers": { "text-matches": "Order confirmed" },
"attempts": [],
"condition_key": "success",
"element_snapshot": "<button fs-assert=\"checkout/submit-order\" …>Submit</button>",
"release_label": "2.4.1",
"status": "passed",
"timestamp": "2026-03-24T14:30:00.000Z",
"user_context": { "userId": "u_123", "plan": "pro" },
"user_cohorts": { "plan": "pro", "region": "us-east" },
"agent_version": "0.6.0"
}
A cross-origin URL sink must answer CORS preflight with Access-Control-Allow-Origin,
Access-Control-Allow-Headers: Content-Type, and Access-Control-Allow-Methods: POST, OPTIONS.
Keep user_cohorts low-cardinality; they're for dimensions like plan tier or region, not
user IDs.
Performance
Faultsense is built to stay off the critical path. The numbers below come from a React 19 stress harness running 50–1000 assertions with background DOM churn and CPU throttling, measured with paired A/B runs (Wilcoxon signed-rank test, Hodges–Lehmann 95% CI) on an Apple M4 Pro.
| Metric | Result |
|---|---|
| INP impact | 0 ms across every configuration, including 1000 assertions under 4× CPU throttle |
| New long tasks | Zero — the agent never creates one in steady state |
| Idle heap footprint | +1.7 KB (measurable; 95% CI [+1.5, +1.7 KB]) |
| Heap @ 1000 assertions | ~140 KB — sub-linear scaling (20× the assertions → ~1.8× the heap) |
MutationObserver callback P99 (worst case) |
2.2 ms at 1000 assertions under 4× CPU — 4% of the 50 ms long-task threshold |
| LCP impact (<200 assertions) | Undetectable (deltas fall within noise) |
| Bundle | 17.7 KB gzipped, zero dependencies |
Demo benchmark (HTMX todolist, 30 pairs, 60 s soak):
| Scenario | LCP Δ | INP Δ | Heap Δ | Long tasks |
|---|---|---|---|---|
| Unthrottled, idle | −4 ms | n/a | +1.7 KB | 0 |
| Slow 4G, idle | −4 ms | n/a | +1.7 KB | 0 |
| Unthrottled, active | −4 ms (noise) | +0 ms | −6.9 KB (noise) | 0 |
A typical instrumented page carries 10–50 assertions; 1000 is a deliberate stress case. The
numbers above were measured on agent v0.4.0 (Chromium-only; INP is a lab estimate in
headless Chromium). Reproduce them yourself:
npm run benchmark:stress # scaling curve across assertion counts
npm run benchmark:demo # against examples/todolist-htmx
See tools/benchmark/README.md for full methodology and
caveats.
Worked examples
The examples/ directory contains reference ports you can run locally. Both
use the same assertion keys, so you can diff them side-by-side and see how the
instrumentation pattern maps across rendering paradigms.
- todolist-tanstack — React + TanStack Router + TanStack Start (virtual DOM, JSX interpolation for dynamic assertion values).
- todolist-htmx — HTMX 2 + Express + EJS (server-rendered
fragments,
hx-boostSPA nav, server-side interpolation).
Package info
- Dependencies: none
- Bundle: 17.7 KB gzipped, single file
- Browser support: modern browsers (ES2020+)
- Framework: anything that renders HTML
- License: FSL-1.1-ALv2
License & links
Licensed under FSL-1.1-ALv2, the Functional Source License. It converts to Apache 2.0 two years after each release.