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,13 +351,21 @@ 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) + '"');
const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
const injectedResult = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
const needsInput = handleInjectedResult(injectedResult);
if (needsInput) {
if (value)
await this._page.keyboard.insertText(value);
else
await this._page.keyboard.press('Delete');
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);
if (needsInput) {
if (value)
await this._page.keyboard.insertText(value);
else
await this._page.keyboard.press('Delete');
}
} finally {
poll.evaluate(poll => poll.cancel()).catch(e => {}).then(() => poll.dispose());
}
}, deadline, options, true);
}

View file

@ -212,53 +212,55 @@ export default class InjectedScript {
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
}
fill(node: Node, value: string): types.InjectedScriptResult<boolean> {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement;
if (!element.isConnected)
return { status: 'notconnected' };
if (!this.isVisible(element))
return { status: 'error', error: 'Element is not visible' };
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = (input.getAttribute('type') || '').toLowerCase();
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local']);
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type))
return { status: 'error', error: 'Cannot fill input of type "' + type + '".' };
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return { status: 'error', error: 'Cannot type text into input[type=number].' };
waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<types.InjectedScriptResult<boolean>> {
return this.poll('raf', () => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement;
if (!element.isConnected)
return { status: 'notconnected' };
if (!this.isVisible(element))
return false;
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = (input.getAttribute('type') || '').toLowerCase();
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local']);
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type))
return { status: 'error', error: 'Cannot fill input of type "' + type + '".' };
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return { status: 'error', error: 'Cannot type text into input[type=number].' };
}
if (input.disabled)
return false;
if (input.readOnly)
return false;
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return { status: 'error', error: `Malformed ${type} "${value}"` };
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { status: 'success', value: false }; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
if (textarea.disabled)
return false;
if (textarea.readOnly)
return false;
} else if (!element.isContentEditable) {
return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
}
if (input.disabled)
return { status: 'error', error: 'Cannot fill a disabled input.' };
if (input.readOnly)
return { status: 'error', error: 'Cannot fill a readonly input.' };
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return { status: 'error', error: `Malformed ${type} "${value}"` };
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { status: 'success', value: false }; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
if (textarea.disabled)
return { status: 'error', error: 'Cannot fill a disabled textarea.' };
if (textarea.readOnly)
return { status: 'error', error: 'Cannot fill a readonly textarea.' };
} else if (!element.isContentEditable) {
return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
}
const result = this.selectText(node);
if (result.status === 'success')
return { status: 'success', value: true }; // Still need to input the value.
return result;
const result = this.selectText(node);
if (result.status === 'success')
return { status: 'success', value: true }; // Still need to input the value.
return result;
});
}
selectText(node: Node): types.InjectedScriptResult {

View file

@ -1013,6 +1013,11 @@ describe('Page.select', 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}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
await page.fill('textarea', 'some value');
@ -1113,27 +1118,47 @@ describe('Page.fill', function() {
await page.fill('textarea', 123).catch(e => error = e);
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.$eval('input', i => i.disabled = true);
const disabledError = await page.fill('input', 'some value').catch(e => e);
expect(disabledError.message).toBe('Cannot fill a disabled input.');
let done = false;
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.$eval('textarea', i => i.readOnly = true);
const readonlyError = await page.fill('textarea', 'some value').catch(e => e);
expect(readonlyError.message).toBe('Cannot fill a readonly textarea.');
let done = false;
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.$eval('input', i => i.style.display = 'none');
const invisibleError = await page.fill('input', 'some value', { force: true }).catch(e => e);
expect(invisibleError.message).toBe('Element is not visible');
let done = false;
await page.goto(server.PREFIX + '/input/textarea.html');
await page.$eval('input', i => i.style.visibility = 'hidden');
const hiddenError = await page.fill('input', 'some value', { force: true }).catch(e => e);
expect(hiddenError.message).toBe('Element is not visible');
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.style.display = 'inline');
await promise;
expect(await page.evaluate(() => result)).toBe('some value');
});
it('should be able to fill the body', async({page}) => {
await page.setContent(`<body contentEditable="true"></body>`);