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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ Usage:
tron snapshot Structured, ref-tagged page snapshot (--json)
tron click <ref> Click a snapshot ref, e.g. @e3
tron fill <ref> <val> Fill an input by ref, e.g. tron fill @e4 "hi@x.com"
tron upload <ref> <f> Attach file(s) to a file input, e.g. tron upload @e7 ~/cv.pdf
tron select <ref> <v> Pick an option by value or label (native or custom dropdowns)
tron press <key> Trusted key press on the focused element (Enter, Tab, ArrowDown…)
tron extract <mode> Extract text|links|forms|tables|main (JSON)
tron screenshot <p> Save a PNG of the current page (--full-page)
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
Expand Down Expand Up @@ -209,7 +212,7 @@ case "${1:-}" in
SESSION="$(session_bin)"
[ -x "$SESSION" ] || { echo "This TronBrowser build has no managed-session support (missing tron-session). Run: tron upgrade" >&2; exit 1; }
exec "$SESSION" browser "$@" ;;
snapshot|click|fill|type|extract|screenshot|pdf|trace|replay)
snapshot|click|fill|type|upload|select|press|extract|screenshot|pdf|trace|replay)
# CDP automation on the managed session's current page (PRD M3.2/M3.3/M3.7).
run_automation "$@" ;;
analyze)
Expand Down
46 changes: 46 additions & 0 deletions packages/browser-core/src/automate-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,52 @@ describe('automate-cli run', () => {
expect(out.join('\n')).toContain('filled @e2');
});

it('uploads files to a file input by object id', async () => {
const sent: Array<[string, unknown]> = [];
const c: CdpConnection = {
send: (async (method: string, params: unknown) => {
sent.push([method, params]);
if (method === 'Runtime.evaluate') return { result: { objectId: 'obj-1' } };
if (method === 'Runtime.callFunctionOn') return { result: { value: true } };
return {};
}) as CdpConnection['send'],
on: vi.fn(),
close: vi.fn(),
};
const { deps, out } = harness({ connect: async () => c });
const code = await run(['upload', '@e7', '/tmp/cv.pdf'], deps);
expect(code).toBe(EXIT.ok);
expect(sent).toContainEqual(['DOM.setFileInputFiles', { objectId: 'obj-1', files: ['/tmp/cv.pdf'] }]);
expect(out.join('\n')).toContain('uploaded 1 file(s) to @e7');
});

it('presses keys with trusted input events', async () => {
const sent: Array<[string, unknown]> = [];
const c: CdpConnection = {
send: (async (method: string, params: unknown) => { sent.push([method, params]); return {}; }) as CdpConnection['send'],
on: vi.fn(),
close: vi.fn(),
};
const { deps } = harness({ connect: async () => c });
expect(await run(['press', 'Enter'], deps)).toBe(EXIT.ok);
const keys = sent.filter(([m]) => m === 'Input.dispatchKeyEvent').map(([, p]) => (p as { type: string; key: string }).type + ':' + (p as { key: string }).key);
expect(keys).toEqual(['keyDown:Enter', 'keyUp:Enter']);
});

it('selects a native option by label', async () => {
const { deps, out } = harness({ connect: async () => conn({ ok: true, chosen: 'United States' }) });
expect(await run(['select', '@e3', 'united states'], deps)).toBe(EXIT.ok);
expect(out.join('\n')).toContain('selected "United States" in @e3');
});

it('prints usage for upload/select/press without arguments', async () => {
for (const cmd of ['upload', 'select', 'press']) {
const { deps, err } = harness();
expect(await run([cmd], deps)).toBe(EXIT.usage);
expect(err.join('\n')).toContain(`tron ${cmd}`);
}
});

