fix(dom): make selectOption wait for options (#5036)

This commit is contained in:
Yury Semikhatsky 2021-01-19 11:27:05 -08:00 committed by GitHub
parent 19acf998da
commit 615954b285
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 156 additions and 45 deletions

View file

@ -545,6 +545,8 @@ Returns the array of option values that have been successfully selected.
Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>` Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>`
element, the method throws an error. element, the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js ```js
// single selection matching the value // single selection matching the value
handle.selectOption('blue'); handle.selectOption('blue');

View file

@ -842,6 +842,8 @@ Returns the array of option values that have been successfully selected.
Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
matching [`param: selector`], the method throws an error. matching [`param: selector`], the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js ```js
// single selection matching the value // single selection matching the value
frame.selectOption('select#colors', 'blue'); frame.selectOption('select#colors', 'blue');

View file

@ -1869,6 +1869,8 @@ Returns the array of option values that have been successfully selected.
Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
matching [`param: selector`], the method throws an error. matching [`param: selector`], the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js ```js
// single selection matching the value // single selection matching the value
page.selectOption('select#colors', 'blue'); page.selectOption('select#colors', 'blue');

View file

@ -446,9 +446,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const selectOptions = [...elements, ...values]; const selectOptions = [...elements, ...values];
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
const value = await this._evaluateInUtility(([injected, node, selectOptions]) => injected.selectOptions(node, selectOptions), selectOptions); progress.log(' selecting specified option(s)');
const poll = await this._evaluateHandleInUtility(([injected, node, selectOptions]) => injected.waitForOptionsAndSelect(node, selectOptions), selectOptions);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwFatalDOMError(await pollHandler.finish());
await this._page._doSlowMo(); await this._page._doSlowMo();
return throwFatalDOMError(value); return result;
}); });
} }
@ -817,7 +820,7 @@ export class InjectedScriptPollHandler<T> {
async finishHandle(): Promise<js.SmartHandle<T>> { async finishHandle(): Promise<js.SmartHandle<T>> {
try { try {
const result = await this._poll!.evaluateHandle(poll => poll.result); const result = await this._poll!.evaluateHandle(poll => poll.run());
await this._finishInternal(); await this._finishInternal();
return result; return result;
} finally { } finally {
@ -827,7 +830,7 @@ export class InjectedScriptPollHandler<T> {
async finish(): Promise<T> { async finish(): Promise<T> {
try { try {
const result = await this._poll!.evaluate(poll => poll.result); const result = await this._poll!.evaluate(poll => poll.run());
await this._finishInternal(); await this._finishInternal();
return result; return result;
} finally { } finally {

View file

@ -31,7 +31,7 @@ export type InjectedScriptProgress = {
}; };
export type InjectedScriptPoll<T> = { export type InjectedScriptPoll<T> = {
result: Promise<T>, run: () => Promise<T>,
// Takes more logs, waiting until at least one message is available. // Takes more logs, waiting until at least one message is available.
takeNextLogs: () => Promise<string[]>, takeNextLogs: () => Promise<string[]>,
// Takes all current logs without waiting. // Takes all current logs without waiting.
@ -242,19 +242,23 @@ export class InjectedScript {
}, },
}; };
const result = task(progress); const run = () => {
const result = task(progress);
// After the task has finished, there should be no more logs. // After the task has finished, there should be no more logs.
// Release any pending `takeNextLogs` call, and do not block any future ones. // Release any pending `takeNextLogs` call, and do not block any future ones.
// This prevents non-finished protocol evaluation calls and memory leaks. // This prevents non-finished protocol evaluation calls and memory leaks.
result.finally(() => { result.finally(() => {
taskFinished = true; taskFinished = true;
logReady(); logReady();
}); });
return result;
};
return { return {
takeNextLogs, takeNextLogs,
result, run,
cancel: () => { progress.aborted = true; }, cancel: () => { progress.aborted = true; },
takeLastLogs: () => unsentLogs, takeLastLogs: () => unsentLogs,
}; };
@ -267,35 +271,52 @@ export class InjectedScript {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) }; return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
} }
selectOptions(node: Node, optionsToSelect: (Node | { value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | FatalDOMError { waitForOptionsAndSelect(node: Node, optionsToSelect: (Node | { value?: string, label?: string, index?: number })[]): InjectedScriptPoll<string[] | 'error:notconnected' | FatalDOMError> {
const element = this.findLabelTarget(node as Element); return this.pollRaf((progress, continuePolling) => {
if (!element || !element.isConnected) const element = this.findLabelTarget(node as Element);
return 'error:notconnected'; if (!element || !element.isConnected)
if (element.nodeName.toLowerCase() !== 'select') return 'error:notconnected';
return 'error:notselect'; if (element.nodeName.toLowerCase() !== 'select')
const select = element as HTMLSelectElement; return 'error:notselect';
const options = Array.from(select.options); const select = element as HTMLSelectElement;
select.value = undefined as any; const options = Array.from(select.options);
for (let index = 0; index < options.length; index++) { const selectedOptions = [];
const option = options[index]; let remainingOptionsToSelect = optionsToSelect.slice();
option.selected = optionsToSelect.some(optionToSelect => { for (let index = 0; index < options.length; index++) {
if (optionToSelect instanceof Node) const option = options[index];
return option === optionToSelect; const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
let matches = true; if (optionToSelect instanceof Node)
if (optionToSelect.value !== undefined) return option === optionToSelect;
matches = matches && optionToSelect.value === option.value; let matches = true;
if (optionToSelect.label !== undefined) if (optionToSelect.value !== undefined)
matches = matches && optionToSelect.label === option.label; matches = matches && optionToSelect.value === option.value;
if (optionToSelect.index !== undefined) if (optionToSelect.label !== undefined)
matches = matches && optionToSelect.index === index; matches = matches && optionToSelect.label === option.label;
return matches; if (optionToSelect.index !== undefined)
}); matches = matches && optionToSelect.index === index;
if (option.selected && !select.multiple) return matches;
break; };
} if (!remainingOptionsToSelect.some(filter))
select.dispatchEvent(new Event('input', { 'bubbles': true })); continue;
select.dispatchEvent(new Event('change', { 'bubbles': true })); selectedOptions.push(option);
return options.filter(option => option.selected).map(option => option.value); if (select.multiple) {
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));
} else {
remainingOptionsToSelect = [];
break;
}
}
if (remainingOptionsToSelect.length) {
progress.logRepeating(' did not find some options - waiting... ');
return continuePolling;
}
select.value = undefined as any;
selectedOptions.forEach(option => option.selected = true);
progress.log(' selected specified option(s)');
select.dispatchEvent(new Event('input', { 'bubbles': true }));
select.dispatchEvent(new Event('change', { 'bubbles': true }));
return selectedOptions.map(option => option.value);
});
} }
waitForEnabledAndFill(node: Node, value: string): InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'needsinput' | 'done'> { waitForEnabledAndFill(node: Node, value: string): InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'needsinput' | 'done'> {

View file

@ -288,3 +288,14 @@ it('should be able to clear', async ({page, server}) => {
await page.fill('input', ''); await page.fill('input', '');
expect(await page.evaluate(() => window['result'])).toBe(''); expect(await page.evaluate(() => window['result'])).toBe('');
}); });
it('should not throw when fill causes navigation', async ({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
await page.setContent('<input type=date>');
await page.$eval('input', select => select.addEventListener('input', () => window.location.href = '/empty.html'));
await Promise.all([
page.fill('input', '2020-03-02'),
page.waitForNavigation(),
]);
expect(page.url()).toContain('empty.html');
});

View file

@ -17,6 +17,12 @@
import { it, expect } from './fixtures'; import { it, expect } from './fixtures';
async function giveItAChanceToResolve(page) {
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
}
it('should select single option', async ({page, server}) => { it('should select single option', async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html'); await page.goto(server.PREFIX + '/input/select.html');
await page.selectOption('select', 'blue'); await page.selectOption('select', 'blue');
@ -61,7 +67,12 @@ it('should select single option by multiple attributes', async ({page, server})
it('should not select single option when some attributes do not match', async ({page, server}) => { it('should not select single option when some attributes do not match', async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html'); await page.goto(server.PREFIX + '/input/select.html');
await page.selectOption('select', { value: 'green', label: 'Brown' }); await page.$eval('select', s => s.value = undefined);
try {
await page.selectOption('select', { value: 'green', label: 'Brown' }, {timeout: 300});
} catch (e) {
expect(e.message).toContain('Timeout');
}
expect(await page.evaluate(() => document.querySelector('select').value)).toEqual(''); expect(await page.evaluate(() => document.querySelector('select').value)).toEqual('');
}); });
@ -134,7 +145,7 @@ it('should throw when element is not a <select>', async ({page, server}) => {
it('should return [] on no matched values', async ({page, server}) => { it('should return [] on no matched values', async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html'); await page.goto(server.PREFIX + '/input/select.html');
const result = await page.selectOption('select', ['42','abc']); const result = await page.selectOption('select', []);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -237,3 +248,56 @@ it('should work when re-defining top-level Event class', async ({page, server})
expect(await page.evaluate(() => window['result'].onInput)).toEqual(['blue']); expect(await page.evaluate(() => window['result'].onInput)).toEqual(['blue']);
expect(await page.evaluate(() => window['result'].onChange)).toEqual(['blue']); expect(await page.evaluate(() => window['result'].onChange)).toEqual(['blue']);
}); });
it('should wait for option to be present',async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html');
const selectPromise = page.selectOption('select', 'scarlet');
let didSelect = false;
selectPromise.then(() => didSelect = true);
await giveItAChanceToResolve(page);
expect(didSelect).toBe(false);
await page.$eval('select', select => {
const option = document.createElement('option');
option.value = 'scarlet';
option.textContent = 'Scarlet';
select.appendChild(option);
});
const items = await selectPromise;
expect(items).toStrictEqual(['scarlet']);
});
it('should wait for option index to be present',async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html');
const len = await page.$eval('select', select => select.options.length);
const selectPromise = page.selectOption('select', {index: len});
let didSelect = false;
selectPromise.then(() => didSelect = true);
await giveItAChanceToResolve(page);
expect(didSelect).toBe(false);
await page.$eval('select', select => {
const option = document.createElement('option');
option.value = 'scarlet';
option.textContent = 'Scarlet';
select.appendChild(option);
});
const items = await selectPromise;
expect(items).toStrictEqual(['scarlet']);
});
it('should wait for multiple options to be present',async ({page, server}) => {
await page.goto(server.PREFIX + '/input/select.html');
await page.evaluate(() => window['makeMultiple']());
const selectPromise = page.selectOption('select', ['green', 'scarlet']);
let didSelect = false;
selectPromise.then(() => didSelect = true);
await giveItAChanceToResolve(page);
expect(didSelect).toBe(false);
await page.$eval('select', select => {
const option = document.createElement('option');
option.value = 'scarlet';
option.textContent = 'Scarlet';
select.appendChild(option);
});
const items = await selectPromise;
expect(items).toStrictEqual(['green', 'scarlet']);
});

6
types/types.d.ts vendored
View file

@ -2417,6 +2417,8 @@ export interface Page {
* Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element * Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
* matching `selector`, the method throws an error. * matching `selector`, the method throws an error.
* *
* Will wait until all specified options are present in the `<select>` element.
*
* ```js * ```js
* // single selection matching the value * // single selection matching the value
* page.selectOption('select#colors', 'blue'); * page.selectOption('select#colors', 'blue');
@ -4090,6 +4092,8 @@ export interface Frame {
* Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element * Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
* matching `selector`, the method throws an error. * matching `selector`, the method throws an error.
* *
* Will wait until all specified options are present in the `<select>` element.
*
* ```js * ```js
* // single selection matching the value * // single selection matching the value
* frame.selectOption('select#colors', 'blue'); * frame.selectOption('select#colors', 'blue');
@ -5940,6 +5944,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
* Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>` * Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>`
* element, the method throws an error. * element, the method throws an error.
* *
* Will wait until all specified options are present in the `<select>` element.
*
* ```js * ```js
* // single selection matching the value * // single selection matching the value
* handle.selectOption('blue'); * handle.selectOption('blue');