Skip to content

Commit 615954b

Browse files
authored
fix(dom): make selectOption wait for options (#5036)
1 parent 19acf99 commit 615954b

File tree

8 files changed

+156
-45
lines changed

8 files changed

+156
-45
lines changed

docs/src/api/class-elementhandle.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,8 @@ Returns the array of option values that have been successfully selected.
545545
Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>`
546546
element, the method throws an error.
547547

548+
Will wait until all specified options are present in the `<select>` element.
549+
548550
```js
549551
// single selection matching the value
550552
handle.selectOption('blue');

docs/src/api/class-frame.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,8 @@ Returns the array of option values that have been successfully selected.
842842
Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
843843
matching [`param: selector`], the method throws an error.
844844

845+
Will wait until all specified options are present in the `<select>` element.
846+
845847
```js
846848
// single selection matching the value
847849
frame.selectOption('select#colors', 'blue');

docs/src/api/class-page.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,6 +1869,8 @@ Returns the array of option values that have been successfully selected.
18691869
Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
18701870
matching [`param: selector`], the method throws an error.
18711871

1872+
Will wait until all specified options are present in the `<select>` element.
1873+
18721874
```js
18731875
// single selection matching the value
18741876
page.selectOption('select#colors', 'blue');

src/server/dom.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
446446
const selectOptions = [...elements, ...values];
447447
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
448448
progress.throwIfAborted(); // Avoid action that has side-effects.
449-
const value = await this._evaluateInUtility(([injected, node, selectOptions]) => injected.selectOptions(node, selectOptions), selectOptions);
449+
progress.log(' selecting specified option(s)');
450+
const poll = await this._evaluateHandleInUtility(([injected, node, selectOptions]) => injected.waitForOptionsAndSelect(node, selectOptions), selectOptions);
451+
const pollHandler = new InjectedScriptPollHandler(progress, poll);
452+
const result = throwFatalDOMError(await pollHandler.finish());
450453
await this._page._doSlowMo();
451-
return throwFatalDOMError(value);
454+
return result;
452455
});
453456
}
454457

