diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 85963d3a37696..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): 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 = isElementVisible(element); + const visible = !!frameVisible && isElementVisible(element); return { matches: state === 'visible' ? visible : !visible, received: visible ? 'visible' : 'hidden' @@ -1464,20 +1466,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 +1490,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 +1547,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/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/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 + `)); +});