diff --git a/apps/desktop/launcher/tron-session b/apps/desktop/launcher/tron-session new file mode 100755 index 0000000..6ac3d8e --- /dev/null +++ b/apps/desktop/launcher/tron-session @@ -0,0 +1,370 @@ +#!/bin/sh +# tron-session — managed browser sessions for the `tron` CLI (PRD M3.1). +# +# Launches a CDP-controllable TronBrowser session and drives it through the +# DevTools HTTP endpoints (loopback only). The session descriptor written under +# ~/.tronbrowser/automation/session.json records the port, pid, profile and the +# browser WebSocket endpoint — the attach point for programmatic tooling (M3.2+). +# +# This is the running implementation; the portable schema + tab-mapping contract +# it mirrors lives (and is unit-tested) in packages/browser-core/src/automation. +# +# Subcommands (invoked by the `tron` dispatcher): +# tron browser launch [--headless] [--profile ] [--port N] [--force] +# tron browser status [--json] +# tron browser tabs [--json] +# tron browser use +# tron browser current +# tron browser close +# tron open # opens in the managed session; exits 3 if none (legacy fallback) +set -eu + +# Resolve our own real directory (we sit next to the `tronbrowser` shim). +SELF="$0" +while [ -L "$SELF" ]; do + link="$(readlink "$SELF")" + case "$link" in + /*) SELF="$link" ;; + *) SELF="$(dirname "$SELF")/$link" ;; + esac +done +DIR="$(CDPATH= cd -- "$(dirname -- "$SELF")" && pwd)" +SHIM="${TRONBROWSER_SHIM:-$DIR/tronbrowser}" + +DATA_ROOT="${TRONBROWSER_DATA:-$HOME/.tronbrowser}" +STATE_DIR="$DATA_ROOT/automation" +DESCRIPTOR="$STATE_DIR/session.json" + +PY="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)" + +die() { echo "tron: $*" >&2; exit 1; } + +session_usage() { + cat <] [--port N] + tron browser status [--json] + tron browser tabs [--json] + tron browser use + tron browser current + tron browser close + tron open Open a URL in the managed session +USAGE +} + +# --- descriptor helpers (python-backed JSON) ----------------------------- +desc_field() { # -> value on stdout, nonzero if absent/missing file + [ -f "$DESCRIPTOR" ] || return 1 + "$PY" - "$DESCRIPTOR" "$1" <<'PY' +import json, sys +try: + d = json.load(open(sys.argv[1])) + v = d.get(sys.argv[2]) +except Exception: + sys.exit(1) +if v is None: + sys.exit(1) +print("1" if v is True else "0" if v is False else v) +PY +} + +write_descriptor() { # pid host port profileDir profileName headless eph createdAt ws + mkdir -p "$STATE_DIR" + D_PID="$1" D_HOST="$2" D_PORT="$3" D_PDIR="$4" D_PNAME="$5" \ + D_HL="$6" D_EPH="$7" D_CREATED="$8" D_WS="${9:-}" \ + "$PY" - "$DESCRIPTOR" <<'PY' +import json, os, sys +d = { + "version": 1, + "pid": int(os.environ["D_PID"]), + "host": os.environ["D_HOST"], + "port": int(os.environ["D_PORT"]), + "profileDir": os.environ["D_PDIR"], + "profileName": os.environ["D_PNAME"], + "headless": os.environ["D_HL"] == "1", + "ephemeral": os.environ["D_EPH"] == "1", + "createdAt": os.environ["D_CREATED"], +} +ws = os.environ.get("D_WS", "") +if ws: + d["webSocketDebuggerUrl"] = ws +open(sys.argv[1], "w").write(json.dumps(d, indent=2) + "\n") +PY +} + +set_active() { # + TRON_ACTIVE="$1" "$PY" - "$DESCRIPTOR" <<'PY' +import json, os, sys +p = sys.argv[1] +d = json.load(open(p)) +d["activeTabId"] = os.environ["TRON_ACTIVE"] +open(p, "w").write(json.dumps(d, indent=2) + "\n") +PY +} + +# --- CDP liveness -------------------------------------------------------- +endpoint_alive() { # + [ -n "${1:-}" ] || return 1 + curl -fsS --max-time 1 "http://127.0.0.1:$1/json/version" >/dev/null 2>&1 +} + +session_state() { # -> running | stale | none + [ -f "$DESCRIPTOR" ] || { echo none; return 0; } + _p="$(desc_field port 2>/dev/null || echo '')" + if endpoint_alive "$_p"; then echo running; else echo stale; fi +} + +ws_url() { # -> webSocketDebuggerUrl + # Data goes through an env var, not stdin: `python - <<'PY'` already consumes + # stdin for the script, so a piped body would never reach json.load. + _v="$(curl -fsS --max-time 2 "http://127.0.0.1:$1/json/version" 2>/dev/null || true)" + [ -n "$_v" ] || return 0 + TRON_VER="$_v" "$PY" - <<'PY' +import json, os +try: + print(json.loads(os.environ["TRON_VER"]).get("webSocketDebuggerUrl", "")) +except Exception: + pass +PY +} + +wait_ready() { # -> prints resolved port + _apf="$1"; _pid="$2"; _timeout="$3"; _n=0 + while [ "$_n" -lt "$_timeout" ]; do + if [ -f "$_apf" ]; then + _p="$(head -n1 "$_apf" 2>/dev/null || true)" + case "$_p" in + ''|*[!0-9]*) : ;; + *) if endpoint_alive "$_p"; then echo "$_p"; return 0; fi ;; + esac + fi + # On Linux the shim exec-replaces itself with the browser, so a dead pid + # means it failed to start. (macOS `open` detaches, so we rely on the port + # file there instead of the pid.) + if [ "$(uname -s 2>/dev/null || echo)" != "Darwin" ] && ! kill -0 "$_pid" 2>/dev/null; then + return 1 + fi + sleep 1; _n=$((_n + 1)) + done + return 1 +} + +# --- subcommands --------------------------------------------------------- +cmd_launch() { + _hl=0; _profile=""; _req_port=0; _force=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --headless) _hl=1 ;; + --headed) _hl=0 ;; + --profile) shift; _profile="${1:-}"; [ -n "$_profile" ] || die "--profile needs a value" ;; + --profile=*) _profile="${1#--profile=}" ;; + --port) shift; _req_port="${1:-0}" ;; + --port=*) _req_port="${1#--port=}" ;; + --force) _force=1 ;; + *) die "unknown option for 'browser launch': $1" ;; + esac + shift + done + + if [ "$(session_state)" = running ] && [ "$_force" != 1 ]; then + echo "managed session already running (port $(desc_field port 2>/dev/null || echo '?')). Run 'tron browser close' first, or pass --force." + return 0 + fi + [ -f "$DESCRIPTOR" ] && rm -f "$DESCRIPTOR" + + # Resolve profile. Headless defaults to an ephemeral profile (PRD §8). + [ -z "$_profile" ] && [ "$_hl" = 1 ] && _profile="ephemeral" + _eph=0 + case "$_profile" in + ""|default) _pname="agent"; _pdir="${DATA_ROOT}-agent" ;; + ephemeral) _pname="ephemeral"; _eph=1; _pdir="$(mktemp -d "${TMPDIR:-/tmp}/tronbrowser-agent-XXXXXX")" ;; + *) _pname="$_profile"; _pdir="${DATA_ROOT}-${_profile}" ;; + esac + + mkdir -p "$STATE_DIR" "$_pdir" + _log="$STATE_DIR/session-browser.log" + : > "$_log" 2>/dev/null || true + _apf="$_pdir/DevToolsActivePort" + rm -f "$_apf" 2>/dev/null || true + + _hlmsg=""; [ "$_hl" = 1 ] && _hlmsg=", headless" + echo "launching managed TronBrowser session (${_pname} profile${_hlmsg})…" >&2 + TRON_AUTOMATION_PORT="$_req_port" TRON_AUTOMATION_HEADLESS="$_hl" \ + TRONBROWSER_DATA="$_pdir" TRONBROWSER_LOG="$_log" \ + nohup "$SHIM" >>"$_log" 2>&1 & + _bpid=$! + + _port="$(wait_ready "$_apf" "$_bpid" 30 || true)" + if [ -z "$_port" ]; then + kill "$_bpid" 2>/dev/null || true + [ "$_eph" = 1 ] && rm -rf "$_pdir" 2>/dev/null || true + die "managed session failed to become ready within 30s (see $_log)" + fi + _ws="$(ws_url "$_port" || true)" + _created="$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" + write_descriptor "$_bpid" "127.0.0.1" "$_port" "$_pdir" "$_pname" "$_hl" "$_eph" "$_created" "$_ws" + echo "managed session ready on 127.0.0.1:$_port (pid $_bpid, profile $_pname)" +} + +cmd_status() { + _json=0; [ "${1:-}" = "--json" ] && _json=1 + _st="$(session_state)" + if [ "$_json" = 1 ]; then + if [ -f "$DESCRIPTOR" ]; then + TRON_STATE="$_st" "$PY" - "$DESCRIPTOR" <<'PY' +import json, os, sys +d = json.load(open(sys.argv[1])) +d["state"] = os.environ["TRON_STATE"] +print(json.dumps(d, indent=2)) +PY + else + printf '{\n "state": "none"\n}\n' + fi + return 0 + fi + case "$_st" in + none) echo "no managed session" ;; + stale) echo "managed session: stale (descriptor present, endpoint unreachable) — run 'tron browser close' to clean up" ;; + running) + _hl="$(desc_field headless 2>/dev/null || echo 0)" + echo "managed session: running" + echo " endpoint : 127.0.0.1:$(desc_field port 2>/dev/null || echo '?')" + echo " profile : $(desc_field profileName 2>/dev/null || echo '?')" + echo " headless : $([ "$_hl" = 1 ] && echo yes || echo no)" + echo " pid : $(desc_field pid 2>/dev/null || echo '?')" ;; + esac +} + +cmd_tabs() { + _json=0; [ "${1:-}" = "--json" ] && _json=1 + [ "$(session_state)" = running ] || die "no managed session (run: tron browser launch)" + _port="$(desc_field port 2>/dev/null || echo '')" + _active="$(desc_field activeTabId 2>/dev/null || echo '')" + _list="$(curl -fsS --max-time 3 "http://127.0.0.1:$_port/json/list" 2>/dev/null || true)" + [ -n "$_list" ] || die "could not query tabs" + TRON_LIST="$_list" TRON_ACTIVE="$_active" TRON_JSON="$_json" "$PY" - <<'PY' +import json, os +data = json.loads(os.environ["TRON_LIST"]) +pages = [t for t in data if t.get("type") == "page"] +active = os.environ.get("TRON_ACTIVE", "") +has = bool(active) and any(t.get("id") == active for t in pages) +rows = [] +for i, t in enumerate(pages): + cur = (t.get("id") == active) if has else (i == 0) + rows.append({"id": t.get("id"), "title": t.get("title", ""), "url": t.get("url", ""), "current": cur}) +if os.environ.get("TRON_JSON") == "1": + print(json.dumps(rows, indent=2)) +else: + if not rows: + print("(no tabs)") + for r in rows: + mark = "*" if r["current"] else " " + title = (r["title"] or "")[:40] + print(f"{mark} {r['id']} {title} {r['url']}") +PY +} + +cmd_current() { + [ "$(session_state)" = running ] || die "no managed session" + _port="$(desc_field port 2>/dev/null || echo '')" + _active="$(desc_field activeTabId 2>/dev/null || echo '')" + _list="$(curl -fsS --max-time 3 "http://127.0.0.1:$_port/json/list" 2>/dev/null || true)" + [ -n "$_list" ] || die "could not query tabs" + TRON_LIST="$_list" TRON_ACTIVE="$_active" "$PY" - <<'PY' +import json, os +data = json.loads(os.environ["TRON_LIST"]) +pages = [t for t in data if t.get("type") == "page"] +active = os.environ.get("TRON_ACTIVE", "") +cur = next((t for t in pages if t.get("id") == active), None) if active else None +if cur is None and pages: + cur = pages[0] +if cur is None: + print("(no tabs)") +else: + print(f"{cur.get('id')} {cur.get('title', '')} {cur.get('url', '')}") +PY +} + +cmd_use() { + _id="${1:-}"; [ -n "$_id" ] || die "usage: tron browser use " + [ "$(session_state)" = running ] || die "no managed session" + _port="$(desc_field port 2>/dev/null || echo '')" + curl -fsS --max-time 2 "http://127.0.0.1:$_port/json/activate/$_id" >/dev/null 2>&1 \ + || die "no such tab: $_id (run: tron browser tabs)" + set_active "$_id" + echo "active tab: $_id" +} + +cmd_close() { + [ -f "$DESCRIPTOR" ] || { echo "no managed session"; return 0; } + _pid="$(desc_field pid 2>/dev/null || echo '')" + _port="$(desc_field port 2>/dev/null || echo '')" + _eph="$(desc_field ephemeral 2>/dev/null || echo 0)" + _pdir="$(desc_field profileDir 2>/dev/null || echo '')" + if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then + kill "$_pid" 2>/dev/null || true + _n=0 + while kill -0 "$_pid" 2>/dev/null && [ "$_n" -lt 10 ]; do sleep 1; _n=$((_n + 1)); done + kill -0 "$_pid" 2>/dev/null && kill -9 "$_pid" 2>/dev/null || true + fi + # macOS `open` detaches, so the stored pid may not be the browser — fall back + # to matching the profile's user-data-dir if the endpoint is still up. + if endpoint_alive "$_port" && [ -n "$_pdir" ]; then + pkill -f "user-data-dir=$_pdir" 2>/dev/null || true + fi + rm -f "$DESCRIPTOR" + if [ "$_eph" = 1 ] && [ -n "$_pdir" ]; then + case "$_pdir" in + /tmp/*|"${TMPDIR:-/tmp}"/*) rm -rf "$_pdir" 2>/dev/null || true ;; + esac + fi + echo "closed managed session" +} + +cmd_open() { # ; returns 3 when no managed session (legacy fallback) + _url="${1:-}"; [ -n "$_url" ] || die "usage: tron open " + [ "$(session_state)" = running ] || return 3 + _port="$(desc_field port 2>/dev/null || echo '')" + _resp="$(curl -fsS --max-time 5 -X PUT "http://127.0.0.1:$_port/json/new?$_url" 2>/dev/null \ + || curl -fsS --max-time 5 "http://127.0.0.1:$_port/json/new?$_url" 2>/dev/null || true)" + [ -n "$_resp" ] || die "could not open tab in managed session" + _id="$(TRON_RESP="$_resp" "$PY" - <<'PY' +import json, os +try: + print(json.loads(os.environ["TRON_RESP"]).get("id", "")) +except Exception: + pass +PY +)" + [ -n "$_id" ] && set_active "$_id" + echo "opened $_url${_id:+ (tab $_id)}" +} + +# --- entrypoint ---------------------------------------------------------- +[ -n "$PY" ] || die "managed sessions need python3 (or python) on PATH" +command -v curl >/dev/null 2>&1 || die "managed sessions need curl on PATH" + +case "${1:-}" in + browser) + shift + case "${1:-}" in + launch) shift; cmd_launch "$@" ;; + status) shift; cmd_status "$@" ;; + tabs) shift; cmd_tabs "$@" ;; + use) shift; cmd_use "$@" ;; + current) shift; cmd_current ;; + close) shift; cmd_close ;; + ""|help|-h|--help) session_usage ;; + *) die "unknown 'tron browser' subcommand: ${1:-}" ;; + esac ;; + open) + shift + if cmd_open "$@"; then :; else + _rc=$? + [ "$_rc" = 3 ] && exit 3 + exit "$_rc" + fi ;; + ""|help|-h|--help) session_usage ;; + *) die "unknown 'tron-session' command: ${1:-}" ;; +esac diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index c3f6637..fe333cc 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -288,6 +288,18 @@ FLAGS="--user-data-dir=$DATA --class=TronBrowser --no-first-run --no-default-bro LOG="${TRONBROWSER_LOG:-$DATA/tron.log}" FLAGS="$FLAGS --log-level=2" +# --- Automation mode (managed sessions, M3.1) ---------------------------- +# `tron browser launch` (via tron-session) sets these to bring up a CDP-driven +# managed session on a LOOPBACK DevTools port. Port 0 lets Chromium pick a free +# port and write it to /DevToolsActivePort. This is additive: without +# TRON_AUTOMATION_PORT the normal `tron ` launch is unchanged. +if [ -n "${TRON_AUTOMATION_PORT:-}" ]; then + FLAGS="$FLAGS --remote-debugging-port=$TRON_AUTOMATION_PORT" + case "${TRON_AUTOMATION_HEADLESS:-0}" in + 1|true|yes|on) FLAGS="$FLAGS --headless=new --disable-gpu" ;; + esac +fi + # --- Start the Tor daemon (only in --tor mode) --------------------------- # Resolve a bundled `tor` next to the launcher, else one on PATH. Start it on a # loopback SOCKS port, wait for the circuit to bootstrap, then add the proxy diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh index b40d8f4..546a922 100755 --- a/apps/desktop/scripts/build-release.sh +++ b/apps/desktop/scripts/build-release.sh @@ -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" @@ -59,6 +78,10 @@ stage() { # dest dir # On-demand Tor control helper for the in-browser 🧅 Tor toggle (the launcher # starts it; it starts Tor only when the toggle asks). install -m 0755 "$DESKTOP/launcher/tron-tor-helper" "$s/tron-tor-helper" + # 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" diff --git a/apps/desktop/src/session.test.ts b/apps/desktop/src/session.test.ts new file mode 100644 index 0000000..bed4ce0 --- /dev/null +++ b/apps/desktop/src/session.test.ts @@ -0,0 +1,130 @@ +// Integration tests for the managed-session engine (apps/desktop/launcher/ +// tron-session), driven through its real CLI against a Node CDP mock. This is +// the regression coverage for the *running* implementation; the pure schema / +// tab-mapping contract it mirrors is unit-tested in @tronbrowser/browser-core. +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const SESSION = fileURLToPath(new URL('../launcher/tron-session', import.meta.url)); +const SHIM = fileURLToPath(new URL('../test/fixtures/fake-shim.sh', import.meta.url)); + +// The engine shells out to curl + python3 (already launcher dependencies). Skip +// the suite gracefully where they are unavailable rather than fail spuriously. +function has(bin: string): boolean { + return spawnSync('sh', ['-c', `command -v ${bin}`], { encoding: 'utf8' }).status === 0; +} +const ready = has('curl') && (has('python3') || has('python')); + +let dataDir: string; + +function baseEnv(): NodeJS.ProcessEnv { + return { ...process.env, TRONBROWSER_DATA: dataDir, TRONBROWSER_SHIM: SHIM }; +} + +/** Run a tron-session command, returning stdout (throws on nonzero exit). */ +function tron(...args: string[]): string { + return execFileSync(SESSION, args, { env: baseEnv(), encoding: 'utf8', timeout: 30_000 }); +} + +/** Run and capture status + stdout without throwing (for exit-code assertions). */ +function tronStatus(...args: string[]): { status: number | null; stdout: string } { + const r = spawnSync(SESSION, args, { env: baseEnv(), encoding: 'utf8', timeout: 30_000 }); + return { status: r.status, stdout: r.stdout ?? '' }; +} + +describe.skipIf(!ready)('tron-session managed sessions', () => { + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'tron-session-test-')); + }); + + afterEach(() => { + try { + tron('browser', 'close'); + } catch { + // ignore — individual tests close their own session + } + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('launches a session and writes a live descriptor', () => { + const out = tron('browser', 'launch'); + expect(out).toMatch(/managed session ready on 127\.0\.0\.1:\d+/); + + const desc = JSON.parse(tron('browser', 'status', '--json')); + expect(desc.state).toBe('running'); + expect(desc.version).toBe(1); + expect(desc.host).toBe('127.0.0.1'); + expect(desc.port).toBeGreaterThan(0); + expect(desc.profileName).toBe('agent'); + expect(desc.headless).toBe(false); + // The M3.2 attach point must be captured. + expect(desc.webSocketDebuggerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//); + + expect(tron('browser', 'status')).toMatch(/running/); + }); + + it('lists the initial tab and marks it current', () => { + tron('browser', 'launch'); + const tabs = JSON.parse(tron('browser', 'tabs', '--json')); + expect(tabs).toHaveLength(1); + expect(tabs[0].current).toBe(true); + expect(tabs[0].url).toBe('chrome://newtab/'); + }); + + it('opens a URL as a new current tab', () => { + tron('browser', 'launch'); + const out = tron('open', 'http://example.com/contact'); + expect(out).toMatch(/opened http:\/\/example\.com\/contact/); + + const tabs = JSON.parse(tron('browser', 'tabs', '--json')); + expect(tabs).toHaveLength(2); + const current = tabs.find((t: { current: boolean }) => t.current); + expect(current.url).toBe('http://example.com/contact'); + }); + + it('switches the current tab with use, reflected by current', () => { + tron('browser', 'launch'); + tron('open', 'http://example.org'); + const tabs = JSON.parse(tron('browser', 'tabs', '--json')); + const first = tabs[0].id as string; + + tron('browser', 'use', first); + const after = JSON.parse(tron('browser', 'tabs', '--json')); + expect(after.find((t: { current: boolean }) => t.current).id).toBe(first); + expect(tron('browser', 'current')).toContain(first); + }); + + it('rejects a launch while one is already running', () => { + tron('browser', 'launch'); + expect(tron('browser', 'launch')).toMatch(/already running/); + }); + + it('uses an ephemeral temp profile for headless and removes it on close', () => { + tron('browser', 'launch', '--headless'); + const desc = JSON.parse(tron('browser', 'status', '--json')); + expect(desc.headless).toBe(true); + expect(desc.ephemeral).toBe(true); + expect(desc.profileName).toBe('ephemeral'); + expect(desc.profileDir.startsWith(tmpdir())).toBe(true); + expect(existsSync(desc.profileDir)).toBe(true); + + tron('browser', 'close'); + expect(existsSync(desc.profileDir)).toBe(false); + expect(tron('browser', 'status')).toMatch(/no managed session/); + }); + + it('closes cleanly and reports no session afterwards', () => { + tron('browser', 'launch'); + expect(tron('browser', 'close')).toMatch(/closed managed session/); + expect(tron('browser', 'status')).toMatch(/no managed session/); + }); + + it('exits 3 from `open` when no session is running (legacy-launch signal)', () => { + const r = tronStatus('open', 'http://fallback.test'); + expect(r.status).toBe(3); + }); +}); diff --git a/apps/desktop/test/fixtures/cdp-mock-server.mjs b/apps/desktop/test/fixtures/cdp-mock-server.mjs new file mode 100644 index 0000000..4102cc5 --- /dev/null +++ b/apps/desktop/test/fixtures/cdp-mock-server.mjs @@ -0,0 +1,75 @@ +// Minimal CDP DevTools HTTP endpoint mock, standing in for Chromium so the +// tron-session shell engine can be integration-tested without a browser. +// The fake shim exec-replaces into this, so tron-session tracks its pid exactly +// like Chromium on Linux. Behavior mirrors packages/browser-core/src/automation. +import { createServer } from 'node:http'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const dataDir = process.env.TRONBROWSER_DATA; +const reqPort = Number(process.env.TRON_AUTOMATION_PORT ?? '0'); + +let counter = 0; +const targets = []; +let PORT = 0; + +function newTarget(url) { + counter += 1; + const id = `TAB${String(counter).padStart(4, '0')}`; + const t = { + id, + type: 'page', + title: url, + url, + webSocketDebuggerUrl: `ws://127.0.0.1:${PORT}/devtools/page/${id}`, + }; + targets.push(t); + return t; +} + +function send(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(body); +} + +const server = createServer((req, res) => { + const path = req.url ?? ''; + if (path === '/json/version') { + return send(res, 200, { + Browser: 'MockChrome/1.0', + webSocketDebuggerUrl: `ws://127.0.0.1:${PORT}/devtools/browser/mock`, + }); + } + if (path === '/json' || path === '/json/list') { + return send(res, 200, targets); + } + if (path.startsWith('/json/new')) { + const q = path.indexOf('?'); + const url = q >= 0 ? path.slice(q + 1) : 'about:blank'; + return send(res, 200, newTarget(url)); + } + if (path.startsWith('/json/close/')) { + const id = path.slice('/json/close/'.length); + const before = targets.length; + for (let i = targets.length - 1; i >= 0; i -= 1) { + if (targets[i].id === id) targets.splice(i, 1); + } + return send(res, targets.length < before ? 200 : 404, { closed: id }); + } + if (path.startsWith('/json/activate/')) { + const id = path.slice('/json/activate/'.length); + const ok = targets.some((t) => t.id === id); + return send(res, ok ? 200 : 404, { activated: id }); + } + send(res, 404, { error: 'not found' }); +}); + +server.listen(reqPort, '127.0.0.1', () => { + PORT = server.address().port; + newTarget('chrome://newtab/'); // a session always opens with one page + const apf = `${dataDir}/DevToolsActivePort`; + mkdirSync(dirname(apf), { recursive: true }); + writeFileSync(apf, `${PORT}\n/devtools/browser/mock\n`); + process.stderr.write(`mock cdp on 127.0.0.1:${PORT}\n`); +}); diff --git a/apps/desktop/test/fixtures/fake-shim.sh b/apps/desktop/test/fixtures/fake-shim.sh new file mode 100755 index 0000000..39ab79a --- /dev/null +++ b/apps/desktop/test/fixtures/fake-shim.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Test double for the `tronbrowser` shim: exec-replaces into the Node CDP mock so +# tron-session tracks the mock's pid exactly like Chromium does on Linux. +exec node "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/cdp-mock-server.mjs" diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index a45a9d1..62dad87 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -75,11 +75,21 @@ tron — TronBrowser CLI Usage: tron [url...] Open URL(s) in TronBrowser (agent-friendly) - tron open Same as above, explicit + tron open Open a URL (in the managed session if one is running) tron Launch TronBrowser tron restart Force-quit and relaunch (loads the latest extension) tron --tor [url] Launch a dedicated Tor session (separate wiped profile) tron tor Start a standalone Tor daemon for the in-browser toggle + tron browser launch Start a managed automation session (CDP, loopback-only) + 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 Click a snapshot ref, e.g. @e3 + tron fill Fill an input by ref, e.g. tron fill @e4 "hi@x.com" + tron extract Extract text|links|forms|tables|main (JSON) + tron screenshot

