@termwright/trace
v0.7.7
Published
trace format: asciicast v3 + events + semantics, readers/writers, HTML report
Downloads
2,032
Readme
@termwright/trace
The .twtrace archive format for termwright:
a writer that records a live terminal session, a streaming reader that powers
time travel in the runner UI, and a self-contained HTML failure report.
An archive is a directory (zippable for transport):
| File | Content |
| ----------------- | ----------------------------------------------------------------------------------- |
| meta.json | session id, command, viewport, platform, terminal profile, exit, crash, log summary |
| session.cast | asciicast v3; test.step() titles become markers |
| events.jsonl | inputs, resizes, steps, driver actions, assertions, crash |
| semantics.jsonl | content-sized semantic keyframes and revision deltas |
| logs.jsonl | application log entries |
| timeline.jsonl | raw-time anchors and hidden-window transforms |
| COMMITTED | versioned SHA-256 manifest written last before atomic publication |
The layout is normative in /CONTRACTS.md §Trace. Nothing
outside this package reads or writes those files directly.
Install
pnpm add @termwright/traceRequires Node >= 22. ESM only.
Recording
import { createTraceWriter } from '@termwright/trace';
const writer = createTraceWriter(harness, {
dir: 'out/login.twtrace',
command: ['node', 'app.js'],
columns: 100,
rows: 30,
idleTimeLimit: 2,
});
writer.hide(); // keep setup noise out of the recording
await harness.waitForText('ready');
writer.show();
const step = writer.addStep('submit the form'); // → cast marker "submit the form"
await harness.getByRole('button', { name: 'Submit' }).click();
step.end('failed', 'button stayed disabled');
await writer.finalize();The writer attaches to anything exposing sessionId and events — a
TerminalHarness, or a fake in tests. Driver actions, application logs and
crashes arrive on their own through those events; nothing above reports them by
hand. recordAction exists only for work the driver cannot see, and calling it
for a harness action would record that action twice.
Records append immediately to a private sibling staging directory through a
bounded queue. Finalization drains and fsyncs it, writes COMMITTED, then
atomically renames it into place. A crash or ENOSPC leaves an explicitly
incomplete staging artifact; readers require and verify COMMITTED.
The two timelines
The disk format carries one canonical time and the reader exposes a derived one:
t— wall-clock milliseconds since recording started.castOffset— reader-derived position on the recording:tafterhide()windows were cut out and idle gaps compressed.
Persisting the derived value on every line made the old writer retain the whole
run until finalization. Trace v4 stores raw t plus timeline.jsonl; readers
derive castOffset lazily and reject the obsolete on-disk field.
Everything a player or UI seeks to is a castOffset.
Reading
import { openTrace } from '@termwright/trace';
const trace = await openTrace('out/login.twtrace'); // directory or zip
const state = await trace.stateAt(1_500);
state.castPrefix; // output to write into an emulator
state.columns; // viewport after resizes up to that point
state.nearestSemanticRevision; // newest tree at or before it
state.step; // the step covering that moment
state.logs; // preceding log entries, bounded
for await (const event of trace.events()) console.log(event.kind, event.castOffset);
for (const step of await trace.steps()) console.log(step.title, step.status);
await trace.close();Failures come back with a code that says whose mistake it was: not-found when
the path holds no archive, protocol-violation when it holds a broken one.
stateAt is the time-travel primitive: scrub to an offset, get everything
needed to render that moment. packTrace(dir, file) and
unpackTrace(file, dir) zip an archive for CI upload and read it back.
Frames, and why they line up
import { frameAt } from '@termwright/trace';
const frame = await frameAt(trace, 1_500);
frame.cell(3, 10); // a driver-shaped CellSnapshot
frame.text();frameAt replays the output prefix back into a cell grid shaped like the
driver's ScreenSnapshot, so a recorded moment can be inspected cell by cell or
handed to @termwright/screenshot.
It measures characters with the profile the session used
(meta.terminalProfile, captured from TerminalHarness.terminalProfile) through
the shared emulator in @termwright/vt. That matters more than it sounds: when
the session and its replay used different width tables, an emoji was two columns
live and one on replay, and the screenshot quietly disagreed with the assertion.
An archive naming a profile this build does not know is rejected rather than
replayed with the wrong tables.
Application logs
A TUI cannot print diagnostics to the screen without corrupting the render, so
logs.jsonl carries what the program said about itself: lines from a followed
log file, and structured records from an adapter that negotiated the logs
capability. Both land in one shape with a message field, so nothing has to
branch on provenance before printing an entry.
if (trace.meta.logs !== undefined) {
for await (const entry of trace.logs()) {
console.log(entry.level ?? 'log', entry.label, entry.message);
}
}
// Or just the window leading up to a moment, for a scrubbing UI:
const around = await trace.stateAt(1_500, { logWindow: 50 });label is the display name of the stream; logger and path are kept
separately, because filtering by channel (db.pool) or attributing a line to a
file are different questions from "which stream do I render this under" — and a
label may be shared between sources.
A followed file line carries no level. The driver does not infer one from the text and neither does this package: colouring a report by substring match is wrong often enough to be worse than no colour.
meta.logs summarises the file — count, per-level counts, sources — and reports
how many entries were refused after the append-only maxLogEntries ceiling
(10 000 by default).
Every log line and structured attribute passes the same artifact sanitizer before the append spool. Source-side redaction remains useful defense in depth, but is no longer the persistence boundary.
Semantic values and input/action payloads use artifactSecurity.mode:
raw is the default, while redacted and none are explicit choices. Enable
redacted before producing artifacts that leave the project; sensitive semantic
values are then stored as typed withheld observations. Executable keyboard
values never enter an ActionReceipt; receipts contain recorded projections only.
When the program dies on its own
A signal, or a non-zero exit nobody asked for, lands in meta.crash and is
marked on the timeline in events.jsonl.
if (trace.meta.crash !== undefined) {
console.error(trace.meta.crash.screenTail.join('\n'));
const tree = await trace.crashSemantic(); // the tree current at the time
}Crash tails, diagnostics and input previews pass the same policy as the live
streams. raw stores them verbatim by default. redacted matches registered
secrets across output chunks and ANSI style controls when explicitly enabled.
The report
import { generateHtmlReport } from '@termwright/trace';
await generateHtmlReport({
outFile: 'out/report.html',
results: [{ id: 't1', title: 'login', status: 'failed', tracePath: 'out/login.twtrace' }],
});One HTML file that makes no network requests at all — the asciinema player is
inlined from node_modules at generation time.
For a failing test it derives the screen before the failing step and at failure,
renders both to styled HTML with the changed rows highlighted, lists the
semantic changes as sentences (button "Submit" state changed to disabled),
shows the crash panel and the logs from the failing step, and embeds the
recording positioned on that step's marker. Failed driver actions sit on the
timeline beside the steps with their error code, so a failure reads as "the
click never landed, and here is why" rather than as a screen that did not
change.
A test that passed keeps its archive too under trace: 'on': its section is
collapsed, but it names the .twtrace path and shows the whole log.
Callers that already have the pieces can supply them instead of a trace —
visual and semantic for a snapshot mismatch, crash when recording was off,
screenshots for PNGs from @termwright/screenshot. The report embeds images;
it never rasterises anything itself, which keeps a native renderer out of every
test run.
Development
pnpm build && pnpm typecheck && pnpm testImplementation decisions and open threads: NOTES.md.
