Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -4477,6 +4477,37 @@ When `true`, includes each element's bounding box as a `box` property with `x`,
relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
Defaults to `false`.

## async method: Page.tabInfo
* since: v1.63
- returns: <[null]|[Object]>
- `active` <[boolean]> Whether the tab is selected in its browser window. Note that this does not imply that the
browser window itself is focused at the OS level.
- `index` <[int]> Zero-based position of the tab in the tab strip of its browser window.
- `pinned` <[boolean]> Whether the tab is pinned.
- `groupId` <[null]|[string]> Id of the tab group this tab belongs to, if any.
- `windowId` <[int]> Id of the browser window containing the tab, matching the window id in the Chrome DevTools
Protocol.

Returns a snapshot of the browser tab state for this page, as reflected in the browser tab strip. Unlike renderer-level
signals such as `document.visibilityState`, this reports the real browser UI state, which is useful when a human and
automation share the same headed browser.

Returns `null` when the information is not available: in browsers other than Chromium, in Chromium versions
below 150, in the Chromium headless shell which has no tab strip, and for pages that are not part of a tab strip.

**Usage**

```js
const tabInfo = await page.tabInfo();
if (tabInfo?.active)
console.log('The user is currently looking at this page');
```

:::note
This API always returns an up-to-date snapshot and does not emit events when the tab state changes. Call it again to
observe changes.
:::

## async method: Page.tap
* since: v1.8
* discouraged: Use locator-based [`method: Locator.tap`] instead. Read more about [locators](../locators.md).
Expand Down
1 change: 1 addition & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['Page.startCSSCoverage', { title: 'Start CSS coverage', group: 'configuration', }],
['Page.stopCSSCoverage', { title: 'Stop CSS coverage', group: 'configuration', }],
['Page.bringToFront', { title: 'Bring to front', }],
['Page.tabInfo', { title: 'Get tab info', }],
['Page.pickLocator', { title: 'Pick locator', group: 'configuration', }],
['Page.cancelPickLocator', { title: 'Cancel pick locator', group: 'configuration', }],
['Page.hideHighlight', { title: 'Hide all element highlights', group: 'configuration', }],
Expand Down
48 changes: 48 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5017,6 +5017,54 @@ export interface Page {
height: number;
}): Promise<void>;

/**
* Returns a snapshot of the browser tab state for this page, as reflected in the browser tab strip. Unlike
* renderer-level signals such as `document.visibilityState`, this reports the real browser UI state, which is useful
* when a human and automation share the same headed browser.
*
* Returns `null` when the information is not available: in browsers other than Chromium, in Chromium versions below
* 150, in the Chromium headless shell which has no tab strip, and for pages that are not part of a tab strip.
*
* **Usage**
*
* ```js
* const tabInfo = await page.tabInfo();
* if (tabInfo?.active)
* console.log('The user is currently looking at this page');
* ```
*
* **NOTE** This API always returns an up-to-date snapshot and does not emit events when the tab state changes. Call
* it again to observe changes.
*
*/
tabInfo(): Promise<null|{
/**
* Whether the tab is selected in its browser window. Note that this does not imply that the browser window itself is
* focused at the OS level.
*/
active: boolean;

/**
* Zero-based position of the tab in the tab strip of its browser window.
*/
index: number;

/**
* Whether the tab is pinned.
*/
pinned: boolean;

/**
* Id of the tab group this tab belongs to, if any.
*/
groupId: null|string;

/**
* Id of the browser window containing the tab, matching the window id in the Chrome DevTools Protocol.
*/
windowId: number;
}>;