Save a PNG of the current page (--full-page) + tron headless One-shot: --snapshot | --screenshot

| --extract tron upgrade Update to the latest release tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version @@ -121,11 +131,53 @@ launch() { exec "$CURRENT" "$@" } +# Path to the managed-session engine, which lives next to the versioned shim. +session_bin() { + _ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")" + 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. +# TRON_SESSION_BIN lets `tron headless` launch/close its own one-shot session. +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 env TRON_SESSION_BIN="$(session_bin)" node "$ENTRY" "$@" +} + case "${1:-}" in open) shift [ "$#" -gt 0 ] || { echo "usage: tron open " >&2; exit 2; } + # Prefer a running managed session (open URL as a tab there); otherwise fall + # back to the classic behavior of opening the URL in a normal window. + SESSION="$(session_bin)" + if [ -x "$SESSION" ]; then + _rc=0 + "$SESSION" open "$@" || _rc=$? + [ "$_rc" = 0 ] && exit 0 + [ "$_rc" = 3 ] || exit "$_rc" # 3 = no managed session → legacy launch + fi launch "$@" ;; + browser) + # Managed automation sessions (launch/status/tabs/use/current/close). + shift + 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). + run_automation "$@" ;; + headless) + # One-shot: launch a headless ephemeral session, navigate, act, tear down. + 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 diff --git a/docs/headless-and-extraction.md b/docs/headless-and-extraction.md new file mode 100644 index 0000000..549a111 --- /dev/null +++ b/docs/headless-and-extraction.md @@ -0,0 +1,66 @@ +# Headless and extraction (M3.3) + +## One-shot headless + +`tron headless ` launches a **headless, ephemeral** managed session, +navigates to the URL, performs one operation, and tears everything down +(profile included) — ideal for CI and agents. + +```sh +tron headless https://example.com --snapshot # structured snapshot (--json) +tron headless https://example.com --screenshot out.png # PNG (--full-page) +tron headless https://example.com --pdf out.pdf # PDF +tron headless https://example.com --extract links # extraction JSON +``` + +It uses its own isolated data dir (a temp `TRONBROWSER_DATA`), so it never +touches an interactive `tron browser` session you may have running, and no +persistent profile is used. + +## Extraction + +`tron extract` reads structured data from the managed session's current page as +deterministic JSON (relative `href`/`src` resolved to absolute): + +```sh +tron extract text # main text content +tron extract links # [{ text, href }] +tron extract forms # [{ name, action, method, fields: [{ name, type, label, required, value }] }] +tron extract tables # [{ headers, rows }] +tron extract main # { text } of

