fix: Make fill_form more appealing when filling forms with checkboxes (#1971)
Fixes #1942 Verified using `npm run eval -- scripts/eval_scenarios/fill_select_and_checkboxes_test.ts` Without this change, I observed 7 runs using fill_form for all controls at once, 14 runs using click to select checkboxes and 10 runs that did nothing (total 31 runs) After this change: 9 fill_form using runs (passes), 1 click based approach and 10 no-attempt fails (20 runs total) Depending how we count the no-attempt runs, its either increase from 23% to 45% or 33% to 90% in eval pass rate. Co-authored-by: Piotr Paulski <piotrpaulski@chromium.org>
This commit is contained in:
@@ -90,14 +90,14 @@
|
||||
**Parameters:**
|
||||
|
||||
- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot
|
||||
- **value** (string) **(required)**: The value to [`fill`](#fill) in
|
||||
- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.
|
||||
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
|
||||
|
||||
---
|
||||
|
||||
### `fill_form`
|
||||
|
||||
**Description:** [`Fill`](#fill) out multiple form elements at once
|
||||
**Description:** [`Fill`](#fill) out multiple form elements (inputs, selects, checkboxes, radios) at once. ALWAYS prefer this tool over multiple individual '[`fill`](#fill)' or '[`click`](#click)' calls when interacting with forms. It is significantly faster, more reliable, and reduces turn count. Example: [`Fill`](#fill) username, password, and check "Remember Me" in one call.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
|
||||
@@ -250,7 +250,8 @@ export const commands: Commands = {
|
||||
value: {
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
description: 'The value to fill in',
|
||||
description:
|
||||
'The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.',
|
||||
required: true,
|
||||
},
|
||||
includeSnapshot: {
|
||||
|
||||
+34
-8
@@ -260,11 +260,29 @@ async function fillFormElement(
|
||||
if (aXNode && aXNode.role === 'combobox' && hasOptionChildren(aXNode)) {
|
||||
await selectOption(handle, aXNode, value);
|
||||
} else {
|
||||
// Increase timeout for longer input values.
|
||||
const timeoutPerChar = 10; // ms
|
||||
const fillTimeout =
|
||||
page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;
|
||||
await handle.asLocator().setTimeout(fillTimeout).fill(value);
|
||||
const isToggle = await handle.evaluate(el => {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
return el.type === 'checkbox' || el.type === 'radio';
|
||||
}
|
||||
const role = el.getAttribute('role');
|
||||
return role === 'checkbox' || role === 'radio' || role === 'switch';
|
||||
});
|
||||
|
||||
if (isToggle) {
|
||||
if (['true', 'false'].includes(value)) {
|
||||
await handle.asLocator().fill(value === 'true');
|
||||
} else {
|
||||
throw new Error(
|
||||
`Checkboxes, radio boxes and toggles require "true" or "false" value, but ${value} was used`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Increase timeout for longer input values.
|
||||
const timeoutPerChar = 10; // ms
|
||||
const fillTimeout =
|
||||
page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;
|
||||
await handle.asLocator().setTimeout(fillTimeout).fill(value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleActionError(error, uid);
|
||||
@@ -286,7 +304,11 @@ export const fill = definePageTool({
|
||||
.describe(
|
||||
'The uid of an element on the page from the page content snapshot',
|
||||
),
|
||||
value: zod.string().describe('The value to fill in'),
|
||||
value: zod
|
||||
.string()
|
||||
.describe(
|
||||
'The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.',
|
||||
),
|
||||
includeSnapshot: includeSnapshotSchema,
|
||||
},
|
||||
blockedByDialog: true,
|
||||
@@ -372,7 +394,7 @@ export const drag = definePageTool({
|
||||
|
||||
export const fillForm = definePageTool({
|
||||
name: 'fill_form',
|
||||
description: `Fill out multiple form elements at once`,
|
||||
description: `Fill out multiple form elements (inputs, selects, checkboxes, radios) at once. ALWAYS prefer this tool over multiple individual 'fill' or 'click' calls when interacting with forms. It is significantly faster, more reliable, and reduces turn count. Example: Fill username, password, and check "Remember Me" in one call.`,
|
||||
annotations: {
|
||||
category: ToolCategory.INPUT,
|
||||
readOnlyHint: false,
|
||||
@@ -383,7 +405,11 @@ export const fillForm = definePageTool({
|
||||
// eslint-disable-next-line @local/enforce-zod-schema
|
||||
zod.object({
|
||||
uid: zod.string().describe('The uid of the element to fill out'),
|
||||
value: zod.string().describe('Value for the element'),
|
||||
value: zod
|
||||
.string()
|
||||
.describe(
|
||||
'Value for the element. "true" or "false" for checkboxes and toggles, "true" for radio buttons.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe('Elements from snapshot to fill out.'),
|
||||
|
||||
@@ -761,6 +761,180 @@ describe('input', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles checkboxes', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(
|
||||
html`<input
|
||||
type="checkbox"
|
||||
id="cb"
|
||||
/>`,
|
||||
);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
|
||||
// Check it
|
||||
await fill.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
value: 'true',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
response.responseLines[0],
|
||||
'Successfully filled out the element',
|
||||
);
|
||||
assert.ok(response.includeSnapshot);
|
||||
let isChecked = await page.$eval(
|
||||
'#cb',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
assert.strictEqual(isChecked, true);
|
||||
|
||||
// Uncheck it
|
||||
await fill.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
value: 'false',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
new McpResponse({} as ParsedArguments),
|
||||
context,
|
||||
);
|
||||
|
||||
isChecked = await page.$eval(
|
||||
'#cb',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
assert.strictEqual(isChecked, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles switches', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(html`
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
id="sw"
|
||||
style="width: 20px; height: 20px; background: blue;"
|
||||
onclick="this.setAttribute('aria-checked', this.getAttribute('aria-checked') === 'true' ? 'false' : 'true')"
|
||||
>
|
||||
switch
|
||||
</div>
|
||||
`);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
|
||||
// Turn it on
|
||||
await fill.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
value: 'true',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
let swChecked = await page.$eval(
|
||||
'#sw',
|
||||
el => el.getAttribute('aria-checked') === 'true',
|
||||
);
|
||||
assert.strictEqual(swChecked, true);
|
||||
|
||||
// Turn it off
|
||||
await fill.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_1',
|
||||
value: 'false',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
new McpResponse({} as ParsedArguments),
|
||||
context,
|
||||
);
|
||||
|
||||
swChecked = await page.$eval(
|
||||
'#sw',
|
||||
el => el.getAttribute('aria-checked') === 'true',
|
||||
);
|
||||
assert.strictEqual(swChecked, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('selects radio buttons', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(html`
|
||||
<input
|
||||
type="radio"
|
||||
name="group1"
|
||||
id="r1"
|
||||
checked
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="group1"
|
||||
id="r2"
|
||||
/>
|
||||
`);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
|
||||
// Initial state
|
||||
let r1Checked = await page.$eval(
|
||||
'#r1',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
let r2Checked = await page.$eval(
|
||||
'#r2',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
assert.strictEqual(r1Checked, true);
|
||||
assert.strictEqual(r2Checked, false);
|
||||
|
||||
// Fill second radio with true
|
||||
await fill.handler(
|
||||
{
|
||||
params: {
|
||||
uid: '1_2',
|
||||
value: 'true',
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
r1Checked = await page.$eval(
|
||||
'#r1',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
r2Checked = await page.$eval(
|
||||
'#r2',
|
||||
el => (el as HTMLInputElement).checked,
|
||||
);
|
||||
assert.strictEqual(r1Checked, false);
|
||||
assert.strictEqual(r2Checked, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('drags', () => {
|
||||
@@ -882,6 +1056,57 @@ describe('input', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('fill_form handles checkboxes', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(
|
||||
html`<input
|
||||
name="username"
|
||||
type="text"
|
||||
/><input
|
||||
name="cb"
|
||||
type="checkbox"
|
||||
/>`,
|
||||
);
|
||||
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await fillForm.handler(
|
||||
{
|
||||
params: {
|
||||
elements: [
|
||||
{
|
||||
uid: '1_1',
|
||||
value: 'test',
|
||||
},
|
||||
{
|
||||
uid: '1_2',
|
||||
value: 'true',
|
||||
},
|
||||
],
|
||||
},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.strictEqual(
|
||||
await page.evaluate(() => {
|
||||
// @ts-expect-error missing types
|
||||
return document.querySelector('input[name=username]').value;
|
||||
}),
|
||||
'test',
|
||||
);
|
||||
assert.strictEqual(
|
||||
await page.evaluate(() => {
|
||||
// @ts-expect-error missing types
|
||||
return document.querySelector('input[name=cb]').checked;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
|
||||
Reference in New Issue
Block a user