@three-ws/witness
Turn a real user session into a runnable failing test.
A bug report is a description of an experiment somebody else has to reconstruct. This makes it the experiment.
import { witness } from '@three-ws/witness';
witness.start();
// ...someone uses your site and something breaks...
import { compileToPlaywright } from '@three-ws/witness/compile';
const { source } = compileToPlaywright(witness.trace(), { title: 'Export does nothing' });
source is a Playwright spec. It is red while the bug exists and green once it
is fixed, so "can you reproduce it" and "is it actually fixed" stop being
conversations.
// Generated by @three-ws/witness from a real user session.
// Report: Export does nothing
// Replay confidence: 80/100. Every step is anchored to a stable handle.
//
// This test asserts the FAILURE IS GONE, so it is red until the bug is fixed.
// What the visitor did:
// Open /avatar-studio
// Click the button [export]
// Type 11 characters into the field labelled "Model name"
// Click the button "Download"
// The request failed: POST /api/export -> 500
import { test, expect } from '@playwright/test';
test.describe('witness repro', () => {
test.use({ viewport: { width: 390, height: 844 }, locale: 'en-GB', hasTouch: true, isMobile: true });
test('Export does nothing', async ({ page }) => {
const pageErrors = [];
const failedRequests = [];
page.on('pageerror', (err) => pageErrors.push(`${err.name}: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) failedRequests.push(`${res.request().method()} ${new URL(res.url()).pathname} -> ${res.status()}`);
});
await page.goto('/avatar-studio');
await page.getByTestId("export").click();
await page.getByLabel("Model name").fill('xxxxxxxxxxx'); // visitor typed 11 text character(s)
await page.getByRole("button", { name: "Download" }).click();
// The session saw these requests fail:
// POST /api/export -> 500
expect(failedRequests, `a request failed during the reported flow`).toEqual([]);
});
});
Why this is not session replay
Session replay records the DOM mutation stream, weighs megabytes per session, costs real money to store, and gives you a video to watch. You still have to reproduce the bug yourself afterwards.
Witness records intent, in about two kilobytes:
goto /avatar-studio
click the button "Export"
fill the field "Model name" (11 chars)
click the button "Download"
xhr POST /api/export -> 500
error TypeError: exportGLB is not a function (studio.js:412)
Because it is that small it can be always-on. Because it is always-on, the trace already exists at the moment somebody decides to complain. Nobody can reproduce a bug on request. Everybody has just done it.
Install
npm install @three-ws/witness
import { witness } from '@three-ws/witness';
witness.start();
// Ask at the moment of failure, which is the only moment the person still
// remembers what they were doing.
witness.onFailure(() => showYourFeedbackPrompt());
No dependencies. No build step. Works on any site, any framework, in about 6 KB.
The selector ladder
A recorder that emits div > div:nth-child(3) > button produces tests that pass
review and break on the next refactor. Every element gets the strongest handle it
actually has, verified unique against the live document before it is
accepted, and demoted one rung at a time until something holds:
| Rung | Handle | Emits | Score |
|---|---|---|---|
| testid | [data-testid], [data-test], [data-qa], [data-cy] |
getByTestId() |
100 |
| id | #id, unless it looks framework-generated |
locator('#id') |
95 |
| label | [aria-label] |
getByLabel() |
85 |
| role | role + accessible name | getByRole() |
80 |
| text | visible text on buttons and links | getByText() |
70 |
| name | [name] on form controls |
locator() |
65 |
| placeholder | [placeholder] |
getByPlaceholder() |
60 |
| class | one stable-looking class token | locator() |
40 |
| path | :nth-of-type chain |
locator() |
10 |
Framework-generated ids (:r3a:, css-1x2y3z4, radix-abc123) and utility
classes (px-4, sm:text-lg, -mt-2) are refused: a test hung off .px-4 is a
test hung off a design decision.
Every trace carries a replay confidence equal to its weakest link, because one fragile locator breaks the whole spec:
import { replayConfidence } from '@three-ws/witness/compile';
replayConfidence(trace);
// { score: 40, weakest: {...}, note: 'A step is anchored to a class name, which a restyle can break.' }
That number is honest feedback about your own markup. A codebase that adds
data-testid to its primary actions watches it climb.
Privacy is a property of what gets recorded
Not of what gets deleted afterwards. A recorder that captures everything and scrubs later has already put the secret in memory, in a buffer, and one bug away from a network call.
- Typed values are never held. A filled field records
text:11, meaning eleven characters of ordinary text. The characters do not exist in the trace. - Password, hidden, email, tel, and date inputs record nothing at all, and
neither does any field whose name, id,
autocomplete, label, or placeholder suggests it holds something personal. - URLs are stripped at capture. Userinfo and the hash go entirely; the names of query parameters are kept, their values redacted when they look like credentials.
- Free text is scanned for the shapes secrets take: emails, JWTs, API keys, card numbers, wallet addresses, long base58 runs.
- Opt out per subtree with
data-witness="off". Nothing inside it is observed, not even that it was clicked.
<section data-witness="off">
<!-- nothing in here is ever recorded -->
</section>
- Mark a request whose failures you already handle with an
x-witness: handledheader. The call is still recorded, so a report the user files carries it, but its 5xx stops counting as an unhandled failure. That matters becausehasFailure()is what a host app reads to decide whether to proactively ask "something just broke, want to tell me what you were doing?". A page that renders a designed state for a 503 (a rate limit, a lane outage) should not trigger that offer, and on a narrow screen the offer landed on top of the recovery buttons the page was already showing.
// This endpoint's 429 and 503 both have designed states in the UI.
await fetch('/api/forge', { method: 'POST', headers: { 'x-witness': 'handled' } });
A request that never resolves at all (a dropped connection, DNS failure) still counts as a failure with or without the marker: no UI can have a designed state for a response it never received.
It never changes the page it watches
- Listeners are capture-phase and never call
preventDefault. - The
fetchandXMLHttpRequestwrappers return the original object untouched on every path, including the throwing ones. - Every handler is wrapped so a throw inside the recorder cannot surface as an error in your page.
- The buffer is bounded, so a tab left open for eight hours costs what one open for eight seconds costs.
Instrumentation that can change behaviour is a bug generator, not a bug reporter.
The honest failure mode
When a session recorded no exception and no failed request, the visitor judged the result wrong, and no recorder can see that. The compiler will not invent an assertion to look useful. It emits the steps, then:
// The browser recorded no exception or failed request for this report:
// the visitor judged the RESULT wrong, which no recorder can see.
// Replace this with the assertion that states what should have happened.
expect(pageErrors, 'no page errors during the reported flow').toEqual([]);
test.fail(true, 'Add the assertion this report is really about.');
You get the reproduction steps for free and write the one line only a human can.
API
witness.start(options?)
Begin recording. Idempotent. Returns the Recorder.
| Option | Default | Meaning |
|---|---|---|
maxEvents |
60 |
Ring buffer size. The initial navigation is never evicted. |
coalesceMs |
900 |
Identical repeats inside this window become a count, not new events. |
captureNetwork |
true |
Wrap fetch/XHR to notice failed requests. |
captureConsole |
true |
Record uncaught errors and unhandled rejections. |
ignore |
none | (path) => boolean. Exclude your own reporting endpoint, or a noisy poller. |
witness.trace()
The trace so far. Valid and empty before start().
witness.hasFailure({ withinMs }) / witness.onFailure(fn)
Whether the session hit something the browser itself called a failure, and a subscription for the moment it happens. This is the trigger for asking.
witness.stop()
Stop and restore every patched global.
compileToPlaywright(trace, { title, baseUrl, reportId })
Returns { filename, source, confidence, steps }.
narrate(trace)
The same sequence as plain English, for a dashboard or an issue body. The narration and the spec come from one source, so they can never disagree.
describeElement(el) / toPlaywrightLocator(desc)
The selector engine on its own, if you want stable locators for something else.
Where this came from
three.ws puts a 3D agent in the corner of every page. When a page throws, the agent turns around and asks what you were doing, and the answer arrives with the session already attached. The maintainer's queue shows one row per problem with a Copy test button.
The rest of that loop is documented at three.ws/docs/feedback. This package is the part that does not depend on any of it.
License
Apache-2.0
Part of three.ws, the open-source platform for 3D AI agents. This repository is exported from the monorepo, which is the source of truth: open issues here, and send larger changes there.