@@ -817,7 +820,7 @@ export class InjectedScriptPollHandler<T> {
817820

818821
async finishHandle(): Promise<js.SmartHandle<T>> {
819822
try {
820-
const result = await this._poll!.evaluateHandle(poll => poll.result);
823+
const result = await this._poll!.evaluateHandle(poll => poll.run());
821824
await this._finishInternal();
822825
return result;
823826
} finally {
@@ -827,7 +830,7 @@ export class InjectedScriptPollHandler<T> {
827830

828831
async finish(): Promise<T> {
829832
try {
830-
const result = await this._poll!.evaluate(poll => poll.result);
833+
const result = await this._poll!.evaluate(poll => poll.run());
831834
await this._finishInternal();
832835
return result;
833836
} finally {

src/server/injected/injectedScript.ts

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export type InjectedScriptProgress = {
3131
};
3232

3333
export type InjectedScriptPoll<T> = {
34-
result: Promise<T>,
34+
run: () => Promise<T>,
3535
// Takes more logs, waiting until at least one message is available.
3636
takeNextLogs: () => Promise<string[]>,
3737
// Takes all current logs without waiting.
@@ -242,19 +242,23 @@ export class InjectedScript {
242242
},
243243
};
244244

245-
const result = task(progress);
245+
const run = () => {
246+
const result = task(progress);
246247

247-
// After the task has finished, there should be no more logs.
248-
// Release any pending `takeNextLogs` call, and do not block any future ones.
249-
// This prevents non-finished protocol evaluation calls and memory leaks.
250-
result.finally(() => {
251-
taskFinished = true;
252-
logReady();
253-
});
248+
// After the task has finished, there should be no more logs.
249+
// Release any pending `takeNextLogs` call, and do not block any future ones.
250+
// This prevents non-finished protocol evaluation calls and memory leaks.
251+
result.finally(() => {
252+
taskFinished = true;
253+
logReady();
254+
});
255+
256+
return result;
257+
};
254258

255259
return {
256260
takeNextLogs,
257-
result,
261+
run,
258262
cancel: () => { progress.aborted = true; },
259263
takeLastLogs: () => unsentLogs,
260264
};
@@ -267,35 +271,52 @@ export class InjectedScript {
267271
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
268272
}
269273

270-
selectOptions(node: Node, optionsToSelect: (Node | { value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | FatalDOMError {
271-
const element = this.findLabelTarget(node as Element);
272-
if (!element || !element.isConnected)
273-
return 'error:notconnected';
274-
if (element.nodeName.toLowerCase() !== 'select')
275-
return 'error:notselect';
276-
const select = element as HTMLSelectElement;
277-
const options = Array.from(select.options);
278-
select.value = undefined as any;
279-
for (let index = 0; index < options.length; index++) {
280-
const option = options[index];
281-
option.selected = optionsToSelect.some(optionToSelect => {
282-
if (optionToSelect instanceof Node)
283-
return option === optionToSelect;
284-
let matches = true;
285-
if (optionToSelect.value !== undefined)
286-
matches = matches && optionToSelect.value === option.value;
287-
if (optionToSelect.label !== undefined)
288-
matches = matches && optionToSelect.label === option.label;
289-
if (optionToSelect.index !== undefined)
290-
matches = matches && optionToSelect.index === index;
291-
return matches;
292-
});
293-
if (option.selected && !select.multiple)
294-
break;
295-
}
296-
select.dispatchEvent(new Event('input', { 'bubbles': true }));
297-
select.dispatchEvent(new Event('change', { 'bubbles': true }));
298-
return options.filter(option => option.selected).map(option => option.value);
274+
waitForOptionsAndSelect(node: Node, optionsToSelect: (Node | { value?: string, label?: string, index?: number })[]): InjectedScriptPoll<string[] | 'error:notconnected' | FatalDOMError> {
275+
return this.pollRaf((progress, continuePolling) => {
276+
const element = this.findLabelTarget(node as Element);
277+
if (!element || !element.isConnected)
278+
return 'error:notconnected';
279+
if (element.nodeName.toLowerCase() !== 'select')
280+
return 'error:notselect';
281+
const select = element as HTMLSelectElement;
282+
const options = Array.from(select.options);
283+
const selectedOptions = [];
284+
let remainingOptionsToSelect = optionsToSelect.slice();
285+
for (let index = 0; index < options.length; index++) {
286+
const option = options[index];
287+
const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
288+
if (optionToSelect instanceof Node)
289+
return option === optionToSelect;
290+
let matches = true;
291+
if (optionToSelect.value !== undefined)
292+
matches = matches && optionToSelect.value === option.value;
293+
if (optionToSelect.label !== undefined)
294+
matches = matches && optionToSelect.label === option.label;
295+
if (optionToSelect.index !== undefined)
296+
matches = matches && optionToSelect.index === index;
297+
return matches;
298+
};
299+
if (!remainingOptionsToSelect.some(filter))
300+
continue;
301+
selectedOptions.push(option);
302+
if (select.multiple) {
303+
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));
304+
} else {
305+
remainingOptionsToSelect = [];
306+
break;
307+
}
308+
}
309+
if (remainingOptionsToSelect.length) {
310+
progress.logRepeating(' did not find some options - waiting... ');
311+
return continuePolling;
312+
}
313+
select.value = undefined as any;
314+
selectedOptions.forEach(option => option.selected = true);
315+
progress.log(' selected specified option(s)');
316+
select.dispatchEvent(new Event('input', { 'bubbles': true }));
317+
select.dispatchEvent(new Event('change', { 'bubbles': true }));
318+
return selectedOptions.map(option => option.value);
319+
});
299320
}
300321

301322
waitForEnabledAndFill(node: Node, value: string): InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'needsinput' | 'done'> {

test/page-fill.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,14 @@ it('should be able to clear', async ({page, server}) => {
288288
await page.fill('input', '');
289289
expect(await page.evaluate(() => window['result'])).toBe('');
290290
});
291+
292+
it('should not throw when fill causes navigation', async ({page, server}) => {
293+
await page.goto(server.PREFIX + '/input/textarea.html');
294+
await page.setContent('<input type=date>');
295+
await page.$eval('input', select => select.addEventListener('input', () => window.location.href = '/empty.html'));
296+
await Promise.all([
297+
page.fill('input', '2020-03-02'),
298+
page.waitForNavigation(),
299+
]);
300+
expect(page.url()).toContain('empty.html');
301+
});

test/page-select-option.spec.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717

1818
import { it, expect } from './fixtures';
1919

20+
21+
async function giveItAChanceToResolve(page) {
22+
for (let i = 0; i < 5; i++)
23+
await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
24+
}
25+
2026
it('should select single option', async ({page, server}) => {
2127
await page.goto(server.PREFIX + '/input/select.html');
2228
await page.selectOption('select', 'blue');
@@ -61,7 +67,12 @@ it('should select single option by multiple attributes', async ({page, server})
6167

6268
it('should not select single option when some attributes do not match', async ({page, server}) => {
6369
await page.goto(server.PREFIX + '/input/select.html');
64-
await page.selectOption('select', { value: 'green', label: 'Brown' });
70+
await page.$eval('select', s => s.value = undefined);
71+
try {
72+
await page.selectOption('select', { value: 'green', label: 'Brown' }, {timeout: 300});
73+
} catch (e) {
74+
expect(e.message).toContain('Timeout');
75+
}
6576
expect(await page.evaluate(() => document.querySelector('select').value)).toEqual('');
6677
});
6778

@@ -134,7 +145,7 @@ it('should throw when element is not a <select>', async ({page, server}) => {
134145

135146
it('should return [] on no matched values', async ({page, server}) => {
136147
await page.goto(server.PREFIX + '/input/select.html');
137-
const result = await page.selectOption('select', ['42','abc']);
148+
const result = await page.selectOption('select', []);
138149
expect(result).toEqual([]);
139150
});
140151

@@ -237,3 +248,56 @@ it('should work when re-defining top-level Event class', async ({page, server})
237248
expect(await page.evaluate(() => window['result'].onInput)).toEqual(['blue']);
238249
expect(await page.evaluate(() => window['result'].onChange)).toEqual(['blue']);
239250
});
251+
252+
it('should wait for option to be present',async ({page, server}) => {
253+
await page.goto(server.PREFIX + '/input/select.html');
254+
const selectPromise = page.selectOption('select', 'scarlet');
255+
let didSelect = false;
256+
selectPromise.then(() => didSelect = true);
257+
await giveItAChanceToResolve(page);
258+
expect(didSelect).toBe(false);
259+
await page.$eval('select', select => {
260+
const option = document.createElement('option');
261+
option.value = 'scarlet';
262+
option.textContent = 'Scarlet';
263+
select.appendChild(option);
264+
});
265+
const items = await selectPromise;
266+
expect(items).toStrictEqual(['scarlet']);
267+
});
268+
269+
it('should wait for option index to be present',async ({page, server}) => {
270+
await page.goto(server.PREFIX + '/input/select.html');
271+
const len = await page.$eval('select', select => select.options.length);
272+
const selectPromise = page.selectOption('select', {index: len});
273+
let didSelect = false;
274+
selectPromise.then(() => didSelect = true);
275+
await giveItAChanceToResolve(page);
276+
expect(didSelect).toBe(false);
277+
await page.$eval('select', select => {
278+
const option = document.createElement('option');
279+
option.value = 'scarlet';
280+
option.textContent = 'Scarlet';
281+
select.appendChild(option);
282+
});
283+
const items = await selectPromise;
284+
expect(items).toStrictEqual(['scarlet']);
285+
});
286+
287+
it('should wait for multiple options to be present',async ({page, server}) => {
288+
await page.goto(server.PREFIX + '/input/select.html');
289+
await page.evaluate(() => window['makeMultiple']());
290+
const selectPromise = page.selectOption('select', ['green', 'scarlet']);
291+
let didSelect = false;
292+
selectPromise.then(() => didSelect = true);
293+
await giveItAChanceToResolve(page);
294+
expect(didSelect).toBe(false);
295+
await page.$eval('select', select => {
296+
const option = document.createElement('option');
297+
option.value = 'scarlet';
298+
option.textContent = 'Scarlet';
299+
select.appendChild(option);
300+
});
301+
const items = await selectPromise;
302+
expect(items).toStrictEqual(['green', 'scarlet']);
303+
});

types/types.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2417,6 +2417,8 @@ export interface Page {
24172417
* Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
24182418
* matching `selector`, the method throws an error.
24192419
*
2420+
* Will wait until all specified options are present in the `<select>` element.
2421+
*
24202422
* ```js
24212423
* // single selection matching the value
24222424
* page.selectOption('select#colors', 'blue');
@@ -4090,6 +4092,8 @@ export interface Frame {
40904092
* Triggers a `change` and `input` event once all the provided options have been selected. If there's no `<select>` element
40914093
* matching `selector`, the method throws an error.
40924094
*
4095+
* Will wait until all specified options are present in the `<select>` element.
4096+
*
40934097
* ```js
40944098
* // single selection matching the value
40954099
* frame.selectOption('select#colors', 'blue');
@@ -5940,6 +5944,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
59405944
* Triggers a `change` and `input` event once all the provided options have been selected. If element is not a `<select>`
59415945
* element, the method throws an error.
59425946
*
5947+
* Will wait until all specified options are present in the `<select>` element.
5948+
*
59435949
* ```js
59445950
* // single selection matching the value
59455951
* handle.selectOption('blue');

0 commit comments

Comments
 (0)