feat(fill): wait for the element to be enabled/writable/visible (#2435)

This commit is contained in:
Dmitry Gozman 2020-06-01 18:56:49 -07:00 committed by GitHub
parent bf67245de6
commit a644f0a881
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 100 additions and 65 deletions

View file

@ -351,7 +351,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
const deadline = this._page._timeoutSettings.computeDeadline(options); const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForSignalsCreatedBy(async () => { await this._page._frameManager.waitForSignalsCreatedBy(async () => {
const injectedResult = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value); const poll = await this._evaluateHandleInUtility(({ injected, node }, { value }) => {
return injected.waitForEnabledAndFill(node, value);
}, { value });
try {
const filledPromise = poll.evaluate(poll => poll.result);
const injectedResult = await helper.waitWithDeadline(filledPromise, 'element to be visible and enabled', deadline, 'pw:input');
const needsInput = handleInjectedResult(injectedResult); const needsInput = handleInjectedResult(injectedResult);
if (needsInput) { if (needsInput) {
if (value) if (value)
@ -359,6 +364,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
else else
await this._page.keyboard.press('Delete'); await this._page.keyboard.press('Delete');
} }
} finally {
poll.evaluate(poll => poll.cancel()).catch(e => {}).then(() => poll.dispose());
}
}, deadline, options, true); }, deadline, options, true);
} }

View file

@ -212,14 +212,15 @@ export default class InjectedScript {
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) }; return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
} }
fill(node: Node, value: string): types.InjectedScriptResult<boolean> { waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<types.InjectedScriptResult<boolean>> {
return this.poll('raf', () => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' }; return { status: 'error', error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement; const element = node as HTMLElement;
if (!element.isConnected) if (!element.isConnected)
return { status: 'notconnected' }; return { status: 'notconnected' };
if (!this.isVisible(element)) if (!this.isVisible(element))
return { status: 'error', error: 'Element is not visible' }; return false;
if (element.nodeName.toLowerCase() === 'input') { if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement; const input = element as HTMLInputElement;
const type = (input.getAttribute('type') || '').toLowerCase(); const type = (input.getAttribute('type') || '').toLowerCase();
@ -233,9 +234,9 @@ export default class InjectedScript {
return { status: 'error', error: 'Cannot type text into input[type=number].' }; return { status: 'error', error: 'Cannot type text into input[type=number].' };
} }
if (input.disabled) if (input.disabled)
return { status: 'error', error: 'Cannot fill a disabled input.' }; return false;
if (input.readOnly) if (input.readOnly)
return { status: 'error', error: 'Cannot fill a readonly input.' }; return false;
if (kDateTypes.has(type)) { if (kDateTypes.has(type)) {
value = value.trim(); value = value.trim();
input.focus(); input.focus();
@ -249,9 +250,9 @@ export default class InjectedScript {
} else if (element.nodeName.toLowerCase() === 'textarea') { } else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement; const textarea = element as HTMLTextAreaElement;
if (textarea.disabled) if (textarea.disabled)
return { status: 'error', error: 'Cannot fill a disabled textarea.' }; return false;
if (textarea.readOnly) if (textarea.readOnly)
return { status: 'error', error: 'Cannot fill a readonly textarea.' }; return false;
} else if (!element.isContentEditable) { } else if (!element.isContentEditable) {
return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' }; return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
} }
@ -259,6 +260,7 @@ export default class InjectedScript {
if (result.status === 'success') if (result.status === 'success')
return { status: 'success', value: true }; // Still need to input the value. return { status: 'success', value: true }; // Still need to input the value.
return result; return result;
});
} }
selectText(node: Node): types.InjectedScriptResult { selectText(node: Node): types.InjectedScriptResult {

View file

@ -1013,6 +1013,11 @@ describe('Page.select', function() {
}); });
describe('Page.fill', function() { describe('Page.fill', function() {
async function giveItAChanceToFill(page) {
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
}
it('should fill textarea', async({page, server}) => { it('should fill textarea', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html'); await page.goto(server.PREFIX + '/input/textarea.html');
await page.fill('textarea', 'some value'); await page.fill('textarea', 'some value');
@ -1113,27 +1118,47 @@ describe('Page.fill', function() {
await page.fill('textarea', 123).catch(e => error = e); await page.fill('textarea', 123).catch(e => error = e);
expect(error.message).toContain('Value must be string.'); expect(error.message).toContain('Value must be string.');
}); });
it('should throw on disabled and readonly elements', async({page, server}) => { it('should retry on disabled element', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html'); await page.goto(server.PREFIX + '/input/textarea.html');
await page.$eval('input', i => i.disabled = true); await page.$eval('input', i => i.disabled = true);
const disabledError = await page.fill('input', 'some value').catch(e => e); let done = false;
expect(disabledError.message).toBe('Cannot fill a disabled input.');
const promise = page.fill('input', 'some value').then(() => done = true);
await giveItAChanceToFill(page);
expect(done).toBe(false);
expect(await page.evaluate(() => result)).toBe('');
await page.$eval('input', i => i.disabled = false);
await promise;
expect(await page.evaluate(() => result)).toBe('some value');
});
it('should retry on readonly element', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html'); await page.goto(server.PREFIX + '/input/textarea.html');
await page.$eval('textarea', i => i.readOnly = true); await page.$eval('textarea', i => i.readOnly = true);
const readonlyError = await page.fill('textarea', 'some value').catch(e => e); let done = false;
expect(readonlyError.message).toBe('Cannot fill a readonly textarea.');
const promise = page.fill('textarea', 'some value').then(() => done = true);
await giveItAChanceToFill(page);
expect(done).toBe(false);
expect(await page.evaluate(() => result)).toBe('');
await page.$eval('textarea', i => i.readOnly = false);
await promise;
expect(await page.evaluate(() => result)).toBe('some value');
}); });
it('should throw on hidden and invisible elements', async({page, server}) => { it('should retry on invisible element', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html'); await page.goto(server.PREFIX + '/input/textarea.html');
await page.$eval('input', i => i.style.display = 'none'); await page.$eval('input', i => i.style.display = 'none');
const invisibleError = await page.fill('input', 'some value', { force: true }).catch(e => e); let done = false;
expect(invisibleError.message).toBe('Element is not visible');
await page.goto(server.PREFIX + '/input/textarea.html'); const promise = page.fill('input', 'some value').then(() => done = true);
await page.$eval('input', i => i.style.visibility = 'hidden'); await giveItAChanceToFill(page);
const hiddenError = await page.fill('input', 'some value', { force: true }).catch(e => e); expect(done).toBe(false);
expect(hiddenError.message).toBe('Element is not visible'); expect(await page.evaluate(() => result)).toBe('');
await page.$eval('input', i => i.style.display = 'inline');
await promise;
expect(await page.evaluate(() => result)).toBe('some value');
}); });
it('should be able to fill the body', async({page}) => { it('should be able to fill the body', async({page}) => {
await page.setContent(`<body contentEditable="true"></body>`); await page.setContent(`<body contentEditable="true"></body>`);