Compare commits

...

3 Commits

Author SHA1 Message Date
Nikhil Sonti
6c5c3094ad refactor: make clear use CDP before DOM fallback 2026-03-17 09:56:53 -07:00
Nikhil Sonti
543af16b38 fix: address PR review comments for 0317-fix_input_clear_and_fill 2026-03-17 09:48:33 -07:00
Nikhil Sonti
1eb96eb081 fix: make clear and fill replace input text reliably 2026-03-17 09:35:20 -07:00
5 changed files with 283 additions and 21 deletions

View File

@@ -763,8 +763,19 @@ export class Browser {
): Promise<void> {
const session = await this.resolveSession(page)
await mouse.dispatchClick(session, x, y, 'left', 1, 0)
if (clear) await keyboard.clearField(session)
await keyboard.typeText(session, text)
if (clear) {
await keyboard.clearField(session)
let value = await elements.getFocusedEditableValue(session)
if (value) {
await mouse.dispatchClick(session, x, y, 'left', 3, 0)
await keyboard.pressCombo(session, 'Backspace')
value = await elements.getFocusedEditableValue(session)
}
if (value) {
await elements.clearFocusedEditableElement(session)
}
}
if (text) await keyboard.typeText(session, text)
}
async dragAt(
@@ -815,27 +826,22 @@ export class Browser {
}
if (clear) {
// Primary: keyboard select-all + backspace
await keyboard.clearField(session)
// Fallback: if field still has content, triple-click to select all
// then typeText will overwrite the selection
if (coords) {
const value = await elements.getInputValue(session, element)
const existingValue = await elements.getInputValue(session, element)
if (existingValue) {
await keyboard.clearField(session)
let value = await elements.getInputValue(session, element)
if (value && coords) {
await mouse.dispatchClick(session, coords.x, coords.y, 'left', 3, 0)
await keyboard.pressCombo(session, 'Backspace')
value = await elements.getInputValue(session, element)
}
if (value) {
await mouse.dispatchClick(
session,
coords.x,
coords.y,
'left',
3,
0,
)
await elements.clearEditableElement(session, element)
}
}
}
await keyboard.typeText(session, text)
if (text) await keyboard.typeText(session, text)
return coords
}

View File

@@ -1,5 +1,58 @@
import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api'
const CLEAR_EDITABLE_TARGET_BODY = `
if (!(target instanceof Element)) return false;
if (typeof target.focus === 'function') target.focus();
const inputType =
target instanceof HTMLInputElement ? target.type.toLowerCase() : '';
const canClearInput =
target instanceof HTMLInputElement &&
![
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
].includes(inputType);
if (target instanceof HTMLTextAreaElement || canClearInput) {
if (target.disabled || target.readOnly) return false;
const prototype =
target instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
if (descriptor?.set) {
descriptor.set.call(target, '');
} else {
target.value = '';
}
if (typeof target.setSelectionRange === 'function') {
try {
target.setSelectionRange(0, 0);
} catch {}
}
} else if (target instanceof HTMLElement && target.isContentEditable) {
target.replaceChildren();
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(target);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
} else {
return false;
}
target.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
return true;
`
function quadCenter(q: number[]): { x: number; y: number } {
const x = ((q[0] ?? 0) + (q[2] ?? 0) + (q[4] ?? 0) + (q[6] ?? 0)) / 4
const y = ((q[1] ?? 0) + (q[3] ?? 0) + (q[5] ?? 0) + (q[7] ?? 0)) / 4
@@ -111,6 +164,69 @@ export async function getInputValue(
}
}
export async function clearEditableElement(
session: ProtocolApi,
backendNodeId: number,
): Promise<boolean> {
try {
const cleared = await callOnElement(
session,
backendNodeId,
`function(){const target=this;${CLEAR_EDITABLE_TARGET_BODY}}`,
)
return Boolean(cleared)
} catch {
return false
}
}
export async function clearFocusedEditableElement(
session: ProtocolApi,
): Promise<boolean> {
try {
const result = await session.Runtime.evaluate({
expression: `(() => {
let target = document.activeElement;
while (
target instanceof HTMLElement &&
target.shadowRoot?.activeElement instanceof Element
) {
target = target.shadowRoot.activeElement;
}
${CLEAR_EDITABLE_TARGET_BODY}
})()`,
returnByValue: true,
})
return Boolean(result.result?.value)
} catch {
return false
}
}
export async function getFocusedEditableValue(
session: ProtocolApi,
): Promise<string> {
try {
const result = await session.Runtime.evaluate({
expression: `(() => {
let target = document.activeElement;
while (
target instanceof HTMLElement &&
target.shadowRoot?.activeElement instanceof Element
) {
target = target.shadowRoot.activeElement;
}
if (!(target instanceof Element)) return '';
return target.value ?? target.textContent ?? '';
})()`,
returnByValue: true,
})
return (result.result?.value as string) ?? ''
} catch {
return ''
}
}
export async function callOnElement(
session: ProtocolApi,
backendNodeId: number,

View File

@@ -1,7 +1,6 @@
import { platform } from 'node:os'
import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api'
// Meta (Cmd) on macOS, Control on everything else
const PLATFORM_MODIFIER = platform() === 'darwin' ? 4 : 2
type KeyInfo = { code: string; keyCode: number | undefined }
@@ -184,7 +183,6 @@ export async function typeText(
}
export async function clearField(session: ProtocolApi): Promise<void> {
// Select all: Cmd+A on macOS, Ctrl+A on others
await session.Input.dispatchKeyEvent({
type: 'keyDown',
key: 'a',
@@ -199,7 +197,6 @@ export async function clearField(session: ProtocolApi): Promise<void> {
modifiers: PLATFORM_MODIFIER,
windowsVirtualKeyCode: 65,
})
// Backspace to delete selection (more reliable cross-platform than Delete)
await session.Input.dispatchKeyEvent({
type: 'keyDown',
key: 'Backspace',

View File

@@ -2,12 +2,14 @@ import { describe, it } from 'bun:test'
import assert from 'node:assert'
import {
check,
clear,
click,
fill,
hover,
press_key,
scroll,
select_option,
type_at,
uncheck,
} from '../../src/tools/input'
import { close_page, new_page } from '../../src/tools/navigation'
@@ -117,6 +119,100 @@ describe('input tools', () => {
})
}, 60_000)
it('fill replaces existing text by default', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, { url: FORM_PAGE })
const pageId = pageIdOf(newResult)
const snap = await execute(take_snapshot, { page: pageId })
const inputId = findElementId(textOf(snap), 'Enter name')
await execute(fill, { page: pageId, element: inputId, text: 'Alpha' })
const fillResult = await execute(fill, {
page: pageId,
element: inputId,
text: 'Beta',
})
assert.ok(!fillResult.isError, textOf(fillResult))
const val = await execute(evaluate_script, {
page: pageId,
expression: 'document.getElementById("name").value',
})
assert.strictEqual(textOf(val), 'Beta')
await execute(close_page, { page: pageId })
})
}, 60_000)
it('clear removes all text from an input', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, { url: FORM_PAGE })
const pageId = pageIdOf(newResult)
const snap = await execute(take_snapshot, { page: pageId })
const inputId = findElementId(textOf(snap), 'Enter name')
await execute(fill, { page: pageId, element: inputId, text: 'Alpha' })
const clearResult = await execute(clear, {
page: pageId,
element: inputId,
})
assert.ok(!clearResult.isError, textOf(clearResult))
const val = await execute(evaluate_script, {
page: pageId,
expression: 'document.getElementById("name").value',
})
assert.strictEqual(textOf(val), '')
await execute(close_page, { page: pageId })
})
}, 60_000)
it('type_at clears the focused input when requested', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, { url: FORM_PAGE })
const pageId = pageIdOf(newResult)
const snap = await execute(take_snapshot, { page: pageId })
const inputId = findElementId(textOf(snap), 'Enter name')
await execute(fill, { page: pageId, element: inputId, text: 'Alpha' })
const coordsResult = await execute(evaluate_script, {
page: pageId,
expression: `(() => {
const rect = document.getElementById("name").getBoundingClientRect();
return JSON.stringify({
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
});
})()`,
})
const coords = JSON.parse(textOf(coordsResult)) as {
x: number
y: number
}
const typeResult = await execute(type_at, {
page: pageId,
x: coords.x,
y: coords.y,
text: 'Beta',
clear: true,
})
assert.ok(!typeResult.isError, textOf(typeResult))
const val = await execute(evaluate_script, {
page: pageId,
expression: 'document.getElementById("name").value',
})
assert.strictEqual(textOf(val), 'Beta')
await execute(close_page, { page: pageId })
})
}, 60_000)
it('click triggers a button', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, { url: FORM_PAGE })

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from 'bun:test'
import { platform } from 'node:os'
import {
clearField,
getKeyInfo,
modifierBitmask,
normalizeKey,
@@ -158,3 +160,48 @@ describe('pressCombo validation', () => {
)
})
})
describe('clearField', () => {
it('uses lowercase select-all without standalone modifier events', async () => {
const events: Array<Record<string, unknown>> = []
const fakeSession = {
Input: {
dispatchKeyEvent: async (event: Record<string, unknown>) => {
events.push(event)
},
},
} as unknown as Parameters<typeof clearField>[0]
await clearField(fakeSession)
const modifier = platform() === 'darwin' ? 4 : 2
expect(events).toEqual([
{
type: 'keyDown',
key: 'a',
code: 'KeyA',
modifiers: modifier,
windowsVirtualKeyCode: 65,
},
{
type: 'keyUp',
key: 'a',
code: 'KeyA',
modifiers: modifier,
windowsVirtualKeyCode: 65,
},
{
type: 'keyDown',
key: 'Backspace',
code: 'Backspace',
windowsVirtualKeyCode: 8,
},
{
type: 'keyUp',
key: 'Backspace',
code: 'Backspace',
windowsVirtualKeyCode: 8,
},
])
})
})