From 4063639f7a66816e16e2aab1719ec4c93c03bddb Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 15 Apr 2026 16:53:20 -0700 Subject: [PATCH 1/2] feat(page): add 'bringtofront' event (#40229) --- docs/src/api/class-browsercontext.md | 7 +++ packages/playwright-client/types/types.d.ts | 43 +++++++++++++++++++ .../src/client/browserContext.ts | 1 + packages/playwright-core/src/client/events.ts | 1 + .../playwright-core/src/protocol/validator.ts | 3 ++ .../src/server/browserContext.ts | 2 + .../dispatchers/browserContextDispatcher.ts | 3 ++ packages/playwright-core/src/server/page.ts | 1 + packages/playwright-core/types/types.d.ts | 43 +++++++++++++++++++ packages/protocol/src/channels.d.ts | 5 +++ packages/protocol/src/protocol.yml | 4 ++ 11 files changed, 113 insertions(+) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 4b9f0b7fc7ecd..e8099c2999340 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -68,6 +68,13 @@ await context.CloseAsync(); This event is not emitted. +## event: BrowserContext.bringToFront +* since: v1.60 +- argument: <[Page]> + +Emitted when a client calls [`method: Page.bringToFront`] on a page in this context. The event is dispatched to all +clients connected to the context, including the one that initiated the call. + ## property: BrowserContext.clock * since: v1.45 - type: <[Clock]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index deb6d9c9c5b56..e72e4392dfc24 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -8225,6 +8225,13 @@ export interface BrowserContext { */ on(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + on(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -8362,6 +8369,11 @@ export interface BrowserContext { */ once(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -8417,6 +8429,13 @@ export interface BrowserContext { */ addListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + addListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -8554,6 +8573,11 @@ export interface BrowserContext { */ removeListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -8609,6 +8633,11 @@ export interface BrowserContext { */ off(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -8664,6 +8693,13 @@ export interface BrowserContext { */ prependListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + prependListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -9453,6 +9489,13 @@ export interface BrowserContext { */ waitForEvent(event: 'backgroundpage', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + waitForEvent(event: 'bringtofront', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 4d593e83ddab8..4ad2b16926b5d 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -149,6 +149,7 @@ export class BrowserContext extends ChannelOwner dialog.dismiss().catch(() => {}); } }); + this._channel.on('bringToFront', ({ page }) => this.emit(Events.BrowserContext.BringToFront, Page.from(page))); this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page))); this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page))); this._channel.on('requestFinished', params => this._onRequestFinished(params)); diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 5eb1cb711783d..b5c4279c67bbd 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -39,6 +39,7 @@ export const Events = { }, BrowserContext: { + BringToFront: 'bringtofront', Console: 'console', Close: 'close', Dialog: 'dialog', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index d090e28ad459f..ce574850eb308 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -941,6 +941,9 @@ scheme.BrowserContextInitializer = tObject({ scheme.BrowserContextBindingCallEvent = tObject({ binding: tChannel(['BindingCall']), }); +scheme.BrowserContextBringToFrontEvent = tObject({ + page: tChannel(['Page']), +}); scheme.BrowserContextConsoleEvent = tObject({ type: tString, text: tString, diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 7173434c8370e..f4d6d2f7c0626 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -45,6 +45,7 @@ import type * as types from './types'; import type * as channels from '@protocol/channels'; const BrowserContextEvent = { + BringToFront: 'bringtofront', Console: 'console', Close: 'close', Page: 'page', @@ -65,6 +66,7 @@ const BrowserContextEvent = { } as const; export type BrowserContextEventMap = { + [BrowserContextEvent.BringToFront]: [page: Page]; [BrowserContextEvent.Console]: [message: ConsoleMessage]; [BrowserContextEvent.Close]: []; [BrowserContextEvent.Page]: [page: Page]; diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 69453cfaccca3..bba8e78ccf209 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -105,6 +105,9 @@ export class BrowserContextDispatcher extends Dispatcher { this._dispatchEvent('page', { page: PageDispatcher.from(this, page) }); }); + this.addObjectListener(BrowserContext.Events.BringToFront, page => { + this._dispatchEvent('bringToFront', { page: PageDispatcher.from(this, page) }); + }); this.addObjectListener(BrowserContext.Events.Close, () => { this._dispatchEvent('close'); this._dispose(); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 257b657ba3d00..c074b91d502a2 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -648,6 +648,7 @@ export class Page extends SdkObject { async bringToFront(progress: Progress): Promise { await progress.race(this.delegate.bringToFront()); + this.emitOnContext(BrowserContext.Events.BringToFront, this); } async addInitScript(progress: Progress, source: string) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index deb6d9c9c5b56..e72e4392dfc24 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8225,6 +8225,13 @@ export interface BrowserContext { */ on(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + on(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -8362,6 +8369,11 @@ export interface BrowserContext { */ once(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -8417,6 +8429,13 @@ export interface BrowserContext { */ addListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + addListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -8554,6 +8573,11 @@ export interface BrowserContext { */ removeListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -8609,6 +8633,11 @@ export interface BrowserContext { */ off(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -8664,6 +8693,13 @@ export interface BrowserContext { */ prependListener(event: 'backgroundpage', listener: (page: Page) => any): this; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + prependListener(event: 'bringtofront', listener: (page: Page) => any): this; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. @@ -9453,6 +9489,13 @@ export interface BrowserContext { */ waitForEvent(event: 'backgroundpage', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + /** + * Emitted when a client calls [page.bringToFront()](https://playwright.dev/docs/api/class-page#page-bring-to-front) + * on a page in this context. The event is dispatched to all clients connected to the context, including the one that + * initiated the call. + */ + waitForEvent(event: 'bringtofront', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + /** * Emitted when Browser context gets closed. This might happen because of one of the following: * - Browser context is closed. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 4f0613005cfa1..a7ca57cf277bd 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1647,6 +1647,7 @@ export type BrowserContextInitializer = { }; export interface BrowserContextEventTarget { on(event: 'bindingCall', callback: (params: BrowserContextBindingCallEvent) => void): this; + on(event: 'bringToFront', callback: (params: BrowserContextBringToFrontEvent) => void): this; on(event: 'console', callback: (params: BrowserContextConsoleEvent) => void): this; on(event: 'close', callback: (params: BrowserContextCloseEvent) => void): this; on(event: 'dialog', callback: (params: BrowserContextDialogEvent) => void): this; @@ -1700,6 +1701,9 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, }; +export type BrowserContextBringToFrontEvent = { + page: PageChannel, +}; export type BrowserContextConsoleEvent = { type: string, text: string, @@ -2075,6 +2079,7 @@ export type BrowserContextClockSetSystemTimeResult = void; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; + 'bringToFront': BrowserContextBringToFrontEvent; 'console': BrowserContextConsoleEvent; 'close': BrowserContextCloseEvent; 'dialog': BrowserContextDialogEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0cdaf38f6dcf6..2387fdd7cc304 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1516,6 +1516,10 @@ BrowserContext: parameters: binding: BindingCall + bringToFront: + parameters: + page: Page + console: parameters: $mixin: ConsoleMessage From ff3852fbdcc068723d62e768997aeb5c1edcd0d5 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 15 Apr 2026 16:53:36 -0700 Subject: [PATCH 2/2] feat(dashboard): unify sessions into a single WebSocket channel (#40235) --- packages/dashboard/src/dashboard.tsx | 155 ++++--- packages/dashboard/src/dashboardChannel.ts | 66 ++- packages/dashboard/src/grid.tsx | 54 ++- packages/dashboard/src/index.tsx | 38 +- packages/dashboard/src/screencast.tsx | 13 +- packages/dashboard/src/sessionModel.ts | 94 +--- packages/playwright-core/src/DEPS.list | 1 + .../playwright-core/src/serverRegistry.ts | 142 ++++-- .../src/tools/dashboard/dashboardApp.ts | 137 +----- .../tools/dashboard/dashboardController.ts | 416 ++++++++++++------ tests/installation/bundle-licenses.spec.ts | 1 + utils/build/build.js | 9 +- utils/check_deps.js | 23 +- 13 files changed, 633 insertions(+), 516 deletions(-) diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 3324570d15417..164c32af87c3f 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -16,14 +16,12 @@ import React from 'react'; import './dashboard.css'; -import { navigate } from './index'; -import { DashboardClient } from './dashboardClient'; +import { navigate, DashboardClientContext } from './index'; import { asLocator } from '@isomorphic/locatorGenerators'; import { SplitView } from '@web/components/splitView'; import { ChevronLeftIcon, ChevronRightIcon, CloseIcon, PlusIcon, ReloadIcon, PickLocatorIcon, InspectorPanelIcon } from './icons'; import { SettingsButton } from './settingsView'; -import type { DashboardClientChannel } from './dashboardClient'; import type { Tab, DashboardChannelEvents } from './dashboardChannel'; function tabFavicon(url: string): string { @@ -38,16 +36,17 @@ function tabFavicon(url: string): string { const BUTTONS = ['left', 'middle', 'right'] as const; -export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { +export const Dashboard: React.FC<{ browser: string }> = ({ browser }) => { + const client = React.useContext(DashboardClientContext); const [interactive, setInteractive] = React.useState(false); const [tabs, setTabs] = React.useState(null); const [url, setUrl] = React.useState(''); const [frame, setFrame] = React.useState(); const [showInspector, setShowInspector] = React.useState(false); - const [pickingTabId, setPickingTabId] = React.useState(null); + const [pickingPage, setPickingPage] = React.useState(null); const [locatorToast, setLocatorToast] = React.useState<{ text: string; timer: ReturnType }>(); + const [context, setContext] = React.useState(); - const [channel, setChannel] = React.useState(); const displayRef = React.useRef(null); const screenRef = React.useRef(null); const tabbarRef = React.useRef(null); @@ -55,26 +54,22 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const moveThrottleRef = React.useRef(0); React.useEffect(() => { - if (!wsUrl) + if (!client) return; - const channel = DashboardClient.create(wsUrl); - - channel.onopen = () => { - setChannel(channel); - setInteractive(false); - setPickingTabId(null); - }; + let disposed = false; + let resized = false; - channel.on('tabs', params => { + const onTabs = (params: DashboardChannelEvents['tabs']) => { + if (params.target.browser !== browser) + return; setTabs(params.tabs); const selected = params.tabs.find(t => t.selected); if (selected) setUrl(selected.url); - }); - - let resized = false; - - channel.on('frame', params => { + }; + const onFrame = (params: DashboardChannelEvents['frame']) => { + if (params.target.browser !== browser) + return; setFrame(params); const tabbar = tabbarRef.current; const toolbar = toolbarRef.current; @@ -87,29 +82,48 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const targetH = Math.min(params.viewportHeight + chromeHeight + extraH, screen.availHeight); window.resizeTo(targetW, targetH); } - }); - - channel.on('elementPicked', params => { + }; + const onElementPicked = (params: DashboardChannelEvents['elementPicked']) => { + if (params.target.browser !== browser) + return; const locator = asLocator('javascript', params.selector); navigator.clipboard?.writeText(locator).catch(() => {}); - setPickingTabId(null); + setPickingPage(null); setLocatorToast(old => { clearTimeout(old?.timer); return { text: locator, timer: setTimeout(() => setLocatorToast(undefined), 3000) }; }); - }); + }; + + client.on('tabs', onTabs); + client.on('frame', onFrame); + client.on('elementPicked', onElementPicked); - channel.onclose = () => { - setChannel(undefined); + client.attach({ browser }).then(result => { + if (!disposed) + setContext(result.context); + }).catch(() => {}); + + return () => { + disposed = true; + client.off('tabs', onTabs); + client.off('frame', onFrame); + client.off('elementPicked', onElementPicked); + client.detach({ browser }).catch(() => {}); + setContext(undefined); + setTabs(null); + setFrame(undefined); setInteractive(false); - setPickingTabId(null); + setPickingPage(null); setShowInspector(false); }; + }, [client, browser]); - return () => { - channel.close(); - }; - }, [wsUrl]); + const selectedTab = tabs?.find(t => t.selected); + const ready = !!client && !!context && !!selectedTab; + const pageTarget = ready && selectedTab + ? { browser, context: context!, page: selectedTab.page } + : undefined; function imgCoords(e: React.MouseEvent): { x: number; y: number } { const vw = frame?.viewportWidth ?? 0; @@ -143,14 +157,16 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } function sendMouseEvent(method: 'mousedown' | 'mouseup', e: React.MouseEvent) { + if (!pageTarget) + return; const { x, y } = imgCoords(e); - channel?.[method]({ x, y, button: BUTTONS[e.button] || 'left' }); + client?.[method]({ ...pageTarget, x, y, button: BUTTONS[e.button] || 'left' }); } function onScreenMouseDown(e: React.MouseEvent) { e.preventDefault(); screenRef.current?.focus(); - if (!channel) + if (!ready) return; if (!interactive) { setInteractive(true); @@ -167,41 +183,42 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } function onScreenMouseMove(e: React.MouseEvent) { - if (!interactive) + if (!interactive || !pageTarget) return; const now = Date.now(); if (now - moveThrottleRef.current < 32) return; moveThrottleRef.current = now; const { x, y } = imgCoords(e); - channel?.mousemove({ x, y }); + client?.mousemove({ ...pageTarget, x, y }); } function onScreenWheel(e: React.WheelEvent) { - if (!interactive) + if (!interactive || !pageTarget) return; e.preventDefault(); - channel?.wheel({ deltaX: e.deltaX, deltaY: e.deltaY }); + client?.wheel({ ...pageTarget, deltaX: e.deltaX, deltaY: e.deltaY }); } function onScreenKeyDown(e: React.KeyboardEvent) { - if (pickingTabId !== null && e.key === 'Escape') { + if (pickingPage !== null && e.key === 'Escape') { e.preventDefault(); - channel?.cancelPickLocator(); - setPickingTabId(null); + if (pageTarget) + client?.cancelPickLocator(pageTarget); + setPickingPage(null); return; } - if (!interactive) + if (!interactive || !pageTarget) return; e.preventDefault(); - channel?.keydown({ key: e.key }); + client?.keydown({ ...pageTarget, key: e.key }); } function onScreenKeyUp(e: React.KeyboardEvent) { - if (!interactive) + if (!interactive || !pageTarget) return; e.preventDefault(); - channel?.keyup({ key: e.key }); + client?.keyup({ ...pageTarget, key: e.key }); } function onOmniboxKeyDown(e: React.KeyboardEvent) { @@ -210,16 +227,16 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { if (!/^https?:\/\//i.test(value)) value = 'https://' + value; setUrl(value); - channel?.navigate({ url: value }); + if (pageTarget) + client?.navigate({ ...pageTarget, url: value }); e.currentTarget.blur(); } } - const selectedTab = tabs?.find(t => t.selected); - const picking = selectedTab?.pageId === pickingTabId; + const picking = selectedTab?.page === pickingPage; let overlayText: string | undefined; - if (!channel) + if (!client || !context) overlayText = 'Disconnected'; else if (tabs === null) overlayText = 'Loading...'; @@ -237,12 +254,12 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
{tabs?.map(tab => (
channel?.selectTab({ pageId: tab.pageId })} + onClick={() => client?.selectTab({ browser, context: tab.context, page: tab.page })} > {tab.title || 'New Tab'} @@ -251,7 +268,7 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { title='Close tab' onClick={e => { e.stopPropagation(); - channel?.closeTab({ pageId: tab.pageId }); + client?.closeTab({ browser, context: tab.context, page: tab.page }); }} > @@ -259,19 +276,23 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
))}
-
- - = ({ wsUrl }) => { className={'nav-btn' + (picking ? ' active-toggle' : '')} title='Pick locator' aria-pressed={picking} - disabled={!channel} + disabled={!ready} onClick={() => { + if (!pageTarget) + return; if (picking) { - channel?.cancelPickLocator(); - setPickingTabId(null); + client?.cancelPickLocator(pageTarget); + setPickingPage(null); } else { setInteractive(true); - setPickingTabId(selectedTab?.pageId ?? null); + setPickingPage(selectedTab?.page ?? null); screenRef.current?.focus(); - channel?.pickLocator(); + client?.pickLocator(pageTarget); } }} > @@ -339,7 +362,7 @@ export const Dashboard: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { className={'nav-btn' + (showInspector ? ' active-toggle' : '')} title='Chrome DevTools' aria-pressed={showInspector} - disabled={!channel} + disabled={!ready} onClick={() => { setInteractive(true); setShowInspector(!showInspector); diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index 094d61664ab70..83d83918c1e7e 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -14,32 +14,56 @@ * limitations under the License. */ -export type Tab = { pageId: string; title: string; url: string; selected: boolean; inspectorUrl?: string }; +import type { ClientInfo } from '../../playwright-core/src/tools/cli-client/registry'; +import type { SessionStatus } from './sessionModel'; + +export type BrowserTarget = { browser: string }; +export type ContextTarget = { browser: string; context: string }; +export type PageTarget = { browser: string; context: string; page: string }; + +export type Tab = { + browser: string; + context: string; + page: string; + title: string; + url: string; + selected: boolean; + inspectorUrl?: string; +}; export type DashboardChannelEvents = { - frame: { data: string; viewportWidth: number; viewportHeight: number }; - tabs: { tabs: Tab[] }; - elementPicked: { selector: string }; + sessions: { sessions: SessionStatus[]; clientInfo: ClientInfo }; + tabs: { target: ContextTarget; tabs: Tab[] }; + frame: { target: PageTarget; data: string; viewportWidth: number; viewportHeight: number }; + elementPicked: { target: PageTarget; selector: string }; }; +export type MouseButton = 'left' | 'middle' | 'right'; + export interface DashboardChannel { - version: 1; - tabs(): Promise<{ tabs: Tab[] }>; - selectTab(params: { pageId: string }): Promise; - closeTab(params: { pageId: string }): Promise; - newTab(): Promise; - navigate(params: { url: string }): Promise; - back(): Promise; - forward(): Promise; - reload(): Promise; - mousemove(params: { x: number; y: number }): Promise; - mousedown(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }): Promise; - mouseup(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }): Promise; - wheel(params: { deltaX: number; deltaY: number }): Promise; - keydown(params: { key: string }): Promise; - keyup(params: { key: string }): Promise; - pickLocator(): Promise; - cancelPickLocator(): Promise; + attach(params: BrowserTarget): Promise<{ context: string }>; + detach(params: BrowserTarget): Promise; + closeSession(params: BrowserTarget): Promise; + deleteSessionData(params: BrowserTarget): Promise; + setVisible(params: { visible: boolean }): Promise; + + tabs(params: ContextTarget): Promise<{ tabs: Tab[] }>; + newTab(params: ContextTarget): Promise<{ page: string }>; + + selectTab(params: PageTarget): Promise; + closeTab(params: PageTarget): Promise; + navigate(params: PageTarget & { url: string }): Promise; + back(params: PageTarget): Promise; + forward(params: PageTarget): Promise; + reload(params: PageTarget): Promise; + mousemove(params: PageTarget & { x: number; y: number }): Promise; + mousedown(params: PageTarget & { x: number; y: number; button?: MouseButton }): Promise; + mouseup(params: PageTarget & { x: number; y: number; button?: MouseButton }): Promise; + wheel(params: PageTarget & { deltaX: number; deltaY: number }): Promise; + keydown(params: PageTarget & { key: string }): Promise; + keyup(params: PageTarget & { key: string }): Promise; + pickLocator(params: PageTarget): Promise; + cancelPickLocator(params: PageTarget): Promise; on(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; off(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; diff --git a/packages/dashboard/src/grid.tsx b/packages/dashboard/src/grid.tsx index ef162d7cd15b3..d20df11b6c3a1 100644 --- a/packages/dashboard/src/grid.tsx +++ b/packages/dashboard/src/grid.tsx @@ -16,13 +16,12 @@ import React from 'react'; import './grid.css'; -import { DashboardClient } from './dashboardClient'; -import { navigate } from './index'; +import { navigate, DashboardClientContext } from './index'; import { Screencast } from './screencast'; import { SettingsButton } from './settingsView'; import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; -import type { Tab } from './dashboardChannel'; +import type { Tab, DashboardChannelEvents } from './dashboardChannel'; import type { SessionModel, SessionStatus } from './sessionModel'; export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { @@ -68,9 +67,8 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
- {model.loading && sessions.length === 0 &&
Loading sessions...
} - {model.error &&
Error: {model.error}
} - {!model.loading && !model.error && sessions.length === 0 &&
No sessions found.
} + {model.loading &&
Loading sessions...
} + {!model.loading && sessions.length === 0 &&
No sessions found.
}
{workspaceGroups.map(([workspace, entries], index) => { @@ -92,7 +90,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
{isExpanded && (
- {entries.map(session => )} + {entries.map(session => )}
)}
@@ -103,45 +101,43 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
); }; -const SessionChip: React.FC<{ descriptor: BrowserDescriptor; wsUrl: string | undefined; visible: boolean; model: SessionModel }> = ({ descriptor, wsUrl, visible, model }) => { +const SessionChip: React.FC<{ descriptor: BrowserDescriptor; canConnect: boolean; visible: boolean; model: SessionModel }> = ({ descriptor, canConnect, visible, model }) => { const href = '#session=' + encodeURIComponent(descriptor.browser.guid); - - const channel = React.useMemo(() => { - if (!wsUrl || !visible) - return undefined; - return DashboardClient.create(wsUrl); - }, [wsUrl, visible]); - + const client = React.useContext(DashboardClientContext); + const browser = descriptor.browser.guid; + const attached = canConnect && visible && !!client; const [selectedTab, setSelectedTab] = React.useState(); React.useEffect(() => { - if (!channel) + if (!attached || !client) return; - const onTabs = (params: { tabs: Tab[] }) => { + const onTabs = (params: DashboardChannelEvents['tabs']) => { + if (params.target.browser !== browser) + return; setSelectedTab(params.tabs.find(t => t.selected)); }; - channel.tabs().then(onTabs); - channel.on('tabs', onTabs); + client.on('tabs', onTabs); + client.attach({ browser }).catch(() => {}); return () => { - channel.off('tabs', onTabs); - channel.close(); + client.off('tabs', onTabs); + client.detach({ browser }).catch(() => {}); }; - }, [channel]); + }, [attached, client, browser]); const chipTitle = selectedTab ? `[${descriptor.title}] ${selectedTab.url} \u2014 ${selectedTab.title}` : descriptor.title; return ( - { + { e.preventDefault(); - if (wsUrl) + if (canConnect) navigate(href); }}>
-
+
{selectedTab ? <>[{descriptor.title}] {selectedTab.url} — {selectedTab.title} : descriptor.title} - {wsUrl && ( + {canConnect && ( )} - {!wsUrl && ( + {!canConnect && (