fix(click): do not retarget from label to control when clicking (#5683)
And in other carefully considered cases.
This commit is contained in:
parent
30e88c36fa
commit
e4d33f56f4
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue