Skip to content

Commit 6ea76f4

Browse files
ralyodioclaude
andcommitted
feat(m3.7): trace and replay (tron trace / tron replay)
Records automation commands into a .trontrace bundle and replays them (PRD §19). tron trace start [path] tron trace stop tron trace status tron replay <bundle> - browser-core/automation/trace.ts: the bundle format — metadata.json, commands.jsonl (values redacted), snapshots/NNNN.json, errors.jsonl. Recording is cross-process via an active-trace pointer under the data dir. - automate-cli: `trace` (start/stop/status) and `replay` commands; snapshot/ click/fill now append to the active trace (a fresh snapshot per command). Form values are redacted by default (fill/type -> "[redacted]"). - replay re-runs clicks and non-redacted fills in order, skips redacted fills and snapshots, and stops at the first failing command with its seq. - install.sh routes `tron trace` / `tron replay` to the automation runtime. Tests (+10): the trace library (start/record/stop/read, redaction, errors) and the CLI trace/replay flow (record between start/stop, redaction persisted, status, replay clicks + skip redacted fills). Verified end-to-end via a WS CDP stub: recorded snapshot/click/fill, finalized bundle with per-command snapshots, replayed the click and skipped the redacted fill. Full workspace suite green. Network/console summaries (§19) need a persistent listener and are a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 29a3ac8 commit 6ea76f4

7 files changed

Lines changed: 464 additions & 4 deletions

File tree

apps/web/public/install.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ Usage:
9393
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
9494
tron analyze [goal] Analyze/fill a form or page (--data, --execute, --json)
9595
tron mcp Run a local MCP server over stdio (--headless)
96+
tron trace start|stop Record commands into a .trontrace bundle
97+
tron replay <bundle> Replay a recorded trace against the session
9698
tron upgrade Update to the latest release
9799
tron remove Uninstall TronBrowser (keeps your profile data)
98100
tron version Print the installed version
@@ -175,8 +177,8 @@ case "${1:-}" in
175177
SESSION="$(session_bin)"
176178
[ -x "$SESSION" ] || { echo "This TronBrowser build has no managed-session support (missing tron-session). Run: tron upgrade" >&2; exit 1; }
177179
exec "$SESSION" browser "$@" ;;
178-
snapshot|click|fill|type|extract|screenshot|pdf)
179-
# CDP automation on the managed session's current page (PRD M3.2/M3.3).
180+
snapshot|click|fill|type|extract|screenshot|pdf|trace|replay)
181+
# CDP automation on the managed session's current page (PRD M3.2/M3.3/M3.7).
180182
run_automation "$@" ;;
181183
analyze)
182184
# AI-assisted unknown-interface analysis / form fill (PRD M3.5). Runs via