it('exits staleRef when a ref no longer resolves', async () => {
const { deps, err } = harness({
connect: async () => conn({ ok: false, error: 'STALE_REF', ref: '@e9' }),
Expand Down
43 changes: 42 additions & 1 deletion packages/browser-core/src/automate-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*
* tron snapshot [--json] [--include-hidden]
* tron click <ref> | fill <ref> <value>
* tron upload <ref> <file...> | select <ref> <value> | press <key>
* tron extract <text|links|forms|tables|main|selector> [--field n=sel[@attr]]
* tron screenshot <path> [--full-page] | tron pdf <path>
* tron headless <url> [--snapshot|--screenshot <path>|--pdf <path>|--extract <mode>] [--json]
Expand All @@ -16,7 +17,7 @@
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 { join, resolve as resolvePath } from 'node:path';
import { promisify } from 'node:util';
import { CdpClient, type CdpConnection } from './automation/cdp-client.js';
import { cdpListUrl } from './automation/cdp.js';
Expand All @@ -39,7 +40,10 @@ import {
fillRef,
formatSnapshotText,
goto,
pressKey,
selectRef,
StaleRefError,
uploadRef,
} from './automation/page.js';
import { resolvePageWsUrl } from './automation/page-target.js';
import type { CdpTarget, SessionDescriptor } from './automation/types.js';
Expand Down Expand Up @@ -209,6 +213,7 @@ async function runOp(deps: CliDeps, conn: CdpConnection, op: HeadlessOp): Promis

const USAGE =
'usage: tron snapshot [--json] | click <ref> | fill <ref> <value> | ' +
'upload <ref> <file...> | select <ref> <value> | press <key> | ' +
'extract <text|links|forms|tables|main|selector> [--field n=sel] | ' +
'screenshot <path> [--full-page] | pdf <path> | ' +
'headless <url> [--snapshot|--screenshot <path>|--pdf <path>|--extract <mode>] [--json]';
Expand Down Expand Up @@ -260,6 +265,42 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Pro
deps.out(`filled ${filled.ref}`);
return EXIT.ok;
}
case 'upload': {
const [ref, ...files] = rest;
if (!ref || !files.length) {
deps.err('usage: tron upload <ref> <file> [file...]');
return EXIT.usage;
}
const paths = files.map((f) => resolvePath(f));
conn = await attach(deps);
const up = await uploadRef(conn, ref, paths);
await traceRecord(deps.env, conn, 'upload', { ref });
deps.out(`uploaded ${paths.length} file(s) to ${up.ref}`);
return EXIT.ok;
}
case 'select': {
const [ref, value] = rest;
if (!ref || value === undefined) {
deps.err('usage: tron select <ref> <value-or-label>');
return EXIT.usage;
}
conn = await attach(deps);
const sel = await selectRef(conn, ref, value);
await traceRecord(deps.env, conn, 'select', { ref, value });
deps.out(`selected ${JSON.stringify(sel.chosen ?? value)} in ${sel.ref}`);
return EXIT.ok;
}
case 'press': {
const key = rest[0];
if (!key) {
deps.err('usage: tron press <key> (Enter, Tab, Escape, ArrowDown, one character…)');
return EXIT.usage;
}
conn = await attach(deps);
await pressKey(conn, key);
deps.out(`pressed ${key}`);
return EXIT.ok;
}
case 'extract': {
const target = rest.find((a) => !a.startsWith('--'));
if (!target) {
Expand Down
8 changes: 6 additions & 2 deletions packages/browser-core/src/automation/action-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function resolvePrelude(ref: string): string {
export function clickExpression(ref: string): string {
return `(() => {
${resolvePrelude(ref)}
el.scrollIntoView({ block: 'center', inline: 'center' });
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
el.click();
return { ok: true, ref: ${JSON.stringify('@' + normalizeRef(ref))} };
})()`;
Expand All @@ -41,13 +41,16 @@ export function fillExpression(ref: string, value: string): string {
return `(() => {
${resolvePrelude(ref)}
const value = ${JSON.stringify(value)};
el.scrollIntoView({ block: 'center', inline: 'center' });
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
if (el.isContentEditable) {
el.focus();
el.textContent = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
return { ok: true, ref: ${JSON.stringify('@' + normalizeRef(ref))} };
}
if (el instanceof HTMLInputElement && el.type === 'file') {
return { ok: false, error: 'NOT_FILLABLE', ref: ${JSON.stringify('@' + normalizeRef(ref))}, message: 'a file input takes files: use upload (browser_upload / tron upload)' };
}
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
el.focus();
Expand All @@ -63,4 +66,5 @@ export interface ActionResult {
ok: boolean;
ref: string;
error?: string;
message?: string;
}
10 changes: 10 additions & 0 deletions packages/browser-core/src/automation/extract-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
document.body.innerHTML = '';
});

describe('text extraction', () => {
it('returns visible words, not <script>/<style> source that would eat the 20k cap', () => {
document.body.innerHTML = `<script>${'var bundle = 1;'.repeat(3000)}</script><style>.x{color:red}</style><main><h1>Thanks for applying</h1></main>`;
const { text } = run<{ text: string }>(extractExpression('text'));
expect(text).toContain('Thanks for applying');
expect(text).not.toContain('var bundle');
expect(text).not.toContain('color:red');
});
});

describe('parseFieldSpec', () => {
it('parses name=selector', () => {
expect(parseFieldSpec('title=.t')).toEqual({ name: 'title', selector: '.t' });
Expand Down
15 changes: 13 additions & 2 deletions packages/browser-core/src/automation/extract-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,20 @@ export function extractExpression(target: string, fields: FieldSpec[] = []): str
return { headers, rows };
});

// Rendered text, not textContent: textContent includes <script>/<style>
// source, and on script-heavy pages (Ashby inlines its bundle) the 20k cap
// was spent on code before any visible words, e.g. "Thanks for applying".
const visibleText = (root) => {
if (!root) return '';
if (typeof root.innerText === 'string' && root.isConnected && getComputedStyle(root).display !== 'none') return clean(root.innerText);
const copy = root.cloneNode(true);
for (const n of copy.querySelectorAll('script, style, noscript, template')) n.remove();
return clean(copy.textContent);
};

const mainText = () => {
const m = document.querySelector('main, article, [role=main]') || document.body;
return { text: clean(m.textContent).slice(0, 20000) };
return { text: visibleText(m).slice(0, 20000) };
};

const target = ${JSON.stringify(target)};
Expand All @@ -105,7 +116,7 @@ export function extractExpression(target: string, fields: FieldSpec[] = []): str
}

switch (target) {
case 'text': return { text: clean(document.body ? document.body.textContent : '').slice(0, 20000) };
case 'text': return { text: visibleText(document.body).slice(0, 20000) };
case 'links': return links();
case 'forms': return forms();
case 'tables': return tables();
Expand Down
Loading
Loading