Guides
Demo recording
The headless Playwright harness that drives a real WebAuthn ceremony via a CDP virtual authenticator.
scripts/record-demo.mjs drives the deployed workstation through a full approval and records it. Run it with:
npm run record:demo
The output is a .webm under recordings/<timestamp>/. Playwright writes the video when the browser context closes, so the file does not appear until the script finishes.
Why Playwright
No system screen recorder and no ffmpeg were available, and installing ffmpeg needed interactive sudo. Playwright was already present in a sibling project at 1.49.1 and has video recording built in, so it became the recorder as well as the driver.
Two things had to be worked around.
ESM ignores NODE_PATH. Node resolves ESM imports relative to the importing file, so import { chromium } from 'playwright' could not pick up the sibling project's install. Playwright is now a devDependency of the demo repo, pinned to the same 1.49.1.
MP4 needs an external encoder. Playwright's bundled ffmpeg is stripped to webm only — it has no MP4 demuxer at all. That produced a misleading diagnosis: probing the transcoded MP4 with the bundled binary failed, which looked like a corrupt file when in fact the tool simply could not read the format. The file was verified correct by walking its atoms directly (ftyp, moov at offset 24, mdat, all 22,396,958 bytes accounted for) and by decoding it in a real player. The transcode itself used the x264 encoder bundled with the VLC snap, which needs no sudo.
Both outputs are kept: .webm at 12 MB from Playwright, and .mp4 at 22 MB, H.264, 2m27s.
The virtual authenticator
The signing step needs a WebAuthn credential, and a recording cannot wait for someone to touch a USB key. The script attaches a CDP virtual authenticator instead:
const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'usb',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true
}
});
This is a real CTAP2 authenticator implemented in Chrome. navigator.credentials.create() and .get() run their normal code paths, the assertion is genuinely signed, and the backend verifies it exactly as it verifies a hardware key. transport: 'usb' makes it present as a cross-platform authenticator, matching quarkus.webauthn.authenticator-attachment=cross-platform.
automaticPresenceSimulation and isUserVerified supply the user gesture and the verification flag that a physical touch would provide. Those two facts are simulated; nothing else is.
This is the only place automation stands in for the hardware. Interactive testing is done with a real key, per the project's own rule — this exists so a two-and-a-half minute video does not require a human finger on cue.
Structure
Ten sections, driven by four helpers that exist to make the recording watchable rather than correct:
| Helper | Purpose |
|---|---|
beat(page, ms) |
pause long enough to read what is on screen |
glide(page, toY, ms) |
eased scroll via requestAnimationFrame, so the view slides instead of jumping |
section(page, path, settle) |
navigate, wait for networkidle, settle |
type(page, selector, text) |
clear, then type at 28 ms per character |
glide runs its own easing loop in the page rather than using scrollIntoView, because instant jumps read badly on video:
const ease = t => t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
The sequence:
| Section | What it shows |
|---|---|
| 1 | /about — problem, architecture, mobile flow, compliance, comparison |
| 2 | / — the six KPIs and the priority queue |
| 3 | /files — status filter, then a DBT search, then cleared |
| 4 | /files/[id] — document of record and the noting sheet thread |
| 5 | Enrol the token |
| 6 | Type a noting and sign |
| 7 | /verify — the six checks and the signer certificate |
| 8 | /audit — the approval now on record |
| 9 | /devices — the inventory |
| 10 | /developer, then back to the dashboard |
It opens on /about rather than the dashboard because the recording has to explain what the product is before showing its screens.
State independence
The script does not hardcode a file id. It asks the API which files are genuinely pending and takes the first, which is the highest-priority one because the backend already sorts that way:
const pending = await page.evaluate(async () => {
const res = await fetch('/backend/files?status=PENDING', { credentials: 'same-origin' });
return res.json();
});
if (pending.length === 0) throw new Error('No PENDING file to sign — reseed the backend first.');
const subject = pending[0];
await page.locator('a.tableLink', { hasText: subject.fileNumber }).first().click();
That matters because signing is destructive to the seed: run the script twice and the file it signed the first time is no longer PENDING. Selecting dynamically means the second run picks the next one instead of failing on a stale id. The explicit throw when nothing is pending gives a clear instruction rather than a confusing selector timeout.
Waiting on state, not on time
Every step that depends on the application waits for an observable condition.
The enrolment step waits for the decision controls to unlock, not for the ceremony to "look" finished:
await page.click('button:has-text("Enrol token")');
await page.locator('textarea').waitFor({ state: 'visible' });
await page.waitForFunction(
() => document.querySelector('textarea')?.disabled === false,
null, { timeout: 25000 }
);
The textarea's disabled attribute is driven by GET /api/session returning authenticated: true. Waiting on it means the recording cannot proceed unless the backend genuinely accepted the assertion and issued a session — so a passing run is evidence the ceremony worked, not just that the UI animated.
Signing waits for the signature panel the same way:
await page.click('button.approveButton');
await page.locator('.signedCard').waitFor({ state: 'visible', timeout: 25000 });
The beat() calls are purely for pacing and never carry correctness.
Coupling to the UI
The script selects on button.approveButton, .signedCard, a.tableLink, input[aria-label="Search files"], select >> nth=0 and textarea. Renaming any of those classes breaks the recording with a timeout rather than a clear error.
select >> nth=0 is the most fragile — it depends on the status filter being the first <select> in the DOM, so adding a filter before it silently changes what gets set. input[aria-label="Search files"] is the sturdiest, because it is anchored to an accessibility label that has a reason to stay stable.
The /developer section clicks tabs labelled iOS and WebView, so it is coupled to that page's tab labels as well as to its class names.
Configuration
| Variable | Default |
|---|---|
DEMO_BASE_URL |
http://webauthn-demo.localhost |
DEMO_OUT_DIR |
<cwd>/recordings |
Viewport is fixed at 1600×1000 with --force-device-scale-factor=1, so the output is deterministic across machines and the recording is not a retina-scaled 3200px file.
The default base URL means the script targets the Kubernetes deployment, not localhost:3000. Point it locally with:
DEMO_BASE_URL=http://localhost:3000 npm run record:demo
Bear in mind the RP ID differs between the two. That does not matter here, because the virtual authenticator enrols fresh during the run rather than reusing a stored credential.
Supporting scripts
Three small Python utilities were written while diagnosing the MP4 question and are kept because the reasoning is worth preserving:
| Script | Purpose |
|---|---|
scripts/validate-mp4.py |
walks MP4 atoms and checks the byte accounting |
scripts/probe-recording.py |
reports container and stream properties |
scripts/probe-ffmpeg-caps.py |
lists what the bundled ffmpeg can actually demux |
The third is the one that resolved the false alarm: it showed the bundled binary has no MP4 demuxer, so its failure said nothing about the file.
recordings/ and data/ are both gitignored, so the video and the local database stay out of version control.