mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
fix(browser): agent fill, select, upload and type change the page or fail with a reason (#36263)
* fix(browser): fill, select, upload and type act on the agent's element or fail * refactor(browser): move agent key commands into their own bridge layer; fix test sender call
This commit is contained in:
@@ -60,12 +60,14 @@ Browser rules:
|
||||
- Use `ORCA set device` or `ORCA viewport --mobile`, not `ORCA exec --command "set device ..."`, so the page also gets touch input and `pointer: coarse`. Each page load (reload, `goto`, link click) resets it, so run it again after navigating.
|
||||
- Prefer `wait --text`, `--url`, `--selector`, or `--load` after async page changes instead of bare timeouts.
|
||||
- Anything not listed above goes through `ORCA exec --page <browserPageId> --command "<agent-browser command>"`.
|
||||
- If `fill` or `type` fails on a custom input, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text "text" --json`.
|
||||
- `fill` sets a field's whole value in one step, at any size. `type` presses real keys into the focused element, so `minlength`/`maxlength`, key handlers, Enter and Tab act as they would for a person; `ORCA focus` the element first. Use `type` when the page reacts to keystrokes.
|
||||
- On server-hosted pages `fill` and `type` still insert text the engine's own way. If either does nothing on a custom input there, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text "text" --json`.
|
||||
- A client-hosted page renders in the paired desktop's browser engine, so every command against it needs that desktop online and returns `browser_host_unavailable` while it is closed, asleep, or disconnected. Server-hosted pages run with no desktop attached; prefer them for long or unattended automation.
|
||||
|
||||
Common recoveries:
|
||||
|
||||
- `browser_no_tab`: open a tab with `ORCA tab create --url <url> --json`.
|
||||
- `browser_stale_ref`: run `ORCA snapshot --page <browserPageId> --json` and retry on the same page with fresh refs.
|
||||
- `browser_element_not_interactable`: the element can't take that command, and the message says why (not a text field, disabled, read-only, not a `<select>`, not a file input). Re-snapshot and pick the right ref; for a custom dropdown, click it and then click the option.
|
||||
- `browser_tab_not_found`: run `ORCA tab list --json` before switching or closing.
|
||||
- `browser_host_unavailable`: the desktop hosting the page is offline. Bring it back, or recreate the page with server placement if the work must outlive the desktop session.
|
||||
|
||||
@@ -871,13 +871,15 @@ const ORCA_CLI_BROWSER_REFERENCE_MARKDOWN = [
|
||||
"- Use `ORCA set device` or `ORCA viewport --mobile`, not `ORCA exec --command \"set device ...\"`, so the page also gets touch input and `pointer: coarse`. Each page load (reload, `goto`, link click) resets it, so run it again after navigating.",
|
||||
"- Prefer `wait --text`, `--url`, `--selector`, or `--load` after async page changes instead of bare timeouts.",
|
||||
"- Anything not listed above goes through `ORCA exec --page <browserPageId> --command \"<agent-browser command>\"`.",
|
||||
"- If `fill` or `type` fails on a custom input, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text \"text\" --json`.",
|
||||
"- `fill` sets a field's whole value in one step, at any size. `type` presses real keys into the focused element, so `minlength`/`maxlength`, key handlers, Enter and Tab act as they would for a person; `ORCA focus` the element first. Use `type` when the page reacts to keystrokes.",
|
||||
"- On server-hosted pages `fill` and `type` still insert text the engine's own way. If either does nothing on a custom input there, try `ORCA focus --page <browserPageId> --element @e1 --json` then `ORCA inserttext --page <browserPageId> --text \"text\" --json`.",
|
||||
"- A client-hosted page renders in the paired desktop's browser engine, so every command against it needs that desktop online and returns `browser_host_unavailable` while it is closed, asleep, or disconnected. Server-hosted pages run with no desktop attached; prefer them for long or unattended automation.",
|
||||
"",
|
||||
"Common recoveries:",
|
||||
"",
|
||||
"- `browser_no_tab`: open a tab with `ORCA tab create --url <url> --json`.",
|
||||
"- `browser_stale_ref`: run `ORCA snapshot --page <browserPageId> --json` and retry on the same page with fresh refs.",
|
||||
"- `browser_element_not_interactable`: the element can't take that command, and the message says why (not a text field, disabled, read-only, not a `<select>`, not a file input). Re-snapshot and pick the right ref; for a custom dropdown, click it and then click the option.",
|
||||
"- `browser_tab_not_found`: run `ORCA tab list --json` before switching or closing.",
|
||||
"- `browser_host_unavailable`: the desktop hosting the page is offline. Bring it back, or recreate the page with server placement if the work must outlive the desktop session.",
|
||||
"",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipb
|
||||
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { BrowserError } from './cdp-bridge'
|
||||
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
|
||||
import { focusedValueSetExpression } from './agent-browser-bridge-input'
|
||||
import { FILL_ELEMENT_FUNCTION } from './browser-element-functions'
|
||||
import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types'
|
||||
import {
|
||||
isAbortedNavigationError,
|
||||
@@ -159,22 +159,14 @@ export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQ
|
||||
browserPageId?: string
|
||||
): Promise<BrowserFillResult> {
|
||||
await assertClipboardTextWriteWithinLimitWithYield(value)
|
||||
// agent-browser's CDP insertion loses focus in Electron guests; use the browser input pipeline.
|
||||
// agent-browser's fill types into whatever holds focus and sends text per character; set the
|
||||
// resolved element's value in one call instead.
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
|
||||
await this.execAgentBrowser(sessionName, ['focus', element])
|
||||
// One stdin edit avoids argv limits and repeated copying of the growing field value.
|
||||
await this.execAgentBrowser(sessionName, ['eval', '--stdin'], {
|
||||
stdinText: focusedValueSetExpression(JSON.stringify(value), { dispatchEvents: true })
|
||||
})
|
||||
return { filled: element } as BrowserFillResult
|
||||
}
|
||||
|
||||
await this.fillExplicitContentEditable(sessionName, element, value)
|
||||
return { filled: element } as BrowserFillResult
|
||||
await this.actOnElement(sessionName, element, 'fill', FILL_ELEMENT_FUNCTION, [value])
|
||||
return { filled: element }
|
||||
},
|
||||
{ requireScopedTarget: true, elements: [element] }
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { WebContents } from 'electron'
|
||||
import { BrowserError } from './cdp-bridge'
|
||||
import {
|
||||
focusedRichTextEditExpression,
|
||||
isExplicitContentEditableResult
|
||||
} from './agent-browser-bridge-input'
|
||||
import type { CapturedElement } from './cdp-element-capture'
|
||||
import { sendGuestCdpCommand } from './guest-cdp-command'
|
||||
import { readRecord, readString } from './guest-frame-input-sessions'
|
||||
import {
|
||||
isTabClosedTransportError,
|
||||
pageUnavailableMessageForSession
|
||||
@@ -170,29 +169,73 @@ export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs
|
||||
return this.runAgentBrowserRaw(sessionName, args, execOptions)
|
||||
}
|
||||
|
||||
protected async isExplicitContentEditableTarget(
|
||||
sessionName: string,
|
||||
element: string
|
||||
): Promise<boolean> {
|
||||
const result = await this.execAgentBrowser(sessionName, [
|
||||
'get',
|
||||
'attr',
|
||||
element,
|
||||
'contenteditable'
|
||||
])
|
||||
return isExplicitContentEditableResult(result)
|
||||
}
|
||||
|
||||
protected async fillExplicitContentEditable(
|
||||
/** Runs a browser-element-functions action on the element the helper resolves, in its own frame. */
|
||||
protected async actOnElement(
|
||||
sessionName: string,
|
||||
element: string,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
await this.execAgentBrowser(sessionName, ['focus', element])
|
||||
// Send one stdin edit to avoid argv limits and focus changes between chunks.
|
||||
await this.execAgentBrowser(sessionName, ['eval', '--stdin'], {
|
||||
stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true })
|
||||
action: string,
|
||||
functionDeclaration: string,
|
||||
args: unknown[] = []
|
||||
): Promise<unknown> {
|
||||
const session = this.sessions.get(sessionName)
|
||||
if (!session) {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
session.proxy.elementCapture.start()
|
||||
session.proxy.pointAtNextNode(true)
|
||||
let captured: CapturedElement | null
|
||||
try {
|
||||
// Any element read makes the helper resolve the ref or selector, in whichever frame owns it.
|
||||
await this.execAgentBrowser(sessionName, ['get', 'attr', element, 'id'])
|
||||
} finally {
|
||||
session.proxy.pointAtNextNode(false)
|
||||
captured = session.proxy.elementCapture.stop()
|
||||
}
|
||||
const wc = this.getWebContents(session.webContentsId)
|
||||
if (!wc) {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
if (!captured) {
|
||||
throw new BrowserError('browser_error', `Could not resolve ${element} in the page`)
|
||||
}
|
||||
const response = await sendGuestCdpCommand(
|
||||
wc,
|
||||
'Runtime.callFunctionOn',
|
||||
{
|
||||
objectId: captured.objectId,
|
||||
functionDeclaration,
|
||||
arguments: args.map((value) => ({ value })),
|
||||
returnByValue: true
|
||||
},
|
||||
captured.sessionId
|
||||
).catch((error: unknown) => {
|
||||
if (error instanceof BrowserError) {
|
||||
throw error
|
||||
}
|
||||
if (!this.getWebContents(session.webContentsId)) {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_cdp_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
const exception = readRecord(response, 'exceptionDetails')
|
||||
if (exception) {
|
||||
const description = readString(readRecord(exception, 'exception'), 'description')
|
||||
throw new BrowserError(
|
||||
'browser_eval_error',
|
||||
description ?? readString(exception, 'text') ?? 'Page function failed'
|
||||
)
|
||||
}
|
||||
const outcome = readRecord(response, 'result')?.value
|
||||
if (typeof outcome === 'string') {
|
||||
throw new BrowserError(
|
||||
'browser_element_not_interactable',
|
||||
`Cannot ${action} ${element}: ${outcome}`
|
||||
)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
protected createPageUnavailableError(sessionName: string): BrowserError {
|
||||
|
||||
@@ -85,6 +85,42 @@ describe('AgentBrowserBridge under a host revocation', () => {
|
||||
bridge.setActiveTab(100)
|
||||
})
|
||||
|
||||
it('stops a long type at the next run once the host is revoked', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.getType.mockReturnValue('webview')
|
||||
let resumeTyping = (): void => {}
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
resumeTyping = resolve
|
||||
})
|
||||
let evaluations = 0
|
||||
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
|
||||
if (method === 'Runtime.evaluate') {
|
||||
evaluations += 1
|
||||
// The first run's settle wait: hold it while the host is revoked.
|
||||
if (evaluations === 2) {
|
||||
await paused
|
||||
}
|
||||
return { result: { type: 'object', subtype: 'null' } }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
const revoked = new AbortController()
|
||||
|
||||
const typing = runInRemoteCliBrowserScope(
|
||||
WORKSPACE,
|
||||
() => bridge.type('x'.repeat(2000), undefined, 'tab-1'),
|
||||
revoked.signal
|
||||
)
|
||||
await vi.waitFor(() => expect(evaluations).toBe(2))
|
||||
const sentBeforeRevocation = wc.sendInputEvent.mock.calls.length
|
||||
revoked.abort()
|
||||
resumeTyping()
|
||||
|
||||
await expect(typing).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' })
|
||||
expect(wc.sendInputEvent.mock.calls.length).toBe(sentBeforeRevocation)
|
||||
})
|
||||
|
||||
it('never presses the mouse once the host is revoked during a running click', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
let answerHitTest = (): void => {}
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
import type {
|
||||
BrowserTypeResult,
|
||||
BrowserSelectResult,
|
||||
BrowserScrollResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { BrowserSelectResult, BrowserScrollResult } from '../../shared/runtime-types'
|
||||
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
|
||||
import { iterateBrowserTextInsertionChunks } from '../../shared/browser-text-insertion-chunks'
|
||||
import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types'
|
||||
import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands'
|
||||
import { SELECT_OPTION_TARGET_FUNCTION } from './browser-element-functions'
|
||||
|
||||
export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands {
|
||||
async type(
|
||||
input: string,
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserTypeResult> {
|
||||
await assertClipboardTextWriteWithinLimitWithYield(input)
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
for (const chunk of iterateBrowserTextInsertionChunks(
|
||||
input,
|
||||
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
|
||||
)) {
|
||||
await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk])
|
||||
}
|
||||
return { typed: true } as BrowserTypeResult
|
||||
},
|
||||
{ requireScopedTarget: true }
|
||||
)
|
||||
}
|
||||
|
||||
async select(
|
||||
element: string,
|
||||
value: string,
|
||||
@@ -41,6 +16,13 @@ export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridge
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
// The helper reports success for any element; only a <select> has options to choose.
|
||||
await this.actOnElement(
|
||||
sessionName,
|
||||
element,
|
||||
'select an option in',
|
||||
SELECT_OPTION_TARGET_FUNCTION
|
||||
)
|
||||
return (await this.execAgentBrowser(sessionName, [
|
||||
'select',
|
||||
element,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export function focusedValueSetExpression(
|
||||
valueExpression: string,
|
||||
options?: { append?: boolean; dispatchEvents?: boolean }
|
||||
): string {
|
||||
const nextValue = options?.append
|
||||
? ["String(target.value ?? '') + ", valueExpression].join('')
|
||||
: valueExpression
|
||||
const dispatchEvents = options?.dispatchEvents
|
||||
? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));"
|
||||
: ''
|
||||
return [
|
||||
'(() => { const el = document.activeElement; if (el) {',
|
||||
// Spinbutton wrappers can hold focus while another input owns the value.
|
||||
" const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";",
|
||||
" const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));",
|
||||
' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;',
|
||||
' let target = el;',
|
||||
" if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {",
|
||||
" const controls = target.getAttribute('aria-controls');",
|
||||
' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }',
|
||||
' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }',
|
||||
' }',
|
||||
" const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;",
|
||||
' const nextValue = ',
|
||||
nextValue,
|
||||
'; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }',
|
||||
dispatchEvents,
|
||||
' } })()'
|
||||
].join('')
|
||||
}
|
||||
|
||||
// Rich editors require browser edit transactions to keep their models synchronized.
|
||||
export function focusedRichTextEditExpression(
|
||||
valueExpression: string,
|
||||
options?: { selectAll?: boolean }
|
||||
): string {
|
||||
const selectAll = options?.selectAll ? 'true' : 'false'
|
||||
return [
|
||||
'(() => {',
|
||||
' const target = document.activeElement;',
|
||||
' const value = ',
|
||||
valueExpression,
|
||||
';',
|
||||
` const selectAll = ${selectAll};`,
|
||||
" const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');",
|
||||
" if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }",
|
||||
' if (selectAll) {',
|
||||
" if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }",
|
||||
' const selection = window.getSelection();',
|
||||
" if (!selection) { throw new Error('Rich-text selection is unavailable'); }",
|
||||
' selection.selectAllChildren(target);',
|
||||
' }',
|
||||
" const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';",
|
||||
' let edited = false;',
|
||||
' try {',
|
||||
' edited = document.execCommand(editCommand, false, value) === true;',
|
||||
' } catch { edited = false; }',
|
||||
" if (!edited) { throw new Error('Browser rich-text editing command failed'); }",
|
||||
' })()'
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function isExplicitContentEditableResult(result: unknown): boolean {
|
||||
const value =
|
||||
result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined
|
||||
return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value)
|
||||
}
|
||||
@@ -7,22 +7,24 @@ import type {
|
||||
BrowserFocusResult,
|
||||
BrowserClearResult,
|
||||
BrowserSelectAllResult,
|
||||
BrowserKeypressResult,
|
||||
BrowserPdfResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { BrowserError } from './cdp-bridge'
|
||||
import {
|
||||
FILE_INPUT_TARGET_FUNCTION,
|
||||
FILL_ELEMENT_FUNCTION,
|
||||
SELECT_ALL_ELEMENT_FUNCTION
|
||||
} from './browser-element-functions'
|
||||
import { assertUploadFilesExist } from './browser-upload-files'
|
||||
import {
|
||||
agentBrowserWaitArguments,
|
||||
type AgentBrowserWaitOptions
|
||||
} from './agent-browser-wait-arguments'
|
||||
import { acquireElectronDebugger } from './electron-debugger-lease'
|
||||
import { parseCdpKeyEvent, imeFallbackKeyEvent } from './cdp-keyboard-us-layout'
|
||||
import { dispatchPageKeyEvent, type GuestCdpSender } from './guest-direct-key-input'
|
||||
import { sendGuestCdpCommand } from './guest-cdp-command'
|
||||
import { parseCdpKeyEvent } from './cdp-keyboard-us-layout'
|
||||
import { readRemoteCliAdmittedPage } from './remote-cli-page-admission'
|
||||
import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands'
|
||||
import { AgentBrowserBridgeKeyCommands } from './agent-browser-bridge-key-commands'
|
||||
|
||||
export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands {
|
||||
export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeKeyCommands {
|
||||
async hover(
|
||||
element: string,
|
||||
worktreeId?: string,
|
||||
@@ -64,6 +66,8 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
await assertUploadFilesExist(filePaths)
|
||||
await this.actOnElement(sessionName, element, 'upload to', FILE_INPUT_TARGET_FUNCTION)
|
||||
return (await this.execAgentBrowser(sessionName, [
|
||||
'upload',
|
||||
element,
|
||||
@@ -143,13 +147,7 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
|
||||
// Let the helper resolve ordinary refs inside iframes and shadow roots, including unfocusable fields.
|
||||
await this.execAgentBrowser(sessionName, ['fill', element, ''])
|
||||
return { cleared: element }
|
||||
}
|
||||
|
||||
await this.fillExplicitContentEditable(sessionName, element, '')
|
||||
await this.actOnElement(sessionName, element, 'clear', FILL_ELEMENT_FUNCTION, [''])
|
||||
return { cleared: element }
|
||||
},
|
||||
{ requireScopedTarget: true, elements: [element] }
|
||||
@@ -164,98 +162,25 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
await this.execAgentBrowser(sessionName, ['focus', element])
|
||||
return (await this.execAgentBrowser(sessionName, [
|
||||
'press',
|
||||
'Control+a'
|
||||
])) as BrowserSelectAllResult
|
||||
async (sessionName, target) => {
|
||||
const focused = await this.actOnElement(
|
||||
sessionName,
|
||||
element,
|
||||
'select all in',
|
||||
SELECT_ALL_ELEMENT_FUNCTION
|
||||
)
|
||||
// Code editors select their whole document from the shortcut, beyond the DOM they render;
|
||||
// an element that can't take focus keeps the DOM selection instead.
|
||||
const shortcut = parseCdpKeyEvent(process.platform === 'darwin' ? 'Meta+a' : 'Control+a')
|
||||
if (focused === true && shortcut) {
|
||||
await this.pressKeysInPage(sessionName, target, [shortcut])
|
||||
}
|
||||
return { selected: element }
|
||||
},
|
||||
{ elements: [element] }
|
||||
)
|
||||
}
|
||||
|
||||
async keypress(
|
||||
key: string,
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserKeypressResult> {
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName, target) => {
|
||||
const parsed = parseCdpKeyEvent(key) ?? imeFallbackKeyEvent(key)
|
||||
if (!parsed) {
|
||||
// Unknown keys need the helper; create its session only on this fallback path.
|
||||
await this.ensureSession(sessionName, target.browserPageId, target.webContentsId)
|
||||
return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult
|
||||
}
|
||||
const wc = this.getWebContents(target.webContentsId)
|
||||
if (!wc || wc.isDestroyed()) {
|
||||
throw new BrowserError(
|
||||
'browser_tab_not_found',
|
||||
`Browser page ${target.browserPageId} is no longer available`
|
||||
)
|
||||
}
|
||||
const event = {
|
||||
windowsVirtualKeyCode: parsed.keyCode,
|
||||
nativeVirtualKeyCode: parsed.keyCode,
|
||||
key: parsed.key,
|
||||
code: parsed.code,
|
||||
modifiers: parsed.modifiers,
|
||||
location: parsed.location
|
||||
}
|
||||
let releaseDebugger = (): void => {}
|
||||
try {
|
||||
releaseDebugger = acquireElectronDebugger(wc).release
|
||||
await this.waitForPagePaint(wc, target.webContentsId)
|
||||
const send: GuestCdpSender = (method, params, sessionId) =>
|
||||
sendGuestCdpCommand(wc, method, params, sessionId)
|
||||
const guardFocus = (): Promise<() => void> =>
|
||||
this.browserManager.guardUserFocusForAgentInput(target.webContentsId)
|
||||
await dispatchPageKeyEvent(
|
||||
wc,
|
||||
{
|
||||
// keyDown without text makes Blink synthesize empty input; use rawKeyDown for editing keys.
|
||||
type: parsed.text === null ? 'rawKeyDown' : 'keyDown',
|
||||
...event,
|
||||
...(parsed.text === null ? {} : { text: parsed.text, unmodifiedText: parsed.text })
|
||||
},
|
||||
send,
|
||||
guardFocus
|
||||
)
|
||||
await dispatchPageKeyEvent(
|
||||
wc,
|
||||
{
|
||||
type: 'keyUp',
|
||||
...event,
|
||||
// Blink clears a modifier's own bit on keyup.
|
||||
modifiers: parsed.modifiers & ~parsed.selfModifier
|
||||
},
|
||||
send,
|
||||
guardFocus
|
||||
)
|
||||
return { pressed: key }
|
||||
} catch (error) {
|
||||
// Browser error codes let the pane recognize and reclaim a dead page.
|
||||
if (error instanceof BrowserError) {
|
||||
throw error
|
||||
}
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_error',
|
||||
`Failed to press ${key} in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
releaseDebugger()
|
||||
}
|
||||
},
|
||||
{ ensureSession: false, requireScopedTarget: true }
|
||||
)
|
||||
}
|
||||
|
||||
async pdf(worktreeId?: string, browserPageId?: string): Promise<BrowserPdfResult> {
|
||||
// Native printing avoids helper CDP hangs and lays out undrawn pages without a paint hold.
|
||||
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { BrowserKeypressResult, BrowserTypeResult } from '../../shared/runtime-types'
|
||||
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
|
||||
import { BrowserError } from './cdp-bridge'
|
||||
import { acquireElectronDebugger } from './electron-debugger-lease'
|
||||
import {
|
||||
parseCdpKeyEvent,
|
||||
imeFallbackKeyEvent,
|
||||
typedInputSteps,
|
||||
type TypedInputStep
|
||||
} from './cdp-keyboard-us-layout'
|
||||
import type { ResolvedBrowserCommandTarget } from './agent-browser-bridge-types'
|
||||
import {
|
||||
dispatchPageKeyEvents,
|
||||
routePageTextInsertion,
|
||||
type GuestCdpSender
|
||||
} from './guest-direct-key-input'
|
||||
import { sendGuestCdpCommand } from './guest-cdp-command'
|
||||
import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands'
|
||||
|
||||
// Bounds the events a typed run holds and how long it keeps the main loop busy.
|
||||
const MAX_KEY_EVENTS_PER_RUN = 512
|
||||
|
||||
export abstract class AgentBrowserBridgeKeyCommands extends AgentBrowserBridgeCaptureCommands {
|
||||
async keypress(
|
||||
key: string,
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserKeypressResult> {
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName, target) => {
|
||||
const parsed = parseCdpKeyEvent(key) ?? imeFallbackKeyEvent(key)
|
||||
if (!parsed) {
|
||||
// Unknown keys need the helper; create its session only on this fallback path.
|
||||
await this.ensureSession(sessionName, target.browserPageId, target.webContentsId)
|
||||
return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult
|
||||
}
|
||||
await this.pressKeysInPage(sessionName, target, [parsed])
|
||||
return { pressed: key }
|
||||
},
|
||||
{ ensureSession: false, requireScopedTarget: true }
|
||||
)
|
||||
}
|
||||
|
||||
async type(
|
||||
input: string,
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserTypeResult> {
|
||||
await assertClipboardTextWriteWithinLimitWithYield(input)
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName, target) => {
|
||||
// The helper inserts printable text without key events, which key-driven editors ignore.
|
||||
await this.pressKeysInPage(sessionName, target, typedInputSteps(input))
|
||||
return { typed: true }
|
||||
},
|
||||
{ ensureSession: false, requireScopedTarget: true }
|
||||
)
|
||||
}
|
||||
|
||||
/** Sends each key as keydown and keyup to the page's focused element, as a person typing would. */
|
||||
protected async pressKeysInPage(
|
||||
sessionName: string,
|
||||
target: ResolvedBrowserCommandTarget,
|
||||
steps: Iterable<TypedInputStep>
|
||||
): Promise<void> {
|
||||
const wc = this.requireTargetWebContents(target)
|
||||
let releaseDebugger = (): void => {}
|
||||
try {
|
||||
releaseDebugger = acquireElectronDebugger(wc).release
|
||||
await this.waitForPagePaint(wc, target.webContentsId)
|
||||
const send: GuestCdpSender = (method, params, sessionId) =>
|
||||
sendGuestCdpCommand(wc, method, params, sessionId)
|
||||
const guardFocus = (): Promise<() => void> =>
|
||||
this.browserManager.guardUserFocusForAgentInput(target.webContentsId)
|
||||
let run: Record<string, unknown>[] = []
|
||||
const flush = async (): Promise<void> => {
|
||||
if (run.length > 0) {
|
||||
await dispatchPageKeyEvents(wc, run, send, guardFocus)
|
||||
run = []
|
||||
}
|
||||
}
|
||||
for (const step of steps) {
|
||||
if (typeof step === 'string') {
|
||||
// Text no key types (emoji, controls) goes in like a paste, between the keys around it.
|
||||
await flush()
|
||||
const route = await routePageTextInsertion(wc, { text: step }, send, guardFocus)
|
||||
try {
|
||||
await route.deliver({ text: step })
|
||||
} finally {
|
||||
route.release()
|
||||
}
|
||||
continue
|
||||
}
|
||||
const event = {
|
||||
windowsVirtualKeyCode: step.keyCode,
|
||||
nativeVirtualKeyCode: step.keyCode,
|
||||
key: step.key,
|
||||
code: step.code,
|
||||
modifiers: step.modifiers,
|
||||
location: step.location
|
||||
}
|
||||
run.push(
|
||||
{
|
||||
// keyDown without text makes Blink synthesize empty input; use rawKeyDown for editing keys.
|
||||
type: step.text === null ? 'rawKeyDown' : 'keyDown',
|
||||
...event,
|
||||
...(step.text === null ? {} : { text: step.text, unmodifiedText: step.text })
|
||||
},
|
||||
// Blink clears a modifier's own bit on keyup.
|
||||
{ type: 'keyUp', ...event, modifiers: step.modifiers & ~step.selfModifier }
|
||||
)
|
||||
// Tab and Enter can move focus into another frame; long lines yield to the main loop.
|
||||
if (step.key === 'Tab' || step.key === 'Enter' || run.length >= MAX_KEY_EVENTS_PER_RUN) {
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
await flush()
|
||||
} catch (error) {
|
||||
// Browser error codes let the pane recognize and reclaim a dead page.
|
||||
if (error instanceof BrowserError) {
|
||||
throw error
|
||||
}
|
||||
if (!this.getWebContents(target.webContentsId)) {
|
||||
throw this.createPageUnavailableError(sessionName)
|
||||
}
|
||||
throw new BrowserError(
|
||||
'browser_error',
|
||||
`Failed to send keys to browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
releaseDebugger()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,38 @@ describe('AgentBrowserBridge keypress input', () => {
|
||||
})
|
||||
expect(keyEventCalls(wc)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('types text as a keydown and keyup per character, which key-driven editors need', async () => {
|
||||
await expect(bridge.type('Hi\n', undefined, 'tab-1')).resolves.toEqual({ typed: true })
|
||||
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
expect(keyEventCalls(wc).map(({ type, key, text }) => [type, key, text])).toEqual([
|
||||
['keyDown', 'H', 'H'],
|
||||
['keyUp', 'H', undefined],
|
||||
['keyDown', 'i', 'i'],
|
||||
['keyUp', 'i', undefined],
|
||||
['keyDown', 'Enter', '\r'],
|
||||
['keyUp', 'Enter', undefined]
|
||||
])
|
||||
})
|
||||
|
||||
it('inserts characters no key types, in order with the typed ones', async () => {
|
||||
const order: string[] = []
|
||||
wc.debugger.sendCommand.mockImplementation(async (method: string, params: unknown) => {
|
||||
if (isRecord(params) && params.type === 'keyDown') {
|
||||
order.push(`key ${String(params.key)}`)
|
||||
} else if (method === 'Input.insertText' && isRecord(params)) {
|
||||
order.push(`text ${String(params.text)}`)
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
await bridge.type('a😀b', undefined, 'tab-1')
|
||||
|
||||
expect(order).toEqual(['key a', 'text 😀', 'key b'])
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe.each(['a', 'MediaPlayPause'])('target admission for %s', (key) => {
|
||||
function registerTwoWorkspaces(): MockWebContents {
|
||||
const otherPage = mockWebContents(200)
|
||||
|
||||
@@ -24,6 +24,12 @@ vi.mock('electron', () => {
|
||||
webContents: { fromId: webContentsFromIdMock }
|
||||
}
|
||||
})
|
||||
const { capturedElement } = vi.hoisted(() => {
|
||||
const capturedElement: { current: { objectId: string; sessionId?: string } | null } = {
|
||||
current: null
|
||||
}
|
||||
return { capturedElement }
|
||||
})
|
||||
const { CdpWsProxyMock } = vi.hoisted(() => {
|
||||
const instances: unknown[] = []
|
||||
const MockClass = vi.fn().mockImplementation(function (
|
||||
@@ -35,6 +41,7 @@ const { CdpWsProxyMock } = vi.hoisted(() => {
|
||||
this.stop = vi.fn(async () => {})
|
||||
this.getPort = vi.fn(() => 9222)
|
||||
this.pointAtNextNode = vi.fn()
|
||||
this.elementCapture = { start: vi.fn(), stop: vi.fn(() => capturedElement.current) }
|
||||
instances.push(this)
|
||||
})
|
||||
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
|
||||
@@ -64,180 +71,31 @@ import {
|
||||
} from '../../shared/clipboard-text'
|
||||
import {
|
||||
agentBrowserCommandsIn,
|
||||
answerAgentBrowserCommands,
|
||||
createSucceedWith,
|
||||
mockBrowserManager,
|
||||
mockWebContents,
|
||||
overrideBridgeWebContentsLookup,
|
||||
resetAgentBrowserBridgeMocks
|
||||
resetAgentBrowserBridgeMocks,
|
||||
type MockWebContents
|
||||
} from './agent-browser-bridge-test-harness'
|
||||
import { FILL_ELEMENT_FUNCTION } from './browser-element-functions'
|
||||
|
||||
overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock)
|
||||
|
||||
const succeedWith = createSucceedWith(execFileMock, stdinWrites)
|
||||
|
||||
function succeedForContentEditable(data: unknown = { ok: true }): void {
|
||||
answerAgentBrowserCommands(execFileMock, stdinWrites, (command, cb) => {
|
||||
const result =
|
||||
command[0] === 'get' && command[1] === 'attr' && command.includes('contenteditable')
|
||||
? { value: 'true' }
|
||||
: data
|
||||
cb(null, JSON.stringify({ success: true, data: result }), '')
|
||||
})
|
||||
}
|
||||
|
||||
// Fill scripts go to `eval --stdin`; every other command arrives as batch JSON.
|
||||
function evalScripts(stdin: string[]): string[] {
|
||||
return stdin.filter((text) => !text.startsWith('[['))
|
||||
}
|
||||
|
||||
class TestEvent {
|
||||
type: string
|
||||
bubbles: boolean
|
||||
|
||||
constructor(type: string, init?: { bubbles?: boolean }) {
|
||||
this.type = type
|
||||
this.bubbles = init?.bubbles ?? false
|
||||
}
|
||||
}
|
||||
|
||||
type FillEvalNode = {
|
||||
tagName: string
|
||||
getAttribute: (name: string) => string | null
|
||||
matches: (selector: string) => boolean
|
||||
querySelector?: (selector: string) => FillEvalNode | null
|
||||
dispatchEvent: (event: TestEvent) => boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
function matchesFillEvalSelector(node: FillEvalNode, selector: string): boolean {
|
||||
return selector.split(',').some((candidate) => {
|
||||
const trimmed = candidate.trim()
|
||||
if (trimmed === 'textarea') {
|
||||
return node.tagName === 'TEXTAREA'
|
||||
}
|
||||
if (!trimmed.startsWith('input') || node.tagName !== 'INPUT') {
|
||||
return false
|
||||
}
|
||||
const excludedTypes = [...trimmed.matchAll(/:not\(\[type='([^']+)'\]\)/g)].map((match) =>
|
||||
match[1].toLowerCase()
|
||||
)
|
||||
const inputType = node.getAttribute('type')?.toLowerCase() ?? ''
|
||||
return !excludedTypes.includes(inputType)
|
||||
})
|
||||
}
|
||||
|
||||
function createFillEvalNode(options: {
|
||||
tagName: string
|
||||
role?: string
|
||||
ariaControls?: string
|
||||
descendant?: FillEvalNode | null
|
||||
descendants?: FillEvalNode[]
|
||||
type?: string
|
||||
}) {
|
||||
const events: TestEvent[] = []
|
||||
let value = ''
|
||||
const proto = {
|
||||
get value() {
|
||||
return value
|
||||
},
|
||||
set value(next: string) {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
const node = Object.create(proto) as FillEvalNode
|
||||
|
||||
node.tagName = options.tagName
|
||||
node.getAttribute = (name: string) => {
|
||||
if (name === 'role') {
|
||||
return options.role ?? null
|
||||
}
|
||||
if (name === 'aria-controls') {
|
||||
return options.ariaControls ?? null
|
||||
}
|
||||
if (name === 'type') {
|
||||
return options.type ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
node.matches = vi.fn((selector: string) => matchesFillEvalSelector(node, selector))
|
||||
const descendants = options.descendants ?? (options.descendant ? [options.descendant] : [])
|
||||
node.querySelector = vi.fn(
|
||||
(selector: string) => descendants.find((descendant) => descendant.matches(selector)) ?? null
|
||||
function elementCalls(wc: MockWebContents): unknown[][] {
|
||||
return wc.debugger.sendCommand.mock.calls.filter(
|
||||
([method]) => method === 'Runtime.callFunctionOn'
|
||||
)
|
||||
node.dispatchEvent = vi.fn((event: TestEvent) => {
|
||||
events.push(event)
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
node,
|
||||
events,
|
||||
get value() {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runFillEvalExpressions(
|
||||
stdin: string[],
|
||||
document: { activeElement: unknown; getElementById: (id: string) => unknown },
|
||||
windowObject: Record<string, unknown> = {}
|
||||
): void {
|
||||
for (const expression of evalScripts(stdin)) {
|
||||
new Function('document', 'Event', 'window', `return (${expression})`)(
|
||||
document,
|
||||
TestEvent,
|
||||
windowObject
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createContentEditableEvalEnvironment(initialText: string) {
|
||||
const editor = {
|
||||
tagName: 'DIV',
|
||||
isContentEditable: true,
|
||||
textContent: initialText,
|
||||
matches: vi.fn(() => false),
|
||||
getAttribute: vi.fn((name: string) => (name === 'contenteditable' ? 'true' : null)),
|
||||
dispatchEvent: vi.fn()
|
||||
}
|
||||
let selected = false
|
||||
const selection = {
|
||||
selectAllChildren: vi.fn(() => {
|
||||
selected = true
|
||||
})
|
||||
}
|
||||
const execCommand = vi.fn((command: string, _showUi: boolean, value: string) => {
|
||||
// Empty insertText succeeds without clearing a selection; simulate Chromium requiring delete.
|
||||
if (command === 'delete') {
|
||||
if (!selected) {
|
||||
return false
|
||||
}
|
||||
editor.textContent = ''
|
||||
} else if (value.length > 0) {
|
||||
editor.textContent = selected ? value : editor.textContent + value
|
||||
}
|
||||
selected = false
|
||||
return true
|
||||
})
|
||||
return {
|
||||
editor,
|
||||
execCommand,
|
||||
document: {
|
||||
activeElement: editor,
|
||||
body: {},
|
||||
getElementById: () => null,
|
||||
execCommand
|
||||
},
|
||||
windowObject: { getSelection: () => selection }
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentBrowserBridge', () => {
|
||||
let bridge: AgentBrowserBridge
|
||||
let wc: MockWebContents
|
||||
|
||||
beforeEach(() => {
|
||||
capturedElement.current = { objectId: 'element-1', sessionId: 'frame-session' }
|
||||
resetAgentBrowserBridgeMocks({
|
||||
webContentsFromIdMock,
|
||||
existsSyncMock,
|
||||
@@ -247,6 +105,8 @@ describe('AgentBrowserBridge', () => {
|
||||
})
|
||||
bridge = new AgentBrowserBridge(mockBrowserManager())
|
||||
bridge.setActiveTab(100)
|
||||
wc = mockWebContents(100)
|
||||
webContentsFromIdMock.mockImplementation((id: number) => (id === 100 ? wc : null))
|
||||
})
|
||||
|
||||
it('rejects browser clipboard writes that exceed the safe agent-browser argument size', async () => {
|
||||
@@ -259,196 +119,81 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces contenteditable text through the browser editing pipeline', async () => {
|
||||
succeedForContentEditable()
|
||||
const environment = createContentEditableEvalEnvironment('existing text')
|
||||
|
||||
await bridge.fill('@editor', 'replacement text')
|
||||
|
||||
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
|
||||
|
||||
expect(environment.editor.textContent).toBe('replacement text')
|
||||
expect(environment.execCommand).toHaveBeenCalledWith('insertText', false, 'replacement text')
|
||||
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears selected contenteditable text with a browser delete command', async () => {
|
||||
succeedForContentEditable()
|
||||
const environment = createContentEditableEvalEnvironment('existing text')
|
||||
|
||||
const result = await bridge.clear('@editor')
|
||||
|
||||
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
|
||||
|
||||
expect(environment.editor.textContent).toBe('')
|
||||
expect(environment.execCommand).toHaveBeenCalledWith('delete', false, '')
|
||||
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ cleared: '@editor' })
|
||||
})
|
||||
|
||||
it('fails contenteditable fill when the browser editing command is unavailable', async () => {
|
||||
succeedForContentEditable()
|
||||
const environment = createContentEditableEvalEnvironment('existing text')
|
||||
environment.execCommand.mockReturnValue(false)
|
||||
|
||||
await bridge.fill('@editor', 'replacement text')
|
||||
|
||||
expect(() =>
|
||||
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
|
||||
).toThrow('Browser rich-text editing command failed')
|
||||
expect(environment.editor.textContent).toBe('existing text')
|
||||
expect(environment.editor.dispatchEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inserts paste-sized contenteditable text as one stdin editing transaction', async () => {
|
||||
const firstChunk = 'x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)
|
||||
succeedForContentEditable()
|
||||
const environment = createContentEditableEvalEnvironment('existing text')
|
||||
|
||||
await bridge.fill('@editor', `${firstChunk}tail`)
|
||||
|
||||
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('eval')
|
||||
)
|
||||
runFillEvalExpressions(stdinWrites, environment.document, environment.windowObject)
|
||||
|
||||
expect(evalCalls).toHaveLength(1)
|
||||
expect(evalCalls[0][1]).toContain('--stdin')
|
||||
expect(evalScripts(stdinWrites)).toHaveLength(1)
|
||||
expect(environment.editor.textContent).toBe(`${firstChunk}tail`)
|
||||
})
|
||||
|
||||
it('routes focused spinbutton wrappers to editable descendants before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = stdinWrites
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
descendant: input.node
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes aria-controlled spinbutton wrappers to editable inputs before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = stdinWrites
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
ariaControls: 'target-id'
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: (id: string) => (id === 'target-id' ? input.node : null)
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes aria-controlled spinbutton containers to editable descendants before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = stdinWrites
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
ariaControls: 'target-id'
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: (id: string) => (id === 'target-id' ? controlled.node : null)
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(controlled.value).toBe('')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(controlled.events).toHaveLength(0)
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips non-text spinbutton descendant inputs before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@spinbutton', '200')
|
||||
|
||||
const expressions = stdinWrites
|
||||
|
||||
const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' })
|
||||
const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' })
|
||||
const wrapper = createFillEvalNode({
|
||||
tagName: 'DIV',
|
||||
role: 'spinbutton',
|
||||
descendants: [hiddenInput.node, numberInput.node]
|
||||
})
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: wrapper.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(numberInput.value).toBe('200')
|
||||
expect(hiddenInput.value).toBe('')
|
||||
expect(wrapper.value).toBe('')
|
||||
expect(numberInput.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
expect(hiddenInput.events).toHaveLength(0)
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fills large plain fields with one stdin edit and one event pair', async () => {
|
||||
it('fills the element the helper resolved, in its own frame, with one page call', async () => {
|
||||
const text = `${'é\n'.repeat(512 * 1024)}tail'\\`
|
||||
succeedWith({ ok: true })
|
||||
succeedWith({ value: null })
|
||||
wc.debugger.sendCommand.mockResolvedValue({ result: { type: 'object', value: null } })
|
||||
|
||||
await bridge.fill('@textarea', text)
|
||||
await expect(bridge.fill('@e3', text)).resolves.toEqual({ filled: '@e3' })
|
||||
|
||||
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('eval')
|
||||
)
|
||||
expect(evalCalls).toHaveLength(1)
|
||||
expect(evalCalls[0][1]).toContain('--stdin')
|
||||
expect(evalScripts(stdinWrites)).toHaveLength(1)
|
||||
expect((evalCalls[0][1] as string[]).join('')).not.toContain(text)
|
||||
const input = createFillEvalNode({ tagName: 'TEXTAREA' })
|
||||
runFillEvalExpressions(stdinWrites, {
|
||||
activeElement: input.node,
|
||||
getElementById: () => null
|
||||
expect(agentBrowserCommandsIn(stdinWrites)).toEqual([['get', 'attr', '@e3', 'id']])
|
||||
expect(execFileMock.mock.calls.flatMap((call) => call[1]).join('')).not.toContain('tail')
|
||||
expect(elementCalls(wc)).toEqual([
|
||||
[
|
||||
'Runtime.callFunctionOn',
|
||||
{
|
||||
objectId: 'element-1',
|
||||
functionDeclaration: FILL_ELEMENT_FUNCTION,
|
||||
arguments: [{ value: text }],
|
||||
returnByValue: true
|
||||
},
|
||||
'frame-session'
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('clears by filling the resolved element with an empty value', async () => {
|
||||
succeedWith({ value: null })
|
||||
wc.debugger.sendCommand.mockResolvedValue({ result: { type: 'object', value: null } })
|
||||
|
||||
await expect(bridge.clear('@e3')).resolves.toEqual({ cleared: '@e3' })
|
||||
|
||||
expect(elementCalls(wc)[0]?.[1]).toMatchObject({ arguments: [{ value: '' }] })
|
||||
})
|
||||
|
||||
it('refuses an element the page function rejects as not interactable', async () => {
|
||||
succeedWith({ value: null })
|
||||
wc.debugger.sendCommand.mockResolvedValue({
|
||||
result: { type: 'string', value: 'it is not a text field or editable element' }
|
||||
})
|
||||
expect(input.value).toBe(text)
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
|
||||
await expect(bridge.fill('@e1', 'x')).rejects.toMatchObject({
|
||||
code: 'browser_element_not_interactable',
|
||||
message: 'Cannot fill @e1: it is not a text field or editable element'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails instead of writing anywhere when the helper resolves no element', async () => {
|
||||
capturedElement.current = null
|
||||
succeedWith({ value: null })
|
||||
|
||||
await expect(bridge.fill('@e1', 'x')).rejects.toMatchObject({ code: 'browser_error' })
|
||||
expect(elementCalls(wc)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses select on an element that is not a <select> before the helper reports success', async () => {
|
||||
succeedWith({ selected: ['Mango'] })
|
||||
wc.debugger.sendCommand.mockResolvedValue({
|
||||
result: { type: 'string', value: 'it is a combobox, not a <select>' }
|
||||
})
|
||||
|
||||
await expect(bridge.select('@e9', 'Mango')).rejects.toMatchObject({
|
||||
code: 'browser_element_not_interactable'
|
||||
})
|
||||
expect(agentBrowserCommandsIn(stdinWrites)).toEqual([['get', 'attr', '@e9', 'id']])
|
||||
})
|
||||
|
||||
it('refuses to upload a path that is not an existing file', async () => {
|
||||
succeedWith({ uploaded: 1 })
|
||||
|
||||
await expect(bridge.upload('@e19', ['/nonexistent/orca-upload.txt'])).rejects.toMatchObject({
|
||||
code: 'invalid_argument'
|
||||
})
|
||||
expect(agentBrowserCommandsIn(stdinWrites).map(([name]) => name)).not.toContain('upload')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fill', (b: AgentBrowserBridge, text: string) => b.fill('@textarea', text)],
|
||||
['type', (b: AgentBrowserBridge, text: string) => b.type(text)],
|
||||
['keyboard insert', (b: AgentBrowserBridge, text: string) => b.keyboardInsertText(text)]
|
||||
])('yields before spawning agent-browser for accepted large %s text', async (_name, run) => {
|
||||
vi.useFakeTimers()
|
||||
@@ -479,19 +224,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(agentBrowserCommandsIn(stdinWrites)).toEqual([['keyboard', 'inserttext', '--help']])
|
||||
})
|
||||
|
||||
it('chunks large agent-browser type text before keyboard transport', async () => {
|
||||
const text = ['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'].join('')
|
||||
succeedWith({ typed: true })
|
||||
|
||||
await bridge.type(text)
|
||||
|
||||
const chunks = agentBrowserCommandsIn(stdinWrites)
|
||||
.filter(([name, subcommand]) => name === 'keyboard' && subcommand === 'type')
|
||||
.map(([, , chunk]) => chunk)
|
||||
|
||||
expect(chunks).toEqual(['y'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'zz'])
|
||||
})
|
||||
|
||||
it('chunks large agent-browser keyboard insert text before transport', async () => {
|
||||
const text = ['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'].join('')
|
||||
succeedWith({ inserted: true })
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
FILE_INPUT_TARGET_FUNCTION,
|
||||
FILL_ELEMENT_FUNCTION,
|
||||
SELECT_ALL_ELEMENT_FUNCTION,
|
||||
SELECT_OPTION_TARGET_FUNCTION
|
||||
} from './browser-element-functions'
|
||||
|
||||
// CDP's Runtime.callFunctionOn calls the declaration with the resolved element as `this`.
|
||||
function callOn(element: Element, declaration: string, ...args: unknown[]): unknown {
|
||||
return new Function(`return (${declaration})`)().apply(element, args)
|
||||
}
|
||||
|
||||
function mount(html: string): HTMLElement {
|
||||
document.body.innerHTML = html
|
||||
return document.body
|
||||
}
|
||||
|
||||
describe('FILL_ELEMENT_FUNCTION', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('sets an input inside an open shadow root and fires one input and change event', () => {
|
||||
const host = mount('<div id="host"></div>').querySelector('#host')!
|
||||
const input = host.attachShadow({ mode: 'open' }).appendChild(document.createElement('input'))
|
||||
const events: string[] = []
|
||||
input.addEventListener('input', () => events.push('input'))
|
||||
input.addEventListener('change', () => events.push('change'))
|
||||
// Like a person's input, it crosses the shadow boundary for page-level listeners.
|
||||
document.addEventListener('input', () => events.push('document input'))
|
||||
|
||||
expect(callOn(input, FILL_ELEMENT_FUNCTION, 'hello')).toBeNull()
|
||||
|
||||
expect(input.value).toBe('hello')
|
||||
expect(events).toEqual(['input', 'document input', 'change'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['<h1>Title</h1>', 'h1', 'it is not a text field or editable element'],
|
||||
['<input disabled>', 'input', 'it is disabled'],
|
||||
['<textarea readonly></textarea>', 'textarea', 'it is read-only'],
|
||||
['<input type="checkbox">', 'input', 'it is not a text field or editable element'],
|
||||
['<select><option>a</option></select>', 'select', 'it is a <select>; use select instead']
|
||||
])('refuses %s', (html, selector, reason) => {
|
||||
const element = mount(html).querySelector(selector)!
|
||||
|
||||
expect(callOn(element, FILL_ELEMENT_FUNCTION, 'x')).toBe(reason)
|
||||
})
|
||||
|
||||
it('keeps the previous value when a number field sanitizes the new one away', () => {
|
||||
const input = mount('<input type="number" value="42">').querySelector('input')!
|
||||
const events: string[] = []
|
||||
input.addEventListener('input', () => events.push('input'))
|
||||
|
||||
expect(callOn(input, FILL_ELEMENT_FUNCTION, 'abc')).toBe('the field rejected this value')
|
||||
|
||||
expect(input.value).toBe('42')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('fills the input an aria-controlled spinbutton points at', () => {
|
||||
const body = mount('<div role="spinbutton" aria-controls="qty"></div><input id="qty">')
|
||||
|
||||
callOn(body.querySelector('[role=spinbutton]')!, FILL_ELEMENT_FUNCTION, '200')
|
||||
|
||||
expect(body.querySelector<HTMLInputElement>('#qty')!.value).toBe('200')
|
||||
})
|
||||
|
||||
it('fills the text descendant of a spinbutton wrapper, skipping non-text inputs', () => {
|
||||
const body = mount(
|
||||
'<div role="spinbutton"><input type="hidden" id="h"><input type="number" id="n"></div>'
|
||||
)
|
||||
|
||||
callOn(body.querySelector('[role=spinbutton]')!, FILL_ELEMENT_FUNCTION, '7')
|
||||
|
||||
expect(body.querySelector<HTMLInputElement>('#n')!.value).toBe('7')
|
||||
expect(body.querySelector<HTMLInputElement>('#h')!.value).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['replaces', 'new text', 'insertText'],
|
||||
['clears', '', 'delete']
|
||||
])('%s contenteditable text through a browser edit command', (_name, value, command) => {
|
||||
const editor = mount('<div contenteditable="true">old</div>').querySelector('div')!
|
||||
const execCommand = vi.fn(() => true)
|
||||
Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
|
||||
|
||||
expect(callOn(editor, FILL_ELEMENT_FUNCTION, value)).toBeNull()
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith(command, false, value)
|
||||
expect(window.getSelection()?.toString()).toBe('old')
|
||||
})
|
||||
|
||||
it('refuses a rich-text edit when a focus handler moves focus to another field', () => {
|
||||
const body = mount('<div contenteditable="true">old</div><input value="keep">')
|
||||
const execCommand = vi.fn(() => true)
|
||||
Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
|
||||
body.querySelector('div')!.addEventListener('focus', () => body.querySelector('input')!.focus())
|
||||
|
||||
expect(callOn(body.querySelector('div')!, FILL_ELEMENT_FUNCTION, 'new')).toBe(
|
||||
'it gave its focus to another element'
|
||||
)
|
||||
expect(execCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an input the page replaces when it takes focus', () => {
|
||||
const input = mount('<input value="old">').querySelector('input')!
|
||||
input.addEventListener('focus', () => input.replaceWith(document.createElement('input')))
|
||||
|
||||
expect(callOn(input, FILL_ELEMENT_FUNCTION, 'new')).toMatch(/replaced it/)
|
||||
})
|
||||
|
||||
it('reports a rich-text edit the browser refused', () => {
|
||||
const editor = mount('<div contenteditable="true">old</div>').querySelector('div')!
|
||||
Object.defineProperty(document, 'execCommand', { configurable: true, value: () => false })
|
||||
|
||||
expect(callOn(editor, FILL_ELEMENT_FUNCTION, 'new')).toBe('the browser editing command failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('element target checks', () => {
|
||||
it.each([
|
||||
['<div role="combobox">Pick</div>', 'div', 'it is a combobox, not a <select>'],
|
||||
['<h1>x</h1>', 'h1', 'it is <h1>, not a <select>'],
|
||||
['<select disabled></select>', 'select', 'it is disabled']
|
||||
])('refuses select on %s', (html, selector, reason) => {
|
||||
const element = mount(html).querySelector(selector)!
|
||||
|
||||
expect(callOn(element, SELECT_OPTION_TARGET_FUNCTION)).toMatch(reason)
|
||||
})
|
||||
|
||||
it('accepts select on an enabled <select> and upload on a file input', () => {
|
||||
const body = mount('<select></select><input type="file">')
|
||||
|
||||
expect(callOn(body.querySelector('select')!, SELECT_OPTION_TARGET_FUNCTION)).toBeNull()
|
||||
expect(callOn(body.querySelector('input')!, FILE_INPUT_TARGET_FUNCTION)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses upload on an element that is not a file input', () => {
|
||||
const button = mount('<button>Attach</button>').querySelector('button')!
|
||||
|
||||
expect(callOn(button, FILE_INPUT_TARGET_FUNCTION)).toBe('it is not a file input')
|
||||
})
|
||||
|
||||
it('selects all of an input value and reports that it holds focus', () => {
|
||||
const input = mount('<input value="abcdef">').querySelector('input')!
|
||||
input.setSelectionRange(3, 3)
|
||||
|
||||
expect(callOn(input, SELECT_ALL_ELEMENT_FUNCTION)).toBe(true)
|
||||
|
||||
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 6])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// Page functions for Runtime.callFunctionOn; `this` is the element the agent's ref resolved to.
|
||||
// A returned string is the reason the element cannot take the action.
|
||||
|
||||
export const FILL_ELEMENT_FUNCTION = `function (value) {
|
||||
const isTextField = (node) =>
|
||||
node?.localName === 'textarea' ||
|
||||
(node?.localName === 'input' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.type));
|
||||
const findTextField = (root) => Array.from(root?.querySelectorAll?.('input, textarea') ?? []).find(isTextField) ?? null;
|
||||
let target = this;
|
||||
// Spinbutton wrappers can take the ref while another input owns the value.
|
||||
if (!isTextField(target) && target.getAttribute?.('role') === 'spinbutton') {
|
||||
const root = target.getRootNode();
|
||||
for (const id of (target.getAttribute('aria-controls') ?? '').split(/\\s+/).filter(Boolean)) {
|
||||
const controlled = root.getElementById?.(id) ?? null;
|
||||
const field = isTextField(controlled) ? controlled : findTextField(controlled);
|
||||
if (field) { target = field; break; }
|
||||
}
|
||||
if (target === this) target = findTextField(this) ?? this;
|
||||
}
|
||||
if (target.matches?.(':disabled')) return 'it is disabled';
|
||||
if (target.isContentEditable && !isTextField(target)) {
|
||||
// Rich editors revert direct DOM writes; browser edit transactions keep their models in sync.
|
||||
target.focus();
|
||||
// The edit lands wherever focus is, so a focus handler that moved it must stop the write.
|
||||
const active = target.getRootNode().activeElement;
|
||||
if (!active || !(active === target || active.contains(target) || target.contains(active))) {
|
||||
return 'it gave its focus to another element';
|
||||
}
|
||||
const selection = target.ownerDocument.defaultView?.getSelection?.();
|
||||
if (!selection) return 'its text selection is unavailable';
|
||||
selection.selectAllChildren(target);
|
||||
let edited = false;
|
||||
try {
|
||||
edited = target.ownerDocument.execCommand(value.length === 0 ? 'delete' : 'insertText', false, value) === true;
|
||||
} catch {}
|
||||
return edited ? null : 'the browser editing command failed';
|
||||
}
|
||||
if (target.localName === 'select') return 'it is a <select>; use select instead';
|
||||
if (!isTextField(target)) return 'it is not a text field or editable element';
|
||||
if (target.readOnly) return 'it is read-only';
|
||||
target.focus();
|
||||
if (!target.isConnected) return 'the page replaced it when it took focus; take a new snapshot';
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
|
||||
const setValue = (next) => (nativeSetter ? nativeSetter.call(target, next) : (target.value = next));
|
||||
const previous = target.value;
|
||||
setValue(value);
|
||||
// Sanitizing fields (number, email, date) empty a value they cannot hold; handlers may clear it later.
|
||||
if (value !== '' && target.value === '') {
|
||||
setValue(previous);
|
||||
return 'the field rejected this value';
|
||||
}
|
||||
target.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
target.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return null;
|
||||
}`
|
||||
|
||||
// Returns whether an editable element holds focus, so a select-all shortcut would stay inside it.
|
||||
export const SELECT_ALL_ELEMENT_FUNCTION = `function () {
|
||||
this.focus();
|
||||
const active = this.getRootNode().activeElement;
|
||||
const selectable = typeof this.select === 'function';
|
||||
const focused = (selectable || this.isContentEditable) && (active === this || this.contains(active));
|
||||
if (selectable) { this.select(); return focused; }
|
||||
const selection = this.ownerDocument.defaultView?.getSelection?.();
|
||||
if (!selection) return 'its text selection is unavailable';
|
||||
selection.selectAllChildren(this);
|
||||
return focused;
|
||||
}`
|
||||
|
||||
export const SELECT_OPTION_TARGET_FUNCTION = `function () {
|
||||
if (this.localName !== 'select') {
|
||||
const role = this.getAttribute?.('role');
|
||||
return 'it is ' + (role ? 'a ' + role : '<' + this.localName + '>') + ', not a <select>; click it and choose the option instead';
|
||||
}
|
||||
return this.matches(':disabled') ? 'it is disabled' : null;
|
||||
}`
|
||||
|
||||
export const FILE_INPUT_TARGET_FUNCTION = `function () {
|
||||
if (this.localName !== 'input' || this.type !== 'file') return 'it is not a file input';
|
||||
return this.matches(':disabled') ? 'it is disabled' : null;
|
||||
}`
|
||||
@@ -0,0 +1,16 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { BrowserError } from './browser-error'
|
||||
|
||||
/** Chromium attaches a missing path as an empty file, so check the paths before handing them over. */
|
||||
export async function assertUploadFilesExist(filePaths: readonly string[]): Promise<void> {
|
||||
for (const filePath of filePaths) {
|
||||
// Directories stay allowed: webkitdirectory inputs take them.
|
||||
const exists = await stat(filePath).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
if (!exists) {
|
||||
throw new BrowserError('invalid_argument', `Cannot upload ${filePath}: it does not exist`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// A helper-resolved remote object; its session is the debugger's, undefined for the page itself.
|
||||
export type CapturedElement = { objectId: string; sessionId: string | undefined }
|
||||
|
||||
/** Records the element the helper's calls run on, so Orca can act on that exact node. */
|
||||
export class CdpElementCapture {
|
||||
// Undefined while not capturing.
|
||||
private captured: CapturedElement | null | undefined
|
||||
|
||||
start(): void {
|
||||
this.captured = null
|
||||
}
|
||||
|
||||
stop(): CapturedElement | null {
|
||||
const captured = this.captured ?? null
|
||||
this.captured = undefined
|
||||
return captured
|
||||
}
|
||||
|
||||
observe(method: string, params: Record<string, unknown> | undefined, sessionId?: string): void {
|
||||
const objectId = params?.objectId
|
||||
if (
|
||||
this.captured !== undefined &&
|
||||
method === 'Runtime.callFunctionOn' &&
|
||||
typeof objectId === 'string'
|
||||
) {
|
||||
this.captured = { objectId, sessionId }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,3 +237,44 @@ export function parseCdpKeyEvent(raw: string): CdpKeyEvent | null {
|
||||
|
||||
return { keyCode, key, code, modifiers, location, selfModifier, text }
|
||||
}
|
||||
|
||||
/** The key a person presses to type one character, or null when no key types it. */
|
||||
function typedCharacterKeyEvent(char: string): CdpKeyEvent | null {
|
||||
if (char === '\n') {
|
||||
return parseCdpKeyEvent('Enter')
|
||||
}
|
||||
if (char === '\t') {
|
||||
return parseCdpKeyEvent('Tab')
|
||||
}
|
||||
return parseCdpKeyEvent(char) ?? imeFallbackKeyEvent(char)
|
||||
}
|
||||
|
||||
const MAX_UNTYPABLE_RUN_LENGTH = 512
|
||||
|
||||
/** A key to press, or a run of text that no key types. */
|
||||
export type TypedInputStep = CdpKeyEvent | string
|
||||
|
||||
/** The keys that type `input`, yielded lazily so long text is never held as events. */
|
||||
export function* typedInputSteps(input: string): Generator<TypedInputStep> {
|
||||
let untypable = ''
|
||||
for (const char of input.replace(/\r\n?/g, '\n')) {
|
||||
const key = typedCharacterKeyEvent(char)
|
||||
if (!key) {
|
||||
untypable += char
|
||||
// Bounded like key runs, since a widget page receives this text one event per character.
|
||||
if (untypable.length >= MAX_UNTYPABLE_RUN_LENGTH) {
|
||||
yield untypable
|
||||
untypable = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (untypable) {
|
||||
yield untypable
|
||||
untypable = ''
|
||||
}
|
||||
yield key
|
||||
}
|
||||
if (untypable) {
|
||||
yield untypable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CdpTargetDiscovery } from './cdp-target-discovery'
|
||||
import { CdpDebuggerChannel } from './cdp-debugger-channel'
|
||||
import { CdpPageNavigationCommands } from './cdp-page-navigation-commands'
|
||||
import { CdpDomFocusReplay } from './cdp-dom-focus-replay'
|
||||
import { CdpElementCapture } from './cdp-element-capture'
|
||||
import { CdpPageCaptureCommands } from './cdp-page-capture-commands'
|
||||
import type { CapturePaintHold } from './cdp-screenshot'
|
||||
import type { AgentInputFocusGuard } from './agent-input-focus-guard'
|
||||
@@ -34,6 +35,7 @@ export class CdpWsProxy {
|
||||
private readonly pageCapture: CdpPageCaptureCommands
|
||||
private readonly input: CdpPageInputCommands
|
||||
private pointAtNextResolvedNode = false
|
||||
readonly elementCapture = new CdpElementCapture()
|
||||
|
||||
constructor(
|
||||
private readonly webContents: WebContents,
|
||||
@@ -248,6 +250,7 @@ export class CdpWsProxy {
|
||||
)
|
||||
return
|
||||
}
|
||||
this.elementCapture.observe(msg.method, msg.params, effectiveSessionId)
|
||||
if (msg.method === 'DOM.focus') {
|
||||
this.domFocusReplay.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import {
|
||||
dispatchPageKeyEvent,
|
||||
dispatchPageKeyEvents,
|
||||
routePageTextInsertion,
|
||||
type GuestCdpSender
|
||||
} from './guest-direct-key-input'
|
||||
@@ -225,6 +226,23 @@ describe('dispatchPageKeyEvent', () => {
|
||||
expect(page.selectAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('settles a run of widget keys with one focus probe and one page round trip', async () => {
|
||||
const { page, webContents } = fakePage('webview')
|
||||
const send = fakeSender()
|
||||
const keys = ['a', 'b', 'c'].flatMap((key) => [
|
||||
{ type: 'keyDown', key, text: key },
|
||||
{ type: 'keyUp', key }
|
||||
])
|
||||
|
||||
await dispatchPageKeyEvents(webContents, keys, send)
|
||||
|
||||
expect(page.sendInputEvent).toHaveBeenCalledTimes(9)
|
||||
expect(send.mock.calls.map(([method]) => method)).toEqual([
|
||||
'Runtime.evaluate',
|
||||
'Runtime.evaluate'
|
||||
])
|
||||
})
|
||||
|
||||
it('types into a focused cross-site frame on its own session, without focusing the guest', async () => {
|
||||
const { page, webContents } = fakePage('webview')
|
||||
const send = crossSiteFrameSender({ attachable: true })
|
||||
@@ -238,6 +256,31 @@ describe('dispatchPageKeyEvent', () => {
|
||||
expect(send).toHaveBeenLastCalledWith('Input.dispatchKeyEvent', params, 'S1')
|
||||
})
|
||||
|
||||
it('follows focus that a frame key handler moves into the parent page mid-run', async () => {
|
||||
const { page, webContents } = fakePage('webview')
|
||||
const inFrame = crossSiteFrameSender({ attachable: true })
|
||||
let focusInFrame = true
|
||||
const send = vi.fn<GuestCdpSender>(async (method, params, sessionId) => {
|
||||
if (method === 'Input.dispatchKeyEvent' && params.type === 'keyUp') {
|
||||
// The frame's handler sends focus to the parent after the first key.
|
||||
focusInFrame = false
|
||||
}
|
||||
return focusInFrame || sessionId
|
||||
? inFrame(method, params, sessionId)
|
||||
: fakeSender()(method, params, sessionId)
|
||||
})
|
||||
const keys = ['a', 'b'].flatMap((key) => [
|
||||
{ type: 'keyDown', key, text: key },
|
||||
{ type: 'keyUp', key }
|
||||
])
|
||||
|
||||
await dispatchPageKeyEvents(webContents, keys, send)
|
||||
|
||||
const frameKeys = send.mock.calls.filter(([method]) => method === 'Input.dispatchKeyEvent')
|
||||
expect(frameKeys.map(([, params]) => params.key)).toEqual(['a', 'a'])
|
||||
expect(page.sendInputEvent).toHaveBeenCalledWith(expect.objectContaining({ keyCode: 'b' }))
|
||||
})
|
||||
|
||||
it('borrows focus for CDP when the focused frame cannot be reached, then gives it back', async () => {
|
||||
const { page, webContents } = fakePage('webview')
|
||||
const send = crossSiteFrameSender({ attachable: false })
|
||||
|
||||
@@ -101,48 +101,88 @@ export async function dispatchPageKeyEvent(
|
||||
send: GuestCdpSender,
|
||||
guardFocus: AgentInputFocusGuard = NO_FOCUS_GUARD
|
||||
): Promise<unknown> {
|
||||
return await dispatchPageKeyEvents(webContents, [params], send, guardFocus)
|
||||
}
|
||||
|
||||
const isTabDown = (params: Record<string, unknown>): boolean =>
|
||||
params.key === 'Tab' && (params.type === 'keyDown' || params.type === 'rawKeyDown')
|
||||
|
||||
/** Deliver a run of key events along one focus route; callers split runs where focus may move. */
|
||||
export async function dispatchPageKeyEvents(
|
||||
webContents: WebContents,
|
||||
events: readonly Record<string, unknown>[],
|
||||
send: GuestCdpSender,
|
||||
guardFocus: AgentInputFocusGuard = NO_FOCUS_GUARD
|
||||
): Promise<unknown> {
|
||||
let result: unknown = {}
|
||||
if (!isEmbeddedGuest(webContents)) {
|
||||
return await send('Input.dispatchKeyEvent', params)
|
||||
for (const params of events) {
|
||||
result = await send('Input.dispatchKeyEvent', params)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const route = await resolveFocusedFrameRoute(webContents, send)
|
||||
if (route.kind === 'session') {
|
||||
let start = 0
|
||||
let route = await resolveFocusedFrameRoute(webContents, send)
|
||||
// A frame's key handler can move focus to another frame, so each key finds its route again.
|
||||
while (route.kind === 'session') {
|
||||
const keyUp = events.findIndex((params, index) => index >= start && params.type === 'keyUp')
|
||||
const end = keyUp === -1 ? events.length : keyUp + 1
|
||||
const key = events.slice(start, end)
|
||||
// Tab from the last frame field can escape into Orca's window.
|
||||
const isTabDown =
|
||||
params.key === 'Tab' && (params.type === 'keyDown' || params.type === 'rawKeyDown')
|
||||
const release = isTabDown ? await guardFocus() : () => {}
|
||||
const release = key.some(isTabDown) ? await guardFocus() : () => {}
|
||||
try {
|
||||
return await sendToFrameSession(
|
||||
webContents,
|
||||
send,
|
||||
route.sessionId,
|
||||
'Input.dispatchKeyEvent',
|
||||
params
|
||||
)
|
||||
for (const params of key) {
|
||||
result = await sendToFrameSession(
|
||||
webContents,
|
||||
send,
|
||||
route.sessionId,
|
||||
'Input.dispatchKeyEvent',
|
||||
params
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
if (end >= events.length) {
|
||||
return result
|
||||
}
|
||||
start = end
|
||||
route = await resolveFocusedFrameRoute(webContents, send)
|
||||
}
|
||||
const plan = planCdpKeyEvent(params)
|
||||
if (plan && route.kind === 'widget') {
|
||||
const remaining = events.slice(start)
|
||||
const plans = remaining.map((params) => planCdpKeyEvent(params))
|
||||
if (route.kind === 'widget' && plans.every((plan) => plan !== null)) {
|
||||
// Tab from the last page field can escape into Orca's window.
|
||||
const release = plan.events.some((event) => event.type === 'keyDown' && event.keyCode === 'Tab')
|
||||
? await guardFocus()
|
||||
: () => {}
|
||||
const release = remaining.some(isTabDown) ? await guardFocus() : () => {}
|
||||
try {
|
||||
await withAgentInputInjection(webContents, () =>
|
||||
// Widget events bypass CDP send, so track held keys here.
|
||||
trackRemoteCliInput(webContents.id, 'Input.dispatchKeyEvent', params, async () => {
|
||||
for (const event of plan.events) {
|
||||
webContents.sendInputEvent(event)
|
||||
}
|
||||
if (plan.commands.length > 0 || plan.events.length > 0) {
|
||||
await waitForPageToHandleInput(send)
|
||||
}
|
||||
for (const command of plan.commands) {
|
||||
runEditCommand(webContents, command)
|
||||
}
|
||||
})
|
||||
)
|
||||
await withAgentInputInjection(webContents, async () => {
|
||||
// One page round trip settles the whole run; edit commands must see the keys before them.
|
||||
let unsettled = false
|
||||
for (const [index, plan] of plans.entries()) {
|
||||
// Widget events bypass CDP send, so track held keys here.
|
||||
await trackRemoteCliInput(
|
||||
webContents.id,
|
||||
'Input.dispatchKeyEvent',
|
||||
remaining[index],
|
||||
async () => {
|
||||
for (const event of plan.events) {
|
||||
webContents.sendInputEvent(event)
|
||||
}
|
||||
unsettled ||= plan.events.length > 0
|
||||
if (plan.commands.length > 0) {
|
||||
await waitForPageToHandleInput(send)
|
||||
unsettled = false
|
||||
for (const command of plan.commands) {
|
||||
runEditCommand(webContents, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (unsettled) {
|
||||
await waitForPageToHandleInput(send)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
@@ -150,7 +190,12 @@ export async function dispatchPageKeyEvent(
|
||||
}
|
||||
const release = await focusGuestForCdpInput(webContents, send, guardFocus)
|
||||
try {
|
||||
return await withAgentInputInjection(webContents, () => send('Input.dispatchKeyEvent', params))
|
||||
for (const params of remaining) {
|
||||
result = await withAgentInputInjection(webContents, () =>
|
||||
send('Input.dispatchKeyEvent', params)
|
||||
)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts'
|
||||
import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
|
||||
import { BrowserError } from '../browser/browser-error'
|
||||
import { assertUploadFilesExist } from '../browser/browser-upload-files'
|
||||
import {
|
||||
ExternalChromiumBrowserSession,
|
||||
type ExternalChromiumLaunch
|
||||
@@ -179,6 +180,10 @@ export class ExternalChromiumBrowserProcess {
|
||||
return { url: tab.url, title: tab.title }
|
||||
}
|
||||
|
||||
if (method === 'browserUpload') {
|
||||
const files: unknown[] = Array.isArray(params.files) ? params.files : []
|
||||
await assertUploadFilesExist(files.map(String))
|
||||
}
|
||||
const command = externalChromiumCommandArguments(method, params)
|
||||
if (!command) {
|
||||
throw new BrowserError(
|
||||
|
||||
@@ -216,8 +216,8 @@ export async function runAgentKeysScenario(
|
||||
`JSON.stringify({ submits: window.__submits, keys: window.__keys })`
|
||||
)
|
||||
)
|
||||
// Why: `type` inserts text without key events, so only the keypresses log keydowns.
|
||||
.toBe(JSON.stringify({ submits: 1, keys: ['Z', 'Enter'] }))
|
||||
// Why: `type` presses real keys, so its characters log keydowns like the keypresses.
|
||||
.toBe(JSON.stringify({ submits: 1, keys: ['Z', 'q', 'r', 'Enter'] }))
|
||||
await afterAgentInput?.(pageId)
|
||||
} finally {
|
||||
await server.close()
|
||||
@@ -257,7 +257,7 @@ export async function runAgentClicksScenario(
|
||||
.toBe(JSON.stringify({ active: 'probe', focused: true, blurs: 0 }))
|
||||
expect((await readHost(page)).terminalFocused).toBe(true)
|
||||
|
||||
// Why: text with a newline cannot be typed as key events; CDP delivers it with focus borrowed.
|
||||
// Why: fill writes multiline values straight into the field, never through the user's focus.
|
||||
await rpc(page, 'browser.fill', { page: pageId, element: '#notes', value: 'a\nb' })
|
||||
await expect
|
||||
.poll(() => evalInPage(page, pageId, `document.getElementById('notes').value`))
|
||||
|
||||
Reference in New Issue
Block a user