fix(dom): make selectText and scrollIntoViewIfNeeded wait for visible (#2628)

All other methods wait for the element to be visible, so we should make
them behave similarly.
This commit is contained in:
Dmitry Gozman 2020-06-23 13:02:31 -07:00 committed by GitHub
parent a8eaee3173
commit d0a6e1a64e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 153 additions and 70 deletions

View file

@ -10,9 +10,8 @@ Some actions like `page.click()` support `{force: true}` option that disable non
| ------ | ------- | | ------ | ------- |
| `check()`<br>`click()`<br>`dblclick()`<br>`hover()`<br>`uncheck()` | [Visible]<br>[Stable]<br>[Enabled]<br>[Receiving Events]<br>[Attached]† | | `check()`<br>`click()`<br>`dblclick()`<br>`hover()`<br>`uncheck()` | [Visible]<br>[Stable]<br>[Enabled]<br>[Receiving Events]<br>[Attached]† |
| `fill()` | [Visible]<br>[Enabled]<br>[Editable]<br>[Attached]† | | `fill()` | [Visible]<br>[Enabled]<br>[Editable]<br>[Attached]† |
| `focus()`<br>`press()`<br>`setInputFiles()`<br>`selectOption()`<br>`type()` | [Attached]† | | `dispatchEvent()`<br>`focus()`<br>`press()`<br>`setInputFiles()`<br>`selectOption()`<br>`type()` | [Attached]† |
| `selectText()` | [Visible] | | `selectText()`<br>`scrollIntoViewIfNeeded()` | [Visible] |
| `dispatchEvent()`<br>`scrollIntoViewIfNeeded()` | -- |
| `getAttribute()`<br>`innerText()`<br>`innerHTML()`<br>`textContent()` | [Attached]† | | `getAttribute()`<br>`innerText()`<br>`innerHTML()`<br>`textContent()` | [Attached]† |
† [Attached] check is only performed during selector-based actions. † [Attached] check is only performed during selector-based actions.
@ -41,7 +40,7 @@ Element is considered receiving pointer events when it is the hit target of the
Element is considered attached when it is [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. Element is considered attached when it is [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot.
Attached check is performed during a selector-based action, like `page.click(selector, options)` as opposite to `elementHandle.click(options)`. Attached check is performed during a selector-based action, like `page.click(selector, options)` as opposite to `elementHandle.click(options)`. First, Playwright waits for an element matching `selector` to be attached to the DOM, and then checks that element is still attached before performing the action.
For example, consider a scenario where Playwright will click `Sign Up` button regardless of when the `page.click()` call was made: For example, consider a scenario where Playwright will click `Sign Up` button regardless of when the `page.click()` call was made:
- page is checking that user name is unique and `Sign Up` button is disabled; - page is checking that user name is unique and `Sign Up` button is disabled;

View file

@ -2627,9 +2627,9 @@ ElementHandle instances can be used as an argument in [`page.$eval()`](#pageeval
- [elementHandle.ownerFrame()](#elementhandleownerframe) - [elementHandle.ownerFrame()](#elementhandleownerframe)
- [elementHandle.press(key[, options])](#elementhandlepresskey-options) - [elementHandle.press(key[, options])](#elementhandlepresskey-options)
- [elementHandle.screenshot([options])](#elementhandlescreenshotoptions) - [elementHandle.screenshot([options])](#elementhandlescreenshotoptions)
- [elementHandle.scrollIntoViewIfNeeded()](#elementhandlescrollintoviewifneeded) - [elementHandle.scrollIntoViewIfNeeded([options])](#elementhandlescrollintoviewifneededoptions)
- [elementHandle.selectOption(values[, options])](#elementhandleselectoptionvalues-options) - [elementHandle.selectOption(values[, options])](#elementhandleselectoptionvalues-options)
- [elementHandle.selectText()](#elementhandleselecttext) - [elementHandle.selectText([options])](#elementhandleselecttextoptions)
- [elementHandle.setInputFiles(files[, options])](#elementhandlesetinputfilesfiles-options) - [elementHandle.setInputFiles(files[, options])](#elementhandlesetinputfilesfiles-options)
- [elementHandle.textContent()](#elementhandletextcontent) - [elementHandle.textContent()](#elementhandletextcontent)
- [elementHandle.toString()](#elementhandletostring) - [elementHandle.toString()](#elementhandletostring)
@ -2860,15 +2860,15 @@ Shortcuts such as `key: "Control+o"` or `key: "Control+Shift+T"` are supported a
This method scrolls element into view if needed before taking a screenshot. If the element is detached from DOM, the method throws an error. This method scrolls element into view if needed before taking a screenshot. If the element is detached from DOM, the method throws an error.
#### elementHandle.scrollIntoViewIfNeeded() #### elementHandle.scrollIntoViewIfNeeded([options])
- returns: <[Promise]> Resolves after the element has been scrolled into view. - `options` <[Object]>
- `timeout` <[number]> Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods.
- returns: <[Promise]>
This method tries to scroll element into view, unless it is completely visible as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)'s ```ratio```. This method waits for [actionability](./actionability.md) checks, then tries to scroll element into view, unless it is completely visible as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)'s ```ratio```.
Throws when ```elementHandle``` does not point to an element [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. Throws when ```elementHandle``` does not point to an element [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot.
> **NOTE** If javascript is disabled, element is scrolled into view even when already completely visible.
#### elementHandle.selectOption(values[, options]) #### elementHandle.selectOption(values[, options])
- `values` <null|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> Options to select. If the `<select>` has the `multiple` attribute, all matching options are selected, otherwise only the first option matching one of the passed options is selected. String values are equivalent to `{value:'string'}`. Option is considered matching if all specified properties match. - `values` <null|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> Options to select. If the `<select>` has the `multiple` attribute, all matching options are selected, otherwise only the first option matching one of the passed options is selected. String values are equivalent to `{value:'string'}`. Option is considered matching if all specified properties match.
- `value` <[string]> Matches by `option.value`. - `value` <[string]> Matches by `option.value`.
@ -2896,10 +2896,12 @@ handle.selectOption('red', 'green', 'blue');
handle.selectOption({ value: 'blue' }, { index: 2 }, 'red'); handle.selectOption({ value: 'blue' }, { index: 2 }, 'red');
``` ```
#### elementHandle.selectText() #### elementHandle.selectText([options])
- returns: <[Promise]> Promise which resolves when the element is successfully selected. - `options` <[Object]>
- `timeout` <[number]> Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods.
- returns: <[Promise]>
This method focuses the element and selects all its text content. This method waits for [actionability](./actionability.md) checks, then focuses the element and selects all its text content.
#### elementHandle.setInputFiles(files[, options]) #### elementHandle.setInputFiles(files[, options])
- `files` <[string]|[Array]<[string]>|[Object]|[Array]<[Object]>> - `files` <[string]|[Array]<[string]>|[Object]|[Array]<[Object]>>

View file

@ -212,11 +212,29 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect); return await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
} }
async scrollIntoViewIfNeeded() { async scrollIntoViewIfNeeded(options: types.TimeoutOptions = {}) {
const result = await this._scrollRectIntoViewIfNeeded(); return this._runAbortableTask(async progress => {
if (result === 'notvisible') while (progress.isRunning()) {
throw new Error('Element is not visible'); const waited = await this._waitForVisible(progress);
throwIfNotConnected(result); throwIfNotConnected(waited);
progress.throwIfAborted(); // Avoid action that has side-effects.
const result = await this._scrollRectIntoViewIfNeeded();
throwIfNotConnected(result);
if (result === 'notvisible')
continue;
assert(result === 'done');
return;
}
}, this._page._timeoutSettings.timeout(options), 'scrollIntoViewIfNeeded');
}
private async _waitForVisible(progress: Progress): Promise<'notconnected' | 'done'> {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeVisible(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
return throwIfError(await pollHandler.finish());
} }
private async _clickablePoint(): Promise<types.Point | 'notvisible' | 'notinviewport'> { private async _clickablePoint(): Promise<types.Point | 'notvisible' | 'notinviewport'> {
@ -455,12 +473,16 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}, 'input'); }, 'input');
} }
async selectText(): Promise<void> { async selectText(options: types.TimeoutOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => { return this._runAbortableTask(async progress => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
const selected = throwIfError(await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {})); const poll = await this._evaluateHandleInUtility(([injected, node]) => {
throwIfNotConnected(selected); return injected.waitForVisibleAndSelectText(node);
}, 0, 'selectText'); }, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwIfError(await pollHandler.finish());
throwIfNotConnected(result);
}, this._page._timeoutSettings.timeout(options), 'selectText');
} }
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) { async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) {

View file

@ -235,7 +235,7 @@ export default class InjectedScript {
return this.poll('raf', progress => { return this.poll('raf', progress => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return { error: 'Node is not of type HTMLElement' }; return { error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement; const element = node as Element;
if (!element.isConnected) if (!element.isConnected)
return { value: 'notconnected' }; return { value: 'notconnected' };
if (!this.isVisible(element)) { if (!this.isVisible(element)) {
@ -282,48 +282,74 @@ export default class InjectedScript {
progress.logRepeating(' element is readonly - waiting...'); progress.logRepeating(' element is readonly - waiting...');
return false; return false;
} }
} else if (!element.isContentEditable) { } else if (!(element as HTMLElement).isContentEditable) {
return { error: 'Element is not an <input>, <textarea> or [contenteditable] element.' }; return { error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
} }
const result = this.selectText(node); const result = this._selectText(element);
if (result.error) if (result === 'notvisible') {
return { error: result.error }; progress.logRepeating(' element is not visible - waiting...');
if (result.value === 'notconnected') return false;
return { value: 'notconnected' }; }
return { value: 'needsinput' }; // Still need to input the value. return { value: 'needsinput' }; // Still need to input the value.
}); });
} }
selectText(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> { waitForVisibleAndSelectText(node: Node): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
if (node.nodeType !== Node.ELEMENT_NODE) return this.poll('raf', progress => {
return { error: 'Node is not of type HTMLElement' }; if (node.nodeType !== Node.ELEMENT_NODE)
if (!node.isConnected) return { error: 'Node is not of type HTMLElement' };
return { value: 'notconnected' }; if (!node.isConnected)
const element = node as HTMLElement; return { value: 'notconnected' };
if (!this.isVisible(element)) const element = node as Element;
return { error: 'Element is not visible' }; if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
}
const result = this._selectText(element);
if (result === 'notvisible') {
progress.logRepeating(' element is not visible - waiting...');
return false;
}
return { value: result };
});
}
private _selectText(element: Element): 'notvisible' | 'done' {
if (element.nodeName.toLowerCase() === 'input') { if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement; const input = element as HTMLInputElement;
input.select(); input.select();
input.focus(); input.focus();
return { value: 'done' }; return 'done';
} }
if (element.nodeName.toLowerCase() === 'textarea') { if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement; const textarea = element as HTMLTextAreaElement;
textarea.selectionStart = 0; textarea.selectionStart = 0;
textarea.selectionEnd = textarea.value.length; textarea.selectionEnd = textarea.value.length;
textarea.focus(); textarea.focus();
return { value: 'done' }; return 'done';
} }
const range = element.ownerDocument.createRange(); const range = element.ownerDocument.createRange();
range.selectNodeContents(element); range.selectNodeContents(element);
const selection = element.ownerDocument.defaultView!.getSelection(); const selection = element.ownerDocument.defaultView!.getSelection();
if (!selection) if (!selection)
return { error: 'Element belongs to invisible iframe.' }; return 'notvisible';
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
element.focus(); (element as HTMLElement | SVGElement).focus();
return { value: 'done' }; return 'done';
}
waitForNodeVisible(node: Node): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
return this.poll('raf', progress => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return { value: 'notconnected' };
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
}
return { value: 'done' };
});
} }
focusNode(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> { focusNode(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> {

View file

@ -95,7 +95,10 @@ export class Screenshotter {
return this._queue.postTask(async () => { return this._queue.postTask(async () => {
const { viewportSize, originalViewportSize } = await this._originalViewportSize(); const { viewportSize, originalViewportSize } = await this._originalViewportSize();
await handle.scrollIntoViewIfNeeded(); // TODO: make screenshot wait visible, migrate to progress.
const scrolled = await handle._scrollRectIntoViewIfNeeded();
if (scrolled === 'notconnected')
throw new Error('Element is not attached to the DOM');
let boundingBox = await handle.boundingBox(); let boundingBox = await handle.boundingBox();
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); assert(boundingBox, 'Node is either not visible or not an HTMLElement');
assert(boundingBox.width !== 0, 'Node has 0 width.'); assert(boundingBox.width !== 0, 'Node has 0 width.');
@ -110,7 +113,9 @@ export class Screenshotter {
}); });
await this._page.setViewportSize(overridenViewportSize); await this._page.setViewportSize(overridenViewportSize);
await handle.scrollIntoViewIfNeeded(); const scrolled = await handle._scrollRectIntoViewIfNeeded();
if (scrolled === 'notconnected')
throw new Error('Element is not attached to the DOM');
boundingBox = await handle.boundingBox(); boundingBox = await handle.boundingBox();
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); assert(boundingBox, 'Node is either not visible or not an HTMLElement');
assert(boundingBox.width !== 0, 'Node has 0 width.'); assert(boundingBox.width !== 0, 'Node has 0 width.');

View file

@ -330,33 +330,43 @@ describe('ElementHandle.scrollIntoViewIfNeeded', function() {
const error = await div.scrollIntoViewIfNeeded().catch(e => e); const error = await div.scrollIntoViewIfNeeded().catch(e => e);
expect(error.message).toContain('Element is not attached to the DOM'); expect(error.message).toContain('Element is not attached to the DOM');
}); });
it('should throw for display:none element', async({page, server}) => {
async function testWaiting(page, after) {
const div = await page.$('div');
let done = false;
const promise = div.scrollIntoViewIfNeeded().then(() => done = true);
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
expect(done).toBe(false);
await div.evaluate(after);
await promise;
expect(done).toBe(true);
}
it('should wait for display:none to become visible', async({page, server}) => {
await page.setContent('<div style="display:none">Hello</div>');
await testWaiting(page, div => div.style.display = 'block');
});
it('should wait for display:contents to become visible', async({page, server}) => {
await page.setContent('<div style="display:contents">Hello</div>');
await testWaiting(page, div => div.style.display = 'block');
});
it('should wait for visibility:hidden to become visible', async({page, server}) => {
await page.setContent('<div style="visibility:hidden">Hello</div>');
await testWaiting(page, div => div.style.visibility = 'visible');
});
it('should wait for zero-sized element to become visible', async({page, server}) => {
await page.setContent('<div style="height:0">Hello</div>');
await testWaiting(page, div => div.style.height = '100px');
});
it('should wait for nested display:none to become visible', async({page, server}) => {
await page.setContent('<span style="display:none"><div>Hello</div></span>');
await testWaiting(page, div => div.parentElement.style.display = 'block');
});
it('should timeout waiting for visible', async({page, server}) => {
await page.setContent('<div style="display:none">Hello</div>'); await page.setContent('<div style="display:none">Hello</div>');
const div = await page.$('div'); const div = await page.$('div');
const error = await div.scrollIntoViewIfNeeded().catch(e => e); const error = await div.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(e => e);
expect(error.message).toContain('Element is not visible'); expect(error.message).toContain('element is not visible');
});
it('should throw for nested display:none element', async({page, server}) => {
await page.setContent('<span style="display:none"><div>Hello</div></span>');
const div = await page.$('div');
const error = await div.scrollIntoViewIfNeeded().catch(e => e);
expect(error.message).toContain('Element is not visible');
});
it('should throw for display:contents element', async({page, server}) => {
await page.setContent('<div style="display:contents">Hello</div>');
const div = await page.$('div');
const error = await div.scrollIntoViewIfNeeded().catch(e => e);
expect(error.message).toContain('Element is not visible');
});
it('should scroll a zero-sized element', async({page, server}) => {
await page.setContent('<br>');
const br = await page.$('br');
await br.scrollIntoViewIfNeeded();
});
it('should scroll a visibility:hidden element', async({page, server}) => {
await page.setContent('<div style="visibility:hidden">Hello</div>');
const div = await page.$('div');
await div.scrollIntoViewIfNeeded();
}); });
}); });
@ -407,6 +417,25 @@ describe('ElementHandle.selectText', function() {
await div.selectText(); await div.selectText();
expect(await page.evaluate(() => window.getSelection().toString())).toBe('Plain div'); expect(await page.evaluate(() => window.getSelection().toString())).toBe('Plain div');
}); });
it('should timeout waiting for invisible element', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
const textarea = await page.$('textarea');
await textarea.evaluate(e => e.style.display = 'none');
const error = await textarea.selectText({ timeout: 3000 }).catch(e => e);
expect(error.message).toContain('element is not visible');
});
it('should wait for visible', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
const textarea = await page.$('textarea');
await textarea.evaluate(textarea => textarea.value = 'some value');
await textarea.evaluate(e => e.style.display = 'none');
let done = false;
const promise = textarea.selectText({ timeout: 3000 }).then(() => done = true);
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
expect(done).toBe(false);
await textarea.evaluate(e => e.style.display = 'block');
await promise;
});
}); });