Skip to content

Commit

Permalink
feat(fill): wait for the element to be enabled/writable/visible (#2435)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman authored Jun 2, 2020
1 parent bf67245 commit a644f0a
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 65 deletions.
22 changes: 15 additions & 7 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
94 changes: 48 additions & 46 deletions src/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].' };
}
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.
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.' };
}
} 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 {
Expand Down
49 changes: 37 additions & 12 deletions test/page.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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>`);
Expand Down

0 comments on commit a644f0a

Please sign in to comment.