/**
* **NOTE** Use locator-based [locator.tap([options])](https://playwright.dev/docs/api/class-locator#locator-tap) instead. Read
* more about [locators](https://playwright.dev/docs/locators).
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/client/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3872,6 +3872,7 @@ export interface PageChannel extends PageEventTarget, Channel {
startCSSCoverage(params: PageStartCSSCoverageParams, options: TimeoutOptions): Promise<PageStartCSSCoverageResult>;
stopCSSCoverage(params: PageStopCSSCoverageParams, options: TimeoutOptions): Promise<PageStopCSSCoverageResult>;
bringToFront(params: PageBringToFrontParams, options: TimeoutOptions): Promise<PageBringToFrontResult>;
tabInfo(params: PageTabInfoParams, options: TimeoutOptions): Promise<PageTabInfoResult>;
pickLocator(params: PagePickLocatorParams, options: TimeoutOptions): Promise<PagePickLocatorResult>;
cancelPickLocator(params: PageCancelPickLocatorParams, options: TimeoutOptions): Promise<PageCancelPickLocatorResult>;
hideHighlight(params: PageHideHighlightParams, options: TimeoutOptions): Promise<PageHideHighlightResult>;
Expand Down Expand Up @@ -4401,6 +4402,17 @@ export type PageStopCSSCoverageResult = {
export type PageBringToFrontParams = {};
export type PageBringToFrontOptions = {};
export type PageBringToFrontResult = void;
export type PageTabInfoParams = {};
export type PageTabInfoOptions = {};
export type PageTabInfoResult = {
tabInfo?: {
active: boolean,
index: number,
pinned: boolean,
groupId?: string,
windowId: number,
},
};
export type PagePickLocatorParams = {};
export type PagePickLocatorOptions = {};
export type PagePickLocatorResult = {
Expand Down
7 changes: 7 additions & 0 deletions packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,13 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
await this._channel.bringToFront({}, kNoTimeout);
}

async tabInfo(): Promise<{ active: boolean, index: number, pinned: boolean, groupId: string | null, windowId: number } | null> {
const { tabInfo } = await this._channel.tabInfo({}, kNoTimeout);
if (!tabInfo)
return null;
return { ...tabInfo, groupId: tabInfo.groupId ?? null };
}

async [Symbol.asyncDispose]() {
await this.close();
}
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/server/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,7 @@ export interface PageChannel extends PageEventTarget, Channel {
startCSSCoverage(params: PageStartCSSCoverageParams, progress: Progress): Promise<PageStartCSSCoverageResult>;
stopCSSCoverage(params: PageStopCSSCoverageParams, progress: Progress): Promise<PageStopCSSCoverageResult>;
bringToFront(params: PageBringToFrontParams, progress: Progress): Promise<PageBringToFrontResult>;
tabInfo(params: PageTabInfoParams, progress: Progress): Promise<PageTabInfoResult>;
pickLocator(params: PagePickLocatorParams, progress: Progress): Promise<PagePickLocatorResult>;
cancelPickLocator(params: PageCancelPickLocatorParams, progress: Progress): Promise<PageCancelPickLocatorResult>;
hideHighlight(params: PageHideHighlightParams, progress: Progress): Promise<PageHideHighlightResult>;
Expand Down Expand Up @@ -4402,6 +4403,17 @@ export type PageStopCSSCoverageResult = {
export type PageBringToFrontParams = {};
export type PageBringToFrontOptions = {};
export type PageBringToFrontResult = void;
export type PageTabInfoParams = {};
export type PageTabInfoOptions = {};
export type PageTabInfoResult = {
tabInfo?: {
active: boolean,
index: number,
pinned: boolean,
groupId?: string,
windowId: number,
},
};
export type PagePickLocatorParams = {};
export type PagePickLocatorOptions = {};
export type PagePickLocatorResult = {
Expand Down
68 changes: 67 additions & 1 deletion packages/playwright-core/src/server/chromium/crBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { CRPage } from './crPage';
import { saveProtocolStream } from './crProtocolHelper';
import { CRServiceWorker } from './crServiceWorker';

import type { InitScript } from '../page';
import type { InitScript, TabInfo } from '../page';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';
import type { HttpCredentials } from '@protocol/structs';
Expand All @@ -56,6 +56,8 @@ export class CRBrowser extends Browser {
private _tracingRecording = false;
private _tracingClient: CRSession | undefined;
private _userAgent: string = '';
private _tabInfoSnapshotPromise: Promise<Map<string, TabInfo>> | undefined;
private _tabInfoBrowserSessionPromise: Promise<CRSession> | undefined;

static async connect(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions, devtools?: CRDevTools): Promise<CRBrowser> {
// Make a copy in case we need to update `headful` property below.
Expand Down Expand Up @@ -276,6 +278,70 @@ export class CRBrowser extends Browser {
await this._session.send('Target.closeTarget', { targetId: crPage._targetId });
}

async _tabInfoForPage(crPage: CRPage): Promise<TabInfo | undefined> {
// Tab targets expose embedderData since Chromium 150.
if (this._majorVersion && this._majorVersion < 150)
return undefined;
if (!this._tabInfoSnapshotPromise)
this._tabInfoSnapshotPromise = this._queryTabInfos().finally(() => this._tabInfoSnapshotPromise = undefined);
const tabInfos = await this._tabInfoSnapshotPromise;
return tabInfos.get(crPage._targetId);
}

private async _queryTabInfos(): Promise<Map<string, TabInfo>> {
const tabInfoByPageTargetId = new Map<string, TabInfo>();
const { targetInfos } = await this._session.send('Target.getTargets', { filter: [{ type: 'tab', exclude: false }, { exclude: true }] });
const tabTargets = targetInfos.filter(targetInfo => !!targetInfo.embedderData);
if (!tabTargets.length)
return tabInfoByPageTargetId;
const browserSession = await this._tabInfoBrowserSession();
await Promise.all(tabTargets.map(async targetInfo => {
const [pageTargetIds, { windowId }] = await Promise.all([
this._collectTabPageTargets(browserSession, targetInfo.targetId),
browserSession.send('Browser.getWindowForTarget', { targetId: targetInfo.targetId }),
]);
// Protocol declares string values, but Chromium sends booleans and numbers.
const embedderData = targetInfo.embedderData as unknown as { tabActive: boolean, tabStripIndex: number, tabPinned: boolean, tabGroupId?: string };
const tabInfo = {
active: embedderData.tabActive,
index: embedderData.tabStripIndex,
pinned: embedderData.tabPinned,
groupId: embedderData.tabGroupId,
windowId,
};
for (const pageTargetId of pageTargetIds)
tabInfoByPageTargetId.set(pageTargetId, tabInfo);
}));
return tabInfoByPageTargetId;
}

// Separate session, so that tab attach events do not interfere with the root session auto-attach handling.
private async _tabInfoBrowserSession(): Promise<CRSession> {
if (!this._tabInfoBrowserSessionPromise) {
this._tabInfoBrowserSessionPromise = this._session.send('Target.attachToBrowserTarget').then(
({ sessionId }) => this._session.createChildSession(sessionId));
this._tabInfoBrowserSessionPromise.catch(() => this._tabInfoBrowserSessionPromise = undefined);
}
return this._tabInfoBrowserSessionPromise;
}

private async _collectTabPageTargets(browserSession: CRSession, tabTargetId: string): Promise<string[]> {
const { sessionId } = await browserSession.send('Target.attachToTarget', { targetId: tabTargetId, flatten: true });
const tabSession = browserSession.createChildSession(sessionId);
const pageTargetIds: string[] = [];
tabSession.on('Target.attachedToTarget', (event: Protocol.Target.attachedToTargetPayload) => {
pageTargetIds.push(event.targetInfo.targetId);
});
try {
// Existing related page targets are reported before the command acknowledgment.
await tabSession.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, filter: [{ type: 'page', exclude: false }, { exclude: true }] });
} finally {
browserSession.send('Target.detachFromTarget', { sessionId }).catch(() => {});
tabSession.dispose();
}
return pageTargetIds;
}

async newBrowserCDPSession(): Promise<CDPSession> {
return await this._connection.createBrowserSession();
}
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/server/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { nullProgress } from '../progress';
import type { CRSession } from './crConnection';
import type { Protocol } from './protocol';
import type { RegisteredListener } from '@utils/eventsHelper';
import type { InitScript, PageDelegate } from '../page';
import type { InitScript, PageDelegate, TabInfo } from '../page';
import type { Progress } from '../progress';
import type * as types from '../types';
import type * as channels from '../channels';
Expand Down Expand Up @@ -208,6 +208,10 @@ export class CRPage implements PageDelegate {
await this._mainFrameSession._client.send('Page.bringToFront');
}

async tabInfo(): Promise<TabInfo | undefined> {
return await this._browserContext._browser._tabInfoForPage(this);
}

async updateEmulateMedia(): Promise<void> {
await this._forAllFrameSessions(frame => frame._updateEmulateMedia());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
await this._page.bringToFront(progress);
}

async tabInfo(params: channels.PageTabInfoParams, progress: Progress): Promise<channels.PageTabInfoResult> {
return { tabInfo: await this._page.tabInfo(progress) };
}

async pickLocator(params: channels.PagePickLocatorParams, progress: Progress): Promise<channels.PagePickLocatorResult> {
const recorder = await progress.race(Recorder.forContext(this._page.browserContext, { omitCallTracking: true, hideToolbar: true }));
const selector = await recorder.pickLocator(progress, this._page);
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright-core/src/server/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import type * as channels from './channels';
import type { BindingPayload } from '@injected/bindingsController';
import type { AriaNodeJSON, AriaSnapshotJSON } from '@isomorphic/ariaSnapshot';

export type TabInfo = NonNullable<channels.PageTabInfoResult['tabInfo']>;

export interface PageDelegate {
readonly rawMouse: input.RawMouse;
readonly rawKeyboard: input.RawKeyboard;
Expand Down Expand Up @@ -95,6 +97,7 @@ export interface PageDelegate {
pdf?: (options: channels.PagePdfParams) => Promise<Buffer>;
coverage?: () => any;
noUtilityWorld?: () => boolean;
tabInfo?: () => Promise<TabInfo | undefined>;

// Work around WebKit's raf issues on Windows.
rafCountForStablePosition(): number;
Expand Down Expand Up @@ -671,6 +674,12 @@ export class Page extends SdkObject<PageEventMap> {
await progress.race(this.delegate.bringToFront());
}

async tabInfo(progress: Progress): Promise<TabInfo | undefined> {
if (!this.delegate.tabInfo)
return undefined;
return await progress.race(this.delegate.tabInfo());
}

async addInitScript(progress: Progress, source: string) {
return await progress.race(this._addInitScript(source));
}
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,16 +357,17 @@ export function renderTabMarkdown(tab: TabHeader): string[] {
return lines;
}

export function renderTabsMarkdown(tabs: TabHeader[]): string[] {
export function renderTabsMarkdown(tabs: (TabHeader & { foreground?: boolean })[]): string[] {
if (!tabs.length)
return ['No open tabs. Navigate to a URL to create one.'];

const lines: string[] = [];
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const current = tab.current ? ' (current)' : '';
const foreground = tab.foreground ? ' (visible to user)' : '';
const crashed = tab.crashed ? ' [crashed]' : '';
lines.push(`- ${i}:${current} [${tab.title}](${tab.url})${crashed}`);
lines.push(`- ${i}:${current}${foreground} [${tab.title}](${tab.url})${crashed}`);
}
return lines;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/playwright-core/src/tools/backend/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ const browserTabs = defineTool({
break;
}
}
const tabHeaders = await Promise.all(context.tabs().map(tab => tab.headerSnapshot()));
const tabHeaders = await Promise.all(context.tabs().map(async tab => {
const [header, tabInfo] = await Promise.all([
tab.headerSnapshot(),
tab.page.tabInfo().catch(() => null),
]);
return { ...header, foreground: tabInfo?.active };
}));
const result = renderTabsMarkdown(tabHeaders);
response.addTextResult(result.join('\n'));
},
Expand Down
Loading
Loading