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:notinput' |
'error:notinputvalue' | 'error:notinputvalue' |
'error:notselect' | 'error:notselect' |
'error:notcheckbox'; 'error:notcheckbox' |
'error:notmultiplefileinput';
export type RetargetableDOMError = 'error:notconnected'; export type RetargetableDOMError = 'error:notconnected';

View file

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

View file

@ -326,7 +326,7 @@ export class InjectedScript {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) }; 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; let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!element) if (!element)
return null; return null;
@ -369,7 +369,7 @@ export class InjectedScript {
continue; continue;
} }
const element = this._retarget(node, 'no-follow-label'); const element = this.retarget(node, 'no-follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
@ -411,7 +411,7 @@ export class InjectedScript {
} }
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError { 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 (!element || !element.isConnected) {
if (state === 'hidden') if (state === 'hidden')
return true; return true;
@ -447,7 +447,7 @@ export class InjectedScript {
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[], selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol { 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) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select') if (element.nodeName.toLowerCase() !== 'select')
@ -493,7 +493,7 @@ export class InjectedScript {
} }
fill(value: string, node: Node, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' { 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) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') { if (element.nodeName.toLowerCase() === 'input') {
@ -530,7 +530,7 @@ export class InjectedScript {
} }
selectText(node: Node): 'error:notconnected' | 'done' { selectText(node: Node): 'error:notconnected' | 'done' {
const element = this._retarget(node, 'follow-label'); const element = this.retarget(node, 'follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') { 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'); 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}) => { it('should set from memory', async ({page}) => {
await page.setContent(`<input type=file>`); await page.setContent(`<input type=file>`);
await page.setInputFiles('input', { await page.setInputFiles('input', {