fix(click): do not retarget from label to control when clicking (#5683)

And in other carefully considered cases.
This commit is contained in:
Dmitry Gozman 2021-03-02 17:29:03 -08:00 committed by GitHub
parent 30e88c36fa
commit e4d33f56f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 49 additions and 21 deletions

View file

@ -326,34 +326,34 @@ 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): Element | null { private _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;
element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element; element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element;
if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') && if (behavior === 'follow-label') {
!(element as any).isContentEditable) { if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') &&
// Go up to the label that might be connected to the input/textarea. !(element as any).isContentEditable) {
element = element.closest('label') || element; // Go up to the label that might be connected to the input/textarea.
element = element.closest('label') || element;
}
if (element.nodeName === 'LABEL')
element = (element as HTMLLabelElement).control || element;
} }
if (element.nodeName === 'LABEL')
element = (element as HTMLLabelElement).control || element;
return element; return element;
} }
waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[], waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[],
callback: (element: Element | null, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> { callback: (node: Node, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> {
let lastRect: { x: number, y: number, width: number, height: number } | undefined; let lastRect: { x: number, y: number, width: number, height: number } | undefined;
let counter = 0; let counter = 0;
let samePositionCounter = 0; let samePositionCounter = 0;
let lastTime = 0; let lastTime = 0;
const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => { const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => {
const element = this._retarget(node);
for (const state of states) { for (const state of states) {
if (state !== 'stable') { if (state !== 'stable') {
const result = this._checkElementState(element, state); const result = this.checkElementState(node, state);
if (typeof result !== 'boolean') if (typeof result !== 'boolean')
return result; return result;
if (!result) { if (!result) {
@ -363,6 +363,7 @@ export class InjectedScript {
continue; continue;
} }
const element = this._retarget(node, 'no-follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
@ -394,7 +395,7 @@ export class InjectedScript {
return continuePolling; return continuePolling;
} }
return callback(element, progress, continuePolling); return callback(node, progress, continuePolling);
}; };
if (this._replaceRafWithTimeout) if (this._replaceRafWithTimeout)
@ -403,12 +404,14 @@ export class InjectedScript {
return this.pollRaf(predicate); return this.pollRaf(predicate);
} }
private _checkElementState(element: Element | null, 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');
if (!element || !element.isConnected) { if (!element || !element.isConnected) {
if (state === 'hidden') if (state === 'hidden')
return true; return true;
return 'error:notconnected'; return 'error:notconnected';
} }
if (state === 'visible') if (state === 'visible')
return this.isVisible(element); return this.isVisible(element);
if (state === 'hidden') if (state === 'hidden')
@ -436,13 +439,9 @@ export class InjectedScript {
throw new Error(`Unexpected element state "${state}"`); throw new Error(`Unexpected element state "${state}"`);
} }
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
const element = this._retarget(node);
return this._checkElementState(element, state);
}
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[], selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
element: Element | null, 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');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select') if (element.nodeName.toLowerCase() !== 'select')
@ -487,7 +486,8 @@ export class InjectedScript {
return selectedOptions.map(option => option.value); return selectedOptions.map(option => option.value);
} }
fill(value: string, element: Element | null, 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');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') { if (element.nodeName.toLowerCase() === 'input') {
@ -523,7 +523,8 @@ export class InjectedScript {
return 'needsinput'; // Still need to input the value. return 'needsinput'; // Still need to input the value.
} }
selectText(element: Element | null): 'error:notconnected' | 'done' { selectText(node: Node): 'error:notconnected' | 'done' {
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

@ -176,6 +176,22 @@ it('isVisible and isHidden should work', async ({ page }) => {
expect(await page.isHidden('no-such-element')).toBe(true); expect(await page.isHidden('no-such-element')).toBe(true);
}); });
it('element state checks should work for label with zero-sized input', async ({page, server}) => {
await page.setContent(`
<label>
Click me
<input disabled style="width:0;height:0;padding:0;margin:0;border:0;">
</label>
`);
// Visible checks the label.
expect(await page.isVisible('text=Click me')).toBe(true);
expect(await page.isHidden('text=Click me')).toBe(false);
// Enabled checks the input.
expect(await page.isEnabled('text=Click me')).toBe(false);
expect(await page.isDisabled('text=Click me')).toBe(true);
});
it('isEnabled and isDisabled should work', async ({ page }) => { it('isEnabled and isDisabled should work', async ({ page }) => {
await page.setContent(` await page.setContent(`
<button disabled>button1</button> <button disabled>button1</button>

View file

@ -768,3 +768,14 @@ it('should click the button when window.innerWidth is corrupted', async ({page,
await page.click('button'); await page.click('button');
expect(await page.evaluate('result')).toBe('Clicked'); expect(await page.evaluate('result')).toBe('Clicked');
}); });
it('should click zero-sized input by label', async ({page, server}) => {
await page.setContent(`
<label>
Click me
<input onclick="window.__clicked=true" style="width:0;height:0;padding:0;margin:0;border:0;">
</label>
`);
await page.click('text=Click me');
expect(await page.evaluate('window.__clicked')).toBe(true);
});