docs/trace-replay.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Trace and replay (M3.7)
2+
3+
Record the automation commands you run against a managed session into a
4+
`.trontrace` bundle, then inspect or replay them.
5+
6+
```sh
7+
tron trace start ./run.trontrace # begin recording (path optional)
8+
tron snapshot # …commands are recorded as you go
9+
tron click @e3
10+
tron fill @e4 "hi@example.com"
11+
tron trace status # show the active trace
12+
tron trace stop # finalize the bundle
13+
tron replay ./run.trontrace # replay the recorded commands
14+
```
15+
16+
`tron run --trace <dir>` also writes a (simpler) trace for SDK scripts — see
17+
[sdk-and-run.md](./sdk-and-run.md).
18+
19+
## Bundle format
20+
21+
A `.trontrace` bundle is a directory:
22+
23+
```
24+
metadata.json version, startedAt/stoppedAt, command count
25+
commands.jsonl one record per command: { seq, t, name, args, snapshot?, error? }
26+
snapshots/NNNN.json the page snapshot captured after each command
27+
errors.jsonl errors keyed by command seq
28+
```
29+
30+
While a trace is active, an "active trace" pointer under
31+
`~/.tronbrowser/automation/trace.json` tells each `tron` command (a separate
32+
process) where to append, and clears on `stop`.
33+
34+
## Privacy
35+
36+
**Form values are redacted by default.** A `tron fill @e4 "secret"` records the
37+
ref and `value: "[redacted]"` (with `valueRedacted: true`) — never the text.
38+
Cookies, tokens, and secrets are never written.
39+
40+
## Replay
41+
42+
`tron replay <bundle>` re-executes the recorded commands in order against the
43+
current session: clicks and non-redacted fills are replayed; redacted fills are
44+
**skipped** (the value isn't in the bundle), and snapshots aren't re-run.
45+
Replay stops at the first failing command (e.g. a stale ref) and reports the
46+
sequence number — good for reproducing deterministic navigation/click flows.
47+
48+
## Scope
49+
50+
- Requires Node ≥22 and a running managed session for recording/replay.
51+
- Network/console summaries (PRD §19) need a persistent session listener and are
52+
a follow-up; the command/snapshot/error core is implemented here.
53+
- Trace library: `packages/browser-core/src/automation/trace.ts`.

packages/browser-core/src/automate-cli.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ import { CdpClient, type CdpConnection } from './automation/cdp-client.js';
2222
import { cdpListUrl } from './automation/cdp.js';
2323
import { descriptorPath, parseDescriptor, resolveDataDir } from './automation/descriptor.js';
2424
import { printPdf, screenshotPng } from './automation/capture.js';
25+
import {
26+
readActiveTrace,
27+
readCommands,
28+
recordCommand,
29+
startTrace,
30+
stopTrace,
31+
} from './automation/trace.js';
32+
import type { AgentSnapshot } from './automation/snapshot-script.js';
2533
import { extractExpression, parseFieldSpec, type FieldSpec } from './automation/extract-script.js';
2634
import {
2735
captureSnapshot,
@@ -107,6 +115,27 @@ async function attach(deps: CliDeps, dataDir = resolveDataDir(deps.env)): Promis
107115
return conn;
108116
}
109117

118+
/** If a trace is active, append this command (+ a fresh snapshot) to it. */
119+
async function traceRecord(
120+
env: NodeJS.ProcessEnv,
121+
conn: CdpConnection,
122+
name: string,
123+
args: Record<string, unknown>,
124+
snapshot?: AgentSnapshot,
125+
): Promise<void> {
126+
const active = await readActiveTrace(resolveDataDir(env));
127+
if (!active) return;
128+
let snap = snapshot;
129+
if (!snap) {
130+
try {
131+
snap = await captureSnapshot(conn);
132+
} catch {
133+
snap = undefined;
134+
}
135+
}
136+
await recordCommand(active.dir, name, args, snap ? { snapshot: snap } : {});
137+
}
138+
110139
/** Collect all `--field name=selector[@attr]` specs from an arg list. */
111140
function collectFields(args: string[]): FieldSpec[] {
112141
const specs: FieldSpec[] = [];
@@ -202,6 +231,7 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
202231
conn,
203232
rest.includes('--include-hidden') ? { includeHidden: true } : {},
204233
);
234+
await traceRecord(deps.env, conn, 'snapshot', {}, snap);
205235
deps.out(rest.includes('--json') ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap));
206236
return EXIT.ok;
207237
}
@@ -212,7 +242,9 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
212242
return EXIT.usage;
213243
}
214244
conn = await attach(deps);
215-
deps.out(`clicked ${(await clickRef(conn, ref)).ref}`);
245+
const clicked = await clickRef(conn, ref);
246+
await traceRecord(deps.env, conn, 'click', { ref });
247+
deps.out(`clicked ${clicked.ref}`);
216248
return EXIT.ok;
217249
}
218250
case 'fill': {
@@ -223,7 +255,9 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
223255
return EXIT.usage;
224256
}
225257
conn = await attach(deps);
226-
deps.out(`filled ${(await fillRef(conn, ref, value)).ref}`);
258+
const filled = await fillRef(conn, ref, value);
259+
await traceRecord(deps.env, conn, 'fill', { ref, value });
260+
deps.out(`filled ${filled.ref}`);
227261
return EXIT.ok;
228262
}
229263
case 'extract': {
@@ -284,6 +318,64 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
284318
await rm(dataDir, { recursive: true, force: true }).catch(() => {});
285319
}
286320
}
321+
case 'trace': {
322+
const dataDir = resolveDataDir(deps.env);
323+
const sub = rest[0];
324+
if (sub === 'start') {
325+
const dir = rest[1] ?? join(process.cwd(), `session-${Date.now()}.trontrace`);
326+
await startTrace(dataDir, dir);
327+
deps.out(`tracing to ${dir}`);
328+
return EXIT.ok;
329+
}
330+
if (sub === 'stop') {
331+
const res = await stopTrace(dataDir);
332+
if (!res) {
333+
deps.err('no active trace');
334+
return EXIT.usage;
335+
}
336+
deps.out(`stopped trace: ${res.dir} (${res.commands} command${res.commands === 1 ? '' : 's'})`);
337+
return EXIT.ok;
338+
}
339+
if (sub === 'status') {
340+
const active = await readActiveTrace(dataDir);
341+
deps.out(active ? `tracing to ${active.dir}` : 'no active trace');
342+
return EXIT.ok;
343+
}
344+
deps.err('usage: tron trace start [path] | stop | status');
345+
return EXIT.usage;
346+
}
347+
case 'replay': {
348+
const dir = rest.find((a) => !a.startsWith('--'));
349+
if (!dir) {
350+
deps.err('usage: tron replay <trace-dir>');
351+
return EXIT.usage;
352+
}
353+
const commands = await readCommands(dir);
354+
conn = await attach(deps);
355+
let done = 0;
356+
for (const c of commands) {
357+
try {
358+
if (c.name === 'click') {
359+
await clickRef(conn, String(c.args.ref));
360+
} else if (c.name === 'fill' || c.name === 'type') {
361+
if (c.args.valueRedacted) {
362+
deps.out(`skip ${c.name} ${String(c.args.ref)} (value redacted)`);
363+
continue;
364+
}
365+
await fillRef(conn, String(c.args.ref), String(c.args.value));
366+
} else {
367+
continue; // snapshots and non-mutating records are not replayed
368+
}
369+
done += 1;
370+
deps.out(`replayed ${c.name} ${String(c.args.ref ?? '')}`.trimEnd());
371+
} catch (err) {
372+
deps.err(`replay stopped at seq ${c.seq}: ${(err as Error).message}`);
373+
return EXIT.failed;
374+
}
375+
}
376+
deps.out(`replayed ${done} command${done === 1 ? '' : 's'}`);
377+
return EXIT.ok;
378+
}
287379
default:
288380
deps.err(`unknown automation command: ${command}`);
289381
return EXIT.usage;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { mkdtempSync, rmSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { EXIT, run, type CliDeps } from './automate-cli.js';
6+
import { readCommands } from './automation/trace.js';
7+
import type { CdpConnection } from './automation/cdp-client.js';
8+
9+
// Fake conn: Runtime.evaluate returns {ok:true} — good enough for clickRef/fillRef
10+
// and for the trace's post-command snapshot capture (shape is not asserted here).
11+
function conn(hooks: { onEval?: (expr: string) => void } = {}): CdpConnection {
12+
return {
13+
send: (async (method: string, params?: { expression?: string }) => {
14+
if (method === 'Runtime.evaluate') {
15+
hooks.onEval?.(params?.expression ?? '');
16+
return { result: { value: { ok: true, ref: '@e1' } } };
17+
}
18+
return {};
19+
}) as CdpConnection['send'],
20+
on: vi.fn(),
21+
close: vi.fn(),
22+
};
23+
}
24+
25+
let dataDir: string;
26+
let bundle: string;
27+
28+
beforeEach(() => {
29+
dataDir = mkdtempSync(join(tmpdir(), 'cli-trace-'));
30+
bundle = join(dataDir, 'run.trontrace');
31+
});
32+
afterEach(() => rmSync(dataDir, { recursive: true, force: true }));
33+
34+
function harness(evalHook?: (expr: string) => void) {
35+
const out: string[] = [];
36+
const err: string[] = [];
37+
const deps: Partial<CliDeps> = {
38+
env: { TRONBROWSER_DATA: dataDir },
39+
loadDescriptor: async () => ({
40+
version: 1, pid: 1, host: '127.0.0.1', port: 9222, profileDir: '/x',
41+
profileName: 'agent', headless: false, ephemeral: false, createdAt: 't', activeTabId: 'p1',
42+
}),
43+
fetchTargets: async () => [{ id: 'p1', type: 'page', webSocketDebuggerUrl: 'ws://x/p1' }],
44+
connect: async () => conn(evalHook ? { onEval: evalHook } : {}),
45+
out: (t) => out.push(t),
46+
err: (t) => err.push(t),
47+
};
48+
return { deps, out, err };
49+
}
50+
51+
describe('tron trace / replay', () => {
52+
it('records commands into a bundle between start and stop', async () => {
53+
const h = harness();
54+
expect(await run(['trace', 'start', bundle], h.deps)).toBe(EXIT.ok);
55+
await run(['click', '@e1'], h.deps);
56+
await run(['fill', '@e2', 'secret'], h.deps);
57+
expect(await run(['trace', 'stop'], h.deps)).toBe(EXIT.ok);
58+
59+
const cmds = await readCommands(bundle);
60+
expect(cmds.map((c) => c.name)).toEqual(['click', 'fill']);
61+
expect(cmds[1].args.value).toBe('[redacted]'); // never persists the value
62+
});
63+
64+
it('does not record when no trace is active', async () => {
65+
const h = harness();
66+
await run(['click', '@e1'], h.deps); // no trace started
67+
// starting now yields an empty bundle
68+
await run(['trace', 'start', bundle], h.deps);
69+
expect(await readCommands(bundle)).toHaveLength(0);
70+
});
71+
72+
it('reports trace status', async () => {
73+
const h = harness();
74+
await run(['trace', 'status'], h.deps);
75+
await run(['trace', 'start', bundle], h.deps);
76+
await run(['trace', 'status'], h.deps);
77+
expect(h.out[0]).toBe('no active trace');
78+
expect(h.out[h.out.length - 1]).toContain(bundle);
79+
});
80+
81+
it('replays clicks and skips redacted fills', async () => {
82+
// record a click + a fill
83+
const rec = harness();
84+
await run(['trace', 'start', bundle], rec.deps);
85+
await run(['click', '@e1'], rec.deps);
86+
await run(['fill', '@e2', 'secret'], rec.deps);
87+
await run(['trace', 'stop'], rec.deps);
88+
89+
// replay against a fresh session, watching which expressions run
90+
const exprs: string[] = [];
91+
const play = harness((e) => exprs.push(e));
92+
const code = await run(['replay', bundle], play.deps);
93+
expect(code).toBe(EXIT.ok);
94+
expect(play.out.join('\n')).toContain('replayed click @e1');
95+
expect(play.out.join('\n')).toContain('skip fill @e2 (value redacted)');
96+
// the click expression ran; the redacted fill did not
97+
expect(exprs.some((e) => e.includes("data-tron-ref") && e.includes('click'))).toBe(true);
98+
});
99+
100+
it('rejects an unknown trace subcommand', async () => {
101+
const h = harness();
102+
expect(await run(['trace', 'wat'], h.deps)).toBe(EXIT.usage);
103+
});
104+
});

packages/browser-core/src/automation/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ export * from './page.js';
1919
// Headless one-shot, extraction, and capture (PRD M3.3).
2020
export * from './extract-script.js';
2121
export * from './capture.js';
22+
23+
// Trace bundle format (PRD M3.7).
24+
export * from './trace.js';

0 commit comments

Comments
 (0)