@three-ws/witness

npm license node zero deps docs part of three.ws

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.

<section data-witness="off">
	<!-- nothing in here is ever recorded -->
</section>
// 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

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.