Skip to content

Commit 57b2cca

Browse files
authored
feat(browser): support iframe locator with playwright provider (#8016)
1 parent afc4eca commit 57b2cca

File tree

10 files changed

+104
-16
lines changed

10 files changed

+104
-16
lines changed

docs/guide/browser/context.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export const page: {
8787
* Wrap an HTML element in a `Locator`. When querying for elements, the search will always return this element.
8888
*/
8989
elementLocator(element: Element): Locator
90+
/**
91+
* The iframe locator. This is a document locator that enters the iframe body
92+
* and works similarly to the `page` object.
93+
* **Warning:** At the moment, this is supported only by the `playwright` provider.
94+
*/
95+
frameLocator(iframeElement: Locator): FrameLocator
9096

9197
/**
9298
* Locator APIs. See its documentation for more details.
@@ -110,8 +116,37 @@ Note that `screenshot` will always return a base64 string if `save` is set to `f
110116
The `path` is also ignored in that case.
111117
:::
112118
119+
### frameLocator
120+
121+
```ts
122+
function frameLocator(iframeElement: Locator): FrameLocator
123+
```
124+
125+
The `frameLocator` method returns a `FrameLocator` instance that can be used to find elements inside the iframe.
126+
127+
The frame locator is similar to `page`. It does not refer to the Iframe HTML element, but to the iframe's document.
128+
129+
```ts
130+
const frame = page.frameLocator(
131+
page.getByTestId('iframe')
132+
)
133+
134+
await frame.getByText('Hello World').click() // ✅
135+
await frame.click() // ❌ Not available
136+
```
137+
138+
::: danger IMPORTANT
139+
At the moment, the `frameLocator` method is only supported by the `playwright` provider.
140+
141+
The interactive methods (like `click` or `fill`) are always available on elements within the iframe, but assertions with `expect.element` require the iframe to have the [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy).
142+
:::
143+
113144
## `cdp`
114145

146+
```ts
147+
function cdp(): CDPSession
148+
```
149+
115150
The `cdp` export returns the current Chrome DevTools Protocol session. It is mostly useful to library authors to build tools on top of it.
116151

117152
::: warning

packages/browser/context.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@ interface LocatorSelectors {
455455
getByTestId: (text: string | RegExp) => Locator
456456
}
457457

458+
export interface FrameLocator extends LocatorSelectors {}
459+
458460
export interface Locator extends LocatorSelectors {
459461
/**
460462
* Selector string that will be used to locate the element by the browser provider.
@@ -701,6 +703,25 @@ export interface BrowserPage extends LocatorSelectors {
701703
* @see {@link https://vitest.dev/guide/browser/locators}
702704
*/
703705
elementLocator(element: Element): Locator
706+
/**
707+
* The iframe locator. This is a document locator that enters the iframe body
708+
* and works similarly to the `page` object.
709+
*
710+
* As the first argument, pass down the locator to the `<iframe>` element itself.
711+
*
712+
* **Warning:** At the moment, this is supported only by the `playwright` provider.
713+
* @example
714+
* ```ts
715+
* const frame = page.frameLocator(
716+
* page.getByTestId('iframe')
717+
* )
718+
*
719+
* await frame.getByText('Hello World').click()
720+
* ```
721+
* @param locator The locator object.
722+
* @see {@link https://vitest.dev/guide/browser/locators}
723+
*/
724+
frameLocator(locator: Locator): FrameLocator
704725
}
705726

706727
export interface BrowserLocators {

packages/browser/src/client/tester/context.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -314,31 +314,34 @@ export const page: BrowserPage = {
314314
))
315315
},
316316
getByRole() {
317-
throw new Error(`Method "getByRole" is not implemented in the "${provider}" provider.`)
317+
throw new Error(`Method "getByRole" is not supported by the "${provider}" provider.`)
318318
},
319319
getByLabelText() {
320-
throw new Error(`Method "getByLabelText" is not implemented in the "${provider}" provider.`)
320+
throw new Error(`Method "getByLabelText" is not supported by the "${provider}" provider.`)
321321
},
322322
getByTestId() {
323-
throw new Error(`Method "getByTestId" is not implemented in the "${provider}" provider.`)
323+
throw new Error(`Method "getByTestId" is not supported by the "${provider}" provider.`)
324324
},
325325
getByAltText() {
326-
throw new Error(`Method "getByAltText" is not implemented in the "${provider}" provider.`)
326+
throw new Error(`Method "getByAltText" is not supported by the "${provider}" provider.`)
327327
},
328328
getByPlaceholder() {
329-
throw new Error(`Method "getByPlaceholder" is not implemented in the "${provider}" provider.`)
329+
throw new Error(`Method "getByPlaceholder" is not supported by the "${provider}" provider.`)
330330
},
331331
getByText() {
332-
throw new Error(`Method "getByText" is not implemented in the "${provider}" provider.`)
332+
throw new Error(`Method "getByText" is not supported by the "${provider}" provider.`)
333333
},
334334
getByTitle() {
335-
throw new Error(`Method "getByTitle" is not implemented in the "${provider}" provider.`)
335+
throw new Error(`Method "getByTitle" is not supported by the "${provider}" provider.`)
336336
},
337337
elementLocator() {
338-
throw new Error(`Method "elementLocator" is not implemented in the "${provider}" provider.`)
338+
throw new Error(`Method "elementLocator" is not supported by the "${provider}" provider.`)
339+
},
340+
frameLocator() {
341+
throw new Error(`Method "frameLocator" is not supported by the "${provider}" provider.`)
339342
},
340343
_createLocator() {
341-
throw new Error(`Method "_createLocator" is not implemented in the "${provider}" provider.`)
344+
throw new Error(`Method "_createLocator" is not supported by the "${provider}" provider.`)
342345
},
343346
extend(methods) {
344347
for (const key in methods) {

packages/browser/src/client/tester/expect/toHaveAccessibleDescription.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default function toHaveAccessibleDescription(
2525
): ExpectationResult {
2626
const htmlElement = getElementFromUserInput(actual, toHaveAccessibleDescription, this)
2727
const actualAccessibleDescription = getElementAccessibleDescription(htmlElement, false)
28+
const defaultView = htmlElement.ownerDocument.defaultView || window
2829

2930
const missingExpectedValue = arguments.length === 1
3031

@@ -36,7 +37,7 @@ export default function toHaveAccessibleDescription(
3637
}
3738
else {
3839
pass
39-
= expectedAccessibleDescription instanceof RegExp
40+
= expectedAccessibleDescription instanceof defaultView.RegExp
4041
? expectedAccessibleDescription.test(actualAccessibleDescription)
4142
: this.equals(
4243
actualAccessibleDescription,

packages/browser/src/client/tester/expect/toHaveAccessibleErrorMessage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default function toHaveAccessibleErrorMessage(
2525
): ExpectationResult {
2626
const htmlElement = getElementFromUserInput(actual, toHaveAccessibleErrorMessage, this)
2727
const actualAccessibleErrorMessage = getElementAccessibleErrorMessage(htmlElement) ?? ''
28+
const defaultView = htmlElement.ownerDocument.defaultView || window
2829

2930
const missingExpectedValue = arguments.length === 1
3031

@@ -36,7 +37,7 @@ export default function toHaveAccessibleErrorMessage(
3637
}
3738
else {
3839
pass
39-
= expectedAccessibleErrorMessage instanceof RegExp
40+
= expectedAccessibleErrorMessage instanceof defaultView.RegExp
4041
? expectedAccessibleErrorMessage.test(actualAccessibleErrorMessage)
4142
: this.equals(
4243
actualAccessibleErrorMessage,

packages/browser/src/client/tester/expect/toHaveAccessibleName.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default function toHaveAccessibleName(
2626
const htmlElement = getElementFromUserInput(actual, toHaveAccessibleName, this)
2727
const actualAccessibleName = getElementAccessibleName(htmlElement, false)
2828
const missingExpectedValue = arguments.length === 1
29+
const defaultView = htmlElement.ownerDocument.defaultView || window
2930

3031
let pass = false
3132
if (missingExpectedValue) {
@@ -35,7 +36,7 @@ export default function toHaveAccessibleName(
3536
}
3637
else {
3738
pass
38-
= expectedAccessibleName instanceof RegExp
39+
= expectedAccessibleName instanceof defaultView.RegExp
3940
? expectedAccessibleName.test(actualAccessibleName)
4041
: this.equals(actualAccessibleName, expectedAccessibleName, this.customTesters)
4142
}

packages/browser/src/client/tester/expect/toHaveFormValues.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ export default function toHaveFormValues(
2525
): ExpectationResult {
2626
const formElement = getElementFromUserInput(actual, toHaveFormValues, this)
2727

28-
if (!(formElement instanceof HTMLFieldSetElement) && !(formElement instanceof HTMLFormElement)) {
28+
const defaultView = formElement.ownerDocument.defaultView || window
29+
30+
if (!(formElement instanceof defaultView.HTMLFieldSetElement) && !(formElement instanceof defaultView.HTMLFormElement)) {
2931
throw new TypeError(`toHaveFormValues must be called on a form or a fieldset, instead got ${getTag(formElement)}`)
3032
}
3133

packages/browser/src/client/tester/expect/utils.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export function getElementFromUserInput(
2626
elementOrLocator = elementOrLocator.element()
2727
}
2828

29+
const defaultView = elementOrLocator?.ownerDocument?.defaultView || window
30+
2931
if (
30-
elementOrLocator instanceof HTMLElement
31-
|| elementOrLocator instanceof SVGElement
32+
elementOrLocator instanceof defaultView.HTMLElement
33+
|| elementOrLocator instanceof defaultView.SVGElement
3234
) {
3335
return elementOrLocator
3436
}
@@ -49,8 +51,10 @@ export function getNodeFromUserInput(
4951
elementOrLocator = elementOrLocator.element()
5052
}
5153

54+
const defaultView = elementOrLocator.ownerDocument?.defaultView || window
55+
5256
if (
53-
elementOrLocator instanceof Node
57+
elementOrLocator instanceof defaultView.Node
5458
) {
5559
return elementOrLocator
5660
}

packages/browser/src/client/tester/locators/playwright.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ page.extend({
5252
element,
5353
)
5454
},
55+
frameLocator(locator: Locator) {
56+
return new PlaywrightLocator(
57+
`${locator.selector} >> internal:control=enter-frame`,
58+
)
59+
},
5560
})
5661

5762
class PlaywrightLocator extends Locator {

test/browser/test/iframe.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { page, server } from '@vitest/browser/context'
2+
import { expect, test } from 'vitest'
3+
4+
test.runIf(server.provider === 'playwright')('locates an iframe', async () => {
5+
const iframe = document.createElement('iframe')
6+
iframe.setAttribute('data-testid', 'iframe')
7+
iframe.srcdoc = '<div onclick="console.log">Hello World!</div>'
8+
document.body.append(iframe)
9+
const frame = page.frameLocator(
10+
page.getByTestId('iframe'),
11+
)
12+
13+
await frame.getByText('Hello World').click()
14+
await expect.element(frame.getByText('Hello World')).toHaveTextContent('Hello World')
15+
})

0 commit comments

Comments
 (0)