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>`
element, the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js
// single selection matching the value
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
matching [`param: selector`], the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js
// single selection matching the value
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
matching [`param: selector`], the method throws an error.
Will wait until all specified options are present in the `<select>` element.
```js
// single selection matching the value
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];
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
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();
return throwFatalDOMError(value);
return result;
});
}
@ -817,7 +820,7 @@ export class InjectedScriptPollHandler<T> {
async finishHandle(): Promise<js.SmartHandle<T>> {
try {
const result = await this._poll!.evaluateHandle(poll => poll.result);
const result = await this._poll!.evaluateHandle(poll => poll.run());
await this._finishInternal();
return result;
} finally {
@ -827,7 +830,7 @@ export class InjectedScriptPollHandler<T> {
async finish(): Promise<T> {
try {
const result = await this._poll!.evaluate(poll => poll.result);
const result = await this._poll!.evaluate(poll => poll.run());
await this._finishInternal();
return result;
} finally {

View file

@ -31,7 +31,7 @@ export type InjectedScriptProgress = {
};
export type InjectedScriptPoll<T> = {
result: Promise<T>,
run: () => Promise<T>,
// Takes more logs, waiting until at least one message is available.
takeNextLogs: () => Promise<string[]>,
// Takes all current logs without waiting.
@ -242,6 +242,7 @@ export class InjectedScript {
},
};
const run = () => {
const result = task(progress);
// After the task has finished, there should be no more logs.
@ -252,9 +253,12 @@ export class InjectedScript {
logReady();
});
return result;
};
return {
takeNextLogs,
result,
run,
cancel: () => { progress.aborted = true; },
takeLastLogs: () => unsentLogs,
};
@ -267,7 +271,8 @@ export class InjectedScript {
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> {
return this.pollRaf((progress, continuePolling) => {
const element = this.findLabelTarget(node as Element);
if (!element || !element.isConnected)
return 'error:notconnected';
@ -275,10 +280,11 @@ export class InjectedScript {
return 'error:notselect';
const select = element as HTMLSelectElement;
const options = Array.from(select.options);
select.value = undefined as any;
const selectedOptions = [];
let remainingOptionsToSelect = optionsToSelect.slice();
for (let index = 0; index < options.length; index++) {
const option = options[index];
option.selected = optionsToSelect.some(optionToSelect => {
const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
if (optionToSelect instanceof Node)
return option === optionToSelect;
let matches = true;
@ -289,13 +295,28 @@ export class InjectedScript {
if (optionToSelect.index !== undefined)
matches = matches && optionToSelect.index === index;
return matches;
});
if (option.selected && !select.multiple)
};
if (!remainingOptionsToSelect.some(filter))
continue;
selectedOptions.push(option);
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 options.filter(option => option.selected).map(option => option.value);
return selectedOptions.map(option => option.value);
});
}
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', '');
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';
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}) => {
await page.goto(server.PREFIX + '/input/select.html');
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}) => {
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('');
});
@ -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}) => {
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([]);
});
@ -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'].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
* matching `selector`, the method throws an error.
*
* Will wait until all specified options are present in the `<select>` element.
*
* ```js
* // single selection matching the value
* 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
* matching `selector`, the method throws an error.
*
* Will wait until all specified options are present in the `<select>` element.
*
* ```js
* // single selection matching the value
* 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>`
* element, the method throws an error.
*
* Will wait until all specified options are present in the `<select>` element.
*
* ```js
* // single selection matching the value
* handle.selectOption('blue');