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
20 changes: 20 additions & 0 deletions apps/desktop/scripts/build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ fetch_marksyncr() {
[ -n "$MKS_SRC" ] || echo " ! MarkSyncr fetch skipped (non-fatal)"
}

# The Node automation runtime for `tron snapshot|click|fill` (PRD M3.2). The
# @tronbrowser/browser-core source has no runtime deps, so its compiled dist tree
# is self-contained; ship it with a {"type":"module"} marker and the shell
# dispatcher runs it via node. Best-effort like the extension fetches — a build
# host without node/pnpm simply omits it (the CLI then reports "run tron upgrade").
stage_automation() { # dest dir
local s="$1"
command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1 || {
echo " ! automation runtime skipped (needs node + pnpm)"; return; }
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core build >/dev/null 2>&1 ); then
rm -rf "$s/automate"
cp -R "$REPO_ROOT/packages/browser-core/dist" "$s/automate"
printf '{\n "type": "module"\n}\n' > "$s/automate/package.json"
echo " + bundled automation runtime (tron snapshot/click/fill)"
else
echo " ! automation runtime skipped (browser-core build failed)"
fi
}

stage() { # dest dir
local s="$1"
mkdir -p "$s/extensions"
Expand All @@ -62,6 +81,7 @@ stage() { # dest dir
# Managed-session engine for `tron browser …` / `tron open` (PRD M3.1). Sits
# next to the shim; the `tron` dispatcher resolves it relative to $CURRENT.
install -m 0755 "$DESKTOP/launcher/tron-session" "$s/tron-session"
stage_automation "$s"
# -L dereferences the branding symlinks (icons/logo.svg -> repo-root logo.svg)
# so the package contains real files, not dangling links.
cp -RL "$DESKTOP/extensions/ai-sidebar" "$s/extensions/ai-sidebar"
Expand Down
20 changes: 20 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ Usage:
tron browser status Show managed-session status (--json for machine output)
tron browser tabs List tabs in the managed session (--json)
tron browser close Close the managed session
tron snapshot Structured, ref-tagged page snapshot (--json)
tron click <ref> Click a snapshot ref, e.g. @e3
tron fill <ref> <val> Fill an input by ref, e.g. tron fill @e4 "hi@x.com"
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 @@ -131,6 +134,20 @@ session_bin() {
echo "$_ld/tron-session"
}

# Node automation runtime entry for `tron snapshot|click|fill|type` (M3.2).
automate_entry() {
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
echo "$_ld/automate/automate-bin.js"
}

# Route a CDP automation subcommand to the Node runtime, or explain what's missing.
run_automation() {
ENTRY="$(automate_entry)"
command -v node >/dev/null 2>&1 || { echo "tron $1 needs Node.js (>=22) on PATH." >&2; exit 1; }
[ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the automation runtime. Run: tron upgrade" >&2; exit 1; }
exec node "$ENTRY" "$@"
}

case "${1:-}" in
open)
shift
Expand All @@ -151,6 +168,9 @@ 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)
# CDP automation on the managed session's current page (PRD M3.2).
run_automation "$@" ;;
restart)
# Force-quit any running TronBrowser, then launch fresh. Chromium forwards a
# new launch to an already-running instance (which keeps the OLD extension
Expand Down
58 changes: 58 additions & 0 deletions docs/snapshots-and-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Snapshots and refs (M3.2)

Once a managed session is running (`tron browser launch`, see
[managed-sessions.md](./managed-sessions.md)), the `tron` CLI can read the
current page as a compact, ref-tagged structure and act on it by ref.

```sh
tron snapshot # compact text snapshot of the current page
tron snapshot --json # machine-readable snapshot
tron snapshot --include-hidden
tron click @e3 # click a ref from the last snapshot
tron fill @e4 "hi@example.com" # fill an input/textarea by ref
```

Text output:

```txt
Page: Contact Us
URL: https://example.com/contact

@e1 heading "Contact Us"
@e2 textbox "Name"
@e3 textbox "Email"
@e4 link "Privacy" -> https://example.com/privacy
@e5 button "Submit"
```

## Refs

A snapshot assigns `@e1`, `@e2`, … to visible interactive elements (and
headings) in document order and tags each element in the page with a
`data-tron-ref` attribute. Because the ref lives in the DOM, a later
`tron click @e3` — a separate process — resolves it with a plain attribute
selector. If the element is gone (navigation, re-render), the action returns a
recoverable **STALE_REF** error (exit code 5) telling you to re-`snapshot`,
rather than acting on the wrong node. Prefer refs over CSS selectors for agents.

Password values are never echoed in snapshots; `--json` includes `role`,
`name`, `value`, `href`, visibility, and interactivity per element.

## How it works

