diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 6a1242084837b..5295065f84a76 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1439,6 +1439,46 @@ Returns the page containing this frame. Parent frame, if any. Detached frames and main frames return `null`. +## method: Frame.pierceFrames +* since: v1.63 +- returns: <[FrameLocator]> + +When working with iframes, you can create a frame locator that will search for elements in the main frame +and in all iframes on the page, so that you don't need to locate each iframe first. + +Note that all elements matching the locator must belong to a single frame. For example, if the page contains +two iframes, each with a `Submit` button, piercing frames and locating a button will throw an error +because it matches elements from multiple frames. + +**Usage** + +Following snippet locates a button, either in the main frame or in one of the iframes: + +```js +const locator = frame.pierceFrames().getByRole('button'); +await locator.click(); +``` + +```java +Locator locator = frame.pierceFrames().getByRole(AriaRole.BUTTON); +locator.click(); +``` + +```python async +locator = frame.pierce_frames.get_by_role("button") +await locator.click() +``` + +```python sync +locator = frame.pierce_frames.get_by_role("button") +locator.click() +``` + +```csharp +var locator = frame.PierceFrames.GetByRole(AriaRole.Button); +await locator.ClickAsync(); +``` + ## async method: Frame.press * since: v1.8 * discouraged: Use locator-based [`method: Locator.press`] instead. Read more about [locators](../locators.md). diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 7771df0eeff3e..bb0b594f61ce5 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2358,7 +2358,7 @@ last redirect. If cannot go back, returns `null`. Navigate to the previous page in history. :::warning -**Testing Back/Forward Cache (BFCache) is not supported.** +**Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized `Page` state. ::: @@ -2383,7 +2383,7 @@ last redirect. If cannot go forward, returns `null`. Navigate to the next page in history. :::warning -**Testing Back/Forward Cache (BFCache) is not supported.** +**Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized `Page` state. ::: @@ -3162,6 +3162,46 @@ var locator = await page.PickLocatorAsync(); Console.WriteLine(locator); ``` +## method: Page.pierceFrames +* since: v1.63 +- returns: <[FrameLocator]> + +When working with iframes, you can create a frame locator that will search for elements in the main frame +and in all iframes on the page, so that you don't need to locate each iframe first. + +Note that all elements matching the locator must belong to a single frame. For example, if the page contains +two iframes, each with a `Submit` button, piercing frames and locating a button will throw an error +because it matches elements from multiple frames. + +**Usage** + +Following snippet locates a button, either in the main frame or in one of the iframes: + +```js +const locator = page.pierceFrames().getByRole('button'); +await locator.click(); +``` + +```java +Locator locator = page.pierceFrames().getByRole(AriaRole.BUTTON); +locator.click(); +``` + +```python async +locator = page.pierce_frames.get_by_role("button") +await locator.click() +``` + +```python sync +locator = page.pierce_frames.get_by_role("button") +locator.click() +``` + +```csharp +var locator = page.PierceFrames.GetByRole(AriaRole.Button); +await locator.ClickAsync(); +``` + ## async method: Page.press * since: v1.8 * discouraged: Use locator-based [`method: Locator.press`] instead. Read more about [locators](../locators.md). diff --git a/packages/injected/src/recorder/recorder.ts b/packages/injected/src/recorder/recorder.ts index 5fa7719cdbc65..26547100b2d14 100644 --- a/packages/injected/src/recorder/recorder.ts +++ b/packages/injected/src/recorder/recorder.ts @@ -171,7 +171,6 @@ class InspectTool implements RecorderTool { void this._recorder.recordAction({ name: 'assertVisible', selector, - signals: [], }); this._recorder.setMode('recording'); this._recorder.overlay?.flashToolSucceeded('assertingVisibility'); @@ -269,39 +268,14 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: checkbox.checked ? 'check' : 'uncheck', selector: this._hoveredModel?.selector ?? this._selectorForElement(target), - signals: [], }, { autoExpect: true }); return; } - // Only single clicks are recorded here; double clicks are recorded in onDblClick. - if (event.detail !== 1) - return; - this._recordAction({ - name: 'click', - selector: this._hoveredModel?.selector ?? this._selectorForElement(target), - position: positionForEvent(event), - signals: [], - button: buttonForEvent(event), - modifiers: modifiersForEvent(event), - clickCount: event.detail - }, { autoExpect: true }); - } - - onDblClick(event: MouseEvent) { - if (this._dialog.isShowing()) - return; - if (isRangeInput(this._hoveredElement)) - return; - if (this._shouldIgnoreMouseEvent(event)) - return; - - const target = this._recorder.deepEventTarget(event); this._recordAction({ name: 'click', selector: this._hoveredModel?.selector ?? this._selectorForElement(target), position: positionForEvent(event), - signals: [], button: buttonForEvent(event), modifiers: modifiersForEvent(event), clickCount: event.detail @@ -324,7 +298,6 @@ class RecordActionTool implements RecorderTool { name: 'click', selector: this._hoveredModel?.selector ?? this._selectorForElement(target), position: positionForEvent(event), - signals: [], button: 'right', modifiers: modifiersForEvent(event), clickCount: 1, @@ -409,7 +382,6 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: 'setInputFiles', selector, - signals: [], files: [...((target as HTMLInputElement).files || [])].map(file => file.name), }); return; @@ -420,7 +392,6 @@ class RecordActionTool implements RecorderTool { name: 'fill', // must use hoveredModel instead of activeModel for it to work in webkit selector: this._hoveredModel?.selector ?? this._selectorForElement(target), - signals: [], text: target.value, }); return; @@ -435,7 +406,6 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: 'fill', selector: this._activeSelectorForEvent(event), - signals: [], text: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value, }); } @@ -446,7 +416,6 @@ class RecordActionTool implements RecorderTool { name: 'select', selector: this._activeSelectorForEvent(event), options: [...selectElement.selectedOptions].map(option => option.value), - signals: [] }); } } @@ -463,7 +432,6 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: checkbox.checked ? 'uncheck' : 'check', selector: this._activeSelectorForEvent(event), - signals: [], }, { autoExpect: true }); return; } @@ -472,7 +440,6 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: 'press', selector: this._activeSelectorForEvent(event), - signals: [], key: event.key, modifiers: modifiersForEvent(event), }, { autoExpect: true }); @@ -498,7 +465,6 @@ class RecordActionTool implements RecorderTool { name: 'click', selector: model.selector, position: actionPosition, - signals: [], button: 'left', modifiers: 0, clickCount: 1, @@ -510,7 +476,6 @@ class RecordActionTool implements RecorderTool { name: 'click', selector: model.selector, position: actionPosition, - signals: [], button: 'right', modifiers: 0, clickCount: 1, @@ -522,7 +487,6 @@ class RecordActionTool implements RecorderTool { name: 'click', selector: model.selector, position: actionPosition, - signals: [], button: 'left', modifiers: 0, clickCount: 2, @@ -534,7 +498,6 @@ class RecordActionTool implements RecorderTool { name: 'hover', selector: model.selector, position: actionPosition, - signals: [], }), }, { @@ -731,7 +694,6 @@ class JsonRecordActionTool implements RecorderTool { name: checkbox.checked ? 'check' : 'uncheck', selector, ref, - signals: [], ariaSnapshot, }); return; @@ -743,7 +705,6 @@ class JsonRecordActionTool implements RecorderTool { ref, ariaSnapshot, position: positionForEvent(event), - signals: [], button: buttonForEvent(event), modifiers: modifiersForEvent(event), clickCount: event.detail, @@ -759,7 +720,6 @@ class JsonRecordActionTool implements RecorderTool { ref, ariaSnapshot, position: positionForEvent(event), - signals: [], button: 'right', modifiers: modifiersForEvent(event), clickCount: 1, @@ -776,7 +736,6 @@ class JsonRecordActionTool implements RecorderTool { selector, ref, ariaSnapshot, - signals: [], text: element.value, }); return; @@ -793,7 +752,6 @@ class JsonRecordActionTool implements RecorderTool { ref, selector, ariaSnapshot, - signals: [], text: element.isContentEditable ? element.innerText : (element as HTMLInputElement).value, }); return; @@ -807,7 +765,6 @@ class JsonRecordActionTool implements RecorderTool { ref, ariaSnapshot, options: [...selectElement.selectedOptions].map(option => option.value), - signals: [] }); return; } @@ -829,7 +786,6 @@ class JsonRecordActionTool implements RecorderTool { selector, ref, ariaSnapshot, - signals: [], }); return; } @@ -840,7 +796,6 @@ class JsonRecordActionTool implements RecorderTool { selector, ref, ariaSnapshot, - signals: [], key: event.key, modifiers: modifiersForEvent(event), }); @@ -992,7 +947,6 @@ class TextAssertionTool implements RecorderTool { return { name: 'assertChecked', selector, - signals: [], // Interestingly, inputElement.checked is reversed inside this event handler. checked: !(target as HTMLInputElement).checked, }; @@ -1000,7 +954,6 @@ class TextAssertionTool implements RecorderTool { return { name: 'assertValue', selector, - signals: [], value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value, }; } @@ -1013,7 +966,6 @@ class TextAssertionTool implements RecorderTool { return { name: 'assertSnapshot', selector: this._hoverHighlight.selector, - signals: [], ariaSnapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'codegen' }), }; } else { @@ -1025,7 +977,6 @@ class TextAssertionTool implements RecorderTool { return { name: 'assertText', selector: this._hoverHighlight.selector, - signals: [], text: this._recorder.injectedScript.utils.elementText(this._textCache, target).normalized, substring: true, }; diff --git a/packages/isomorphic/codegen/actions.d.ts b/packages/isomorphic/codegen/actions.d.ts index e75e5234bbc74..b397f45fc1345 100644 --- a/packages/isomorphic/codegen/actions.d.ts +++ b/packages/isomorphic/codegen/actions.d.ts @@ -36,7 +36,6 @@ export type ActionName = export type ActionBase = { name: ActionName, - signals: Signal[], ariaSnapshot?: string, }; @@ -166,12 +165,10 @@ export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSig export type ActionInContext = { pageGuid: string; action: Action; - startTime: number; - endTime?: number; + signals: Signal[]; }; export type SignalInContext = { pageGuid: string; signal: Signal; - timestamp: number; }; diff --git a/packages/isomorphic/codegen/csharp.ts b/packages/isomorphic/codegen/csharp.ts index a3a57ba93d10c..eeb0eade0460a 100644 --- a/packages/isomorphic/codegen/csharp.ts +++ b/packages/isomorphic/codegen/csharp.ts @@ -91,7 +91,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { } const subject = pageAlias; - const signals = toSignalMap(action); + const signals = toSignalMap(actionInContext); if (signals.dialog) { formatter.add(` void ${pageAlias}_Dialog${signals.dialog.dialogAlias}_EventHandler(object sender, IDialog dialog) diff --git a/packages/isomorphic/codegen/java.ts b/packages/isomorphic/codegen/java.ts index 3e318114e2681..2c36a649d68b0 100644 --- a/packages/isomorphic/codegen/java.ts +++ b/packages/isomorphic/codegen/java.ts @@ -79,7 +79,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { } const subject = pageAlias; - const signals = toSignalMap(action); + const signals = toSignalMap(actionInContext); if (signals.dialog) { formatter.add(` ${pageAlias}.onceDialog(dialog -> { diff --git a/packages/isomorphic/codegen/javascript.ts b/packages/isomorphic/codegen/javascript.ts index 01002d3f5901a..ca0fcb9f61886 100644 --- a/packages/isomorphic/codegen/javascript.ts +++ b/packages/isomorphic/codegen/javascript.ts @@ -67,7 +67,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { } const subject = pageAlias; - const signals = toSignalMap(action); + const signals = toSignalMap(actionInContext); if (signals.dialog) { formatter.add(` ${pageAlias}.once('dialog', dialog => { diff --git a/packages/isomorphic/codegen/jsonl.ts b/packages/isomorphic/codegen/jsonl.ts index d31858594e5ed..ec817cb3b5110 100644 --- a/packages/isomorphic/codegen/jsonl.ts +++ b/packages/isomorphic/codegen/jsonl.ts @@ -33,12 +33,13 @@ export class JsonlLanguageGenerator implements LanguageGenerator { const locator = (actionInContext.action as any).selector ? JSON.parse(asLocator('jsonl', (actionInContext.action as any).selector)) : undefined; const entry = { ...actionInContext.action, + signals: actionInContext.signals, pageGuid: actionInContext.pageGuid, locator, ariaSnapshot: undefined, }; const lines = [JSON.stringify(entry)]; - const expect = toSignalMap(actionInContext.action).expect; + const expect = toSignalMap(actionInContext).expect; if (options.generateExpectSignal && expect) lines.push(this.generateAction(expectSignalAction(actionInContext, expect), options)); return lines.join('\n'); diff --git a/packages/isomorphic/codegen/language.ts b/packages/isomorphic/codegen/language.ts index eb3df508efd3d..f6d717b990fe5 100644 --- a/packages/isomorphic/codegen/language.ts +++ b/packages/isomorphic/codegen/language.ts @@ -31,12 +31,10 @@ export function generateCode(actions: actions.ActionInContext[], languageGenerat export function expectSignalAction(actionInContext: actions.ActionInContext, signal: actions.ExpectSignal): actions.ActionInContext { return { pageGuid: actionInContext.pageGuid, - startTime: actionInContext.startTime, - endTime: actionInContext.startTime, + signals: [], action: { name: 'assertVisible', selector: signal.selector, - signals: [], }, }; } @@ -51,12 +49,12 @@ export function sanitizeDeviceOptions(device: any, options: BrowserContextOption return cleanedOptions; } -export function toSignalMap(action: actions.Action) { +export function toSignalMap(actionInContext: actions.ActionInContext) { let popup: actions.PopupSignal | undefined; let download: actions.DownloadSignal | undefined; let dialog: actions.DialogSignal | undefined; let expect: actions.ExpectSignal | undefined; - for (const signal of action.signals) { + for (const signal of actionInContext.signals) { if (signal.name === 'popup') popup = signal; else if (signal.name === 'download') diff --git a/packages/isomorphic/codegen/python.ts b/packages/isomorphic/codegen/python.ts index 2640b52c17e3a..f193f727d5fb7 100644 --- a/packages/isomorphic/codegen/python.ts +++ b/packages/isomorphic/codegen/python.ts @@ -74,7 +74,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { } const subject = pageAlias; - const signals = toSignalMap(action); + const signals = toSignalMap(actionInContext); if (signals.dialog) formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index 744b7a69cc380..9bb9d31814411 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -21,7 +21,7 @@ import type { NestedSelectorBody } from './selectorParser'; import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'pierce-frames' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; export type Quote = '\'' | '"' | '`'; @@ -205,6 +205,11 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram continue; } } + if (part.name === 'internal:control' && (part.body as string) === 'pierce-frames') { + tokens.push([factory.generateLocator(base, 'pierce-frames', '')]); + nextBase = 'frame-locator'; + continue; + } if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') { // transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})` const lastTokens = tokens[tokens.length - 1]; @@ -315,6 +320,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; + case 'pierce-frames': + return `pierceFrames()`; case 'nth': return `nth(${body})`; case 'first': @@ -414,6 +421,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `frame_locator(${this.quote(body as string)})`; case 'frame': return `content_frame`; + case 'pierce-frames': + return `pierce_frames`; case 'nth': return `nth(${body})`; case 'first': @@ -526,6 +535,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; + case 'pierce-frames': + return `pierceFrames()`; case 'nth': return `nth(${body})`; case 'first': @@ -628,6 +639,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `FrameLocator(${this.quote(body as string)})`; case 'frame': return `ContentFrame`; + case 'pierce-frames': + return `PierceFrames`; case 'nth': return `Nth(${body})`; case 'first': diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index 35212cc571bdd..fcb417b5563d1 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -78,6 +78,7 @@ function parseLocator(locator: string, testIdAttributeName: string): { selector: .replace(/has_not/g, 'hasnot') .replace(/frame_locator/g, 'framelocator') .replace(/content_frame/g, 'contentframe') + .replace(/pierce_frames/g, 'pierceframes') .replace(/[{}\s]/g, '') .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') @@ -159,6 +160,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') .replace(/contentframe(\(\))?/g, 'internal:control=enter-frame') + .replace(/pierceframes(\(\))?/g, 'internal:control=pierce-frames') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index 0e084733c55cf..17f1edced4e14 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -126,8 +126,11 @@ export function splitSelectorByFrame(selectorText: string): { pierce: boolean, c chunk.capture = i - chunkStartIndex; chunk.parts.push(part); } - if (!chunk.parts.length) + if (!chunk.parts.length) { + if (pierce) + throw new InvalidSelectorError(`Selector cannot be empty when piercing frames, while parsing selector ${selectorText}`); throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`); + } chunks.push(chunk); if (typeof selector.capture === 'number' && typeof chunks[chunks.length - 1].capture !== 'number') throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 101f50fe293a2..eaa024a641d57 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -3293,7 +3293,7 @@ export interface Page { * * Navigate to the previous page in history. * - * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the * Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on * network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using * `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized @@ -3341,7 +3341,7 @@ export interface Page { * * Navigate to the next page in history. * - * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the * Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on * network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using * `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized @@ -4132,6 +4132,26 @@ export interface Page { */ pickLocator(): Promise; + /** + * When working with iframes, you can create a frame locator that will search for elements in the main frame and in + * all iframes on the page, so that you don't need to locate each iframe first. + * + * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches + * elements from multiple frames. + * + * **Usage** + * + * Following snippet locates a button, either in the main frame or in one of the iframes: + * + * ```js + * const locator = page.pierceFrames().getByRole('button'); + * await locator.click(); + * ``` + * + */ + pierceFrames(): FrameLocator; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -8145,6 +8165,26 @@ export interface Frame { */ parentFrame(): null|Frame; + /** + * When working with iframes, you can create a frame locator that will search for elements in the main frame and in + * all iframes on the page, so that you don't need to locate each iframe first. + * + * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches + * elements from multiple frames. + * + * **Usage** + * + * Following snippet locates a button, either in the main frame or in one of the iframes: + * + * ```js + * const locator = frame.pierceFrames().getByRole('button'); + * await locator.click(); + * ``` + * + */ + pierceFrames(): FrameLocator; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index e752abe2fe42a..3f1ce5e3d63b5 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -56,9 +56,8 @@ import type * as channels from './channels'; import type * as actions from '@isomorphic/codegen/actions'; interface RecorderEventSink { - actionAdded?(page: Page, actionInContext: actions.ActionInContext, code: string): void; - actionUpdated?(page: Page, actionInContext: actions.ActionInContext, code: string): void; - signalAdded?(page: Page, signal: actions.SignalInContext): void; + actionAdded?(page: Page, action: actions.Action, code: string): void; + signalAdded?(page: Page, signal: actions.Signal, code: string): void; } export class BrowserContext extends ChannelOwner implements api.BrowserContext { @@ -162,11 +161,9 @@ export class BrowserContext extends ChannelOwner this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page))); this._channel.on('recorderEvent', ({ event, data, page, code }) => { if (event === 'actionAdded') - this._onRecorderEventSink?.actionAdded?.(Page.from(page), data as actions.ActionInContext, code); - else if (event === 'actionUpdated') - this._onRecorderEventSink?.actionUpdated?.(Page.from(page), data as actions.ActionInContext, code); + this._onRecorderEventSink?.actionAdded?.(Page.from(page), data as actions.Action, code); else if (event === 'signalAdded') - this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.SignalInContext); + this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.Signal, code); }); this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f)); diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 6deafa90d2c4b..d62e20c022afd 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -1358,7 +1358,7 @@ export type BrowserContextResponseEvent = { page?: PageChannel, }; export type BrowserContextRecorderEventEvent = { - event: 'actionAdded' | 'actionUpdated' | 'signalAdded', + event: 'actionAdded' | 'signalAdded', data: any, page: PageChannel, code: string, diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 2b315dd685642..7bec0b12902ee 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -28,7 +28,7 @@ import { ElementHandle, convertInputFiles, convertSelectOptionValues } from './e import { AbortError, PlaywrightError } from './errors'; import { Events } from './events'; import { JSHandle, assertEvaluateOptions, assertMaxArguments, parseResult, serializeArgument, serializeArgumentWithCallbacks } from './jsHandle'; -import { FrameLocator, Locator, testIdAttributeName } from './locator'; +import { FrameLocator, kPierceFramesSelector, Locator, testIdAttributeName } from './locator'; import * as network from './network'; import { kLifecycleEvents } from './types'; import { Waiter } from './waiter'; @@ -397,6 +397,10 @@ export class Frame extends ChannelOwner implements api.Fr return new FrameLocator(this, selector); } + pierceFrames(): FrameLocator { + return new FrameLocator(this, kPierceFramesSelector); + } + async focus(selector: string, options: channels.FrameFocusOptions & TimeoutOptions = {}) { await this._channel.focus({ selector, ...options }, this._timeout(options)); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 078234fd9f110..dd58b18101734 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -207,6 +207,8 @@ export class Locator implements api.Locator { } frameLocator(selector: string): FrameLocator { + if (selectorPiercesFrames(this._selector)) + throw new Error(`Entering frames is not allowed while piercing frames.`); return new FrameLocator(this._frame, this._selector + ' >> ' + selector); } @@ -421,6 +423,12 @@ export class Locator implements api.Locator { } } +export const kPierceFramesSelector = 'internal:control=pierce-frames'; + +function selectorPiercesFrames(selector: string): boolean { + return selector === kPierceFramesSelector || selector.startsWith(kPierceFramesSelector + ' >> '); +} + export class FrameLocator implements api.FrameLocator { private _frame: Frame; private _frameSelector: string; @@ -430,12 +438,18 @@ export class FrameLocator implements api.FrameLocator { this._frameSelector = selector; } + private _childSelector(selector: string): string { + if (this._frameSelector === kPierceFramesSelector) + return this._frameSelector + ' >> ' + selector; + return this._frameSelector + ' >> internal:control=enter-frame >> ' + selector; + } + locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { if (isString(selectorOrLocator)) - return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator, options); + return new Locator(this._frame, this._childSelector(selectorOrLocator), options); if (selectorOrLocator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); - return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator._selector, options); + return new Locator(this._frame, this._childSelector(selectorOrLocator._selector), options); } getByTestId(testId: string | RegExp): Locator { @@ -471,19 +485,27 @@ export class FrameLocator implements api.FrameLocator { } frameLocator(selector: string): FrameLocator { - return new FrameLocator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selector); + if (selectorPiercesFrames(this._frameSelector)) + throw new Error(`Entering frames is not allowed while piercing frames.`); + return new FrameLocator(this._frame, this._childSelector(selector)); + } + + private _nthSelector(nth: string): string { + if (selectorPiercesFrames(this._frameSelector)) + throw new Error(`Selecting the nth frame is not allowed while piercing frames.`); + return this._frameSelector + ` >> nth=${nth}`; } first(): FrameLocator { - return new FrameLocator(this._frame, this._frameSelector + ' >> nth=0'); + return new FrameLocator(this._frame, this._nthSelector('0')); } last(): FrameLocator { - return new FrameLocator(this._frame, this._frameSelector + ` >> nth=-1`); + return new FrameLocator(this._frame, this._nthSelector('-1')); } nth(index: number): FrameLocator { - return new FrameLocator(this._frame, this._frameSelector + ` >> nth=${index}`); + return new FrameLocator(this._frame, this._nthSelector(String(index))); } } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 1227ce1c5307b..957507a151b05 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -782,6 +782,10 @@ export class Page extends ChannelOwner implements api.Page return this.mainFrame().frameLocator(selector); } + pierceFrames(): FrameLocator { + return this.mainFrame().pierceFrames(); + } + async focus(selector: string, options?: channels.FrameFocusOptions & TimeoutOptions) { return await this._mainFrame.focus(selector, options); } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index fb2023c8fff83..04f888bfa5afa 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -81,7 +81,7 @@ export type BrowserContextEventMap = { [BrowserContextEvent.RequestFulfilled]: [request: network.Request]; [BrowserContextEvent.RequestContinued]: [request: network.Request]; [BrowserContextEvent.BeforeClose]: []; - [BrowserContextEvent.RecorderEvent]: [event: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }]; + [BrowserContextEvent.RecorderEvent]: [event: { event: 'actionAdded' | 'signalAdded', data: any, page: Page, code: string }]; [BrowserContextEvent.PageClosed]: [page: Page]; [BrowserContextEvent.InternalFrameNavigatedToNewDocument]: [frame: frames.Frame]; [BrowserContextEvent.FrameAttached]: [frame: frames.Frame]; diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 5b65ebfbc058e..07a6414f49295 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -1359,7 +1359,7 @@ export type BrowserContextResponseEvent = { page?: PageChannel, }; export type BrowserContextRecorderEventEvent = { - event: 'actionAdded' | 'actionUpdated' | 'signalAdded', + event: 'actionAdded' | 'signalAdded', data: any, page: PageChannel, code: string, diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index d09d972131923..99b51cb77f0c6 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -23,7 +23,6 @@ import { generateCode } from '@isomorphic/codegen/language'; import { JavaScriptLanguageGenerator } from '@isomorphic/codegen/javascript'; import { SdkObject, createInstrumentation } from './instrumentation'; import { Recorder, RecorderEvent } from './recorder'; -import { collapseActions } from './recorder/recorderUtils'; import type { Language } from '@isomorphic/locatorGenerators'; import type { BrowserContext } from './browserContext'; @@ -188,8 +187,7 @@ function wireListeners(recorder: Recorder, debugController: DebugController) { const languageGenerator = new JavaScriptLanguageGenerator(/* isPlaywrightTest */true); const actionsChanged = () => { - const aa = collapseActions(actions); - const { header, footer, text, actionTexts } = generateCode(aa, languageGenerator, { + const { header, footer, text, actionTexts } = generateCode(actions, languageGenerator, { browserName: 'chromium', launchOptions: {}, contextOptions: {}, @@ -215,7 +213,7 @@ function wireListeners(recorder: Recorder, debugController: DebugController) { recorder.on(RecorderEvent.SignalAdded, (signal: actions.SignalInContext) => { const lastAction = actions.findLast(a => a.pageGuid === signal.pageGuid); if (lastAction) - lastAction.action.signals.push(signal.signal); + lastAction.signals.push(signal.signal); actionsChanged(); }); } diff --git a/packages/playwright-core/src/server/dialog.ts b/packages/playwright-core/src/server/dialog.ts index 5afce865e1ec6..e25db0f6484c2 100644 --- a/packages/playwright-core/src/server/dialog.ts +++ b/packages/playwright-core/src/server/dialog.ts @@ -16,6 +16,7 @@ */ import { assert } from '@isomorphic/assert'; +import { EvaluationStalledError } from './errors'; import { SdkObject } from './instrumentation'; import type { Instrumentation } from './instrumentation'; @@ -101,7 +102,7 @@ export class DialogManager { dialogDidOpen(dialog: Dialog) { // Any ongoing evaluations will be stalled until the dialog is closed. for (const frame of dialog.page().frameManager.frames()) - frame.invalidateNonStallingEvaluations(new Error('JavaScript dialog interrupted evaluation')); + frame.invalidateNonStallingEvaluations(new EvaluationStalledError('JavaScript dialog interrupted evaluation')); this._openedDialogs.add(dialog); this._instrumentation.onDialog(dialog); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 3616c35cd75ff..215dd68a5dce5 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -190,7 +190,7 @@ export class BrowserContextDispatcher extends Dispatcher { + this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'signalAdded', data: any, page: Page, code: string }) => { this._dispatchEvent('recorderEvent', { event, data, code, page: PageDispatcher.from(this, page) }); }); } diff --git a/packages/playwright-core/src/server/errors.ts b/packages/playwright-core/src/server/errors.ts index 6d18b4ddcb018..e30e3be9e1ecf 100644 --- a/packages/playwright-core/src/server/errors.ts +++ b/packages/playwright-core/src/server/errors.ts @@ -28,6 +28,8 @@ class CustomError extends Error { export class TimeoutError extends CustomError {} +export class EvaluationStalledError extends CustomError {} + export class TargetClosedError extends CustomError { constructor(cause: string | undefined, logs?: string) { super((cause || 'Target page, context or browser has been closed') + (logs || '')); diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 039e318fdc21b..27f6d7b554e6b 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -18,6 +18,7 @@ import { InvalidSelectorError, splitSelectorByFrame, stringifySelector, visitAl import { asLocator } from '@isomorphic/locatorGenerators'; import { NonRecoverableDOMError } from './dom'; +import { EvaluationStalledError } from './errors'; import type { ElementHandle, FrameExecutionContext } from './dom'; import type { Frame } from './frames'; @@ -306,7 +307,11 @@ export class FrameSelectors { } return { result: evalResult as R | SmartHandle }; }; - const maybeResult = noStall ? await frame.raceAgainstEvaluationStallingEvents(getResult).catch(() => undefined) : await getResult(); + const maybeResult = noStall ? await frame.raceAgainstEvaluationStallingEvents(getResult).catch(e => { + if (e instanceof EvaluationStalledError) + return; + throw e; + }) : await getResult(); if (!maybeResult) continue; if (aggregatedResult) diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ff517998054eb..c629a813019b0 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -28,7 +28,7 @@ import { makeWaitForNextTask } from '@utils/task'; import { createGuid } from '@utils/crypto'; import { BrowserContext } from './browserContext'; import * as dom from './dom'; -import { TimeoutError, isTargetClosedError } from './errors'; +import { EvaluationStalledError, TimeoutError, isTargetClosedError } from './errors'; import { prepareFilesForUpload } from './fileUploadUtils'; import { FrameSelectors } from './frameSelectors'; import { helper } from './helper'; @@ -585,7 +585,7 @@ export class Frame extends SdkObject { _setPendingDocument(documentInfo: DocumentInfo | undefined) { this._pendingDocument = documentInfo; if (documentInfo) - this.invalidateNonStallingEvaluations(new Error('Navigation interrupted the evaluation')); + this.invalidateNonStallingEvaluations(new EvaluationStalledError('Navigation interrupted the evaluation')); } pendingDocument(): DocumentInfo | undefined { @@ -601,9 +601,9 @@ export class Frame extends SdkObject { async raceAgainstEvaluationStallingEvents(cb: () => Promise): Promise { if (this._pendingDocument) - throw new Error('Frame is currently attempting a navigation'); + throw new EvaluationStalledError('Frame is currently attempting a navigation'); if (this._page.browserContext.dialogManager.hasOpenDialogsForPage(this._page)) - throw new Error('Open JavaScript dialog prevents evaluation'); + throw new EvaluationStalledError('Open JavaScript dialog prevents evaluation'); const promise = new ManualPromise(); this._raceAgainstEvaluationStallingEventsPromises.add(promise); @@ -1535,7 +1535,7 @@ export class Frame extends SdkObject { }); } catch (e) { const details: ExpectErrorDetails = {}; - if (isInvalidSelectorError(e)) { + if (isInvalidSelectorError(e) || dom.isNonRecoverableDOMError(e)) { details.customErrorMessage = e.message; } else if (js.isJavaScriptErrorInEvaluate(e)) { details.customErrorMessage = e.message.startsWith('Error: ') ? e.message.substring('Error: '.length) : e.message; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index a49150e239b5b..30c13f84ba99b 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -22,7 +22,6 @@ import { stringifySelector } from '@isomorphic/selectorParser'; import { ManualPromise } from '@isomorphic/manualPromise'; import { isUnderTest } from '@utils/debug'; import { eventsHelper } from '@utils/eventsHelper'; -import { monotonicTime } from '@isomorphic/time'; import { BrowserContext } from './browserContext'; import { Debugger } from './debugger'; import { buildFullSelector, generateFrameSelector, metadataToCallLog } from './recorder/recorderUtils'; @@ -496,9 +495,8 @@ export class Recorder extends EventEmitter implements Instrume pageGuid: page.guid, action: { name: 'closePage', - signals: [], }, - startTime: monotonicTime() + signals: [], }); this._filePrimaryURLChanged(); }); @@ -518,9 +516,8 @@ export class Recorder extends EventEmitter implements Instrume action: { name: 'openPage', url: page.mainFrame().url(), - signals: [], }, - startTime: monotonicTime() + signals: [], }); } this._filePrimaryURLChanged(); @@ -548,7 +545,7 @@ export class Recorder extends EventEmitter implements Instrume const actionInContext: actions.ActionInContext = { pageGuid: frame._page.guid, action, - startTime: monotonicTime(), + signals: [], }; return actionInContext; } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index e61603a964c64..17e2096b7f397 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -26,7 +26,6 @@ import { syncLocalStorageWithSettings } from '../launchApp'; import { launchApp } from '../launchApp'; import { nullProgress, ProgressController } from '../progress'; import { ThrottledFile } from './throttledFile'; -import { collapseActions, shouldMergeAction } from './recorderUtils'; import { Recorder, RecorderEvent } from '../recorder'; import { BrowserContext } from '../browserContext'; @@ -290,7 +289,7 @@ export class RecorderApp { private _onSignalAdded(signal: actions.SignalInContext) { const lastAction = this._actions.findLast(a => a.pageGuid === signal.pageGuid); if (lastAction) - lastAction.action.signals.push(signal.signal); + lastAction.signals.push(signal.signal); this._updateActions(); } @@ -315,11 +314,9 @@ export class RecorderApp { private _updateActions(reveal?: 'reveal') { const recorderSources = []; - const actions = collapseActions(this._actions); - let revealSourceId: string | undefined; for (const languageGenerator of languageSet()) { - const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, this._languageGeneratorOptions); + const { header, footer, actionTexts, text } = generateCode(this._actions, languageGenerator, this._languageGeneratorOptions); const source: Source = { isRecorded: true, label: languageGenerator.name, @@ -357,7 +354,7 @@ function determinePrimaryGeneratorId(sdkLanguage: Language): string { export class ProgrammaticRecorderApp { static async run(inspectedContext: BrowserContext, recorder: Recorder, browserName: string, params: channels.BrowserContextEnableRecorderParams) { - let lastAction: actions.ActionInContext | null = null; + let lastAction: actions.ActionInContext | undefined; const languages = [...languageSet()]; const languageGeneratorOptions = { @@ -369,22 +366,23 @@ export class ProgrammaticRecorderApp { }; const languageGenerator = languages.find(l => l.id === params.language) ?? languages.find(l => l.id === 'playwright-test')!; - recorder.on(RecorderEvent.ActionAdded, action => { - const page = findPageByGuid(inspectedContext, action.pageGuid); + recorder.on(RecorderEvent.ActionAdded, actionInContext => { + const page = findPageByGuid(inspectedContext, actionInContext.pageGuid); if (!page) return; - const code = languageGenerator.generateAction(action, languageGeneratorOptions); - if (!lastAction || !shouldMergeAction(action, lastAction)) - inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: action, page, code }); - else - inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionUpdated', data: action, page, code }); - lastAction = action; + lastAction = actionInContext; + const code = languageGenerator.generateAction(actionInContext, languageGeneratorOptions); + inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: actionInContext.action, page, code }); }); - recorder.on(RecorderEvent.SignalAdded, signal => { - const page = findPageByGuid(inspectedContext, signal.pageGuid); + recorder.on(RecorderEvent.SignalAdded, signalInContext => { + const page = findPageByGuid(inspectedContext, signalInContext.pageGuid); if (!page) return; - inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signal, page, code: '' }); + // The signal belongs to the last action, so re-generate its code with the signal + // included (e.g. a popup or download wait around the action). + lastAction?.signals.push(signalInContext.signal); + const code = lastAction ? languageGenerator.generateAction(lastAction, languageGeneratorOptions) : ''; + inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signalInContext.signal, page, code }); }); } } diff --git a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts index aedc4a4278b3c..6c970ef831278 100644 --- a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts +++ b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts @@ -26,81 +26,105 @@ export interface ProcessorDelegate { addSignal(signalInContext: actions.SignalInContext): void; } -// How long a single click is held back, waiting for a double click to arrive and merge with it. -const kClickBufferTimeout = 500; +// How long an action is held back, waiting for a superseding action to merge with it: +// a double click after a click, another keystroke after a fill, another navigation. +const kActionBufferTimeout = 500; type BufferedSignal = { frame: Frame, signal: Signal, timestamp: number }; export class RecorderSignalProcessor { private _delegate: ProcessorDelegate; private _lastAction: actions.ActionInContext | null = null; - private _bufferedClick: { actionInContext: actions.ActionInContext, signals: BufferedSignal[], timeout: NodeJS.Timeout } | undefined; + private _lastActionTimestamp = 0; + private _pendingAction: { actionInContext: actions.ActionInContext, receivedAt: number, signals: BufferedSignal[], timeout: NodeJS.Timeout } | undefined; constructor(actionSink: ProcessorDelegate) { this._delegate = actionSink; } addAction(actionInContext: actions.ActionInContext) { - if (this._bufferedClick) { - if (this._isDoubleClick(actionInContext, this._bufferedClick.actionInContext)) { - // A double click - merge it into the buffered single click and emit the result. - actionInContext.startTime = this._bufferedClick.actionInContext.startTime; - this._flushBufferedClick(actionInContext); + const timestamp = monotonicTime(); + if (this._pendingAction) { + if (this._supersedes(actionInContext, this._pendingAction.actionInContext)) { + this._pendingAction.actionInContext = actionInContext; + this._pendingAction.receivedAt = timestamp; + this._resetPendingTimeout(); return; } - // A different action - emit the buffered click before proceeding. - this._flushBufferedClick(); + this._flushPendingAction(); } - if (this._isBufferableClick(actionInContext)) { - this._bufferedClick = { + if (this._shouldBuffer(actionInContext)) { + this._pendingAction = { actionInContext, + receivedAt: timestamp, signals: [], - timeout: setTimeout(() => this._flushBufferedClick(), kClickBufferTimeout), + timeout: setTimeout(() => this._flushPendingAction(), kActionBufferTimeout), }; return; } - this._emitAction(actionInContext); + this._emitAction(actionInContext, timestamp); } signal(frame: Frame, signal: Signal) { const timestamp = monotonicTime(); - if (this._bufferedClick) { - this._bufferedClick.signals.push({ frame, signal, timestamp }); + const isMainFrameNavigation = signal.name === 'navigation' && frame._page.mainFrame() === frame; + if (this._pendingAction?.actionInContext.action.name === 'navigate' && isMainFrameNavigation && this._pendingAction.actionInContext.pageGuid === frame._page.guid) { + this._pendingAction.actionInContext.action.url = frame.url(); + this._resetPendingTimeout(); return; } - this._processSignal(frame, signal, timestamp); + if (this._pendingAction) + this._pendingAction.signals.push({ frame, signal, timestamp }); + else + this._processSignal(frame, signal, timestamp); } - private _isBufferableClick(actionInContext: actions.ActionInContext): boolean { + private _shouldBuffer(actionInContext: actions.ActionInContext): boolean { const action = actionInContext.action; - return action.name === 'click' && action.button === 'left' && action.clickCount === 1; + return (action.name === 'click' && action.button === 'left') || action.name === 'fill' || action.name === 'navigate'; } - private _isDoubleClick(actionInContext: actions.ActionInContext, bufferedClick: actions.ActionInContext): boolean { + private _supersedes(actionInContext: actions.ActionInContext, pending: actions.ActionInContext): boolean { const action = actionInContext.action; - const buffered = bufferedClick.action; - return action.name === 'click' && buffered.name === 'click' - && actionInContext.pageGuid === bufferedClick.pageGuid - && action.selector === buffered.selector - && action.clickCount > buffered.clickCount; + const pendingAction = pending.action; + if (actionInContext.pageGuid !== pending.pageGuid) + return false; + // A higher click count on the same target is a double (or triple) click. + if (action.name === 'click' && pendingAction.name === 'click') + return action.selector === pendingAction.selector && action.clickCount > pendingAction.clickCount; + // Another keystroke into the same field supersedes the previous value. + if (action.name === 'fill' && pendingAction.name === 'fill') + return action.selector === pendingAction.selector; + // Another navigation on the same page supersedes the previous url. + if (action.name === 'navigate' && pendingAction.name === 'navigate') + return true; + return false; + } + + private _resetPendingTimeout() { + if (!this._pendingAction) + return; + clearTimeout(this._pendingAction.timeout); + this._pendingAction.timeout = setTimeout(() => this._flushPendingAction(), kActionBufferTimeout); } - private _emitAction(actionInContext: actions.ActionInContext) { + private _emitAction(actionInContext: actions.ActionInContext, timestamp: number) { this._lastAction = actionInContext; + this._lastActionTimestamp = timestamp; this._delegate.addAction(actionInContext); } - private _flushBufferedClick(replacement?: actions.ActionInContext) { - const buffered = this._bufferedClick; - if (!buffered) + private _flushPendingAction() { + const pending = this._pendingAction; + if (!pending) return; - clearTimeout(buffered.timeout); - this._bufferedClick = undefined; - this._emitAction(replacement ?? buffered.actionInContext); + clearTimeout(pending.timeout); + this._pendingAction = undefined; + this._emitAction(pending.actionInContext, pending.receivedAt); // Replay the signals with their original timestamps, so that they attach to the emitted action. - for (const { frame, signal, timestamp } of buffered.signals) + for (const { frame, signal, timestamp } of pending.signals) this._processSignal(frame, signal, timestamp); } @@ -114,19 +138,17 @@ export class RecorderSignalProcessor { generateGoto = true; else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press' && lastAction.action.name !== 'fill') generateGoto = true; - else if (timestamp - lastAction.startTime > signalThreshold) + else if (timestamp - this._lastActionTimestamp > signalThreshold) generateGoto = true; if (generateGoto) { - this._emitAction({ + this.addAction({ pageGuid: frame._page.guid, action: { name: 'navigate', url: frame.url(), - signals: [], }, - startTime: timestamp, - endTime: timestamp, + signals: [], }); } return; @@ -135,7 +157,6 @@ export class RecorderSignalProcessor { this._delegate.addSignal({ pageGuid: frame._page.guid, signal, - timestamp, }); } } diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index 38abf76dde1b7..402bc39a739a4 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -21,7 +21,6 @@ import { quoteCSSAttributeValue } from '@isomorphic/stringUtils'; import { Frame } from '../frames'; import type { CallMetadata } from '../instrumentation'; -import type * as actions from '@isomorphic/codegen/actions'; import type { CallLog, CallLogStatus } from '@recorder/recorderTypes'; import type { Progress } from '../progress'; @@ -54,41 +53,6 @@ export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus) return callLog; } -function isSameAction(a: actions.ActionInContext, b: actions.ActionInContext): boolean { - return a.action.name === b.action.name && a.pageGuid === b.pageGuid; -} - -function isSameSelector(action: actions.ActionInContext, lastAction: actions.ActionInContext): boolean { - return 'selector' in action.action && 'selector' in lastAction.action && action.action.selector === lastAction.action.selector; -} - -export function shouldMergeAction(action: actions.ActionInContext, lastAction: actions.ActionInContext | undefined): boolean { - if (!lastAction) - return false; - switch (action.action.name) { - case 'fill': - return isSameAction(action, lastAction) && isSameSelector(action, lastAction); - case 'navigate': - return isSameAction(action, lastAction); - } - return false; -} - -export function collapseActions(actions: actions.ActionInContext[]): actions.ActionInContext[] { - const result: actions.ActionInContext[] = []; - for (const action of actions) { - const lastAction = result[result.length - 1]; - const shouldMerge = shouldMergeAction(action, lastAction); - if (!shouldMerge) { - result.push(action); - continue; - } - const startTime = result[result.length - 1].startTime; - result[result.length - 1] = action; - result[result.length - 1].startTime = startTime; - } - return result; -} export async function generateFrameSelector(progress: Progress, frame: Frame): Promise { const selectorPromises: Promise[] = []; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 101f50fe293a2..eaa024a641d57 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3293,7 +3293,7 @@ export interface Page { * * Navigate to the previous page in history. * - * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the * Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on * network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using * `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized @@ -3341,7 +3341,7 @@ export interface Page { * * Navigate to the next page in history. * - * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + * **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the * Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on * network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using * `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized @@ -4132,6 +4132,26 @@ export interface Page { */ pickLocator(): Promise; + /** + * When working with iframes, you can create a frame locator that will search for elements in the main frame and in + * all iframes on the page, so that you don't need to locate each iframe first. + * + * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches + * elements from multiple frames. + * + * **Usage** + * + * Following snippet locates a button, either in the main frame or in one of the iframes: + * + * ```js + * const locator = page.pierceFrames().getByRole('button'); + * await locator.click(); + * ``` + * + */ + pierceFrames(): FrameLocator; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -8145,6 +8165,26 @@ export interface Frame { */ parentFrame(): null|Frame; + /** + * When working with iframes, you can create a frame locator that will search for elements in the main frame and in + * all iframes on the page, so that you don't need to locate each iframe first. + * + * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two + * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches + * elements from multiple frames. + * + * **Usage** + * + * Following snippet locates a button, either in the main frame or in one of the iframes: + * + * ```js + * const locator = frame.pierceFrames().getByRole('button'); + * await locator.click(); + * ``` + * + */ + pierceFrames(): FrameLocator; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index e3b5700e5ae8b..c87e60bd75af2 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -429,7 +429,6 @@ BrowserContext: type: enum literals: - actionAdded - - actionUpdated - signalAdded data: json page: Page diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 8a17a1ce87f7d..d4c876cb078bf 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -732,7 +732,7 @@ scheme.BrowserContextResponseEvent = tObject({ page: tOptional(tChannel(['Page'])), }); scheme.BrowserContextRecorderEventEvent = tObject({ - event: tEnum(['actionAdded', 'actionUpdated', 'signalAdded']), + event: tEnum(['actionAdded', 'signalAdded']), data: tAny, page: tChannel(['Page']), code: tString, diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index a86ff53fb45bc..3b1217a1118f0 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -20,14 +20,15 @@ import type { Page } from '@playwright/test'; import type * as actions from '@isomorphic/codegen/actions'; class RecorderLog { - actions: (actions.ActionInContext & { code: string })[] = []; + actions: { action: actions.Action, code: string }[] = []; + signals: { signal: actions.Signal, code: string }[] = []; - actionAdded(page: Page, actionInContext: actions.ActionInContext, code: string): void { - this.actions.push({ ...actionInContext, code }); + actionAdded(page: Page, action: actions.Action, code: string): void { + this.actions.push({ action, code }); } - actionUpdated(page: Page, actionInContext: actions.ActionInContext, code: string): void { - this.actions[this.actions.length - 1] = { ...actionInContext, code }; + signalAdded(page: Page, signal: actions.Signal, code: string): void { + this.signals.push({ signal, code }); } } @@ -39,6 +40,7 @@ async function startRecording(context) { }, log); return { action: (name: string) => log.actions.filter(a => a.action.name === name), + signals: () => log.signals, }; } @@ -61,7 +63,6 @@ test('should click', async ({ context, browserName, platform, channel }) => { // Safari does not focus after a click: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus ariaSnapshot: (browserName === 'webkit' && (platform === 'darwin' || (platform === 'win32' && channel !== 'webkit-wsl'))) ? '- button "Submit" [ref=e2]' : '- button "Submit" [active] [ref=e2]', }), - startTime: expect.any(Number), }) ]); @@ -74,8 +75,7 @@ test('should double click', async ({ context, browserName, platform, channel }) await page.setContent(``); await page.getByRole('button', { name: 'Submit' }).dblclick(); - const clickActions = log.action('click'); - expect(clickActions).toEqual([ + await expect.poll(() => log.action('click')).toEqual([ expect.objectContaining({ action: expect.objectContaining({ name: 'click', @@ -85,11 +85,10 @@ test('should double click', async ({ context, browserName, platform, channel }) // Safari does not focus after a click: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus ariaSnapshot: (browserName === 'webkit' && (platform === 'darwin' || (platform === 'win32' && channel !== 'webkit-wsl'))) ? '- button "Submit" [ref=e2]' : '- button "Submit" [active] [ref=e2]', }), - startTime: expect.any(Number), }) ]); - expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).dblclick();`); + expect(normalizeCode(log.action('click')[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).dblclick();`); }); test('should right click', async ({ context, browserName, platform, channel }) => { @@ -109,13 +108,26 @@ test('should right click', async ({ context, browserName, platform, channel }) = // Safari does not focus after a click: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus ariaSnapshot: (browserName === 'webkit' && (platform === 'darwin' || (platform === 'win32' && channel !== 'webkit-wsl'))) ? '- button "Submit" [ref=e2]' : '- button "Submit" [active] [ref=e2]', }), - startTime: expect.any(Number), }) ]); expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).click({ button: 'right' });`); }); +test('should send updated code with the signal', async ({ context, server }) => { + const recorder = await startRecording(context); + const page = await context.newPage(); + await page.setContent(`link`); + await page.getByRole('link', { name: 'link' }).click(); + + // The popup signal attaches to the click, so the click's code is re-generated to await it. + await expect.poll(() => recorder.signals().map(s => s.signal.name)).toContain('popup'); + const code = recorder.signals().find(s => s.signal.name === 'popup')!.code; + expect(normalizeCode(code)).toContain(`const page1Promise = page.waitForEvent('popup');`); + expect(normalizeCode(code)).toContain(`await page.getByRole('link', { name: 'link' }).click();`); + expect(normalizeCode(code)).toContain(`const page1 = await page1Promise;`); +}); + test('should type', async ({ context }) => { const log = await startRecording(context); const page = await context.newPage(); @@ -123,8 +135,7 @@ test('should type', async ({ context }) => { await page.getByRole('textbox').pressSequentially('Hello'); - const fillActions = log.action('fill'); - expect(fillActions).toEqual([ + await expect.poll(() => log.action('fill')).toEqual([ expect.objectContaining({ action: expect.objectContaining({ name: 'fill', @@ -132,11 +143,10 @@ test('should type', async ({ context }) => { ref: 'e2', ariaSnapshot: '- textbox [active] [ref=e2]: Hello', }), - startTime: expect.any(Number), }) ]); - expect(normalizeCode(fillActions[0].code)).toEqual(`await page.getByRole('textbox').fill('Hello');`); + expect(normalizeCode(log.action('fill')[0].code)).toEqual(`await page.getByRole('textbox').fill('Hello');`); }); test('should disable recorder', async ({ context }) => { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index fa9a863592e9c..8cc0aec6b6a92 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -442,6 +442,22 @@ it('reverse engineer frameLocator', async ({ page }) => { expect.soft(asLocator('javascript', selector)).toBe(`locator('div').locator('iframe').contentFrame().locator('span')`); }); +it('reverse engineer pierceFrames', async ({ page }) => { + expect.soft(generate(page.pierceFrames().getByText('foo').locator('span'))).toEqual({ + csharp: `PierceFrames.GetByText("foo").Locator("span")`, + java: `pierceFrames().getByText("foo").locator("span")`, + javascript: `pierceFrames().getByText('foo').locator('span')`, + python: `pierce_frames.get_by_text("foo").locator("span")`, + }); + + // Note that bare `pierce_frames` and `PierceFrames` are not restored back, + // because they are indistinguishable from a css selector. + expect.soft(asLocator('javascript', 'internal:control=pierce-frames')).toBe(`pierceFrames()`); + expect.soft(asLocator('python', 'internal:control=pierce-frames')).toBe(`pierce_frames`); + expect.soft(asLocator('java', 'internal:control=pierce-frames')).toBe(`pierceFrames()`); + expect.soft(asLocator('csharp', 'internal:control=pierce-frames')).toBe(`PierceFrames`); +}); + it('generate multiple locators', async ({ page }) => { const selector = (page.locator('div', { hasText: 'foo' }).nth(0).filter({ has: page.locator('span', { hasNotText: 'bar' }).nth(-1) }) as any)._selector; const locators = { diff --git a/tests/page/locator-pierce-frames.spec.ts b/tests/page/locator-pierce-frames.spec.ts new file mode 100644 index 0000000000000..08dcba04932e3 --- /dev/null +++ b/tests/page/locator-pierce-frames.spec.ts @@ -0,0 +1,305 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Page } from 'playwright-core'; +import { test as it, expect } from './pageTest'; + +function routePage(page: Page, url: string, body: string) { + return page.route('**/' + url, route => { + route.fulfill({ body, contentType: 'text/html' }).catch(() => {}); + }); +} + +async function waitForAllFrames(page: Page, frameCount: number, selector: string) { + // Wait for all child frames to load their content, so that piercing + // deterministically sees elements in all of them. + await expect.poll(() => page.frames().length).toBe(frameCount); + for (const frame of page.frames()) { + if (frame !== page.mainFrame()) + await frame.waitForSelector(selector, { state: 'attached' }); + } +} + +it('should click a button inside an iframe', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await page.goto(server.EMPTY_PAGE); + await page.pierceFrames().getByRole('button', { name: 'Click me' }).click(); + expect(await page.frames()[1].evaluate(() => (window as any).__clicked)).toBe(true); +}); + +it('should click a button in the main frame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
No buttons here
`); + await page.goto(server.EMPTY_PAGE); + await page.pierceFrames().locator('button').click(); + expect(await page.evaluate(() => (window as any).__clicked)).toBe(true); +}); + +it('should fail click when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await routePage(page, 'b.html', ``); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'button'); + const error = await page.pierceFrames().locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); +}); + +it('should fail click upon strict mode violation inside a single frame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'button'); + const error = await page.pierceFrames().locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('strict mode violation'); + expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); +}); + +it('should time out on click when there are no matches', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
Nothing here
`); + await page.goto(server.EMPTY_PAGE); + const error = await page.pierceFrames().locator('button').click({ timeout: 1000 }).catch(e => e); + expect(error.message).toContain('Timeout 1000ms exceeded'); + expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); +}); + +it('should count elements in a single frame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
1
2
3
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'div'); + expect(await page.pierceFrames().locator('div').count()).toBe(3); + expect(await page.pierceFrames().locator('button').count()).toBe(0); +}); + +it('should fail count when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', `
main
`); + await routePage(page, 'a.html', `
child
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'div'); + const error = await page.pierceFrames().locator('div').count().catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should support toHaveCount', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `onetwo`); + await page.goto(server.EMPTY_PAGE); + await expect(page.pierceFrames().locator('span')).toHaveCount(2); + await expect(page.pierceFrames().locator('button')).toHaveCount(0); +}); + +it('should wait for a frame to appear with toHaveCount', async ({ page, server }) => { + await routePage(page, 'empty.html', `
No frames yet
`); + await routePage(page, 'a.html', `onetwo`); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + window.builtins.setTimeout(() => { + const iframe = document.createElement('iframe'); + iframe.src = 'a.html'; + document.body.appendChild(iframe); + }, 500); + }); + await expect(page.pierceFrames().locator('span')).toHaveCount(2); +}); + +it('should fail toHaveCount when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `one`); + await routePage(page, 'b.html', `two`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'span'); + const error = await expect(page.pierceFrames().locator('span')).toHaveCount(2, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + expect(error.message).toContain(`Locator: pierceFrames().locator('span')`); +}); + +it('should support toHaveText', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
Hello iframe
`); + await page.goto(server.EMPTY_PAGE); + await expect(page.pierceFrames().locator('div')).toHaveText('Hello iframe'); +}); + +it('should support toHaveText with an array', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `onetwo`); + await page.goto(server.EMPTY_PAGE); + await expect(page.pierceFrames().locator('span')).toHaveText(['one', 'two']); +}); + +it('should fail toHaveText when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
one
`); + await routePage(page, 'b.html', `
two
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'div'); + const error = await expect(page.pierceFrames().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + expect(error.message).toContain(`Locator: pierceFrames().locator('div')`); +}); + +it('should fail toHaveText with an array when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `one`); + await routePage(page, 'b.html', `two`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'span'); + const error = await expect(page.pierceFrames().locator('span')).toHaveText(['one', 'two'], { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should fail toHaveText upon strict mode violation inside a single frame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
one
two
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'div'); + const error = await expect(page.pierceFrames().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('strict mode violation'); + expect(error.message).toContain(`Locator: pierceFrames().locator('div')`); +}); + +it('should support evaluate', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
Hello
`); + await page.goto(server.EMPTY_PAGE); + expect(await page.pierceFrames().locator('div').evaluate(e => e.getAttribute('data-foo'))).toBe('bar'); +}); + +it('should fail evaluate when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
one
`); + await routePage(page, 'b.html', `
two
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'div'); + const error = await page.pierceFrames().locator('div').evaluate(e => e.textContent, undefined, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should time out on evaluate when there are no matches', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
Nothing here
`); + await page.goto(server.EMPTY_PAGE); + const error = await page.pierceFrames().locator('button').evaluate(e => e.textContent, undefined, { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('Timeout 1000ms exceeded'); + expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); +}); + +it('should support evaluateAll', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `onetwo`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'span'); + expect(await page.pierceFrames().locator('span').evaluateAll(els => els.map(e => e.textContent))).toEqual(['one', 'two']); + expect(await page.pierceFrames().locator('button').evaluateAll(els => els.length)).toBe(0); +}); + +it('should fail evaluateAll when elements match in multiple frames', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `one`); + await routePage(page, 'b.html', `two`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 3, 'span'); + const error = await page.pierceFrames().locator('span').evaluateAll(els => els.length).catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should support hasText filter', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
foo
bar
`); + await page.goto(server.EMPTY_PAGE); + await expect(page.pierceFrames().locator('div', { hasText: 'bar' })).toHaveText('bar'); +}); + +it('should support first/last/nth as the last operation', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `onetwothree`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'span'); + await expect(page.pierceFrames().locator('span').first()).toHaveText('one'); + await expect(page.pierceFrames().locator('span').last()).toHaveText('three'); + await expect(page.pierceFrames().locator('span').nth(1)).toHaveText('two'); +}); + +it('should not allow nth in the middle', async ({ page }) => { + const error = await page.pierceFrames().locator('div').first().locator('span').count().catch(e => e); + expect(error.message).toContain(`nth can only be the last locator when piercing frames, while querying "pierceFrames().locator('div').first().locator('span')"`); +}); + +it('should not allow frameLocator after pierceFrames', async ({ page }) => { + expect(() => page.pierceFrames().frameLocator('iframe')).toThrow('Entering frames is not allowed while piercing frames'); + expect(() => page.pierceFrames().locator('div').frameLocator('iframe')).toThrow('Entering frames is not allowed while piercing frames'); +}); + +it('should not allow first/last/nth after pierceFrames', async ({ page }) => { + expect(() => page.pierceFrames().first()).toThrow('Selecting the nth frame is not allowed while piercing frames'); + expect(() => page.pierceFrames().last()).toThrow('Selecting the nth frame is not allowed while piercing frames'); + expect(() => page.pierceFrames().nth(1)).toThrow('Selecting the nth frame is not allowed while piercing frames'); +}); + +it('should not allow chaining pierce-frames and enter-frame selectors', async ({ page }) => { + const error1 = await page.locator('internal:control=pierce-frames >> iframe >> internal:control=enter-frame >> button').count().catch(e => e); + expect(error1.message).toContain('Entering frames is not allowed while piercing frames'); + const error2 = await page.locator('iframe >> internal:control=enter-frame >> internal:control=pierce-frames >> button').count().catch(e => e); + expect(error2.message).toContain('"pierce-frames" is only allowed as the first selector token'); +}); + +it('should not allow composite locators', async ({ page }) => { + const error = await page.pierceFrames().locator('div', { has: page.locator('span') }).count().catch(e => e); + expect(error.message).toContain(`Composite locators are not supported with piercing frames, while querying "pierceFrames().locator('div').filter({ has: locator('span') })"`); +}); + +it('should not allow owner', async ({ page }) => { + const error = await page.pierceFrames().owner().count().catch(e => e); + expect(error.message).toContain('Selector cannot be empty when piercing frames'); +}); + +it('should render pierceFrames in the locator description', async ({ page }) => { + expect(String(page.pierceFrames().locator('button'))).toBe(`pierceFrames().locator('button')`); + expect(String(page.pierceFrames().getByText('foo'))).toBe(`pierceFrames().getByText('foo')`); +}); + +it('should click while another iframe is stalled', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await page.route('**/stall.html', () => {}); + await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => page.frames().length).toBe(3); + await page.pierceFrames().locator('button').click(); + const frame = page.frames().find(f => f.url().includes('a.html'))!; + expect(await frame.evaluate(() => (window as any).__clicked)).toBe(true); +}); + +it('should support toBeVisible while another iframe is stalled', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await page.route('**/stall.html', () => {}); + await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => page.frames().length).toBe(3); + await expect(page.pierceFrames().locator('button')).toBeVisible(); +}); + +it('should support toHaveCount while another iframe is stalled', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await page.route('**/stall.html', () => {}); + await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => page.frames().length).toBe(3); + await expect(page.pierceFrames().locator('button')).toHaveCount(1); +});