Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Usage:
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
tron analyze [goal] Analyze/fill a form or page (--data, --execute, --json)
tron mcp Run a local MCP server over stdio (--headless)
tron trace start|stop Record commands into a .trontrace bundle
tron replay <bundle> Replay a recorded trace against the session
tron upgrade Update to the latest release
tron remove Uninstall TronBrowser (keeps your profile data)
tron version Print the installed version
Expand Down Expand Up @@ -175,8 +177,8 @@ case "${1:-}" in
SESSION="$(session_bin)"
[ -x "$SESSION" ] || { echo "This TronBrowser build has no managed-session support (missing tron-session). Run: tron upgrade" >&2; exit 1; }
exec "$SESSION" browser "$@" ;;
snapshot|click|fill|type|extract|screenshot|pdf)
# CDP automation on the managed session's current page (PRD M3.2/M3.3).
snapshot|click|fill|type|extract|screenshot|pdf|trace|replay)
# CDP automation on the managed session's current page (PRD M3.2/M3.3/M3.7).
run_automation "$@" ;;
analyze)
# AI-assisted unknown-interface analysis / form fill (PRD M3.5). Runs via
Expand Down
53 changes: 53 additions & 0 deletions docs/trace-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Trace and replay (M3.7)

Record the automation commands you run against a managed session into a
`.trontrace` bundle, then inspect or replay them.

```sh
tron trace start ./run.trontrace # begin recording (path optional)
tron snapshot # …commands are recorded as you go
tron click @e3
tron fill @e4 "hi@example.com"
tron trace status # show the active trace
tron trace stop # finalize the bundle
tron replay ./run.trontrace # replay the recorded commands
```

`tron run --trace <dir>` also writes a (simpler) trace for SDK scripts — see
[sdk-and-run.md](./sdk-and-run.md).

## Bundle format

A `.trontrace` bundle is a directory:

```
metadata.json version, startedAt/stoppedAt, command count
commands.jsonl one record per command: { seq, t, name, args, snapshot?, error? }
snapshots/NNNN.json the page snapshot captured after each command
errors.jsonl errors keyed by command seq
```

While a trace is active, an "active trace" pointer under
`~/.tronbrowser/automation/trace.json` tells each `tron` command (a separate
process) where to append, and clears on `stop`.

## Privacy

**Form values are redacted by default.** A `tron fill @e4 "secret"` records the
ref and `value: "[redacted]"` (with `valueRedacted: true`) — never the text.
Cookies, tokens, and secrets are never written.

## Replay

