diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 4eaf5b64..199e406a 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -87,6 +87,9 @@ Usage: 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 upload Attach file(s) to a file input, e.g. tron upload @e7 ~/cv.pdf + tron select Pick an option by value or label (native or custom dropdowns) + tron press Trusted key press on the focused element (Enter, Tab, ArrowDown…) 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 @@ -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) diff --git a/packages/browser-core/src/automate-cli.test.ts b/packages/browser-core/src/automate-cli.test.ts index 72cf28d2..c7a5d8aa 100644 --- a/packages/browser-core/src/automate-cli.test.ts +++ b/packages/browser-core/src/automate-cli.test.ts @@ -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' }), diff --git a/packages/browser-core/src/automate-cli.ts b/packages/browser-core/src/automate-cli.ts index 70816866..56013c1d 100644 --- a/packages/browser-core/src/automate-cli.ts +++ b/packages/browser-core/src/automate-cli.ts @@ -4,6 +4,7 @@ * * tron snapshot [--json] [--include-hidden] * tron click | fill + * tron upload | select | press * tron extract [--field n=sel[@attr]] * tron screenshot [--full-page] | tron pdf * tron headless [--snapshot|--screenshot |--pdf |--extract ] [--json] @@ -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'; @@ -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'; @@ -209,6 +213,7 @@ async function runOp(deps: CliDeps, conn: CdpConnection, op: HeadlessOp): Promis const USAGE = 'usage: tron snapshot [--json] | click | fill | ' + + 'upload | select | press | ' + 'extract [--field n=sel] | ' + 'screenshot [--full-page] | pdf | ' + 'headless [--snapshot|--screenshot |--pdf |--extract ] [--json]'; @@ -260,6 +265,42 @@ export async function run(argv: string[], overrides: Partial = {}): Pro deps.out(`filled ${filled.ref}`); return EXIT.ok; } + case 'upload': { + const [ref, ...files] = rest; + if (!ref || !files.length) { + deps.err('usage: tron upload [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 '); + 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 (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) { diff --git a/packages/browser-core/src/automation/action-script.ts b/packages/browser-core/src/automation/action-script.ts index 868137e6..a212bde6 100644 --- a/packages/browser-core/src/automation/action-script.ts +++ b/packages/browser-core/src/automation/action-script.ts @@ -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))} }; })()`; @@ -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(); @@ -63,4 +66,5 @@ export interface ActionResult { ok: boolean; ref: string; error?: string; + message?: string; } diff --git a/packages/browser-core/src/automation/extract-script.test.ts b/packages/browser-core/src/automation/extract-script.test.ts index 91b12042..d5789ac4 100644 --- a/packages/browser-core/src/automation/extract-script.test.ts +++ b/packages/browser-core/src/automation/extract-script.test.ts @@ -11,6 +11,16 @@ beforeEach(() => { document.body.innerHTML = ''; }); +describe('text extraction', () => { + it('returns visible words, not

Thanks for applying

`; + 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' }); diff --git a/packages/browser-core/src/automation/extract-script.ts b/packages/browser-core/src/automation/extract-script.ts index 1323a040..d07bc288 100644 --- a/packages/browser-core/src/automation/extract-script.ts +++ b/packages/browser-core/src/automation/extract-script.ts @@ -84,9 +84,20 @@ export function extractExpression(target: string, fields: FieldSpec[] = []): str return { headers, rows }; }); + // Rendered text, not textContent: textContent includes