/
+ +# Custom selector + fields (name=selector[@attr]); @href/@src come back absolute: +tron extract '.product-card' \ + --field title='.title' \ + --field price='.price' \ + --field url='a@href' +``` + +Password field values are never included. `forms` omits hidden inputs. + +## Screenshots and PDF + +```sh +tron screenshot page.png # viewport PNG of the current page +tron screenshot page.png --full-page +tron pdf page.pdf # headless only +``` + +## How it works + +- `extract`, `screenshot`, `pdf`, and `headless` are Node subcommands the shell + `tron` dispatcher delegates to (see [snapshots-and-refs.md](./snapshots-and-refs.md)). +- Extraction runs one in-page script via CDP `Runtime.evaluate`; capture uses + `Page.captureScreenshot` / `Page.printToPDF`. +- `headless` orchestrates in Node: it shells out to the `tron-session` engine + (`TRON_SESSION_BIN`) to launch/close a headless session, then drives + navigate + op over CDP, and always cleans up (even on failure). + +## Scope / limitations + +- Requires Node.js (>= 22). PDF requires headless. +- macOS headless is limited by the detached-launch model (see + [managed-sessions.md](./managed-sessions.md)); Linux native/flatpak is primary. +- Contracts + DOM logic unit-tested in `packages/browser-core` + (`automation/extract-script`, `capture`, `automate-*.test.ts`), plus an + end-to-end run over the real HTTP+WebSocket transport. diff --git a/docs/managed-sessions.md b/docs/managed-sessions.md new file mode 100644 index 0000000..354c1c4 --- /dev/null +++ b/docs/managed-sessions.md @@ -0,0 +1,63 @@ +# Managed browser sessions (M3.1) + +TronBrowser can run a **managed automation session**: a normal Ungoogled Chromium +launch that also exposes a loopback Chrome DevTools Protocol (CDP) endpoint, so +the `tron` CLI (and, from M3.2, the SDK/MCP tooling) can drive it. + +```sh +tron browser launch # start a managed session (headed, "agent" profile) +tron browser launch --headless # headless, ephemeral profile (deleted on close) +tron browser status # is a session running? (--json for machine output) +tron browser tabs # list page tabs; "*" marks the current one (--json) +tron browser use # make a tab current + bring it to the foreground +tron browser current # print the current tab +tron open # open a URL as a tab in the managed session +tron browser close # stop the session (and wipe an ephemeral profile) +``` + +`tron open ` prefers a running managed session; when none is running it +falls back to the classic behavior of opening the URL in a normal window, so +existing `tron ` / `tron open ` usage is unchanged. + +## How it works + +- The `tron` dispatcher routes `browser`/`open` to `tron-session`, a POSIX-sh + engine shipped next to the launcher shim. +- `launch` starts the shim with `TRON_AUTOMATION_PORT` set. The shim adds + `--remote-debugging-port` (and `--headless=new` when asked). Port `0` lets + Chromium pick a free loopback port, which it records in + `/DevToolsActivePort`. +- The engine waits for the DevTools endpoint, then writes a **session + descriptor** to `~/.tronbrowser/automation/session.json` + (`$TRONBROWSER_DATA/automation/…`). It records the pid, port, profile, and the + browser-level `webSocketDebuggerUrl` — the attach point programmatic tooling + uses from M3.2 onward. +- `status`/`tabs`/`use`/`current`/`open`/`close` drive the session through the + DevTools HTTP endpoints (`/json/version`, `/json/list`, `/json/new`, + `/json/activate`, `/json/close`) — no WebSocket or extra dependency is needed + for M3.1. + +## Profiles + +- Headed default → a persistent `…/.tronbrowser-agent` profile, isolated from + your day-to-day `~/.tronbrowser` browsing. +- `--headless` (no `--profile`) → an **ephemeral** temp profile, removed on + `close`. +- `--profile ` → a persistent `…/.tronbrowser-` profile. +- `--profile ephemeral` → an ephemeral temp profile. + +## Security + +- The DevTools endpoint binds to `127.0.0.1` only — never exposed off-host. +- The descriptor lives under the stable data dir, not inside an ephemeral + profile, so `status`/`close` always find the session. + +## Scope / limitations + +- Requires `curl` and `python3` (already used by the launcher and Tor helper). +- Linux (native/flatpak) is the primary target; macOS headed launches via + `open` work, but the detached process model means headless and pid-based + liveness are best-effort there. Windows managed sessions are out of scope for + M3.1. +- The portable schema and CDP tab-mapping contract this engine mirrors live (and + are unit-tested) in `packages/browser-core/src/automation`. diff --git a/docs/snapshots-and-refs.md b/docs/snapshots-and-refs.md new file mode 100644 index 0000000..446adb3 --- /dev/null +++ b/docs/snapshots-and-refs.md @@ -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 ` 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`. diff --git a/packages/browser-core/package.json b/packages/browser-core/package.json index 0d9210f..2ae5036 100644 --- a/packages/browser-core/package.json +++ b/packages/browser-core/package.json @@ -19,6 +19,7 @@ "lint": "eslint src" }, "devDependencies": { + "happy-dom": "^20.10.6", "typescript": "^5.6.3", "vitest": "^2.1.4" } diff --git a/packages/browser-core/src/automate-bin.ts b/packages/browser-core/src/automate-bin.ts new file mode 100644 index 0000000..69d0f77 --- /dev/null +++ b/packages/browser-core/src/automate-bin.ts @@ -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); + }, +); diff --git a/packages/browser-core/src/automate-cli.test.ts b/packages/browser-core/src/automate-cli.test.ts new file mode 100644 index 0000000..72cf28d --- /dev/null +++ b/packages/browser-core/src/automate-cli.test.ts @@ -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 = {}) { + const out: string[] = []; + const err: string[] = []; + const deps: Partial = { + 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); + }); +}); diff --git a/packages/browser-core/src/automate-cli.ts b/packages/browser-core/src/automate-cli.ts new file mode 100644 index 0000000..e935a07 --- /dev/null +++ b/packages/browser-core/src/automate-cli.ts @@ -0,0 +1,302 @@ +/** + * `tron-automate` — Node entrypoint for the CDP-driven automation subcommands + * the shell `tron` dispatcher delegates to (PRD M3.2 + M3.3): + * + * tron snapshot [--json] [--include-hidden] + * tron click | fill + * tron extract [--field n=sel[@attr]] + * tron screenshot [--full-page] | tron pdf + * tron headless [--snapshot|--screenshot |--pdf |--extract ] [--json] + * + * It attaches to the M3.1-managed session via its descriptor + the page target's + * webSocketDebuggerUrl. Dependencies (descriptor read, target fetch, CDP connect, + * one-shot session launch/close, byte writes) are injectable so the command layer + * is testable without a real browser. + */ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +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 { extractExpression, parseFieldSpec, type FieldSpec } from './automation/extract-script.js'; +import { + captureSnapshot, + clickRef, + enableRuntime, + extract, + fillRef, + formatSnapshotText, + goto, + StaleRefError, +} from './automation/page.js'; +import { resolvePageWsUrl } from './automation/page-target.js'; +import type { CdpTarget, SessionDescriptor } from './automation/types.js'; + +const execFileP = promisify(execFile); + +/** Process exit codes shared with the shell dispatcher. */ +export const EXIT = { + ok: 0, + usage: 2, + noSession: 4, + staleRef: 5, + failed: 1, +} as const; + +export interface CliDeps { + env: NodeJS.ProcessEnv; + loadDescriptor(path: string): Promise; + fetchTargets(listUrl: string): Promise; + connect(wsUrl: string): Promise; + launchHeadless(dataDir: string): Promise; + closeSession(dataDir: string): Promise; + writeBytes(path: string, bytes: Uint8Array): Promise; + out(text: string): void; + err(text: string): void; +} + +const defaultDeps: CliDeps = { + env: process.env, + async loadDescriptor(path) { + return parseDescriptor(await readFile(path, 'utf8')); + }, + async fetchTargets(listUrl) { + const res = await fetch(listUrl); + if (!res.ok) throw new Error(`DevTools /json/list returned ${res.status}`); + return (await res.json()) as CdpTarget[]; + }, + connect: (wsUrl) => CdpClient.connect(wsUrl), + async launchHeadless(dataDir) { + const bin = process.env.TRON_SESSION_BIN; + if (!bin) throw new Error('headless needs the session engine (TRON_SESSION_BIN unset)'); + await execFileP(bin, ['browser', 'launch', '--headless'], { + env: { ...process.env, TRONBROWSER_DATA: dataDir }, + }); + }, + async closeSession(dataDir) { + const bin = process.env.TRON_SESSION_BIN; + if (!bin) return; + await execFileP(bin, ['browser', 'close'], { + env: { ...process.env, TRONBROWSER_DATA: dataDir }, + }); + }, + writeBytes: (path, bytes) => writeFile(path, bytes), + out: (t) => process.stdout.write(t + '\n'), + err: (t) => process.stderr.write(t + '\n'), +}; + +/** Attach to the current page of a managed session, or throw a coded error. */ +async function attach(deps: CliDeps, dataDir = resolveDataDir(deps.env)): Promise { + let descriptor: SessionDescriptor; + try { + descriptor = await deps.loadDescriptor(descriptorPath(dataDir)); + } catch { + const e = new Error('No managed session. Run: tron browser launch') as Error & { exit?: number }; + e.exit = EXIT.noSession; + throw e; + } + const targets = await deps.fetchTargets( + cdpListUrl({ host: descriptor.host, port: descriptor.port }), + ); + const conn = await deps.connect(resolvePageWsUrl(targets, descriptor.activeTabId)); + await enableRuntime(conn); + return conn; +} + +/** Collect all `--field name=selector[@attr]` specs from an arg list. */ +function collectFields(args: string[]): FieldSpec[] { + const specs: FieldSpec[] = []; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '--field' && args[i + 1] !== undefined) { + specs.push(parseFieldSpec(args[i + 1]!)); + i += 1; + } else if (a?.startsWith('--field=')) { + specs.push(parseFieldSpec(a.slice('--field='.length))); + } + } + return specs; +} + +/** Value that follows a flag, e.g. valueAfter(args, '--screenshot'). */ +function valueAfter(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +type HeadlessOp = + | { kind: 'snapshot'; json: boolean } + | { kind: 'extract'; target: string; fields: FieldSpec[] } + | { kind: 'screenshot'; path: string; fullPage: boolean } + | { kind: 'pdf'; path: string }; + +function parseHeadlessOp(args: string[]): HeadlessOp | { error: string } { + if (args.includes('--screenshot')) { + const path = valueAfter(args, '--screenshot'); + if (!path) return { error: '--screenshot needs a path' }; + return { kind: 'screenshot', path, fullPage: args.includes('--full-page') }; + } + if (args.includes('--pdf')) { + const path = valueAfter(args, '--pdf'); + if (!path) return { error: '--pdf needs a path' }; + return { kind: 'pdf', path }; + } + if (args.includes('--extract')) { + const target = valueAfter(args, '--extract'); + if (!target) return { error: '--extract needs a mode or selector' }; + return { kind: 'extract', target, fields: collectFields(args) }; + } + return { kind: 'snapshot', json: args.includes('--json') }; +} + +async function runOp(deps: CliDeps, conn: CdpConnection, op: HeadlessOp): Promise { + switch (op.kind) { + case 'snapshot': { + const snap = await captureSnapshot(conn); + deps.out(op.json ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap)); + return; + } + case 'extract': { + const data = await extract(conn, extractExpression(op.target, op.fields)); + deps.out(JSON.stringify(data, null, 2)); + return; + } + case 'screenshot': { + await deps.writeBytes(op.path, await screenshotPng(conn, { fullPage: op.fullPage })); + deps.out(`screenshot -> ${op.path}`); + return; + } + case 'pdf': { + await deps.writeBytes(op.path, await printPdf(conn)); + deps.out(`pdf -> ${op.path}`); + return; + } + } +} + +const USAGE = + 'usage: tron snapshot [--json] | click | fill | ' + + 'extract [--field n=sel] | ' + + 'screenshot [--full-page] | pdf | ' + + 'headless [--snapshot|--screenshot |--pdf |--extract ] [--json]'; + +export async function run(argv: string[], overrides: Partial = {}): Promise { + const deps: CliDeps = { ...defaultDeps, ...overrides }; + const [command, ...rest] = argv; + + if (command === undefined || command === 'help' || command === '--help') { + deps.out(USAGE); + return EXIT.ok; + } + + let conn: CdpConnection | undefined; + try { + switch (command) { + case 'snapshot': { + conn = await attach(deps); + const snap = await captureSnapshot( + conn, + rest.includes('--include-hidden') ? { includeHidden: true } : {}, + ); + deps.out(rest.includes('--json') ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap)); + return EXIT.ok; + } + case 'click': { + const ref = rest[0]; + if (!ref) { + deps.err('usage: tron click '); + return EXIT.usage; + } + conn = await attach(deps); + deps.out(`clicked ${(await clickRef(conn, ref)).ref}`); + return EXIT.ok; + } + case 'fill': { + const ref = rest[0]; + const value = rest[1]; + if (!ref || value === undefined) { + deps.err('usage: tron fill '); + return EXIT.usage; + } + conn = await attach(deps); + deps.out(`filled ${(await fillRef(conn, ref, value)).ref}`); + return EXIT.ok; + } + case 'extract': { + const target = rest.find((a) => !a.startsWith('--')); + if (!target) { + deps.err('usage: tron extract [--field n=sel] [--json]'); + return EXIT.usage; + } + conn = await attach(deps); + const data = await extract(conn, extractExpression(target, collectFields(rest))); + deps.out(JSON.stringify(data, null, 2)); + return EXIT.ok; + } + case 'screenshot': { + const path = rest.find((a) => !a.startsWith('--')); + if (!path) { + deps.err('usage: tron screenshot [--full-page]'); + return EXIT.usage; + } + conn = await attach(deps); + await deps.writeBytes(path, await screenshotPng(conn, { fullPage: rest.includes('--full-page') })); + deps.out(`screenshot -> ${path}`); + return EXIT.ok; + } + case 'pdf': { + const path = rest.find((a) => !a.startsWith('--')); + if (!path) { + deps.err('usage: tron pdf '); + return EXIT.usage; + } + conn = await attach(deps); + await deps.writeBytes(path, await printPdf(conn)); + deps.out(`pdf -> ${path}`); + return EXIT.ok; + } + case 'headless': { + const url = rest.find((a) => !a.startsWith('--')); + if (!url) { + deps.err('usage: tron headless [--snapshot|--screenshot |--pdf |--extract ] [--json]'); + return EXIT.usage; + } + const op = parseHeadlessOp(rest); + if ('error' in op) { + deps.err(`usage: ${op.error}`); + return EXIT.usage; + } + const dataDir = await mkdtemp(join(tmpdir(), 'tron-headless-')); + try { + await deps.launchHeadless(dataDir); + conn = await attach(deps, dataDir); + await goto(conn, url); + await runOp(deps, conn, op); + return EXIT.ok; + } finally { + conn?.close(); + conn = undefined; + await deps.closeSession(dataDir).catch(() => {}); + await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + } + } + default: + deps.err(`unknown automation command: ${command}`); + return EXIT.usage; + } + } catch (err) { + if (err instanceof StaleRefError) { + deps.err(err.message); + return EXIT.staleRef; + } + const coded = err as Error & { exit?: number }; + deps.err(`tron: ${coded.message}`); + return typeof coded.exit === 'number' ? coded.exit : EXIT.failed; + } finally { + conn?.close(); + } +} diff --git a/packages/browser-core/src/automate-e2e.test.ts b/packages/browser-core/src/automate-e2e.test.ts new file mode 100644 index 0000000..151448b --- /dev/null +++ b/packages/browser-core/src/automate-e2e.test.ts @@ -0,0 +1,185 @@ +// End-to-end: drive the automation CLI with its REAL default deps (global fetch +// + real CdpClient over a real WebSocket) against a mock DevTools server. This +// covers the glue the unit tests exercise only in isolation: descriptor -> +// /json/list -> page WS -> Runtime.evaluate -> formatted output. The in-page +// scripts themselves are verified against a real DOM in snapshot-script.test.ts, +// so here the mock returns canned evaluate results. +import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EXIT, run } from './automate-cli.js'; +import { serializeDescriptor } from './automation/descriptor.js'; +import type { SessionDescriptor } from './automation/types.js'; +import type { AgentSnapshot } from './automation/snapshot-script.js'; + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function decodeFrame(buf: Buffer): string | null { + if ((buf[0] & 0x0f) === 0x8) return null; + const masked = (buf[1] & 0x80) !== 0; + let len = buf[1] & 0x7f; + let off = 2; + if (len === 126) { len = buf.readUInt16BE(2); off = 4; } + let mask: Buffer | null = null; + if (masked) { mask = buf.subarray(off, off + 4); off += 4; } + const p = buf.subarray(off, off + len); + const out = Buffer.alloc(len); + for (let i = 0; i < len; i += 1) out[i] = mask ? p[i] ^ mask[i % 4] : p[i]; + return out.toString('utf8'); +} +function encodeFrame(str: string): Buffer { + const p = Buffer.from(str, 'utf8'); + if (p.length < 126) return Buffer.concat([Buffer.from([0x81, p.length]), p]); + const h = Buffer.alloc(4); + h[0] = 0x81; h[1] = 126; h.writeUInt16BE(p.length, 2); + return Buffer.concat([h, p]); +} + +interface Mock { + port: number; + close: () => Promise; +} + +/** Mock DevTools server: HTTP /json/list + a page WS that answers Runtime.*. + * `evaluate` supplies Runtime.evaluate values; `methodResults` supplies command + * results for other CDP methods (e.g. Page.captureScreenshot). */ +async function startMock( + evaluate: (expression: string) => unknown, + methodResults: Record = {}, +): Promise { + const sockets = new Set(); + const server: Server = createServer((req, res) => { + if (req.url === '/json/list') { + const port = (server.address() as { port: number }).port; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify([ + { id: 'p1', type: 'page', url: 'https://example.com', webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/p1` }, + ]), + ); + return; + } + res.writeHead(404).end(); + }); + server.on('upgrade', (req, socket) => { + sockets.add(socket as Socket); + socket.on('close', () => sockets.delete(socket as Socket)); + const accept = createHash('sha1').update((req.headers['sec-websocket-key'] ?? '') + GUID).digest('base64'); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.on('data', (buf: Buffer) => { + const text = decodeFrame(buf); + if (text === null) { socket.destroy(); return; } + const msg = JSON.parse(text) as { id: number; method: string; params?: { expression?: string } }; + // Real CDP nests twice: {result: {result: , exceptionDetails?}}. + let result: Record = {}; + if (msg.method === 'Runtime.evaluate') { + result = { result: { result: { value: evaluate(msg.params?.expression ?? '') } } }; + } else if (msg.method in methodResults) { + result = { result: methodResults[msg.method] }; + } + socket.write(encodeFrame(JSON.stringify({ id: msg.id, ...result }))); + }); + socket.on('error', () => {}); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + port: (server.address() as { port: number }).port, + close: () => + new Promise((resolve) => { + for (const s of sockets) s.destroy(); + server.close(() => resolve()); + }), + }; +} + +const snap: AgentSnapshot = { + url: 'https://example.com/contact', + title: 'Contact Us', + timestamp: '2026-07-04T00:00:00.000Z', + elements: [ + { ref: '@e1', role: 'textbox', name: 'Email', tag: 'input', interactive: true, visible: true }, + ], +}; + +let mock: Mock; +let dataDir: string; + +function writeDescriptor(port: number): void { + const d: SessionDescriptor = { + version: 1, pid: process.pid, host: '127.0.0.1', port, + profileDir: '/x', profileName: 'agent', headless: false, ephemeral: false, + createdAt: '2026-07-04T00:00:00.000Z', activeTabId: 'p1', + }; + mkdirSync(join(dataDir, 'automation'), { recursive: true }); + writeFileSync(join(dataDir, 'automation', 'session.json'), serializeDescriptor(d)); +} + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'automate-e2e-')); +}); +afterEach(async () => { + await mock.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('automation CLI end-to-end over HTTP + WebSocket', () => { + it('snapshots the current page through the real transport', async () => { + mock = await startMock(() => snap); + writeDescriptor(mock.port); + const out: string[] = []; + const code = await run(['snapshot'], { env: { TRONBROWSER_DATA: dataDir }, out: (t) => out.push(t) }); + expect(code).toBe(EXIT.ok); + expect(out.join('\n')).toContain('@e1 textbox "Email"'); + }); + + it('clicks a ref end-to-end', async () => { + mock = await startMock(() => ({ ok: true, ref: '@e1' })); + writeDescriptor(mock.port); + const out: string[] = []; + const code = await run(['click', '@e1'], { env: { TRONBROWSER_DATA: dataDir }, out: (t) => out.push(t) }); + expect(code).toBe(EXIT.ok); + expect(out.join('\n')).toContain('clicked @e1'); + }); + + it('returns staleRef end-to-end when the ref is gone', async () => { + mock = await startMock(() => ({ ok: false, error: 'STALE_REF', ref: '@e9' })); + writeDescriptor(mock.port); + const err: string[] = []; + const code = await run(['click', '@e9'], { env: { TRONBROWSER_DATA: dataDir }, err: (t) => err.push(t) }); + expect(code).toBe(EXIT.staleRef); + expect(err.join('\n')).toMatch(/stale/i); + }); + + it('extracts links end-to-end', async () => { + mock = await startMock(() => [{ text: 'More', href: 'https://example.com/more' }]); + writeDescriptor(mock.port); + const out: string[] = []; + const code = await run(['extract', 'links'], { env: { TRONBROWSER_DATA: dataDir }, out: (t) => out.push(t) }); + expect(code).toBe(EXIT.ok); + expect(JSON.parse(out.join('\n'))[0].href).toBe('https://example.com/more'); + }); + + it('captures a screenshot end-to-end', async () => { + mock = await startMock(() => null, { + 'Page.captureScreenshot': { data: Buffer.from('PNGBYTES').toString('base64') }, + }); + writeDescriptor(mock.port); + let written: Uint8Array | undefined; + const code = await run(['screenshot', 'out.png'], { + env: { TRONBROWSER_DATA: dataDir }, + out: () => {}, + writeBytes: async (_p, bytes) => { + written = bytes; + }, + }); + expect(code).toBe(EXIT.ok); + expect(Buffer.from(written!).toString()).toBe('PNGBYTES'); + }); +}); diff --git a/packages/browser-core/src/automate-headless.test.ts b/packages/browser-core/src/automate-headless.test.ts new file mode 100644 index 0000000..a6fa026 --- /dev/null +++ b/packages/browser-core/src/automate-headless.test.ts @@ -0,0 +1,131 @@ +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 { 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', +}; + +/** Fake connection with per-method canned results; simulates page load on navigate. */ +function connWith(handlers: Record): CdpConnection { + const evs: Record void> = {}; + return { + send: (async (method: string) => { + if (method === 'Page.navigate') { + queueMicrotask(() => evs['Page.loadEventFired']?.({})); + return {}; + } + return handlers[method] ?? {}; + }) as CdpConnection['send'], + on: (m: string, h: (p: unknown) => void) => { + evs[m] = h; + }, + close: vi.fn(), + }; +} + +function harness(handlers: Record, overrides: Partial = {}) { + const out: string[] = []; + const err: string[] = []; + const writes: Array<{ path: string; bytes: Uint8Array }> = []; + const calls: string[] = []; + const deps: Partial = { + env: {}, + loadDescriptor: async () => descriptor, + fetchTargets: async () => [ + { id: 'p1', type: 'page', url: 'https://example.com', webSocketDebuggerUrl: 'ws://x/p1' }, + ], + connect: async () => connWith(handlers), + launchHeadless: async () => { + calls.push('launch'); + }, + closeSession: async () => { + calls.push('close'); + }, + writeBytes: async (path, bytes) => { + writes.push({ path, bytes }); + }, + out: (t) => out.push(t), + err: (t) => err.push(t), + ...overrides, + }; + return { deps, out, err, writes, calls }; +} + +const evalResult = (value: unknown) => ({ 'Runtime.evaluate': { result: { value } } }); +const png = { 'Page.captureScreenshot': { data: Buffer.from('PNGDATA').toString('base64') } }; +const pdf = { 'Page.printToPDF': { data: Buffer.from('PDFDATA').toString('base64') } }; + +describe('extract command', () => { + it('prints extraction JSON', async () => { + const { deps, out } = harness(evalResult([{ text: 'A', href: 'https://x/a' }])); + const code = await run(['extract', 'links'], deps); + expect(code).toBe(EXIT.ok); + expect(JSON.parse(out.join('\n'))).toEqual([{ text: 'A', href: 'https://x/a' }]); + }); + + it('rejects missing target', async () => { + const { deps } = harness({}); + expect(await run(['extract'], deps)).toBe(EXIT.usage); + }); +}); + +describe('screenshot / pdf commands', () => { + it('writes a screenshot to the given path', async () => { + const { deps, writes, out } = harness(png); + const code = await run(['screenshot', 'shot.png'], deps); + expect(code).toBe(EXIT.ok); + expect(writes[0].path).toBe('shot.png'); + expect(Buffer.from(writes[0].bytes).toString()).toBe('PNGDATA'); + expect(out.join('\n')).toContain('screenshot -> shot.png'); + }); + + it('writes a pdf', async () => { + const { deps, writes } = harness(pdf); + expect(await run(['pdf', 'out.pdf'], deps)).toBe(EXIT.ok); + expect(Buffer.from(writes[0].bytes).toString()).toBe('PDFDATA'); + }); + + it('rejects screenshot without a path', async () => { + const { deps } = harness({}); + expect(await run(['screenshot'], deps)).toBe(EXIT.usage); + }); +}); + +describe('headless one-shot', () => { + it('launches, navigates, snapshots, and always closes', async () => { + const snap = { url: 'u', title: 'T', timestamp: 't', elements: [] }; + const { deps, out, calls } = harness(evalResult(snap)); + const code = await run(['headless', 'https://example.com', '--snapshot', '--json'], deps); + expect(code).toBe(EXIT.ok); + expect(calls).toEqual(['launch', 'close']); + expect(JSON.parse(out.join('\n')).title).toBe('T'); + }); + + it('captures a screenshot in headless mode', async () => { + const { deps, writes, calls } = harness(png); + const code = await run(['headless', 'https://example.com', '--screenshot', 'h.png'], deps); + expect(code).toBe(EXIT.ok); + expect(writes[0].path).toBe('h.png'); + expect(calls).toContain('close'); + }); + + it('closes the session even when the op fails', async () => { + const { deps, calls } = harness(png, { + writeBytes: async () => { + throw new Error('disk full'); + }, + }); + const code = await run(['headless', 'https://example.com', '--screenshot', 'h.png'], deps); + expect(code).toBe(EXIT.failed); + expect(calls).toEqual(['launch', 'close']); // cleanup still ran + }); + + it('rejects headless without a url', async () => { + const { deps } = harness({}); + expect(await run(['headless'], deps)).toBe(EXIT.usage); + }); +}); diff --git a/packages/browser-core/src/automation/action-script.ts b/packages/browser-core/src/automation/action-script.ts new file mode 100644 index 0000000..868137e --- /dev/null +++ b/packages/browser-core/src/automation/action-script.ts @@ -0,0 +1,66 @@ +/** + * In-page scripts for ref-based actions (PRD M3.2): click, fill, type. + * + * Each resolves the ref via its `data-tron-ref` attribute (set by the last + * snapshot). A missing element returns `{ok:false, error:'STALE_REF'}` so the + * caller can raise a recoverable error telling the agent to re-snapshot, rather + * than acting on the wrong node. + */ + +/** A `data-tron-ref` value: strip a leading `@`, require the `e` form. */ +export function normalizeRef(ref: string): string { + const trimmed = ref.trim(); + const bare = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; + if (!/^e[0-9]+$/.test(bare)) { + throw new Error(`Not a snapshot ref: "${ref}" (expected @e1, @e2, …)`); + } + return bare; +} + +/** Shared prelude: resolve `ref` to an element or bail with STALE_REF. */ +function resolvePrelude(ref: string): string { + const bare = normalizeRef(ref); + return `const el = document.querySelector('[data-tron-ref=' + ${JSON.stringify( + JSON.stringify(bare), + )} + ']'); + if (!el) return { ok: false, error: 'STALE_REF', ref: ${JSON.stringify('@' + bare)} };`; +} + +/** Click the element referenced by `ref`. */ +export function clickExpression(ref: string): string { + return `(() => { + ${resolvePrelude(ref)} + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + return { ok: true, ref: ${JSON.stringify('@' + normalizeRef(ref))} }; +})()`; +} + +/** Fill an input/textarea/contenteditable referenced by `ref` with `value`. */ +export function fillExpression(ref: string, value: string): string { + return `(() => { + ${resolvePrelude(ref)} + const value = ${JSON.stringify(value)}; + el.scrollIntoView({ block: 'center', inline: 'center' }); + if (el.isContentEditable) { + el.focus(); + el.textContent = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + return { ok: true, ref: ${JSON.stringify('@' + normalizeRef(ref))} }; + } + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + el.focus(); + if (desc && desc.set) { desc.set.call(el, value); } else { el.value = value; } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true, ref: ${JSON.stringify('@' + normalizeRef(ref))} }; +})()`; +} + +/** Result shape returned by the action scripts (via Runtime.evaluate). */ +export interface ActionResult { + ok: boolean; + ref: string; + error?: string; +} diff --git a/packages/browser-core/src/automation/capture.ts b/packages/browser-core/src/automation/capture.ts new file mode 100644 index 0000000..0e291b2 --- /dev/null +++ b/packages/browser-core/src/automation/capture.ts @@ -0,0 +1,42 @@ +/** + * Screenshot and PDF capture over CDP (PRD M3.3). Returns raw bytes; the CLI + * writes them to the requested path. PDF requires headless Chromium. + */ +import type { CdpConnection } from './cdp-client.js'; + +export interface ScreenshotOptions { + fullPage?: boolean; +} + +interface LayoutMetrics { + cssContentSize?: { width: number; height: number }; + contentSize?: { width: number; height: number }; +} + +/** Capture a PNG screenshot of the current page. */ +export async function screenshotPng( + conn: CdpConnection, + options: ScreenshotOptions = {}, +): Promise { + await conn.send('Page.enable'); + const params: Record = { + format: 'png', + captureBeyondViewport: options.fullPage === true, + }; + if (options.fullPage) { + const metrics = await conn.send('Page.getLayoutMetrics'); + const size = metrics.cssContentSize ?? metrics.contentSize; + if (size) { + params.clip = { x: 0, y: 0, width: size.width, height: size.height, scale: 1 }; + } + } + const res = await conn.send<{ data: string }>('Page.captureScreenshot', params); + return Buffer.from(res.data, 'base64'); +} + +/** Print the current page to PDF (headless only). */ +export async function printPdf(conn: CdpConnection): Promise { + await conn.send('Page.enable'); + const res = await conn.send<{ data: string }>('Page.printToPDF', { printBackground: true }); + return Buffer.from(res.data, 'base64'); +} diff --git a/packages/browser-core/src/automation/cdp-client.test.ts b/packages/browser-core/src/automation/cdp-client.test.ts new file mode 100644 index 0000000..ef92f8c --- /dev/null +++ b/packages/browser-core/src/automation/cdp-client.test.ts @@ -0,0 +1,144 @@ +import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CdpClient, CdpError } from './cdp-client.js'; + +// A tiny WebSocket server (handshake + single-frame text codec) so the CDP +// client is exercised over a real socket without pulling in a `ws` dependency. +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function decodeFrame(buf: Buffer): string | null { + const opcode = buf[0] & 0x0f; + if (opcode === 0x8) return null; // close + const masked = (buf[1] & 0x80) !== 0; + let len = buf[1] & 0x7f; + let offset = 2; + if (len === 126) { + len = buf.readUInt16BE(2); + offset = 4; + } + let mask: Buffer | null = null; + if (masked) { + mask = buf.subarray(offset, offset + 4); + offset += 4; + } + const payload = buf.subarray(offset, offset + len); + const out = Buffer.alloc(len); + for (let i = 0; i < len; i += 1) out[i] = mask ? payload[i] ^ mask[i % 4] : payload[i]; + return out.toString('utf8'); +} + +function encodeFrame(str: string): Buffer { + const payload = Buffer.from(str, 'utf8'); + const len = payload.length; + if (len < 126) return Buffer.concat([Buffer.from([0x81, len]), payload]); + const head = Buffer.alloc(4); + head[0] = 0x81; + head[1] = 126; + head.writeUInt16BE(len, 2); + return Buffer.concat([head, payload]); +} + +type Handler = (msg: { id?: number; method?: string; params?: unknown }, socket: Socket) => void; + +interface Mock { + url: string; + close: () => Promise; +} + +async function startMock(handler: Handler): Promise { + const server: Server = createServer(); + const sockets = new Set(); + server.on('upgrade', (req, socket) => { + sockets.add(socket as Socket); + socket.on('close', () => sockets.delete(socket as Socket)); + const key = req.headers['sec-websocket-key'] ?? ''; + const accept = createHash('sha1').update(key + GUID).digest('base64'); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\nConnection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.on('data', (buf: Buffer) => { + const text = decodeFrame(buf); + if (text === null) { + socket.destroy(); // client close frame → drop the socket + return; + } + handler(JSON.parse(text), socket as Socket); + }); + socket.on('error', () => {}); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { + url: `ws://127.0.0.1:${port}/devtools/page/mock`, + close: () => + new Promise((resolve) => { + for (const s of sockets) s.destroy(); + sockets.clear(); + server.close(() => resolve()); + }), + }; +} + +function reply(socket: Socket, obj: unknown): void { + socket.write(encodeFrame(JSON.stringify(obj))); +} + +let mock: Mock; +let client: CdpClient | undefined; + +afterEach(async () => { + client?.close(); + client = undefined; + await mock.close(); +}); + +describe('CdpClient over a WebSocket', () => { + it('matches command responses by id', async () => { + mock = await startMock((msg, socket) => { + reply(socket, { id: msg.id, result: { echoed: msg.method } }); + }); + client = await CdpClient.connect(mock.url); + const res = await client.send<{ echoed: string }>('Runtime.evaluate', { expression: '1' }); + expect(res.echoed).toBe('Runtime.evaluate'); + }); + + it('rejects with CdpError on an error result', async () => { + mock = await startMock((msg, socket) => { + reply(socket, { id: msg.id, error: { code: -32000, message: 'no such target' } }); + }); + client = await CdpClient.connect(mock.url); + const err = await client.send('Bad.method').catch((e) => e); + expect(err).toBeInstanceOf(CdpError); + expect(err.code).toBe(-32000); + expect(err.message).toContain('no such target'); + }); + + it('dispatches protocol events to on() handlers', async () => { + mock = await startMock((msg, socket) => { + // Reply, then emit an unsolicited event. + reply(socket, { id: msg.id, result: {} }); + reply(socket, { method: 'Page.loadEventFired', params: { timestamp: 42 } }); + }); + client = await CdpClient.connect(mock.url); + const event = new Promise((resolve) => client!.on('Page.loadEventFired', resolve)); + await client.send('Page.enable'); + await expect(event).resolves.toEqual({ timestamp: 42 }); + }); + + it('rejects pending commands when the connection closes', async () => { + mock = await startMock(() => { + /* never respond */ + }); + client = await CdpClient.connect(mock.url); + const pending = client.send('Runtime.evaluate').catch((e) => e); + client.close(); + const err = await pending; + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/closed/); + }); +}); diff --git a/packages/browser-core/src/automation/cdp-client.ts b/packages/browser-core/src/automation/cdp-client.ts new file mode 100644 index 0000000..643bb2f --- /dev/null +++ b/packages/browser-core/src/automation/cdp-client.ts @@ -0,0 +1,160 @@ +/** + * Minimal Chrome DevTools Protocol client over a WebSocket (PRD M3.2). + * + * Uses the Node global `WebSocket` (Node >= 22), so it needs no dependency. This + * is the programmatic control channel M3.1's session descriptor points at via + * `webSocketDebuggerUrl`; snapshots and ref actions drive a page target through + * it. Commands are JSON-RPC ({id, method, params} -> {id, result|error}); + * unmatched messages are protocol events dispatched to `on` handlers. + */ + +/** The subset of the CDP transport the snapshot/action layer depends on. */ +export interface CdpConnection { + send(method: string, params?: Record): Promise; + on(method: string, handler: (params: unknown) => void): void; + close(): void; +} + +/** A CDP command returned an error result. */ +export class CdpError extends Error { + readonly code: number; + constructor(method: string, code: number, message: string) { + super(`CDP ${method} failed (${code}): ${message}`); + this.name = 'CdpError'; + this.code = code; + } +} + +interface Pending { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + method: string; +} + +export interface CdpConnectOptions { + timeoutMs?: number; +} + +export class CdpClient implements CdpConnection { + #ws: WebSocket; + #nextId = 1; + #pending = new Map(); + #handlers = new Map void>>(); + #closed = false; + + private constructor(ws: WebSocket) { + this.#ws = ws; + ws.onmessage = (ev: MessageEvent) => this.#onMessage(ev); + ws.onclose = () => this.#onClose(); + } + + /** Open a CDP connection to a DevTools WebSocket URL. */ + static connect(url: string, options: CdpConnectOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 10_000; + return new Promise((resolve, reject) => { + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + const timer = setTimeout(() => { + try { + ws.close(); + } catch { + // already closing + } + reject(new Error(`CDP connect timed out after ${timeoutMs}ms`)); + }, timeoutMs); + ws.onopen = () => { + clearTimeout(timer); + resolve(new CdpClient(ws)); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error(`CDP connect failed for ${url}`)); + }; + }); + } + + send(method: string, params: Record = {}): Promise { + if (this.#closed) return Promise.reject(new Error('CDP connection is closed')); + const id = this.#nextId++; + const payload = JSON.stringify({ id, method, params }); + return new Promise((resolve, reject) => { + this.#pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + method, + }); + try { + this.#ws.send(payload); + } catch (err) { + this.#pending.delete(id); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + on(method: string, handler: (params: unknown) => void): void { + let set = this.#handlers.get(method); + if (!set) { + set = new Set(); + this.#handlers.set(method, set); + } + set.add(handler); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + try { + this.#ws.close(); + } catch { + // ignore + } + this.#onClose(); + } + + #onMessage(ev: MessageEvent): void { + const raw = typeof ev.data === 'string' ? ev.data : String(ev.data); + let msg: { + id?: number; + result?: unknown; + error?: { code?: number; message?: string }; + method?: string; + params?: unknown; + }; + try { + msg = JSON.parse(raw); + } catch { + return; // ignore malformed frames + } + if (typeof msg.id === 'number') { + const pending = this.#pending.get(msg.id); + if (!pending) return; + this.#pending.delete(msg.id); + if (msg.error) { + pending.reject( + new CdpError(pending.method, msg.error.code ?? -1, msg.error.message ?? 'unknown'), + ); + } else { + pending.resolve(msg.result); + } + return; + } + if (typeof msg.method === 'string') { + const set = this.#handlers.get(msg.method); + if (set) for (const h of set) h(msg.params); + } + } + + #onClose(): void { + this.#closed = true; + if (this.#pending.size === 0) return; + const err = new Error('CDP connection closed'); + for (const p of this.#pending.values()) p.reject(err); + this.#pending.clear(); + } +} diff --git a/packages/browser-core/src/automation/cdp.test.ts b/packages/browser-core/src/automation/cdp.test.ts new file mode 100644 index 0000000..6df2df4 --- /dev/null +++ b/packages/browser-core/src/automation/cdp.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { + cdpActivateTabUrl, + cdpBaseUrl, + cdpCloseTabUrl, + cdpListUrl, + cdpNewTabUrl, + cdpVersionUrl, + mapTargetsToTabs, + selectCurrentTab, +} from './cdp.js'; +import type { CdpTarget } from './types.js'; + +const endpoint = { host: '127.0.0.1', port: 9222 }; + +describe('CDP endpoint URLs', () => { + it('builds loopback DevTools URLs', () => { + expect(cdpBaseUrl(endpoint)).toBe('http://127.0.0.1:9222'); + expect(cdpVersionUrl(endpoint)).toBe('http://127.0.0.1:9222/json/version'); + expect(cdpListUrl(endpoint)).toBe('http://127.0.0.1:9222/json/list'); + }); + + it('appends the raw URL to /json/new so Chromium can decode it', () => { + expect(cdpNewTabUrl(endpoint, 'https://example.com/contact')).toBe( + 'http://127.0.0.1:9222/json/new?https://example.com/contact', + ); + }); + + it('targets close/activate by id', () => { + expect(cdpCloseTabUrl(endpoint, 'ABC123')).toBe( + 'http://127.0.0.1:9222/json/close/ABC123', + ); + expect(cdpActivateTabUrl(endpoint, 'ABC123')).toBe( + 'http://127.0.0.1:9222/json/activate/ABC123', + ); + }); +}); + +describe('mapTargetsToTabs', () => { + const targets: CdpTarget[] = [ + { id: 'w1', type: 'service_worker', url: 'chrome-extension://x/sw.js' }, + { id: 'p1', type: 'page', title: 'First', url: 'https://a.example' }, + { id: 'p2', type: 'page', title: 'Second', url: 'https://b.example' }, + ]; + + it('surfaces only page targets', () => { + const tabs = mapTargetsToTabs(targets); + expect(tabs.map((t) => t.id)).toEqual(['p1', 'p2']); + }); + + it('marks the first page current when no active tab is set', () => { + const tabs = mapTargetsToTabs(targets); + expect(tabs.find((t) => t.current)?.id).toBe('p1'); + }); + + it('marks the active tab current when it is still present', () => { + const tabs = mapTargetsToTabs(targets, 'p2'); + expect(tabs.find((t) => t.current)?.id).toBe('p2'); + expect(tabs.filter((t) => t.current)).toHaveLength(1); + }); + + it('falls back to the first page when the active tab has closed', () => { + const tabs = mapTargetsToTabs(targets, 'gone'); + expect(tabs.find((t) => t.current)?.id).toBe('p1'); + }); + + it('defaults missing title/url to empty strings', () => { + const tabs = mapTargetsToTabs([{ id: 'p1', type: 'page' }]); + expect(tabs[0]).toEqual({ id: 'p1', title: '', url: '', current: true }); + }); +}); + +describe('selectCurrentTab', () => { + it('returns the marked tab', () => { + const tab = selectCurrentTab([ + { id: 'p1', url: '', title: '', current: false }, + { id: 'p2', url: '', title: '', current: true }, + ]); + expect(tab?.id).toBe('p2'); + }); + + it('falls back to the first tab when none is marked', () => { + const tab = selectCurrentTab([ + { id: 'p1', url: '', title: '', current: false }, + { id: 'p2', url: '', title: '', current: false }, + ]); + expect(tab?.id).toBe('p1'); + }); + + it('returns undefined for an empty list', () => { + expect(selectCurrentTab([])).toBeUndefined(); + }); +}); diff --git a/packages/browser-core/src/automation/cdp.ts b/packages/browser-core/src/automation/cdp.ts new file mode 100644 index 0000000..9f51b2b --- /dev/null +++ b/packages/browser-core/src/automation/cdp.ts @@ -0,0 +1,80 @@ +/** + * CDP DevTools HTTP-endpoint helpers. + * + * M3.1 drives managed sessions through the DevTools HTTP JSON endpoints + * (`/json/version`, `/json/list`, `/json/new`, `/json/close`, `/json/activate`) + * — enough for launch/status/tabs/close/open without a WebSocket, keeping the + * milestone dependency-free. The URL builders below are the shared contract the + * shell engine mirrors with `curl`. + */ +import type { AutomationTab, CdpTarget } from './types.js'; + +/** A loopback DevTools endpoint. */ +export interface CdpEndpoint { + host: string; + port: number; +} + +/** Base `http://host:port` origin for the DevTools endpoint. */ +export function cdpBaseUrl(endpoint: CdpEndpoint): string { + return `http://${endpoint.host}:${endpoint.port}`; +} + +/** Browser/version info (also carries the browser-level `webSocketDebuggerUrl`). */ +export function cdpVersionUrl(endpoint: CdpEndpoint): string { + return `${cdpBaseUrl(endpoint)}/json/version`; +} + +/** List of open targets (tabs, workers, ...). */ +export function cdpListUrl(endpoint: CdpEndpoint): string { + return `${cdpBaseUrl(endpoint)}/json/list`; +} + +/** + * Open a new tab. Chromium reads the raw URL after `?` and URL-decodes it, so + * `url` must be a well-formed absolute URL. Modern Chromium requires this to be + * issued as an HTTP `PUT`. + */ +export function cdpNewTabUrl(endpoint: CdpEndpoint, url: string): string { + return `${cdpBaseUrl(endpoint)}/json/new?${url}`; +} + +/** Close the target with the given id. */ +export function cdpCloseTabUrl(endpoint: CdpEndpoint, targetId: string): string { + return `${cdpBaseUrl(endpoint)}/json/close/${targetId}`; +} + +/** Bring the target with the given id to the foreground. */ +export function cdpActivateTabUrl(endpoint: CdpEndpoint, targetId: string): string { + return `${cdpBaseUrl(endpoint)}/json/activate/${targetId}`; +} + +/** + * Normalize raw CDP targets into page tabs, marking the current one. + * + * Only `type === 'page'` targets are surfaced (background/service-worker and + * devtools targets are hidden). The current tab is the descriptor's active tab + * when it is still present, otherwise the first page — so `tron browser tabs` + * can always identify a current tab (PRD M3.1 acceptance). + */ +export function mapTargetsToTabs( + targets: readonly CdpTarget[], + activeTabId?: string, +): AutomationTab[] { + const pages = targets.filter((t) => t.type === 'page'); + const activeIsPresent = + activeTabId !== undefined && pages.some((t) => t.id === activeTabId); + return pages.map((t, index) => ({ + id: t.id, + url: t.url ?? '', + title: t.title ?? '', + current: activeIsPresent ? t.id === activeTabId : index === 0, + })); +} + +/** The current tab from a normalized list (the marked one, else the first). */ +export function selectCurrentTab( + tabs: readonly AutomationTab[], +): AutomationTab | undefined { + return tabs.find((t) => t.current) ?? tabs[0]; +} diff --git a/packages/browser-core/src/automation/descriptor.test.ts b/packages/browser-core/src/automation/descriptor.test.ts new file mode 100644 index 0000000..234514c --- /dev/null +++ b/packages/browser-core/src/automation/descriptor.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + descriptorPath, + parseDescriptor, + resolveDataDir, + serializeDescriptor, +} from './descriptor.js'; +import type { SessionDescriptor } from './types.js'; + +const base: SessionDescriptor = { + version: 1, + pid: 4242, + host: '127.0.0.1', + port: 9222, + profileDir: '/home/u/.tronbrowser-agent', + profileName: 'agent', + headless: false, + ephemeral: false, + createdAt: '2026-07-04T00:00:00.000Z', +}; + +describe('resolveDataDir', () => { + it('prefers TRONBROWSER_DATA', () => { + expect(resolveDataDir({ TRONBROWSER_DATA: '/custom', HOME: '/home/u' })).toBe('/custom'); + }); + + it('falls back to $HOME/.tronbrowser', () => { + expect(resolveDataDir({ HOME: '/home/u' })).toBe('/home/u/.tronbrowser'); + }); + + it('ignores an empty TRONBROWSER_DATA', () => { + expect(resolveDataDir({ TRONBROWSER_DATA: '', HOME: '/home/u' })).toBe( + '/home/u/.tronbrowser', + ); + }); +}); + +describe('descriptorPath', () => { + it('nests the descriptor under automation/', () => { + expect(descriptorPath('/home/u/.tronbrowser')).toBe( + '/home/u/.tronbrowser/automation/session.json', + ); + }); +}); + +describe('serialize/parse round-trip', () => { + it('omits absent optionals and round-trips', () => { + const raw = serializeDescriptor(base); + expect(raw.endsWith('\n')).toBe(true); + expect(raw).not.toContain('webSocketDebuggerUrl'); + expect(raw).not.toContain('activeTabId'); + expect(parseDescriptor(raw)).toEqual(base); + }); + + it('preserves present optionals', () => { + const full: SessionDescriptor = { + ...base, + webSocketDebuggerUrl: 'ws://127.0.0.1:9222/devtools/browser/abc', + activeTabId: 'p2', + }; + expect(parseDescriptor(serializeDescriptor(full))).toEqual(full); + }); +}); + +describe('parseDescriptor validation', () => { + it('rejects non-JSON', () => { + expect(() => parseDescriptor('not json')).toThrow(/not valid JSON/); + }); + + it('rejects an unknown schema version', () => { + expect(() => parseDescriptor(JSON.stringify({ ...base, version: 2 }))).toThrow( + /version/, + ); + }); + + it('rejects a non-integer pid', () => { + expect(() => parseDescriptor(JSON.stringify({ ...base, pid: 'x' }))).toThrow(/pid/); + }); + + it('rejects a wrong-typed optional', () => { + expect(() => + parseDescriptor(JSON.stringify({ ...base, activeTabId: 5 })), + ).toThrow(/activeTabId/); + }); +}); diff --git a/packages/browser-core/src/automation/descriptor.ts b/packages/browser-core/src/automation/descriptor.ts new file mode 100644 index 0000000..777ae4b --- /dev/null +++ b/packages/browser-core/src/automation/descriptor.ts @@ -0,0 +1,103 @@ +/** + * Read/write helpers for the managed-session descriptor. + * + * The descriptor always lives under the stable data dir (never inside an + * ephemeral profile) so `status`/`close` can find a session regardless of which + * profile it launched. Serialization omits absent optionals and appends a + * trailing newline so the file is diff/POSIX-tool friendly for the shell engine. + */ +import type { SessionDescriptor } from './types.js'; + +/** Env slice used to locate the data dir (mirrors the shell launcher's rules). */ +export interface DataDirEnv { + TRONBROWSER_DATA?: string; + HOME?: string; +} + +/** + * Resolve the TronBrowser data dir: `$TRONBROWSER_DATA` when set, else + * `$HOME/.tronbrowser` (the flat convention the launcher and Tor helper use — + * there is no XDG layout). + */ +export function resolveDataDir(env: DataDirEnv): string { + if (env.TRONBROWSER_DATA !== undefined && env.TRONBROWSER_DATA.length > 0) { + return env.TRONBROWSER_DATA; + } + return `${env.HOME ?? ''}/.tronbrowser`; +} + +/** Absolute path of the session descriptor within a data dir. */ +export function descriptorPath(dataDir: string): string { + return `${dataDir}/automation/session.json`; +} + +/** Serialize a descriptor to pretty JSON (absent optionals omitted). */ +export function serializeDescriptor(descriptor: SessionDescriptor): string { + const out: Record = { + version: descriptor.version, + pid: descriptor.pid, + host: descriptor.host, + port: descriptor.port, + profileDir: descriptor.profileDir, + profileName: descriptor.profileName, + headless: descriptor.headless, + ephemeral: descriptor.ephemeral, + createdAt: descriptor.createdAt, + }; + if (descriptor.webSocketDebuggerUrl !== undefined) { + out.webSocketDebuggerUrl = descriptor.webSocketDebuggerUrl; + } + if (descriptor.activeTabId !== undefined) { + out.activeTabId = descriptor.activeTabId; + } + return `${JSON.stringify(out, null, 2)}\n`; +} + +function fail(field: string): never { + throw new Error(`Invalid session descriptor: missing or malformed "${field}"`); +} + +/** Parse and validate a descriptor, throwing a descriptive error on bad input. */ +export function parseDescriptor(raw: string): SessionDescriptor { + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + throw new Error('Invalid session descriptor: not valid JSON'); + } + if (typeof obj !== 'object' || obj === null) { + throw new Error('Invalid session descriptor: expected an object'); + } + const o = obj as Record; + + if (o.version !== 1) fail('version'); + if (typeof o.pid !== 'number' || !Number.isInteger(o.pid)) fail('pid'); + if (typeof o.host !== 'string' || o.host.length === 0) fail('host'); + if (typeof o.port !== 'number' || !Number.isInteger(o.port)) fail('port'); + if (typeof o.profileDir !== 'string' || o.profileDir.length === 0) fail('profileDir'); + if (typeof o.profileName !== 'string' || o.profileName.length === 0) fail('profileName'); + if (typeof o.headless !== 'boolean') fail('headless'); + if (typeof o.ephemeral !== 'boolean') fail('ephemeral'); + if (typeof o.createdAt !== 'string' || o.createdAt.length === 0) fail('createdAt'); + + const descriptor: SessionDescriptor = { + version: 1, + pid: o.pid, + host: o.host, + port: o.port, + profileDir: o.profileDir, + profileName: o.profileName, + headless: o.headless, + ephemeral: o.ephemeral, + createdAt: o.createdAt, + }; + if (o.webSocketDebuggerUrl !== undefined) { + if (typeof o.webSocketDebuggerUrl !== 'string') fail('webSocketDebuggerUrl'); + descriptor.webSocketDebuggerUrl = o.webSocketDebuggerUrl; + } + if (o.activeTabId !== undefined) { + if (typeof o.activeTabId !== 'string') fail('activeTabId'); + descriptor.activeTabId = o.activeTabId; + } + return descriptor; +} diff --git a/packages/browser-core/src/automation/extract-script.test.ts b/packages/browser-core/src/automation/extract-script.test.ts new file mode 100644 index 0000000..91b1204 --- /dev/null +++ b/packages/browser-core/src/automation/extract-script.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest'; +import { extractExpression, isExtractMode, parseFieldSpec } from './extract-script.js'; + +function run(expr: string): T { + return new Function('return ' + expr)() as T; +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +describe('parseFieldSpec', () => { + it('parses name=selector', () => { + expect(parseFieldSpec('title=.t')).toEqual({ name: 'title', selector: '.t' }); + }); + it('parses name=selector@attr', () => { + expect(parseFieldSpec('url=a@href')).toEqual({ name: 'url', selector: 'a', attr: 'href' }); + }); + it('rejects a malformed spec', () => { + expect(() => parseFieldSpec('nope')).toThrow(/name=selector/); + }); +}); + +describe('isExtractMode', () => { + it('recognizes built-in modes', () => { + expect(isExtractMode('links')).toBe(true); + expect(isExtractMode('.card')).toBe(false); + }); +}); + +describe('extract links', () => { + it('returns text + absolute href, resolving relatives', () => { + document.body.innerHTML = ` + Abs + Rel`; + const links = run>(extractExpression('links')); + expect(links[0]).toEqual({ text: 'Abs', href: 'https://other.com/x' }); + // The relative href is resolved to an absolute URL (host is env-dependent). + expect(links[1].href).toMatch(/^https?:\/\/.+\/page$/); + }); +}); + +describe('extract forms', () => { + it('maps fields with labels, required, and omits password values', () => { + document.body.innerHTML = ` +
+ + + +
`; + const forms = run>>(extractExpression('forms')); + expect(forms).toHaveLength(1); + const f = forms[0] as { name: string; method: string; fields: Array> }; + expect(f.name).toBe('contact'); + expect(f.method).toBe('post'); + // hidden excluded; email + password only + expect(f.fields.map((x) => x.name)).toEqual(['email', 'pw']); + const email = f.fields[0]; + expect(email.label).toBe('Email'); + expect(email.required).toBe(true); + expect(email.value).toBe('a@b.com'); + expect(f.fields[1]).not.toHaveProperty('value'); // password value omitted + }); +}); + +describe('extract tables', () => { + it('returns headers and rows', () => { + document.body.innerHTML = ` + + + + + + +
NamePrice
Apple$1
Pear$2
`; + const tables = run>(extractExpression('tables')); + expect(tables[0].headers).toEqual(['Name', 'Price']); + expect(tables[0].rows).toEqual([ + ['Apple', '$1'], + ['Pear', '$2'], + ]); + }); +}); + +describe('extract custom selector + fields', () => { + it('maps each match to the requested fields with absolute urls', () => { + document.body.innerHTML = ` +
OneA
+
TwoB
`; + const rows = run>( + extractExpression('.card', [ + { name: 'title', selector: '.t' }, + { name: 'url', selector: 'a', attr: 'href' }, + ]), + ); + expect(rows.map((r) => r.title)).toEqual(['One', 'Two']); + expect(rows[0].url).toMatch(/^https?:\/\/.+\/a$/); + }); +}); + +describe('extract text / main', () => { + it('extracts main content text', () => { + document.body.innerHTML = `
Hello world
`; + const res = run<{ text: string }>(extractExpression('main')); + expect(res.text).toContain('Hello world'); + }); +}); diff --git a/packages/browser-core/src/automation/extract-script.ts b/packages/browser-core/src/automation/extract-script.ts new file mode 100644 index 0000000..1323a04 --- /dev/null +++ b/packages/browser-core/src/automation/extract-script.ts @@ -0,0 +1,118 @@ +/** + * Structured page extraction (PRD M3.3): built-in modes (text/links/forms/ + * tables/main) plus a custom CSS selector with `--field name=selector[@attr]`. + * + * `extractExpression` returns an IIFE evaluated in the page via CDP. Output is + * deterministic JSON with stable field names; relative URLs (href/src) resolve + * to absolute so callers never see page-relative links. + */ + +/** Built-in extraction modes. */ +export const EXTRACT_MODES = ['text', 'links', 'forms', 'tables', 'main'] as const; +export type ExtractMode = (typeof EXTRACT_MODES)[number]; + +export function isExtractMode(value: string): value is ExtractMode { + return (EXTRACT_MODES as readonly string[]).includes(value); +} + +/** A `--field name=selector` or `--field name=selector@attr` mapping. */ +export interface FieldSpec { + name: string; + selector: string; + attr?: string; +} + +/** Parse one `name=selector[@attr]` field spec. */ +export function parseFieldSpec(spec: string): FieldSpec { + const eq = spec.indexOf('='); + if (eq <= 0) throw new Error(`Bad --field (expected name=selector[@attr]): "${spec}"`); + const name = spec.slice(0, eq).trim(); + let selectorPart = spec.slice(eq + 1).trim(); + let attr: string | undefined; + const at = selectorPart.lastIndexOf('@'); + if (at >= 0) { + attr = selectorPart.slice(at + 1).trim(); + selectorPart = selectorPart.slice(0, at).trim(); + } + if (!name || !selectorPart) throw new Error(`Bad --field: "${spec}"`); + return attr ? { name, selector: selectorPart, attr } : { name, selector: selectorPart }; +} + +/** + * Build the in-page extraction expression. + * `target` is a built-in mode, or a CSS selector when `fields` are provided + * (or when it isn't a known mode). + */ +export function extractExpression(target: string, fields: FieldSpec[] = []): string { + return `(() => { + const abs = (el, attr) => { + if (attr === 'href' || attr === 'src') { const v = el[attr]; if (typeof v === 'string' && v) return v; } + return el.getAttribute(attr); + }; + const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim(); + const labelFor = (el) => { + try { if (el.id) { const l = document.querySelector('label[for="' + (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(el.id) : el.id) + '"]'); if (l) return clean(l.textContent); } } catch (_) {} + const w = el.closest('label'); return w ? clean(w.textContent) : ''; + }; + + const links = () => [...document.querySelectorAll('a[href]')].map((a) => ({ text: clean(a.textContent), href: a.href })); + + const forms = () => [...document.querySelectorAll('form')].map((f) => ({ + name: f.getAttribute('name') || f.id || null, + action: f.action || null, + method: (f.getAttribute('method') || 'get').toLowerCase(), + fields: [...f.querySelectorAll('input, select, textarea')] + .filter((el) => (el.getAttribute('type') || '') !== 'hidden') + .map((el) => { + const type = el.tagName.toLowerCase() === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : el.tagName.toLowerCase(); + const out = { + name: el.getAttribute('name') || el.id || null, + type, + label: labelFor(el) || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '', + required: el.hasAttribute('required'), + }; + if (type !== 'password') out.value = el.value || ''; + return out; + }), + })); + + const tables = () => [...document.querySelectorAll('table')].map((t) => { + const headers = [...t.querySelectorAll('thead th, tr:first-child th')].map((th) => clean(th.textContent)); + const rows = [...t.querySelectorAll('tbody tr, tr')] + .filter((tr) => tr.querySelector('td')) + .map((tr) => [...tr.querySelectorAll('td')].map((td) => clean(td.textContent))); + return { headers, rows }; + }); + + const mainText = () => { + const m = document.querySelector('main, article, [role=main]') || document.body; + return { text: clean(m.textContent).slice(0, 20000) }; + }; + + const target = ${JSON.stringify(target)}; + const fields = ${JSON.stringify(fields)}; + + if (fields.length > 0) { + return [...document.querySelectorAll(target)].map((el) => { + const rec = {}; + for (const f of fields) { + const node = el.querySelector(f.selector) || (el.matches(f.selector) ? el : null); + if (!node) { rec[f.name] = null; continue; } + rec[f.name] = f.attr ? abs(node, f.attr) : clean(node.textContent); + } + return rec; + }); + } + + switch (target) { + case 'text': return { text: clean(document.body ? document.body.textContent : '').slice(0, 20000) }; + case 'links': return links(); + case 'forms': return forms(); + case 'tables': return tables(); + case 'main': return mainText(); + default: + // Bare selector: the text of each match. + return [...document.querySelectorAll(target)].map((el) => clean(el.textContent)); + } +})()`; +} diff --git a/packages/browser-core/src/automation/index.ts b/packages/browser-core/src/automation/index.ts new file mode 100644 index 0000000..594513b --- /dev/null +++ b/packages/browser-core/src/automation/index.ts @@ -0,0 +1,21 @@ +/** + * @tronbrowser/browser-core — automation (managed sessions, PRD M3.1). + * + * Portable contracts and pure helpers for CDP-driven managed browser sessions. + * Desktop-specific process/IO glue stays in apps/desktop; this module holds the + * schema and logic the shell engine and future TS CDP client both conform to. + */ +export * from './types.js'; +export * from './cdp.js'; +export * from './descriptor.js'; + +// Snapshots and ref actions (PRD M3.2). +export * from './cdp-client.js'; +export * from './snapshot-script.js'; +export * from './action-script.js'; +export * from './page-target.js'; +export * from './page.js'; + +// Headless one-shot, extraction, and capture (PRD M3.3). +export * from './extract-script.js'; +export * from './capture.js'; diff --git a/packages/browser-core/src/automation/page-target.test.ts b/packages/browser-core/src/automation/page-target.test.ts new file mode 100644 index 0000000..a9b2086 --- /dev/null +++ b/packages/browser-core/src/automation/page-target.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { resolvePageWsUrl, selectPageTarget } from './page-target.js'; +import type { CdpTarget } from './types.js'; + +const targets: CdpTarget[] = [ + { id: 'sw', type: 'service_worker', url: 'x' }, + { id: 'p1', type: 'page', url: 'https://a', webSocketDebuggerUrl: 'ws://h/p1' }, + { id: 'p2', type: 'page', url: 'https://b', webSocketDebuggerUrl: 'ws://h/p2' }, +]; + +describe('selectPageTarget', () => { + it('prefers the active tab', () => { + expect(selectPageTarget(targets, 'p2')?.id).toBe('p2'); + }); + it('falls back to the first page when active is absent/closed', () => { + expect(selectPageTarget(targets, 'gone')?.id).toBe('p1'); + expect(selectPageTarget(targets)?.id).toBe('p1'); + }); + it('returns undefined when there are no pages', () => { + expect(selectPageTarget([{ id: 'sw', type: 'service_worker' }])).toBeUndefined(); + }); +}); + +describe('resolvePageWsUrl', () => { + it('returns the chosen page ws url', () => { + expect(resolvePageWsUrl(targets, 'p2')).toBe('ws://h/p2'); + }); + it('throws when there is no page target', () => { + expect(() => resolvePageWsUrl([])).toThrow(/No page target/); + }); + it('throws when the page has no ws url', () => { + expect(() => resolvePageWsUrl([{ id: 'p', type: 'page' }])).toThrow(/no webSocketDebuggerUrl/); + }); +}); diff --git a/packages/browser-core/src/automation/page-target.ts b/packages/browser-core/src/automation/page-target.ts new file mode 100644 index 0000000..ea5561f --- /dev/null +++ b/packages/browser-core/src/automation/page-target.ts @@ -0,0 +1,34 @@ +/** + * Resolve which page target's DevTools WebSocket to drive (PRD M3.2). + * + * A managed session can have several page targets; snapshot/click/fill act on + * the "current" one — the descriptor's active tab when present, else the first + * page — matching how `tron browser tabs` marks the current tab. + */ +import type { CdpTarget } from './types.js'; + +/** Page target chosen to act on: the active tab if present, else the first page. */ +export function selectPageTarget( + targets: readonly CdpTarget[], + activeTabId?: string, +): CdpTarget | undefined { + const pages = targets.filter((t) => t.type === 'page'); + if (activeTabId !== undefined) { + const active = pages.find((t) => t.id === activeTabId); + if (active) return active; + } + return pages[0]; +} + +/** The page WebSocket URL to attach to, or throw a clear error if none. */ +export function resolvePageWsUrl( + targets: readonly CdpTarget[], + activeTabId?: string, +): string { + const target = selectPageTarget(targets, activeTabId); + if (!target) throw new Error('No page target in the managed session'); + if (!target.webSocketDebuggerUrl) { + throw new Error(`Page target ${target.id} has no webSocketDebuggerUrl`); + } + return target.webSocketDebuggerUrl; +} diff --git a/packages/browser-core/src/automation/page.test.ts b/packages/browser-core/src/automation/page.test.ts new file mode 100644 index 0000000..a1f5a01 --- /dev/null +++ b/packages/browser-core/src/automation/page.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CdpConnection } from './cdp-client.js'; +import { + captureSnapshot, + clickRef, + fillRef, + formatSnapshotText, + StaleRefError, +} from './page.js'; +import type { AgentSnapshot } from './snapshot-script.js'; + +/** A CdpConnection whose Runtime.evaluate returns a canned by-value result. */ +function fakeConn(evalValue: unknown, opts: { exception?: string } = {}): CdpConnection { + const send = vi.fn(async (method: string) => { + if (method === 'Runtime.evaluate') { + return opts.exception + ? { exceptionDetails: { text: opts.exception } } + : { result: { value: evalValue } }; + } + return {}; + }); + return { send: send as unknown as CdpConnection['send'], on: vi.fn(), close: vi.fn() }; +} + +const snap: AgentSnapshot = { + url: 'https://example.com/contact', + title: 'Contact Us', + timestamp: '2026-07-04T00:00:00.000Z', + elements: [ + { ref: '@e1', role: 'heading', name: 'Contact Us', tag: 'h1', interactive: false, visible: true }, + { ref: '@e2', role: 'textbox', name: 'Email', tag: 'input', interactive: true, visible: true, value: 'a@b.com' }, + { ref: '@e3', role: 'link', name: 'More', tag: 'a', interactive: true, visible: true, href: 'https://x/y' }, + ], +}; + +describe('captureSnapshot', () => { + it('returns the page-provided snapshot value', async () => { + const result = await captureSnapshot(fakeConn(snap)); + expect(result.title).toBe('Contact Us'); + expect(result.elements).toHaveLength(3); + }); + + it('throws when the page evaluation raises', async () => { + await expect(captureSnapshot(fakeConn(null, { exception: 'boom' }))).rejects.toThrow( + /Page evaluation failed: boom/, + ); + }); +}); + +describe('ref actions', () => { + it('clickRef returns the action result', async () => { + const res = await clickRef(fakeConn({ ok: true, ref: '@e3' }), '@e3'); + expect(res.ok).toBe(true); + }); + + it('clickRef throws StaleRefError when the ref is gone', async () => { + await expect( + clickRef(fakeConn({ ok: false, error: 'STALE_REF', ref: '@e9' }), '@e9'), + ).rejects.toBeInstanceOf(StaleRefError); + }); + + it('fillRef throws StaleRefError when the ref is gone', async () => { + const err = await fillRef( + fakeConn({ ok: false, error: 'STALE_REF', ref: '@e9' }), + '@e9', + 'x', + ).catch((e) => e); + expect(err).toBeInstanceOf(StaleRefError); + expect(err.recoverable).toBe(true); + expect(err.code).toBe('STALE_REF'); + }); + + it('rejects a malformed ref before touching the page', async () => { + await expect(clickRef(fakeConn({}), 'not-a-ref')).rejects.toThrow(/snapshot ref/); + }); +}); + +describe('formatSnapshotText', () => { + it('renders compact ref lines with value and href hints', () => { + const text = formatSnapshotText(snap); + expect(text).toContain('Page: Contact Us'); + expect(text).toContain('URL: https://example.com/contact'); + expect(text).toContain('@e1 heading "Contact Us"'); + expect(text).toContain('@e2 textbox "Email" = "a@b.com"'); + expect(text).toContain('@e3 link "More" -> https://x/y'); + }); + + it('notes when there are no interactive elements', () => { + const text = formatSnapshotText({ ...snap, elements: [] }); + expect(text).toContain('(no interactive elements)'); + }); +}); diff --git a/packages/browser-core/src/automation/page.ts b/packages/browser-core/src/automation/page.ts new file mode 100644 index 0000000..9e5b11b --- /dev/null +++ b/packages/browser-core/src/automation/page.ts @@ -0,0 +1,127 @@ +/** + * Page-level automation over a CDP connection (PRD M3.2): evaluate the snapshot + * and ref-action scripts, parse their results, and surface a recoverable + * STALE_REF error when a ref no longer resolves. + */ +import type { CdpConnection } from './cdp-client.js'; +import { + clickExpression, + fillExpression, + normalizeRef, + type ActionResult, +} from './action-script.js'; +import { + snapshotExpression, + type AgentSnapshot, + type SnapshotElement, + type SnapshotOptions, +} from './snapshot-script.js'; + +/** A ref no longer resolves in the page; the caller should re-snapshot. */ +export class StaleRefError extends Error { + readonly ref: string; + readonly code = 'STALE_REF' as const; + readonly recoverable = true; + constructor(ref: string) { + super( + `Ref ${ref} not found on the page — it may be stale. Run \`tron snapshot\` and use a current ref.`, + ); + this.name = 'StaleRefError'; + this.ref = ref; + } +} + +interface EvalResult { + result?: { value?: unknown }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; +} + +/** Evaluate an expression in the page and return its by-value result. */ +async function evaluate(conn: CdpConnection, expression: string): Promise { + const res = await conn.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (res.exceptionDetails) { + const detail = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + 'evaluation failed'; + throw new Error(`Page evaluation failed: ${detail}`); + } + return res.result?.value as T; +} + +/** Enable the CDP Runtime domain (idempotent) before evaluating. */ +export async function enableRuntime(conn: CdpConnection): Promise { + await conn.send('Runtime.enable'); +} + +/** Navigate the page to `url` and wait for load (or `timeoutMs`). */ +export async function goto( + conn: CdpConnection, + url: string, + options: { timeoutMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 30_000; + await conn.send('Page.enable'); + const loaded = new Promise((resolve) => conn.on('Page.loadEventFired', () => resolve())); + await conn.send('Page.navigate', { url }); + await Promise.race([ + loaded, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +/** Run the extraction expression and return its deterministic JSON value. */ +export async function extract(conn: CdpConnection, expression: string): Promise { + return evaluate(conn, expression); +} + +/** Capture a structured, ref-tagged snapshot of the current page. */ +export async function captureSnapshot( + conn: CdpConnection, + options: SnapshotOptions = {}, +): Promise { + return evaluate(conn, snapshotExpression(options)); +} + +/** Click the element referenced by `ref` (throws StaleRefError if gone). */ +export async function clickRef(conn: CdpConnection, ref: string): Promise { + const result = await evaluate(conn, clickExpression(ref)); + if (!result.ok && result.error === 'STALE_REF') throw new StaleRefError(`@${normalizeRef(ref)}`); + return result; +} + +/** Fill the element referenced by `ref` with `value` (throws StaleRefError if gone). */ +export async function fillRef( + conn: CdpConnection, + ref: string, + value: string, +): Promise { + const result = await evaluate(conn, fillExpression(ref, value)); + if (!result.ok && result.error === 'STALE_REF') throw new StaleRefError(`@${normalizeRef(ref)}`); + return result; +} + +/** Render a snapshot as compact text (the default `tron snapshot` output). */ +export function formatSnapshotText(snapshot: AgentSnapshot): string { + const lines: string[] = [ + `Page: ${snapshot.title || '(untitled)'}`, + `URL: ${snapshot.url}`, + '', + ]; + for (const el of snapshot.elements) { + lines.push(formatElementLine(el)); + } + if (snapshot.elements.length === 0) lines.push('(no interactive elements)'); + return lines.join('\n'); +} + +function formatElementLine(el: SnapshotElement): string { + let line = `${el.ref} ${el.role} ${JSON.stringify(el.name)}`; + if (el.value !== undefined && el.value !== '') line += ` = ${JSON.stringify(el.value)}`; + if (el.href) line += ` -> ${el.href}`; + return line; +} diff --git a/packages/browser-core/src/automation/snapshot-script.test.ts b/packages/browser-core/src/automation/snapshot-script.test.ts new file mode 100644 index 0000000..dbfde35 --- /dev/null +++ b/packages/browser-core/src/automation/snapshot-script.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest'; +import { snapshotExpression, type AgentSnapshot } from './snapshot-script.js'; +import { clickExpression, fillExpression, type ActionResult } from './action-script.js'; + +// happy-dom does no layout, so getBoundingClientRect() is all zeros. The snapshot +// script uses a non-zero box as a visibility signal; give visible elements one so +// the display/hidden/visibility filters (which happy-dom does honor) are what's +// under test. +function run(expr: string): T { + return new Function('return ' + expr)() as T; +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + Element.prototype.getBoundingClientRect = function () { + return { width: 120, height: 20, top: 0, left: 0, right: 120, bottom: 20, x: 0, y: 0, toJSON() {} }; + } as typeof Element.prototype.getBoundingClientRect; +}); + +describe('snapshotExpression', () => { + it('tags interactive + heading elements with refs in document order', () => { + document.body.innerHTML = ` +

Contact Us

+
+ + + + More information + + +
`; + const snap = run(snapshotExpression()); + + expect(snap.title).toBe(document.title); + const byRole = Object.fromEntries(snap.elements.map((e) => [e.name, e])); + expect(snap.elements.map((e) => e.ref)).toEqual(['@e1', '@e2', '@e3', '@e4', '@e5', '@e6']); + expect(byRole['Contact Us'].role).toBe('heading'); + expect(byRole['Name'].role).toBe('textbox'); + expect(byRole['Email'].value).toBe('a@b.com'); + expect(byRole['Message'].role).toBe('textbox'); + expect(byRole['More information'].role).toBe('link'); + expect(byRole['More information'].href).toContain('example.com/more'); + expect(byRole['Submit'].role).toBe('button'); + // The hidden input has no layout role here and type=hidden is excluded. + expect(snap.elements.some((e) => e.name === 'csrf')).toBe(false); + }); + + it('writes data-tron-ref attributes so later actions can resolve refs', () => { + document.body.innerHTML = ``; + run(snapshotExpression()); + expect(document.querySelector('[data-tron-ref="e1"]')?.textContent).toBe('Go'); + }); + + it('redacts password values', () => { + document.body.innerHTML = ``; + const snap = run(snapshotExpression()); + expect(snap.elements[0].value).not.toContain('hunter2'); + }); + + it('excludes display:none and [hidden] elements by default', () => { + document.body.innerHTML = ` + + + `; + const snap = run(snapshotExpression()); + expect(snap.elements.map((e) => e.name)).toEqual(['Yes']); + }); + + it('includes hidden elements when asked', () => { + document.body.innerHTML = ``; + const snap = run(snapshotExpression({ includeHidden: true })); + expect(snap.elements.map((e) => e.name)).toEqual(['Nope']); + expect(snap.elements[0].visible).toBe(false); + }); + + it('reports the focused ref', () => { + document.body.innerHTML = ``; + (document.getElementById('b') as HTMLInputElement).focus(); + const snap = run(snapshotExpression()); + expect(snap.focusedRef).toBe('@e2'); + }); +}); + +describe('action expressions', () => { + it('clicks the referenced element', () => { + document.body.innerHTML = ``; + run(snapshotExpression()); + let clicked = false; + document.querySelector('button')!.addEventListener('click', () => { + clicked = true; + }); + const res = run(clickExpression('@e1')); + expect(res.ok).toBe(true); + expect(clicked).toBe(true); + }); + + it('fills an input and dispatches input/change', () => { + document.body.innerHTML = ``; + run(snapshotExpression()); + const input = document.getElementById('x') as HTMLInputElement; + const events: string[] = []; + input.addEventListener('input', () => events.push('input')); + input.addEventListener('change', () => events.push('change')); + const res = run(fillExpression('@e1', 'hello@example.com')); + expect(res.ok).toBe(true); + expect(input.value).toBe('hello@example.com'); + expect(events).toEqual(['input', 'change']); + }); + + it('returns STALE_REF when the ref no longer resolves', () => { + document.body.innerHTML = ``; + // No snapshot taken, so no data-tron-ref exists. + const res = run(clickExpression('@e9')); + expect(res.ok).toBe(false); + expect(res.error).toBe('STALE_REF'); + }); +}); diff --git a/packages/browser-core/src/automation/snapshot-script.ts b/packages/browser-core/src/automation/snapshot-script.ts new file mode 100644 index 0000000..17a174e --- /dev/null +++ b/packages/browser-core/src/automation/snapshot-script.ts @@ -0,0 +1,189 @@ +/** + * The in-page snapshot script (PRD M3.2) and its result types. + * + * `SNAPSHOT_JS` is evaluated in the page via CDP `Runtime.evaluate`. It tags each + * surfaced element with a `data-tron-ref` attribute and returns a compact, + * LLM-friendly list. Encoding the ref in the DOM (rather than a server-side node + * map) is what lets a later `tron click @e3` — a separate process — resolve the + * ref with a plain attribute selector, and makes a vanished element a clean + * STALE_REF instead of a dangling handle. + */ + +/** DOM attribute that carries a snapshot ref (e.g. `e3` for `@e3`). */ +export const TRON_REF_ATTR = 'data-tron-ref'; + +export interface SnapshotElement { + ref: string; // "@e3" + role: string; + name: string; + tag: string; + interactive: boolean; + visible: boolean; + value?: string; + href?: string; +} + +export interface AgentSnapshot { + url: string; + title: string; + timestamp: string; + elements: SnapshotElement[]; + focusedRef?: string; +} + +export interface SnapshotOptions { + includeHidden?: boolean; +} + +/** + * Build the in-page snapshot expression. Returns an IIFE string suitable for + * `Runtime.evaluate` with `returnByValue: true`. + */ +export function snapshotExpression(options: SnapshotOptions = {}): string { + const includeHidden = options.includeHidden === true; + return `(() => { + const ATTR = ${JSON.stringify(TRON_REF_ATTR)}; + const includeHidden = ${includeHidden ? 'true' : 'false'}; + const INTERACTIVE = 'a[href], button, input:not([type=hidden]), select, textarea, ' + + '[role=button], [role=link], [role=checkbox], [role=radio], [role=tab], ' + + '[role=menuitem], [role=switch], [role=textbox], [contenteditable=""], ' + + '[contenteditable=true], summary, [tabindex]:not([tabindex="-1"])'; + const HEADING = 'h1, h2, h3, h4, h5, h6, [role=heading]'; + + for (const el of document.querySelectorAll('[' + ATTR + ']')) el.removeAttribute(ATTR); + + const isVisible = (el) => { + if (el.hasAttribute('hidden')) return false; + const st = getComputedStyle(el); + if (st.display === 'none' || st.visibility === 'hidden' || st.visibility === 'collapse') return false; + if (parseFloat(st.opacity || '1') === 0) return false; + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + const roleOf = (el) => { + const explicit = el.getAttribute('role'); + if (explicit) return explicit; + const tag = el.tagName.toLowerCase(); + if (tag === 'a') return el.hasAttribute('href') ? 'link' : 'generic'; + if (tag === 'button' || tag === 'summary') return 'button'; + if (tag === 'select') return 'combobox'; + if (tag === 'textarea') return 'textbox'; + if (/^h[1-6]$/.test(tag)) return 'heading'; + if (tag === 'input') { + const t = (el.getAttribute('type') || 'text').toLowerCase(); + if (t === 'checkbox') return 'checkbox'; + if (t === 'radio') return 'radio'; + if (t === 'button' || t === 'submit' || t === 'reset') return 'button'; + if (t === 'range') return 'slider'; + return 'textbox'; + } + return 'generic'; + }; + + const escapeId = (id) => (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\\\]/g, '\\\\$&')); + const labelFor = (el) => { + try { + if (el.id) { + const lab = document.querySelector('label[for="' + escapeId(el.id) + '"]'); + if (lab && lab.textContent) return lab.textContent.trim(); + } + } catch (_) { /* bad id selector — fall through */ } + const wrap = el.closest('label'); + if (wrap && wrap.textContent) return wrap.textContent.trim(); + return ''; + }; + + const nameOf = (el) => { + const aria = el.getAttribute('aria-label'); + if (aria) return aria.trim(); + const labelledby = el.getAttribute('aria-labelledby'); + if (labelledby) { + const parts = labelledby.split(/\\s+/).map((id) => { + const n = document.getElementById(id); + return n && n.textContent ? n.textContent.trim() : ''; + }).filter(Boolean); + if (parts.length) return parts.join(' '); + } + const lab = labelFor(el); + if (lab) return lab; + const tag = el.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') { + const ph = el.getAttribute('placeholder'); + if (ph) return ph.trim(); + const nm = el.getAttribute('name'); + if (nm) return nm.trim(); + } + if (tag === 'img') { + const alt = el.getAttribute('alt'); + if (alt) return alt.trim(); + } + const text = (el.textContent || '').replace(/\\s+/g, ' ').trim(); + if (text) return text.slice(0, 120); + const title = el.getAttribute('title'); + return title ? title.trim() : ''; + }; + + const valueOf = (el) => { + const tag = el.tagName.toLowerCase(); + if (tag === 'input') { + const t = (el.getAttribute('type') || 'text').toLowerCase(); + if (t === 'password') return '\\u2022\\u2022\\u2022'; // never echo secrets + if (t === 'checkbox' || t === 'radio') return el.checked ? 'checked' : 'unchecked'; + return el.value || ''; + } + if (tag === 'textarea' || tag === 'select') return el.value || ''; + return undefined; + }; + + const seen = new Set(); + const nodes = []; + const collect = (sel, interactive) => { + for (const el of document.querySelectorAll(sel)) { + if (seen.has(el)) continue; + seen.add(el); + const visible = isVisible(el); + if (!visible && !includeHidden) continue; + nodes.push({ el, interactive, visible }); + } + }; + collect(INTERACTIVE, true); + collect(HEADING, false); + + // Document order keeps refs stable and readable. + nodes.sort((a, b) => { + const p = a.el.compareDocumentPosition(b.el); + if (p & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (p & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); + + const active = document.activeElement; + let focusedRef; + const elements = nodes.map((n, i) => { + const ref = 'e' + (i + 1); + n.el.setAttribute(ATTR, ref); + if (n.el === active) focusedRef = '@' + ref; + const out = { + ref: '@' + ref, + role: roleOf(n.el), + name: nameOf(n.el), + tag: n.el.tagName.toLowerCase(), + interactive: n.interactive, + visible: n.visible, + }; + const v = valueOf(n.el); + if (v !== undefined) out.value = v; + if (n.el.tagName.toLowerCase() === 'a' && n.el.href) out.href = n.el.href; + return out; + }); + + return { + url: location.href, + title: document.title, + timestamp: new Date().toISOString(), + elements, + focusedRef, + }; +})()`; +} diff --git a/packages/browser-core/src/automation/types.ts b/packages/browser-core/src/automation/types.ts new file mode 100644 index 0000000..5637c85 --- /dev/null +++ b/packages/browser-core/src/automation/types.ts @@ -0,0 +1,62 @@ +/** + * Managed browser-session contracts (PRD M3.1). + * + * These types are the portable spec shared by the shell session engine + * (`apps/desktop/launcher/tron-session`, the running implementation today) and + * the future TypeScript CDP client (M3.2+). The descriptor's + * `webSocketDebuggerUrl` is the attach point programmatic tooling uses to drive + * a session the CLI launched. + */ + +/** A raw DevTools target as returned by the CDP `/json/list` endpoint. */ +export interface CdpTarget { + id: string; + type: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; +} + +/** A page tab, normalized for humans and agents. */ +export interface AutomationTab { + id: string; + url: string; + title: string; + /** The tab ref-less actions target: the descriptor's active tab, else the first page. */ + current: boolean; +} + +/** + * On-disk descriptor for a managed session, written by `tron browser launch` + * and read by `status`/`tabs`/`use`/`current`/`close`/`open`. + */ +export interface SessionDescriptor { + /** Schema version so later tooling can migrate older descriptors. */ + version: 1; + /** PID of the launched managed browser process. */ + pid: number; + /** Loopback host the DevTools endpoint binds to. Always 127.0.0.1 for M3.1. */ + host: string; + /** Remote-debugging port. */ + port: number; + /** Absolute path to the Chromium user-data-dir backing this session. */ + profileDir: string; + /** Profile label: "default", "ephemeral", or a caller-supplied name. */ + profileName: string; + /** Whether the session runs headless. */ + headless: boolean; + /** Ephemeral profiles are deleted on close. */ + ephemeral: boolean; + /** ISO-8601 launch timestamp. */ + createdAt: string; + /** CDP browser-level WebSocket endpoint (attach point for M3.2+). */ + webSocketDebuggerUrl?: string; + /** Target id of the tab subsequent ref-less actions target. */ + activeTabId?: string; +} + +/** Liveness of a managed session, derived from the descriptor plus runtime checks. */ +export type SessionState = + | 'running' // descriptor present, process alive, DevTools endpoint reachable + | 'stale' // descriptor present but process dead or endpoint unreachable + | 'none'; // no descriptor diff --git a/packages/browser-core/src/index.ts b/packages/browser-core/src/index.ts index 0bac422..6cda0cb 100644 --- a/packages/browser-core/src/index.ts +++ b/packages/browser-core/src/index.ts @@ -6,6 +6,9 @@ export const PACKAGE_NAME = '@tronbrowser/browser-core' as const; +// Managed browser-session contracts and CDP helpers (PRD M3.1). +export * from './automation/index.js'; + /** Capabilities that must be preserved from upstream Chromium (PRD §Desktop). */ export const PRESERVED_CAPABILITIES = [ 'chrome-extensions', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8c90e3..160a2b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 8.62.0(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) apps/desktop: devDependencies: @@ -40,7 +40,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) apps/docs: devDependencies: @@ -49,7 +49,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) apps/extensions: devDependencies: @@ -58,7 +58,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) apps/mobile: dependencies: @@ -92,7 +92,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) apps/web: devDependencies: @@ -101,7 +101,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/agent-runtime: devDependencies: @@ -110,7 +110,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/ai-core: devDependencies: @@ -119,7 +119,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/auth: devDependencies: @@ -128,16 +128,19 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/browser-core: devDependencies: + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 typescript: specifier: ^5.6.3 version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/model-providers: devDependencies: @@ -146,7 +149,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/payments: devDependencies: @@ -155,7 +158,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/plugins: devDependencies: @@ -164,7 +167,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/sdk: devDependencies: @@ -173,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/shared: devDependencies: @@ -182,7 +185,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/storage: devDependencies: @@ -191,7 +194,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/sync: devDependencies: @@ -200,7 +203,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/ui: devDependencies: @@ -209,7 +212,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/workflow-engine: devDependencies: @@ -218,7 +221,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) services/api: dependencies: @@ -252,7 +255,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) services/scheduler: devDependencies: @@ -261,7 +264,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) services/sync-server: devDependencies: @@ -270,7 +273,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) services/worker: devDependencies: @@ -279,7 +282,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0) + version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages: @@ -1379,6 +1382,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1653,6 +1659,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1855,6 +1865,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -2163,6 +2177,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -3299,6 +3317,10 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url-minimum@0.1.2: resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} @@ -4694,6 +4716,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.2 @@ -5043,6 +5067,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.2 + bytes@3.1.2: {} cac@6.7.14: {} @@ -5237,6 +5265,8 @@ snapshots: encodeurl@2.0.0: {} + entities@7.0.1: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -5579,6 +5609,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 24.13.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -6696,7 +6739,7 @@ snapshots: lightningcss: 1.32.0 terser: 5.48.0 - vitest@2.1.9(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0): + vitest@2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.13.2)(lightningcss@1.32.0)(terser@5.48.0)) @@ -6720,6 +6763,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 + happy-dom: 20.10.6 transitivePeerDependencies: - less - lightningcss @@ -6745,6 +6789,8 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-mimetype@3.0.0: {} + whatwg-url-minimum@0.1.2: {} which@2.0.2: