diff --git a/docs/tools.md b/docs/tools.md index 63b17882..aeb118e2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -196,7 +196,7 @@ Parameters: ### `fill_by_uid` -Fill text input/textarea by UID. +Fill text input/textarea or pick a select option by UID. Parameters: diff --git a/src/firefox/dom.ts b/src/firefox/dom.ts index 58a868d5..35ed1a1a 100644 --- a/src/firefox/dom.ts +++ b/src/firefox/dom.ts @@ -4,6 +4,7 @@ import { Key } from 'selenium-webdriver'; import type { BrowsingContext, Input, Script } from 'webdriver-bidi-protocol'; +import { nativeToLocalValue } from '../utils/local-value.js'; import type { BiDiFacade } from './bidi'; /** @@ -273,6 +274,49 @@ export class DomInteractions { await this.sendPointerActions(context, [{ type: 'pointerMove', x, y }]); } + /** + * Pick the option of a select by its value or visible text. Typing would only reach it through + * the browser's type-ahead, which ignores option values and does nothing when no text matches. + * @returns false if the element is not a select + */ + private async selectOption( + context: BrowsingContext.BrowsingContext, + uid: string, + el: Script.SharedReference, + value: string + ): Promise { + const unmatched = await this.bidi.callFunction( + context, + ` + (el, value) => { + if (el.nodeName !== 'SELECT') { + return null; + } + const options = [...el.options]; + const option = + options.find((o) => o.value === value) ?? options.find((o) => o.text.trim() === value); + if (!option) { + return options.map((o) => o.text.trim()); + } + if (!option.selected) { + option.selected = true; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + return []; + } + `, + [el, nativeToLocalValue(value)] + ); + if (unmatched === null) { + return false; + } + if (unmatched.length > 0) { + throw new Error(`${uid} has no option "${value}". Options: ${unmatched.join(', ')}`); + } + return true; + } + /** * Fill input field by UID */ @@ -282,6 +326,9 @@ export class DomInteractions { value: string ): Promise { const el = await this.resolveUid(context, uid); + if (await this.selectOption(context, uid, el, value)) { + return; + } // Select the old content after focusing and delete it with a key press, // instead of assigning the value directly: a page that writes its own value // back on focus (such as a controlled React input that re-renders) would diff --git a/src/tools/input.ts b/src/tools/input.ts index a2424fb6..5b854c49 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -51,7 +51,7 @@ export const hoverByUidTool = { export const fillByUidTool = { name: 'fill_by_uid', - description: 'Fill text input/textarea by UID.', + description: 'Fill text input/textarea or pick a select option by UID.', annotations: { readOnlyHint: false, }, diff --git a/tests/integration/firefox/fill.test.ts b/tests/integration/firefox/fill.test.ts index 00db0308..3bf9a094 100644 --- a/tests/integration/firefox/fill.test.ts +++ b/tests/integration/firefox/fill.test.ts @@ -74,4 +74,42 @@ describe('fill_by_uid Integration Tests', () => { expect(await valueOf('r')).toBe(''); }, 20000); + + describe('select elements', () => { + const SELECT_PAGE = + 'data:text/html,' + + ''; + + it('should select an option by its value', async () => { + await firefox.navigate(SELECT_PAGE); + + await firefox.fillByUid(await uidOf('s'), '2'); + + expect(await firefox.evaluate('document.getElementById("s").value')).toBe('2'); + }, 20000); + + it('should select an option by its visible text', async () => { + await firefox.navigate(SELECT_PAGE); + + await firefox.fillByUid(await uidOf('s'), 'Green'); + + expect(await firefox.evaluate('document.getElementById("s").value')).toBe('2'); + }, 20000); + + it('should fire a change event like a user selection does', async () => { + await firefox.navigate(SELECT_PAGE); + + await firefox.fillByUid(await uidOf('s'), 'Green'); + + expect(await firefox.evaluate('window.changes')).toEqual(['2']); + }, 20000); + + it('should reject an unknown option and name the available ones', async () => { + await firefox.navigate(SELECT_PAGE); + const uid = await uidOf('s'); + + await expect(firefox.fillByUid(uid, 'Purple')).rejects.toThrow(/"Purple".*Red.*Green/); + expect(await firefox.evaluate('document.getElementById("s").value')).toBe('1'); + }, 20000); + }); });