diff --git a/packages/puppeteer-core/src/api/Locator.ts b/packages/puppeteer-core/src/api/Locator.ts index 2ac870f0c1f..b26815cc1e6 100644 --- a/packages/puppeteer-core/src/api/Locator.ts +++ b/packages/puppeteer-core/src/api/Locator.ts @@ -358,6 +358,116 @@ export class Locator extends EventEmitter { ); } + /** + * Fills out the input identified by the locator using the provided value. The + * type of the input is determined at runtime and the appropriate fill-out + * method is chosen based on the type. contenteditable, selector, inputs are + * supported. + */ + async fill( + value: string, + fillOptions?: {signal?: AbortSignal} + ): Promise { + return await this.#run( + async element => { + const input = element as ElementHandle; + const inputType = await input.evaluate(el => { + if (el instanceof HTMLSelectElement) { + return 'select'; + } + if (el instanceof HTMLInputElement) { + if ( + new Set([ + 'textarea', + 'text', + 'url', + 'tel', + 'search', + 'password', + 'number', + 'email', + ]).has(el.type) + ) { + return 'typeable-input'; + } else { + return 'other-input'; + } + } + + if (el.isContentEditable) { + return 'contenteditable'; + } + + return 'unknown'; + }); + + switch (inputType) { + case 'select': + await input.select(value); + break; + case 'contenteditable': + case 'typeable-input': + const textToType = await ( + input as ElementHandle + ).evaluate((input, newValue) => { + const currentValue = input.isContentEditable + ? input.innerText + : input.value; + + // Clear the input if the current value does not match the filled + // out value. + if ( + newValue.length <= currentValue.length || + !newValue.startsWith(input.value) + ) { + if (input.isContentEditable) { + input.innerText = ''; + } else { + input.value = ''; + } + return newValue; + } + const originalValue = input.isContentEditable + ? input.innerText + : input.value; + + // If the value is partially filled out, only type the rest. Move + // cursor to the end of the common prefix. + if (input.isContentEditable) { + input.innerText = ''; + input.innerText = originalValue; + } else { + input.value = ''; + input.value = originalValue; + } + return newValue.substring(originalValue.length); + }, value); + await input.type(textToType); + break; + case 'other-input': + await input.focus(); + await input.evaluate((input, value) => { + (input as HTMLInputElement).value = value; + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + }, value); + break; + case 'unknown': + throw new Error(`Element cannot be filled out.`); + } + }, + { + signal: fillOptions?.signal, + conditions: [ + this.#ensureElementIsInTheViewport, + this.#waitForVisibility, + this.#waitForEnabled, + this.#waitForStableBoundingBox, + ], + } + ); + } + async hover(hoverOptions?: {signal?: AbortSignal}): Promise { return await this.#run( async element => { diff --git a/test/src/locator.spec.ts b/test/src/locator.spec.ts index 8cdc94a34bb..9a54606017f 100644 --- a/test/src/locator.spec.ts +++ b/test/src/locator.spec.ts @@ -279,4 +279,119 @@ describe('Locator', function () { expect(scrolled).toBe(true); }); }); + + describe('Locator.change', function () { + it('should work for selects', async () => { + const {page} = getTestState(); + + await page.setContent(` + + `); + let filled = false; + await page + .locator('select') + .on(LocatorEmittedEvents.Action, () => { + filled = true; + }) + .fill('value2'); + expect( + await page.evaluate(() => { + return document.querySelector('select')?.value === 'value2'; + }) + ).toBe(true); + expect(filled).toBe(true); + }); + + it('should work for inputs', async () => { + const {page} = getTestState(); + await page.setContent(` + + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should work if the input becomes enabled later', async () => { + const {page} = getTestState(); + + await page.setContent(` + + `); + const input = await page.$('input'); + const result = page.locator('input').fill('test'); + expect( + await input?.evaluate(el => { + return el.value; + }) + ).toBe(''); + await input?.evaluate(el => { + el.disabled = false; + }); + await result; + expect( + await input?.evaluate(el => { + return el.value; + }) + ).toBe('test'); + }); + + it('should work for contenteditable', async () => { + const {page} = getTestState(); + await page.setContent(` +
+ `); + await page.locator('div').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('div')?.innerText === 'test'; + }) + ).toBe(true); + }); + + it('should work for pre-filled inputs', async () => { + const {page} = getTestState(); + await page.setContent(` + + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should override pre-filled inputs', async () => { + const {page} = getTestState(); + await page.setContent(` + + `); + await page.locator('input').fill('test'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === 'test'; + }) + ).toBe(true); + }); + + it('should work for non-text inputs', async () => { + const {page} = getTestState(); + await page.setContent(` + + `); + await page.locator('input').fill('#333333'); + expect( + await page.evaluate(() => { + return document.querySelector('input')?.value === '#333333'; + }) + ).toBe(true); + }); + }); });