feat(setInputFiles): support label retargeting (#7364)

This way `page.setInputFiles('label')` works, similarly to other input actions.
This commit is contained in:
Dmitry Gozman 2021-06-28 14:18:01 -07:00 committed by GitHub
parent 014c224db6
commit 530523cb67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 29 additions and 17 deletions

View File

@ -24,6 +24,7 @@ export type FatalDOMError =
'error:notinput' |
'error:notinputvalue' |
'error:notselect' |
'error:notcheckbox';
'error:notcheckbox' |
'error:notmultiplefileinput';
export type RetargetableDOMError = 'error:notconnected';

View File

@ -16,7 +16,6 @@
import * as channels from '../protocol/channels';
import * as frames from './frames';
import { assert } from '../utils/utils';
import type { ElementStateWithoutStable, InjectedScript, InjectedScriptPoll } from './injected/injectedScript';
import * as injectedScriptSource from '../generated/injectedScriptSource';
import * as js from './javascript';
@ -552,19 +551,22 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (!payload.mimeType)
payload.mimeType = mime.getType(payload.name) || 'application/octet-stream';
}
const multiple = throwFatalDOMError(await this.evaluateInUtility(([injected, node]): 'error:notinput' | 'error:notconnected' | boolean => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
const retargeted = await this.evaluateHandleInUtility(([injected, node, multiple]): FatalDOMError | 'error:notconnected' | Element => {
const element = injected.retarget(node, 'follow-label');
if (!element)
return 'error:notconnected';
if (element.tagName !== 'INPUT')
return 'error:notinput';
const input = node as Node as HTMLInputElement;
return input.multiple;
}, {}));
if (typeof multiple === 'string')
return multiple;
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
if (multiple && !(element as HTMLInputElement).multiple)
return 'error:notmultiplefileinput';
return element;
}, files.length > 1);
if (!retargeted._objectId)
return throwFatalDOMError(retargeted.rawValue() as FatalDOMError | 'error:notconnected');
await progress.beforeInputAction(this);
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, files as types.FilePayload[]);
await this._page._delegate.setInputFiles(retargeted as ElementHandle<HTMLInputElement>, files as types.FilePayload[]);
});
await this._page._doSlowMo();
return 'done';
@ -887,6 +889,8 @@ export function throwFatalDOMError<T>(result: T | FatalDOMError): T {
throw new Error('Element is not a <select> element.');
if (result === 'error:notcheckbox')
throw new Error('Not a checkbox or radio button');
if (result === 'error:notmultiplefileinput')
throw new Error('Non-multiple file input can only accept single file');
return result;
}

View File

@ -326,7 +326,7 @@ export class InjectedScript {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
}
private _retarget(node: Node, behavior: 'follow-label' | 'no-follow-label'): Element | null {
retarget(node: Node, behavior: 'follow-label' | 'no-follow-label'): Element | null {
let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!element)
return null;
@ -369,7 +369,7 @@ export class InjectedScript {
continue;
}
const element = this._retarget(node, 'no-follow-label');
const element = this.retarget(node, 'no-follow-label');
if (!element)
return 'error:notconnected';
@ -411,7 +411,7 @@ export class InjectedScript {
}
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
const element = this._retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
if (!element || !element.isConnected) {
if (state === 'hidden')
return true;
@ -447,7 +447,7 @@ export class InjectedScript {
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol {
const element = this._retarget(node, 'follow-label');
const element = this.retarget(node, 'follow-label');
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select')
@ -493,7 +493,7 @@ export class InjectedScript {
}
fill(value: string, node: Node, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' {
const element = this._retarget(node, 'follow-label');
const element = this.retarget(node, 'follow-label');
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') {
@ -530,7 +530,7 @@ export class InjectedScript {
}
selectText(node: Node): 'error:notconnected' | 'done' {
const element = this._retarget(node, 'follow-label');
const element = this.retarget(node, 'follow-label');
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') {

View File

@ -42,6 +42,13 @@ it('should work', async ({page, asset}) => {
expect(await page.$eval('input', input => input.files[0].name)).toBe('file-to-upload.txt');
});
it('should work with label', async ({page, asset}) => {
await page.setContent(`<label for=target>Choose a file</label><input id=target type=file>`);
await page.setInputFiles('text=Choose a file', asset('file-to-upload.txt'));
expect(await page.$eval('input', input => input.files.length)).toBe(1);
expect(await page.$eval('input', input => input.files[0].name)).toBe('file-to-upload.txt');
});
it('should set from memory', async ({page}) => {
await page.setContent(`<input type=file>`);
await page.setInputFiles('input', {