Skip to content

Commit 1dae530

Browse files
authored
cherry-pick(release-1.9): fix(click): do not retarget from label to control when clicking (#5769)
And in other carefully considered cases.
1 parent ccc89e3 commit 1dae530

File tree

3 files changed

+49
-21
lines changed

3 files changed

+49
-21
lines changed

src/server/injected/injectedScript.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -326,34 +326,34 @@ export class InjectedScript {
326326
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
327327
}
328328

329-
private _retarget(node: Node): Element | null {
329+
private _retarget(node: Node, behavior: 'follow-label' | 'no-follow-label'): Element | null {
330330
let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
331331
if (!element)
332332
return null;
333333
element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element;
334-
if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') &&
335-
!(element as any).isContentEditable) {
336-
// Go up to the label that might be connected to the input/textarea.
337-
element = element.closest('label') || element;
334+
if (behavior === 'follow-label') {
335+
if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') &&
336+
!(element as any).isContentEditable) {
337+
// Go up to the label that might be connected to the input/textarea.
338+
element = element.closest('label') || element;
339+
}
340+
if (element.nodeName === 'LABEL')
341+
element = (element as HTMLLabelElement).control || element;
338342
}
339-
if (element.nodeName === 'LABEL')
340-
element = (element as HTMLLabelElement).control || element;
341343
return element;
342344
}
343345

344346
waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[],
345-
callback: (element: Element | null, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> {
347+
callback: (node: Node, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> {
346348
let lastRect: { x: number, y: number, width: number, height: number } | undefined;
347349
let counter = 0;
348350
let samePositionCounter = 0;
349351
let lastTime = 0;
350352

351353
const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => {
352-
const element = this._retarget(node);
353-
354354
for (const state of states) {
355355
if (state !== 'stable') {
356-
const result = this._checkElementState(element, state);
356+
const result = this.checkElementState(node, state);
357357
if (typeof result !== 'boolean')
358358
return result;
359359
if (!result) {
@@ -363,6 +363,7 @@ export class InjectedScript {
363363
continue;
364364
}
365365

366+
const element = this._retarget(node, 'no-follow-label');
366367
if (!element)
367368
return 'error:notconnected';
368369

@@ -394,7 +395,7 @@ export class InjectedScript {
394395
return continuePolling;
395396
}
396397

397-
return callback(element, progress, continuePolling);
398+
return callback(node, progress, continuePolling);
398399
};
399400

400401
if (this._replaceRafWithTimeout)
@@ -403,12 +404,14 @@ export class InjectedScript {
403404
return this.pollRaf(predicate);
404405
}
405406

406-
private _checkElementState(element: Element | null, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
407+
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
408+
const element = this._retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
407409
if (!element || !element.isConnected) {
408410
if (state === 'hidden')
409411
return true;
410412
return 'error:notconnected';
411413
}
414+
412415
if (state === 'visible')
413416
return this.isVisible(element);
414417
if (state === 'hidden')
@@ -436,13 +439,9 @@ export class InjectedScript {
436439
throw new Error(`Unexpected element state "${state}"`);
437440
}
438441

439-
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
440-
const element = this._retarget(node);
441-
return this._checkElementState(element, state);
442-
}
443-
444442
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
445-
element: Element | null, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol {
443+
node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol {
444+
const element = this._retarget(node, 'follow-label');
446445
if (!element)
447446
return 'error:notconnected';
448447
if (element.nodeName.toLowerCase() !== 'select')
@@ -487,7 +486,8 @@ export class InjectedScript {
487486
return selectedOptions.map(option => option.value);
488487
}
489488

490-
fill(value: string, element: Element | null, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' {
489+
fill(value: string, node: Node, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' {
490+
const element = this._retarget(node, 'follow-label');
491491
if (!element)
492492
return 'error:notconnected';
493493
if (element.nodeName.toLowerCase() === 'input') {
@@ -523,7 +523,8 @@ export class InjectedScript {
523523
return 'needsinput'; // Still need to input the value.
524524
}
525525

526-
selectText(element: Element | null): 'error:notconnected' | 'done' {
526+
selectText(node: Node): 'error:notconnected' | 'done' {
527+
const element = this._retarget(node, 'follow-label');
527528
if (!element)
528529
return 'error:notconnected';
529530
if (element.nodeName.toLowerCase() === 'input') {

test/elementhandle-convenience.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,22 @@ it('isVisible and isHidden should work', async ({ page }) => {
176176
expect(await page.isHidden('no-such-element')).toBe(true);
177177
});
178178

179+
it('element state checks should work for label with zero-sized input', async ({page, server}) => {
180+
await page.setContent(`
181+
<label>
182+
Click me
183+
<input disabled style="width:0;height:0;padding:0;margin:0;border:0;">
184+
</label>
185+
`);
186+
// Visible checks the label.
187+
expect(await page.isVisible('text=Click me')).toBe(true);
188+
expect(await page.isHidden('text=Click me')).toBe(false);
189+
190+
// Enabled checks the input.
191+
expect(await page.isEnabled('text=Click me')).toBe(false);
192+
expect(await page.isDisabled('text=Click me')).toBe(true);
193+
});
194+
179195
it('isEnabled and isDisabled should work', async ({ page }) => {
180196
await page.setContent(`
181197
<button disabled>button1</button>

test/page-click.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,3 +768,14 @@ it('should click the button when window.innerWidth is corrupted', async ({page,
768768
await page.click('button');
769769
expect(await page.evaluate('result')).toBe('Clicked');
770770
});
771+
772+
it('should click zero-sized input by label', async ({page, server}) => {
773+
await page.setContent(`
774+
<label>
775+
Click me
776+
<input onclick="window.__clicked=true" style="width:0;height:0;padding:0;margin:0;border:0;">
777+
</label>
778+
`);
779+
await page.click('text=Click me');
780+
expect(await page.evaluate('window.__clicked')).toBe(true);
781+
});

0 commit comments

Comments
 (0)