`tron replay <bundle>` re-executes the recorded commands in order against the
current session: clicks and non-redacted fills are replayed; redacted fills are
**skipped** (the value isn't in the bundle), and snapshots aren't re-run.
Replay stops at the first failing command (e.g. a stale ref) and reports the
sequence number — good for reproducing deterministic navigation/click flows.

## Scope

- Requires Node ≥22 and a running managed session for recording/replay.
- Network/console summaries (PRD §19) need a persistent session listener and are
a follow-up; the command/snapshot/error core is implemented here.
- Trace library: `packages/browser-core/src/automation/trace.ts`.
96 changes: 94 additions & 2 deletions packages/browser-core/src/automate-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import { CdpClient, type CdpConnection } from './automation/cdp-client.js';
import { cdpListUrl } from './automation/cdp.js';
import { descriptorPath, parseDescriptor, resolveDataDir } from './automation/descriptor.js';
import { printPdf, screenshotPng } from './automation/capture.js';
import {
readActiveTrace,
readCommands,
recordCommand,
startTrace,
stopTrace,
} from './automation/trace.js';
import type { AgentSnapshot } from './automation/snapshot-script.js';
import { extractExpression, parseFieldSpec, type FieldSpec } from './automation/extract-script.js';
import {
captureSnapshot,
Expand Down Expand Up @@ -107,6 +115,27 @@ async function attach(deps: CliDeps, dataDir = resolveDataDir(deps.env)): Promis
return conn;
}

/** If a trace is active, append this command (+ a fresh snapshot) to it. */
async function traceRecord(
env: NodeJS.ProcessEnv,
conn: CdpConnection,
name: string,
args: Record<string, unknown>,
snapshot?: AgentSnapshot,
): Promise<void> {
const active = await readActiveTrace(resolveDataDir(env));
if (!active) return;
let snap = snapshot;
if (!snap) {
try {
snap = await captureSnapshot(conn);
} catch {
snap = undefined;
}
}
await recordCommand(active.dir, name, args, snap ? { snapshot: snap } : {});
}

/** Collect all `--field name=selector[@attr]` specs from an arg list. */
function collectFields(args: string[]): FieldSpec[] {
const specs: FieldSpec[] = [];
Expand Down Expand Up @@ -202,6 +231,7 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
conn,
rest.includes('--include-hidden') ? { includeHidden: true } : {},
);
await traceRecord(deps.env, conn, 'snapshot', {}, snap);
deps.out(rest.includes('--json') ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap));
return EXIT.ok;
}
Expand All @@ -212,7 +242,9 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
return EXIT.usage;
}
conn = await attach(deps);
deps.out(`clicked ${(await clickRef(conn, ref)).ref}`);
const clicked = await clickRef(conn, ref);
await traceRecord(deps.env, conn, 'click', { ref });
deps.out(`clicked ${clicked.ref}`);
return EXIT.ok;
}
case 'fill': {
Expand All @@ -223,7 +255,9 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
return EXIT.usage;
}
conn = await attach(deps);
deps.out(`filled ${(await fillRef(conn, ref, value)).ref}`);
const filled = await fillRef(conn, ref, value);
await traceRecord(deps.env, conn, 'fill', { ref, value });
deps.out(`filled ${filled.ref}`);
return EXIT.ok;
}
case 'extract': {
Expand Down Expand Up @@ -284,6 +318,64 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
await rm(dataDir, { recursive: true, force: true }).catch(() => {});
}
}
case 'trace': {
const dataDir = resolveDataDir(deps.env);
const sub = rest[0];
if (sub === 'start') {
const dir = rest[1] ?? join(process.cwd(), `session-${Date.now()}.trontrace`);
await startTrace(dataDir, dir);
deps.out(`tracing to ${dir}`);
return EXIT.ok;
}
if (sub === 'stop') {
const res = await stopTrace(dataDir);
if (!res) {
deps.err('no active trace');
return EXIT.usage;
}
deps.out(`stopped trace: ${res.dir} (${res.commands} command${res.commands === 1 ? '' : 's'})`);
return EXIT.ok;
}
if (sub === 'status') {
const active = await readActiveTrace(dataDir);
deps.out(active ? `tracing to ${active.dir}` : 'no active trace');
return EXIT.ok;
}
deps.err('usage: tron trace start [path] | stop | status');
return EXIT.usage;
}
case 'replay': {
const dir = rest.find((a) => !a.startsWith('--'));
if (!dir) {
deps.err('usage: tron replay <trace-dir>');
return EXIT.usage;
}
const commands = await readCommands(dir);
conn = await attach(deps);
let done = 0;
for (const c of commands) {
try {
if (c.name === 'click') {
await clickRef(conn, String(c.args.ref));
} else if (c.name === 'fill' || c.name === 'type') {
if (c.args.valueRedacted) {
deps.out(`skip ${c.name} ${String(c.args.ref)} (value redacted)`);
continue;
}
await fillRef(conn, String(c.args.ref), String(c.args.value));
} else {
continue; // snapshots and non-mutating records are not replayed
}
done += 1;
deps.out(`replayed ${c.name} ${String(c.args.ref ?? '')}`.trimEnd());
} catch (err) {
deps.err(`replay stopped at seq ${c.seq}: ${(err as Error).message}`);
return EXIT.failed;
}
}
deps.out(`replayed ${done} command${done === 1 ? '' : 's'}`);
return EXIT.ok;
}
default:
deps.err(`unknown automation command: ${command}`);
return EXIT.usage;
Expand Down
104 changes: 104 additions & 0 deletions packages/browser-core/src/automate-trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EXIT, run, type CliDeps } from './automate-cli.js';
import { readCommands } from './automation/trace.js';
import type { CdpConnection } from './automation/cdp-client.js';

// Fake conn: Runtime.evaluate returns {ok:true} — good enough for clickRef/fillRef
// and for the trace's post-command snapshot capture (shape is not asserted here).
function conn(hooks: { onEval?: (expr: string) => void } = {}): CdpConnection {
return {
send: (async (method: string, params?: { expression?: string }) => {
if (method === 'Runtime.evaluate') {
hooks.onEval?.(params?.expression ?? '');
return { result: { value: { ok: true, ref: '@e1' } } };
}
return {};
}) as CdpConnection['send'],
on: vi.fn(),
close: vi.fn(),
};
}

let dataDir: string;
let bundle: string;

beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'cli-trace-'));
bundle = join(dataDir, 'run.trontrace');
});
afterEach(() => rmSync(dataDir, { recursive: true, force: true }));

function harness(evalHook?: (expr: string) => void) {
const out: string[] = [];
const err: string[] = [];
const deps: Partial<CliDeps> = {
env: { TRONBROWSER_DATA: dataDir },
loadDescriptor: async () => ({
version: 1, pid: 1, host: '127.0.0.1', port: 9222, profileDir: '/x',
profileName: 'agent', headless: false, ephemeral: false, createdAt: 't', activeTabId: 'p1',
}),
fetchTargets: async () => [{ id: 'p1', type: 'page', webSocketDebuggerUrl: 'ws://x/p1' }],
connect: async () => conn(evalHook ? { onEval: evalHook } : {}),
out: (t) => out.push(t),
err: (t) => err.push(t),
};
return { deps, out, err };
}

describe('tron trace / replay', () => {
it('records commands into a bundle between start and stop', async () => {
const h = harness();
expect(await run(['trace', 'start', bundle], h.deps)).toBe(EXIT.ok);
await run(['click', '@e1'], h.deps);
await run(['fill', '@e2', 'secret'], h.deps);
expect(await run(['trace', 'stop'], h.deps)).toBe(EXIT.ok);

const cmds = await readCommands(bundle);
expect(cmds.map((c) => c.name)).toEqual(['click', 'fill']);
expect(cmds[1].args.value).toBe('[redacted]'); // never persists the value
});

it('does not record when no trace is active', async () => {
const h = harness();
await run(['click', '@e1'], h.deps); // no trace started
// starting now yields an empty bundle
await run(['trace', 'start', bundle], h.deps);
expect(await readCommands(bundle)).toHaveLength(0);
});

it('reports trace status', async () => {
const h = harness();
await run(['trace', 'status'], h.deps);
await run(['trace', 'start', bundle], h.deps);
await run(['trace', 'status'], h.deps);
expect(h.out[0]).toBe('no active trace');
expect(h.out[h.out.length - 1]).toContain(bundle);
});

it('replays clicks and skips redacted fills', async () => {
// record a click + a fill
const rec = harness();
await run(['trace', 'start', bundle], rec.deps);
await run(['click', '@e1'], rec.deps);
await run(['fill', '@e2', 'secret'], rec.deps);
await run(['trace', 'stop'], rec.deps);

// replay against a fresh session, watching which expressions run
const exprs: string[] = [];
const play = harness((e) => exprs.push(e));
const code = await run(['replay', bundle], play.deps);
expect(code).toBe(EXIT.ok);
expect(play.out.join('\n')).toContain('replayed click @e1');
expect(play.out.join('\n')).toContain('skip fill @e2 (value redacted)');
// the click expression ran; the redacted fill did not
expect(exprs.some((e) => e.includes("data-tron-ref") && e.includes('click'))).toBe(true);
});

it('rejects an unknown trace subcommand', async () => {
const h = harness();
expect(await run(['trace', 'wat'], h.deps)).toBe(EXIT.usage);
});
});
3 changes: 3 additions & 0 deletions packages/browser-core/src/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ export * from './page.js';
// Headless one-shot, extraction, and capture (PRD M3.3).
export * from './extract-script.js';
export * from './capture.js';

// Trace bundle format (PRD M3.7).
export * from './trace.js';
Loading
Loading