- `snapshot`/`click`/`fill` are Node subcommands the shell `tron` dispatcher
delegates to. They attach to the session's current page via the descriptor's
`webSocketDebuggerUrl` and drive it over the Chrome DevTools Protocol
(`Runtime.evaluate`).
- The CDP client uses Node's global `WebSocket` (Node >= 22) — no dependency.
The runtime is `@tronbrowser/browser-core`'s compiled tree, shipped in the
launcher payload; the dispatcher runs it with `node`.
- Everything stays on `127.0.0.1` — no page content leaves the machine.

## Scope / limitations

- Requires Node.js (>= 22) on PATH, plus a running managed session.
- The snapshot targets the session's current tab (`tron browser use <id>` to
switch). Shadow DOM and cross-origin iframes are out of scope for M3.2.
- Contracts and CDP/DOM logic are unit-tested in
`packages/browser-core/src/automation` and `src/automate-*.test.ts`.
1 change: 1 addition & 0 deletions packages/browser-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"lint": "eslint src"
},
"devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
Expand Down
14 changes: 14 additions & 0 deletions packages/browser-core/src/automate-bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Executable wrapper around the automation CLI. Built into a self-contained
* `automate.js` (see apps/desktop/scripts/build-release.sh) that the shell
* `tron` dispatcher runs via `node`.
*/
import { run } from './automate-cli.js';

run(process.argv.slice(2)).then(
(code) => process.exit(code),
(err: unknown) => {
process.stderr.write(`tron: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
},
);
108 changes: 108 additions & 0 deletions packages/browser-core/src/automate-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { EXIT, run, type CliDeps } from './automate-cli.js';
import type { CdpConnection } from './automation/cdp-client.js';
import type { AgentSnapshot } from './automation/snapshot-script.js';
import type { SessionDescriptor } from './automation/types.js';

const descriptor: SessionDescriptor = {
version: 1,
pid: 1,
host: '127.0.0.1',
port: 9222,
profileDir: '/x',
profileName: 'agent',
headless: false,
ephemeral: false,
createdAt: '2026-07-04T00:00:00.000Z',
activeTabId: 'p1',
};

const snap: AgentSnapshot = {
url: 'https://example.com',
title: 'Example',
timestamp: '2026-07-04T00:00:00.000Z',
elements: [
{ ref: '@e1', role: 'link', name: 'More', tag: 'a', interactive: true, visible: true, href: 'https://x' },
],
};

/** A CdpConnection whose Runtime.evaluate yields `evalValue`. */
function conn(evalValue: unknown): CdpConnection {
return {
send: (async (method: string) =>
method === 'Runtime.evaluate' ? { result: { value: evalValue } } : {}) as CdpConnection['send'],
on: vi.fn(),
close: vi.fn(),
};
}

function harness(overrides: Partial<CliDeps> = {}) {
const out: string[] = [];
const err: string[] = [];
const deps: Partial<CliDeps> = {
env: {},
loadDescriptor: async () => descriptor,
fetchTargets: async () => [
{ id: 'p1', type: 'page', url: 'https://example.com', webSocketDebuggerUrl: 'ws://x/p1' },
],
connect: async () => conn(snap),
out: (t) => out.push(t),
err: (t) => err.push(t),
...overrides,
};
return { deps, out, err };
}

describe('automate-cli run', () => {
it('prints a text snapshot', async () => {
const { deps, out } = harness();
const code = await run(['snapshot'], deps);
expect(code).toBe(EXIT.ok);
expect(out.join('\n')).toContain('@e1 link "More"');
});

it('prints JSON with --json', async () => {
const { deps, out } = harness();
await run(['snapshot', '--json'], deps);
expect(JSON.parse(out.join('\n')).title).toBe('Example');
});

it('clicks a ref', async () => {
const { deps, out } = harness({ connect: async () => conn({ ok: true, ref: '@e1' }) });
const code = await run(['click', '@e1'], deps);
expect(code).toBe(EXIT.ok);
expect(out.join('\n')).toContain('clicked @e1');
});

it('fills a ref', async () => {
const { deps, out } = harness({ connect: async () => conn({ ok: true, ref: '@e2' }) });
const code = await run(['fill', '@e2', 'hello'], deps);
expect(code).toBe(EXIT.ok);
expect(out.join('\n')).toContain('filled @e2');
});

it('exits staleRef when a ref no longer resolves', async () => {
const { deps, err } = harness({
connect: async () => conn({ ok: false, error: 'STALE_REF', ref: '@e9' }),
});
const code = await run(['click', '@e9'], deps);
expect(code).toBe(EXIT.staleRef);
expect(err.join('\n')).toMatch(/stale/i);
});

it('exits noSession when there is no descriptor', async () => {
const { deps, err } = harness({
loadDescriptor: async () => {
throw new Error('ENOENT');
},
});
const code = await run(['snapshot'], deps);
expect(code).toBe(EXIT.noSession);
expect(err.join('\n')).toContain('tron browser launch');
});

it('exits usage when click is missing a ref', async () => {
const { deps } = harness();
expect(await run(['click'], deps)).toBe(EXIT.usage);
});
});
Loading
Loading