From 9c9657274ab3a8857d7c077a86b6107d565077af Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 23 Sep 2026 15:41:56 +0100 Subject: [PATCH 1/2] fix(frames): treat elements inside hidden iframes as hidden Frame visibility is determined during selector resolution and passed to the injected script, so that elements inside a hidden frame element are never considered visible, including cross-origin frames. Fixes https://github.com/microsoft/playwright/issues/42719 --- packages/injected/src/injectedScript.ts | 20 +-- .../src/server/frameSelectors.ts | 135 +++++++++--------- packages/playwright-core/src/server/frames.ts | 44 +++--- tests/page/locator-any-frame.spec.ts | 19 +++ tests/page/locator-frame.spec.ts | 62 ++++++++ tests/page/page-aria-snapshot-ai.spec.ts | 15 ++ tests/page/page-aria-snapshot.spec.ts | 11 ++ 7 files changed, 212 insertions(+), 94 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 85963d3a37696..540cf17bb9ed0 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -753,7 +753,7 @@ export class InjectedScript { return { queryAll }; } - elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult { + elementState(node: Node, state: ElementStateWithoutStable, frameVisible = true): ElementStateQueryResult { const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); if (!element || !element.isConnected) { if (state === 'hidden') @@ -762,7 +762,7 @@ export class InjectedScript { } if (state === 'visible' || state === 'hidden') { - const visible = isElementVisible(element); + const visible = frameVisible && isElementVisible(element); return { matches: state === 'visible' ? visible : !visible, received: visible ? 'visible' : 'hidden' @@ -1464,20 +1464,20 @@ export class InjectedScript { this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners); } - async expect(element: Element, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived }> { + async expect(element: Element, options: FrameExpectParams, elements: Element[], frameVisible: boolean): Promise<{ matches: boolean, received?: ExpectReceived }> { const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); - const core = isArray ? this.expectArray(elements, options) : await this.expectSingleElement(element, options); - const ariaSnapshot = core.matches !== options.isNot ? undefined : this._ariaSnapshotForExpect(element, options); + const core = isArray ? this.expectArray(elements, options) : await this.expectSingleElement(element, options, frameVisible); + const ariaSnapshot = core.matches !== options.isNot ? undefined : this._ariaSnapshotForExpect(element, options, frameVisible); if (core.received === undefined && ariaSnapshot === undefined) return { matches: core.matches }; return { matches: core.matches, received: { value: core.received, ariaSnapshot } }; } - private _ariaSnapshotForExpect(element: Element, options: FrameExpectParams): string | undefined { + private _ariaSnapshotForExpect(element: Element, options: FrameExpectParams, frameVisible: boolean): string | undefined { const expression = options.expression; if (expression === 'to.have.count' || expression.endsWith('.array') || expression === 'to.match.aria') return undefined; - if (isElementVisible(element) && expression !== 'to.have.title' && expression !== 'to.have.url') { + if (frameVisible && isElementVisible(element) && expression !== 'to.have.title' && expression !== 'to.have.url') { // Element-scoped snapshot. Containment matchers want the full subtree; // property matchers only need the element's own line. const isContainment = expression === 'to.have.text'; @@ -1488,7 +1488,7 @@ export class InjectedScript { return this.ariaSnapshotForExpectFailure(this.document.body, { mode: 'default' }); } - private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> { + private async expectSingleElement(element: Element, options: FrameExpectParams, frameVisible: boolean): Promise<{ matches: boolean, received?: any }> { const expression = options.expression; { @@ -1545,9 +1545,9 @@ export class InjectedScript { received: focused ? 'focused' : 'inactive', }; } else if (expression === 'to.be.hidden') { - result = this.elementState(element, 'hidden'); + result = this.elementState(element, 'hidden', frameVisible); } else if (expression === 'to.be.visible') { - result = this.elementState(element, 'visible'); + result = this.elementState(element, 'visible', frameVisible); } else if (expression === 'to.be.attached') { result = { matches: true, diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 7d25600ff5da9..64f69770b35f2 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -37,10 +37,11 @@ export type SelectorInfo = { type SelectorInFrame = { frame: Frame; info: SelectorInfo; + frameVisible: boolean; scope?: ElementHandle; }; -type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Unboxed) => R | Promise; +type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo, frameVisible: boolean }, arg: Unboxed) => R | Promise; export class FrameSelectors { readonly frame: Frame; @@ -154,8 +155,8 @@ export class FrameSelectors { const result: SelectorInFrame[] = []; const seenFrames = new Set(); - for (const startFrame of await this._anyFrameCandidates(scope)) { - const resolved = await this._resolveChainedSelector(startFrame, selector, options, chunks, startFrame === this.frame ? scope : undefined, startFrame !== this.frame /* noStall */); + for (const { frame: startFrame, frameVisible } of await this._anyFrameCandidates(scope)) { + const resolved = await this._resolveChainedSelector(startFrame, selector, options, chunks, startFrame === this.frame ? scope : undefined, startFrame !== this.frame /* noStall */, frameVisible); // Different starting frames may resolve into the same frame, e.g. with an aria-ref selector. if (!resolved || seenFrames.has(resolved.frame)) continue; @@ -166,75 +167,77 @@ export class FrameSelectors { } // All the frames the selector may start in: the frame subtree, restricted to the scope when given. - private async _anyFrameCandidates(scope: ElementHandle | undefined): Promise { - const result: Frame[] = []; - const collectSubtree = (frame: Frame) => { - result.push(frame); - for (const child of frame.childFrames()) - collectSubtree(child); + private async _anyFrameCandidates(scope: ElementHandle | undefined): Promise<{ frame: Frame, frameVisible: boolean }[]> { + const result: { frame: Frame, frameVisible: boolean }[] = []; + const info = this._parseSelector('css=frame,iframe', { strict: false }); + const collectSubtree = async (frame: Frame, frameVisible: boolean, scope: ElementHandle | undefined) => { + result.push({ frame, frameVisible }); + const childFrames = await this._queryFrames(frame, info, scope, true /* noStall */, frameVisible); + for (const childFrame of childFrames ?? []) + await collectSubtree(childFrame.frame, childFrame.frameVisible, undefined); }; + await collectSubtree(this.frame, true, scope); + return result; + } - if (!scope) { - collectSubtree(this.frame); - return result; - } - - result.push(this.frame); - await this.frame.raceAgainstEvaluationStallingEvents(async () => { - const injected = await scope._context.injectedScript(); - const frameElements = await injected.evaluateHandle((injected, scope) => { - return injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope); - }, scope); - const count = await frameElements.evaluate(elements => elements.length); - for (let i = 0; i < count; ++i) { - const frameElement = await frameElements.evaluateHandle((elements, i) => elements[i], i) as ElementHandle; - const childFrame = await this.frame._page.delegate.getContentFrame(frameElement); - frameElement.dispose(); - if (childFrame) - collectSubtree(childFrame); + private async _queryFrames(frame: Frame, info: SelectorInfo, scope: ElementHandle | undefined, noStall: boolean, parentFrameVisible: boolean): Promise<{ frame: Frame, frameVisible: boolean }[] | null> { + const context = noStall ? frame.existingContext(info.world) : await frame.context(info.world); + if (!context) + return null; + const query = async () => { + const injected = await context.injectedScript(); + const arrayHandle = await injected.evaluateHandle((injected, { info, scope, selectorString }) => { + const elements = injected.querySelectorAll(info.parsed, scope || document); + if (info.strict && elements.length > 1) + throw injected.strictModeViolationError(info.parsed, elements); + for (const element of elements) { + if (element.nodeName !== 'IFRAME' && element.nodeName !== 'FRAME') + throw injected.createStacklessError(`Selector "${selectorString}" resolved to ${injected.previewNode(element)}, `); + await routePage(page, 'a.html', ``); + await page.goto(server.EMPTY_PAGE); + const button = page.frameLocator().locator('button'); + await expect(button).toBeAttached(); + expect(await button.isVisible()).toBe(false); + await expect(button).toBeHidden(); + + const error = await button.click({ timeout: 1000 }).catch(e => e); + expect(error.message).toContain('element is inside a hidden frame, retrying'); + expect(await page.frames()[1].evaluate(() => (window as any).__clicked)).toBe(undefined); + + await page.evaluate(() => document.querySelector('iframe')!.style.visibility = 'visible'); + await expect(button).toBeVisible(); + await button.click(); + expect(await page.frames()[1].evaluate(() => (window as any).__clicked)).toBe(true); +}); diff --git a/tests/page/locator-frame.spec.ts b/tests/page/locator-frame.spec.ts index 661cc24cc6cb0..e39e63f4035f9 100644 --- a/tests/page/locator-frame.spec.ts +++ b/tests/page/locator-frame.spec.ts @@ -318,3 +318,65 @@ it('frameLocator.owner should work', async ({ page, server }) => { await expect(locator).toBeVisible(); expect(await locator.getAttribute('name')).toBe('frame1'); }); + +it('should treat elements inside hidden iframe as hidden', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42719' } }, async ({ page }) => { + await page.setContent(` + +
+ + +
+ + `); + + for (const button of [ + page.frameLocator('[name=hidden]').locator('button'), + page.frameLocator('[name=hidden-parent]').locator('button'), + page.frameLocator('[name=outer]').frameLocator('[name=inner]').locator('button'), + ]) { + await expect(button).toBeAttached(); + expect(await button.isVisible()).toBe(false); + expect(await button.isHidden()).toBe(true); + await expect(button).toBeHidden(); + await expect(button).not.toBeVisible(); + await button.waitFor({ state: 'hidden' }); + } + + const button = page.frameLocator('[name=visible-override]').locator('button'); + expect(await button.isVisible()).toBe(true); + await expect(button).toBeVisible(); + await button.waitFor({ state: 'visible' }); +}); + +it('should wait for hidden iframe to become visible', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42719' } }, async ({ page }) => { + await page.setContent(``); + const button = page.frameLocator('[name=frame]').locator('button'); + await expect(button).toBeHidden(); + + const error = await button.click({ timeout: 1000 }).catch(e => e); + expect(error.message).toContain('element is inside a hidden frame, retrying'); + expect(await button.evaluate(() => (window as any).__clicked)).toBe(undefined); + + const clickPromise = button.click(); + const visiblePromise = expect(button).toBeVisible(); + await page.evaluate(() => document.querySelector('iframe')!.style.visibility = 'visible'); + await Promise.all([clickPromise, visiblePromise]); + expect(await button.evaluate(() => (window as any).__clicked)).toBe(true); + + await page.evaluate(() => document.querySelector('iframe')!.style.visibility = 'hidden'); + await expect(button).toBeHidden(); + expect(await button.isVisible()).toBe(false); +}); + +it('should treat elements inside hidden cross-origin iframe as hidden', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42719' } }, async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(``); + const div = page.frameLocator('iframe').locator('div'); + await expect(div).toHaveText(`Hi, I'm frame`); + expect(await div.isVisible()).toBe(false); + await expect(div).toBeHidden(); + + await page.evaluate(() => document.querySelector('iframe')!.style.visibility = 'visible'); + await expect(div).toBeVisible(); + expect(await div.isVisible()).toBe(true); +}); diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index 27f0eea5ca411..7d070c25c58a5 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -971,3 +971,18 @@ it('should only annotate the top element in a hidden subtree', { annotation: { t - paragraph [ref=e4]: Paragraph `); }); + +it('should not include hidden iframes', async ({ page }) => { + await page.setContent(` + + + + `); + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - iframe [ref=e2]: + - button "Visible" [ref=f1e2] + `); + expect(snapshot).not.toContain('Hidden'); + expect(snapshot).not.toContain('None'); +}); diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 7cb41c8881620..bf0dd98a3be03 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -794,3 +794,14 @@ it('should not include box when option is omitted', async ({ page }) => { const snapshot = await page.ariaSnapshot(); expect(snapshot).not.toMatch(/\[box=/); }); + +it('should not include hidden iframes', async ({ page }) => { + await page.setContent(` + + + + `); + expect(await page.ariaSnapshot()).toBe(unshift(` + - iframe + `)); +}); From feb19909d403257fb1936f489684d3855e09fee7 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 23 Sep 2026 16:05:13 +0100 Subject: [PATCH 2/2] chore(frames): require frameVisible for visibility checks Make frameVisible a required argument of elementState() for 'visible' and 'hidden' states, and plumb it explicitly from locator resolution and ElementHandle api methods. --- packages/injected/src/injectedScript.ts | 10 ++- packages/playwright-core/src/server/dom.ts | 87 ++++++++++--------- packages/playwright-core/src/server/frames.ts | 36 ++++---- .../src/server/screenshotter.ts | 4 +- 4 files changed, 70 insertions(+), 67 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 540cf17bb9ed0..f20a9872249ea 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -665,7 +665,7 @@ export class InjectedScript { return element; } - async checkElementStates(node: Node, states: ElementState[]): Promise<'error:notconnected' | { missingState: ElementState } | undefined> { + async checkElementStates(node: Node, states: ElementState[], frameVisible: boolean): Promise<'error:notconnected' | { missingState: ElementState } | undefined> { if (states.includes('stable')) { const stableResult = await this._checkElementIsStable(node); if (stableResult === false) @@ -675,7 +675,7 @@ export class InjectedScript { } for (const state of states) { if (state !== 'stable') { - const result = this.elementState(node, state); + const result = this.elementState(node, state, frameVisible); if (result.received === 'error:notconnected') return 'error:notconnected'; if (!result.matches) @@ -753,7 +753,9 @@ export class InjectedScript { return { queryAll }; } - elementState(node: Node, state: ElementStateWithoutStable, frameVisible = true): ElementStateQueryResult { + elementState(node: Node, state: ElementStateWithoutStable, frameVisible: boolean): ElementStateQueryResult; + elementState(node: Node, state: Exclude): ElementStateQueryResult; + elementState(node: Node, state: ElementStateWithoutStable, frameVisible?: boolean): ElementStateQueryResult { const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); if (!element || !element.isConnected) { if (state === 'hidden') @@ -762,7 +764,7 @@ export class InjectedScript { } if (state === 'visible' || state === 'hidden') { - const visible = frameVisible && isElementVisible(element); + const visible = !!frameVisible && isElementVisible(element); return { matches: state === 'visible' ? visible : !visible, received: visible ? 'visible' : 'hidden' diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index c90aa05331964..186d95a3d1d03 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -225,12 +225,12 @@ export class ElementHandle extends js.JSHandle { return await progress.race(this._page.delegate.scrollRectIntoViewIfNeeded(this, rect)); } - async _waitAndScrollIntoViewIfNeeded(progress: Progress, waitForVisible: boolean): Promise { + async _waitAndScrollIntoViewIfNeeded(progress: Progress, waitForVisible: boolean, frameVisible: boolean): Promise { const result = await this._retryAction(progress, 'scroll into view', async progress => { progress.log(` waiting for element to be stable`); - const waitResult = await progress.race(this.evaluateInUtility(async ([injected, node, { waitForVisible }]) => { - return await injected.checkElementStates(node, waitForVisible ? ['visible', 'stable'] : ['stable']); - }, { waitForVisible })); + const waitResult = await progress.race(this.evaluateInUtility(async ([injected, node, { waitForVisible, frameVisible }]) => { + return await injected.checkElementStates(node, waitForVisible ? ['visible', 'stable'] : ['stable'], frameVisible); + }, { waitForVisible, frameVisible })); if (waitResult) return waitResult; return await this._scrollRectIntoViewIfNeeded(progress); @@ -239,7 +239,7 @@ export class ElementHandle extends js.JSHandle { } async scrollIntoViewIfNeeded(progress: Progress) { - await this._waitAndScrollIntoViewIfNeeded(progress, false /* waitForVisible */); + await this._waitAndScrollIntoViewIfNeeded(progress, false /* waitForVisible */, true /* frameVisible */); } private async _clickablePoint(progress: Progress): Promise<{ point: types.Point, box: types.Rect } | 'error:notvisible' | 'error:notinviewport' | 'error:notconnected'> { @@ -378,7 +378,7 @@ export class ElementHandle extends js.JSHandle { } async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (progress: Progress, point: types.Point) => Promise, - options: { waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + options: { waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. const skipActionPreChecks = actionName === 'move and up'; return await this._retryAction(progress, actionName, async (progress, retry) => { @@ -393,7 +393,7 @@ export class ElementHandle extends js.JSHandle { { block: 'start', inline: 'start' }, ]; const forceScrollOptions = scrollOptions[retry % scrollOptions.length]; - return await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options); + return await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options, frameVisible); }, { ...options, skipActionPreChecks }); } @@ -404,6 +404,7 @@ export class ElementHandle extends js.JSHandle { action: (progress: Progress, point: types.Point) => Promise, forceScrollOptions: ScrollIntoViewOptions | undefined, options: { waitAfter: boolean | 'disabled' } & types.PointerActionOptions & types.PointerActionWaitOptions, + frameVisible: boolean, ): Promise { const { force = false, position, scroll } = options; @@ -433,9 +434,9 @@ export class ElementHandle extends js.JSHandle { if (!force) { const elementStates: ElementState[] = waitForEnabled ? ['visible', 'enabled', 'stable'] : ['visible', 'stable']; progress.log(` waiting for element to be ${waitForEnabled ? 'visible, enabled and stable' : 'visible and stable'}`); - const result = await progress.race(this.evaluateInUtility(async ([injected, node, { elementStates }]) => { - return await injected.checkElementStates(node, elementStates); - }, { elementStates })); + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { elementStates, frameVisible }]) => { + return await injected.checkElementStates(node, elementStates, frameVisible); + }, { elementStates, frameVisible })); if (result) return result; progress.log(` element is ${waitForEnabled ? 'visible, enabled and stable' : 'visible and stable'}`); @@ -530,65 +531,65 @@ export class ElementHandle extends js.JSHandle { async hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._hover(progress, options); + const result = await this._hover(progress, options, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } - _hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, (progress, point) => this._page.mouse.move(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }); + _hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { + return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, (progress, point) => this._page.mouse.move(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }, frameVisible); } async click(progress: Progress, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter }); + const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter }, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } - _click(progress: Progress, options: { waitAfter: boolean | 'disabled' } & types.MouseClickOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'click', true /* waitForEnabled */, (progress, point) => this._page.mouse.click(progress, point.x, point.y, options), options); + _click(progress: Progress, options: { waitAfter: boolean | 'disabled' } & types.MouseClickOptions & types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { + return this._retryPointerAction(progress, 'click', true /* waitForEnabled */, (progress, point) => this._page.mouse.click(progress, point.x, point.y, options), options, frameVisible); } async dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._dblclick(progress, options); + const result = await this._dblclick(progress, options, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } - _dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'dblclick', true /* waitForEnabled */, (progress, point) => this._page.mouse.click(progress, point.x, point.y, { ...options, clickCount: 2 }), { ...options, waitAfter: 'disabled' }); + _dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { + return this._retryPointerAction(progress, 'dblclick', true /* waitForEnabled */, (progress, point) => this._page.mouse.click(progress, point.x, point.y, { ...options, clickCount: 2 }), { ...options, waitAfter: 'disabled' }, frameVisible); } async tap(progress: Progress, options: types.PointerActionWaitOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._tap(progress, options); + const result = await this._tap(progress, options, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } - _tap(progress: Progress, options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { - return this._retryPointerAction(progress, 'tap', true /* waitForEnabled */, (progress, point) => this._page.touchscreen.tap(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }); + _tap(progress: Progress, options: types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { + return this._retryPointerAction(progress, 'tap', true /* waitForEnabled */, (progress, point) => this._page.touchscreen.tap(progress, point.x, point.y), { ...options, waitAfter: 'disabled' }, frameVisible); } async selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._selectOption(progress, elements, values, options); + const result = await this._selectOption(progress, elements, values, options, true /* frameVisible */); return throwRetargetableDOMError(result); } - async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions, box?: types.Rect): Promise { + async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions, frameVisible: boolean, box?: types.Rect): Promise { let resultingOptions: string[] = []; const result = await this._retryAction(progress, 'select option', async progress => { await this.instrumentation.onBeforeInputAction(progress, this, undefined, box); if (!options.force) progress.log(` waiting for element to be visible and enabled`); const optionsToSelect = [...elements, ...values]; - const result = await progress.race(this.evaluateInUtility(async ([injected, node, { optionsToSelect, force }]) => { + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { optionsToSelect, force, frameVisible }]) => { if (!force) { - const checkResult = await injected.checkElementStates(node, ['visible', 'enabled']); + const checkResult = await injected.checkElementStates(node, ['visible', 'enabled'], frameVisible); if (checkResult) return checkResult; } return injected.selectOptions(node, optionsToSelect); - }, { optionsToSelect, force: options.force })); + }, { optionsToSelect, force: options.force, frameVisible })); if (Array.isArray(result)) { progress.log(' selected specified option(s)'); resultingOptions = result; @@ -603,24 +604,24 @@ export class ElementHandle extends js.JSHandle { async fill(progress: Progress, value: string, options: types.CommonActionOptions): Promise { await this._markAsTargetElement(progress); - const result = await this._fill(progress, value, options); + const result = await this._fill(progress, value, options, true /* frameVisible */); assertDone(throwRetargetableDOMError(result)); } - async _fill(progress: Progress, value: string, options: types.CommonActionOptions, box?: types.Rect): Promise<'error:notconnected' | 'done'> { + async _fill(progress: Progress, value: string, options: types.CommonActionOptions, frameVisible: boolean, box?: types.Rect): Promise<'error:notconnected' | 'done'> { progress.log(` fill("${value}")`); return await this._retryAction(progress, 'fill', async progress => { await this.instrumentation.onBeforeInputAction(progress, this, undefined, box); if (!options.force) progress.log(' waiting for element to be visible, enabled and editable'); - const result = await progress.race(this.evaluateInUtility(async ([injected, node, { value, force }]) => { + const result = await progress.race(this.evaluateInUtility(async ([injected, node, { value, force, frameVisible }]) => { if (!force) { - const checkResult = await injected.checkElementStates(node, ['visible', 'enabled', 'editable']); + const checkResult = await injected.checkElementStates(node, ['visible', 'enabled', 'editable'], frameVisible); if (checkResult) return checkResult; } return injected.fill(node, value); - }, { value, force: options.force })); + }, { value, force: options.force, frameVisible })); if (result === 'needsinput') { if (value) await this._page.keyboard.insertText(progress, value); @@ -637,14 +638,14 @@ export class ElementHandle extends js.JSHandle { const result = await this._retryAction(progress, 'selectText', async progress => { if (!options.force) progress.log(' waiting for element to be visible'); - return await progress.race(this.evaluateInUtility(async ([injected, node, { force }]) => { + return await progress.race(this.evaluateInUtility(async ([injected, node, { force, frameVisible }]) => { if (!force) { - const checkResult = await injected.checkElementStates(node, ['visible']); + const checkResult = await injected.checkElementStates(node, ['visible'], frameVisible); if (checkResult) return checkResult; } return injected.selectText(node); - }, { force: options.force })); + }, { force: options.force, frameVisible: true })); }, options); assertDone(throwRetargetableDOMError(result)); } @@ -656,7 +657,7 @@ export class ElementHandle extends js.JSHandle { return assertDone(throwRetargetableDOMError(result)); } - async _drop(progress: Progress, inputFileItems: InputFilesItems, data: { mimeType: string, value: string }[], options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + async _drop(progress: Progress, inputFileItems: InputFilesItems, data: { mimeType: string, value: string }[], options: types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { const { filePayloads, localPaths } = inputFileItems; let payloads: { name: string, mimeType: string, buffer: string, lastModifiedMs?: number }[]; if (localPaths && !filePayloads) { @@ -720,7 +721,7 @@ export class ElementHandle extends js.JSHandle { if (disposeHandle) handle.dispose(); } - }, { ...options, waitAfter: 'disabled' }); + }, { ...options, waitAfter: 'disabled' }, frameVisible); } async _setInputFiles(progress: Progress, items: InputFilesItems, box?: types.Rect): Promise<'error:notconnected' | 'done'> { @@ -812,16 +813,16 @@ export class ElementHandle extends js.JSHandle { } async check(progress: Progress, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const result = await this._setChecked(progress, true, options); + const result = await this._setChecked(progress, true, options, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } async uncheck(progress: Progress, options: { position?: types.Point } & types.PointerActionWaitOptions) { - const result = await this._setChecked(progress, false, options); + const result = await this._setChecked(progress, false, options, true /* frameVisible */); return assertDone(throwRetargetableDOMError(result)); } - async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions, frameVisible: boolean): Promise<'error:notconnected' | 'done'> { const isChecked = async (progress: Progress) => { const result = await progress.race(this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {})); if (result === 'error:notconnected' || result.received === 'error:notconnected') @@ -834,7 +835,7 @@ export class ElementHandle extends js.JSHandle { return 'done'; if (!state && checkedState.isRadio) throw new NonRecoverableDOMError('Cannot uncheck radio button. Radio buttons can only be unchecked by selecting another radio button in the same group.'); - const result = await this._click(progress, { ...options, waitAfter: 'disabled' }); + const result = await this._click(progress, { ...options, waitAfter: 'disabled' }, frameVisible); if (result !== 'done') return result; if (options.trial) @@ -850,7 +851,7 @@ export class ElementHandle extends js.JSHandle { } async screenshot(progress: Progress, options: ScreenshotOptions): Promise { - return await this._page.screenshotter.screenshotElement(progress, this, options); + return await this._page.screenshotter.screenshotElement(progress, this, options, true /* frameVisible */); } async querySelector(progress: Progress, selector: string, options: types.StrictOptions): Promise { @@ -914,7 +915,7 @@ export class ElementHandle extends js.JSHandle { const actionName = `wait for ${state}`; const result = await this._retryAction(progress, actionName, async progress => { return await progress.race(this.evaluateInUtility(async ([injected, node, state]) => { - return (await injected.checkElementStates(node, [state])) || 'done'; + return (await injected.checkElementStates(node, [state], true /* frameVisible */)) || 'done'; }, state)); }, {}); assertDone(throwRetargetableDOMError(result)); diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 4dc50c84c54f2..61d866696109a 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1200,7 +1200,7 @@ export class Frame extends SdkObject { progress: Progress, selector: string, options: { strict?: boolean, noAutoWaiting?: boolean, force?: boolean, performActionPreChecks?: boolean, waitForFrameVisible?: boolean }, - action: (progress: Progress, handle: dom.ElementHandle, box?: types.Rect) => Promise): Promise { + action: (progress: Progress, handle: dom.ElementHandle, box: types.Rect | undefined, frameVisible: boolean) => Promise): Promise { progress.log(`waiting for ${this._asLocator(selector)}`); const noAutoWaiting = (options as any).__testHookNoAutoWaiting ?? options.noAutoWaiting; const performActionPreChecks = (options.performActionPreChecks ?? !options.force) && !noAutoWaiting; @@ -1244,7 +1244,7 @@ export class Frame extends SdkObject { const element = await progress.race(result.evaluateHandle(r => r.element)) as dom.ElementHandle; result.dispose(); try { - const result = await action(progress, element, box); + const result = await action(progress, element, box, resolved.frameVisible); if (result === 'error:notconnected') { if (noAutoWaiting) throw new dom.NonRecoverableDOMError('Element is not attached to the DOM'); @@ -1259,22 +1259,22 @@ export class Frame extends SdkObject { } async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise { - return await this._retryWithProgressIfNotConnected(progress, selector, { strict: true, performActionPreChecks: true, waitForFrameVisible: true }, async (progress, handle) => { + return await this._retryWithProgressIfNotConnected(progress, selector, { strict: true, performActionPreChecks: true, waitForFrameVisible: true }, async (progress, handle, box, frameVisible) => { await handle._frame.rafrafTimeout(progress, timeout); - return await this._page.screenshotter.screenshotElement(progress, handle, options); + return await this._page.screenshotter.screenshotElement(progress, handle, options, frameVisible); }); } async click(progress: Progress, selector: string, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._click(progress, { ...options, waitAfter: !options.noWaitAfter }))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._click(progress, { ...options, waitAfter: !options.noWaitAfter }, frameVisible))); } async dblclick(progress: Progress, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._dblclick(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._dblclick(progress, options, frameVisible))); } async dragAndDrop(progress: Progress, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions) { - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, { ...options, waitForFrameVisible: true }, async (progress, handle) => { + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, { ...options, waitForFrameVisible: true }, async (progress, handle, box, frameVisible) => { return handle._retryPointerAction(progress, 'move and down', false, async (progress, point) => { await this._page.mouse.move(progress, point.x, point.y); await this._page.mouse.down(progress); @@ -1282,10 +1282,10 @@ export class Frame extends SdkObject { ...options, waitAfter: 'disabled', position: options.sourcePosition, - }); + }, frameVisible); })); // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, { ...options, performActionPreChecks: false, waitForFrameVisible: true }, async (progress, handle) => { + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, { ...options, performActionPreChecks: false, waitForFrameVisible: true }, async (progress, handle, box, frameVisible) => { return handle._retryPointerAction(progress, 'move and up', false, async (progress, point) => { await this._page.mouse.move(progress, point.x, point.y, { steps: options.steps }); await this._page.mouse.up(progress); @@ -1293,18 +1293,18 @@ export class Frame extends SdkObject { ...options, waitAfter: 'disabled', position: options.targetPosition, - }); + }, frameVisible); })); } async tap(progress: Progress, selector: string, options: types.PointerActionWaitOptions) { if (!this._page.browserContext._options.hasTouch) throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.'); - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._tap(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._tap(progress, options, frameVisible))); } async fill(progress: Progress, selector: string, value: string, options: types.CommonActionOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box) => handle._fill(progress, value, options, box))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._fill(progress, value, options, frameVisible, box))); } async focus(progress: Progress, selector: string, options: types.StrictOptions & { noAutoWaiting?: boolean }) { @@ -1379,7 +1379,7 @@ export class Frame extends SdkObject { return result; } - private async _elementState(progress: Progress, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions, scope?: dom.ElementHandle): Promise { + private async _elementState(progress: Progress, selector: string, state: Exclude, options: types.QueryOnSelectorOptions, scope?: dom.ElementHandle): Promise { const { result } = await this._waitForFunctionOnSelector(progress, selector, (injected, element, data) => { return { result: injected.elementState(element, data.state) }; }, { state }, options, scope); @@ -1429,11 +1429,11 @@ export class Frame extends SdkObject { } async hover(progress: Progress, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._hover(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._hover(progress, options, frameVisible))); } async selectOption(progress: Progress, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise { - return await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box) => handle._selectOption(progress, elements, values, options, box)); + return await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._selectOption(progress, elements, values, options, frameVisible, box)); } async setInputFiles(progress: Progress, selector: string, params: Omit & { noAutoWaiting?: boolean }): Promise { @@ -1448,7 +1448,7 @@ export class Frame extends SdkObject { throw new Error('At least one of "files" or "data" must be provided.'); const inputFileItems = hasFiles ? await progress.race(prepareFilesForUpload(this, params)) : { filePayloads: undefined, localPaths: undefined }; const data = params.data ?? []; - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options, (progress, handle) => handle._drop(progress, inputFileItems, data, options))); + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._drop(progress, inputFileItems, data, options, frameVisible))); } async type(progress: Progress, selector: string, text: string, options: { delay?: number, noAutoWaiting?: boolean } & types.StrictOptions) { @@ -1460,11 +1460,11 @@ export class Frame extends SdkObject { } async check(progress: Progress, selector: string, options: types.PointerActionWaitOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._setChecked(progress, true, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._setChecked(progress, true, options, frameVisible))); } async uncheck(progress: Progress, selector: string, options: types.PointerActionWaitOptions) { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle) => handle._setChecked(progress, false, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, { ...options, waitForFrameVisible: true }, (progress, handle, box, frameVisible) => handle._setChecked(progress, false, options, frameVisible))); } async waitForTimeout(progress: Progress, timeout: number) { diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 33db059bbb111..c2aa7b12c759e 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -221,7 +221,7 @@ export class Screenshotter { }); } - async screenshotElement(progress: Progress, handle: dom.ElementHandle, options: ScreenshotOptions): Promise { + async screenshotElement(progress: Progress, handle: dom.ElementHandle, options: ScreenshotOptions, frameVisible: boolean): Promise { const format = validateScreenshotOptions(options); return this._queue._postTask(async () => { progress.log('taking element screenshot'); @@ -229,7 +229,7 @@ export class Screenshotter { await this._preparePageForScreenshot(progress, handle._frame, options.style, options.caret !== 'initial', options.animations === 'disabled'); try { - await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */); + await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */, frameVisible); const boundingBox = await handle.boundingBox(progress); assert(boundingBox, 'Node is either not visible or not an HTMLElement');