diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index fb484cbfcb168..2fe3b8799ec91 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -47,9 +47,7 @@ export interface DashboardChannel { closeTab(params: { browser: string; context: string; page: string }): Promise; newTab(params: { browser: string; context: string }): Promise; closeSession(params: { browser: string }): Promise; - deleteSessionData(params: { browser: string }): Promise; setVisible(params: { visible: boolean }): Promise; - reveal(params: { path: string }): Promise; navigate(params: { url: string }): Promise; back(): Promise; diff --git a/packages/dashboard/src/dashboardModel.ts b/packages/dashboard/src/dashboardModel.ts index 0817f3725dff1..3896ab71d1da9 100644 --- a/packages/dashboard/src/dashboardModel.ts +++ b/packages/dashboard/src/dashboardModel.ts @@ -92,10 +92,6 @@ export class DashboardModel { void this._client.closeSession({ browser: descriptor.browser.guid }); } - deleteSessionData(descriptor: BrowserDescriptor) { - void this._client.deleteSessionData({ browser: descriptor.browser.guid }); - } - setVisible(visible: boolean) { void this._client.setVisible({ visible }); } diff --git a/packages/playwright-core/src/tools/backend/DEPS.list b/packages/playwright-core/src/tools/backend/DEPS.list index 00720253e24a7..4a77eb12c7ed6 100644 --- a/packages/playwright-core/src/tools/backend/DEPS.list +++ b/packages/playwright-core/src/tools/backend/DEPS.list @@ -1,6 +1,7 @@ [*] ../../.. ../.. +../dashboard/dashboardApp.ts @utils/** @utils/** @isomorphic/** diff --git a/packages/playwright-core/src/tools/backend/devtools.ts b/packages/playwright-core/src/tools/backend/devtools.ts index 4fc8cf7794e41..7269040d07833 100644 --- a/packages/playwright-core/src/tools/backend/devtools.ts +++ b/packages/playwright-core/src/tools/backend/devtools.ts @@ -19,6 +19,7 @@ import { spawn } from 'child_process'; import * as z from 'zod'; import { libPath } from '../../package'; +import { annotateInContext } from '../dashboard/dashboardApp'; import { defineTabTool, defineTool } from './tool'; import { elementSchema, optionalElementSchema } from './snapshot'; @@ -120,52 +121,78 @@ const annotate = defineTabTool({ name: 'browser_annotate', title: 'Annotate the current page', description: 'Open the Playwright Dashboard in annotation mode for the current page and wait for the user to draw annotations. Returns the annotated screenshot, ARIA snapshot, and the list of annotations.', - inputSchema: z.object({}), + inputSchema: z.object({ + modal: z.boolean().optional().describe('Open a dedicated dashboard window for this annotation (modal), instead of reusing the singleton dashboard daemon. Experimental — used to A/B-compare the two implementations.'), + }), type: 'readOnly', }, handle: async (tab, params, response, signal) => { - // eslint-disable-next-line no-restricted-syntax -- _guid is the cross-process page identifier shared with the dashboard daemon. - const pageId = (tab.page as any)._guid as string; - const daemonScript = libPath('entry', 'dashboardApp.js'); - const daemonArgs = [daemonScript, `--pageId=${pageId}`]; - - // Spawn the dashboard daemon (idempotent — the singleton socket guards against duplicates). - const daemon = spawn(process.execPath, daemonArgs, { detached: true, stdio: 'ignore' }); - daemon.unref(); - - // Spawn the annotate client in JSON mode to capture the raw payload over stdout. - const client = spawn(process.execPath, [...daemonArgs, '--annotate', '--json'], { - stdio: ['pipe', 'pipe', 'inherit'], - }); - const onAbort = () => client.kill(); - signal?.addEventListener('abort', onAbort); - const stdoutChunks: Buffer[] = []; - client.stdout!.on('data', chunk => stdoutChunks.push(chunk)); - const exitCode = await new Promise(resolve => client.on('exit', code => resolve(code))); - signal?.removeEventListener('abort', onAbort); - if (signal?.aborted) { + const result = params.modal + ? await runModalAnnotate(tab, signal) + : await runDaemonAnnotate(tab, signal); + if (result === 'cancelled') { response.addTextResult('Annotation cancelled.'); return; } - if (exitCode !== 0) { - response.addError(`Annotation client exited with code ${exitCode}`); + if ('error' in result) { + response.addError(result.error); return; } - const text = Buffer.concat(stdoutChunks).toString('utf8').trim(); - if (!text) { + if (!result.annotations.length && !result.png && !result.ariaSnapshot) { response.addTextResult('No annotations were submitted.'); return; } - const { png, ariaSnapshot, annotations } = JSON.parse(text) as { png?: string; ariaSnapshot?: string; annotations: AnnotationData[] }; - for (const a of annotations) + for (const a of result.annotations) response.addTextResult(`{ x: ${a.x}, y: ${a.y}, width: ${a.width}, height: ${a.height} }: ${a.text}`); const date = new Date(); - if (png) - await response.addResult('Annotation image', Buffer.from(png, 'base64'), { prefix: 'annotations', ext: 'png', date }); - if (ariaSnapshot) - await response.addResult('Annotation snapshot', Buffer.from(ariaSnapshot, 'utf8'), { prefix: 'annotations', ext: 'yaml', date }); + if (result.png) + await response.addResult('Annotation image', Buffer.from(result.png, 'base64'), { prefix: 'annotations', ext: 'png', date }); + if (result.ariaSnapshot) + await response.addResult('Annotation snapshot', Buffer.from(result.ariaSnapshot, 'utf8'), { prefix: 'annotations', ext: 'yaml', date }); }, }); +type AnnotateResult = + | { png?: string; ariaSnapshot?: string; annotations: AnnotationData[] } + | { error: string } + | 'cancelled'; + +async function runDaemonAnnotate(tab: import('./tab').Tab, signal: AbortSignal | undefined): Promise { + // eslint-disable-next-line no-restricted-syntax -- _guid is the cross-process page identifier shared with the dashboard daemon. + const pageId = (tab.page as any)._guid as string; + const daemonScript = libPath('entry', 'dashboardApp.js'); + const daemonArgs = [daemonScript, `--pageId=${pageId}`]; + + const daemon = spawn(process.execPath, daemonArgs, { detached: true, stdio: 'ignore' }); + daemon.unref(); + + const client = spawn(process.execPath, [...daemonArgs, '--annotate', '--json'], { + stdio: ['pipe', 'pipe', 'inherit'], + }); + const onAbort = () => client.kill(); + signal?.addEventListener('abort', onAbort); + const stdoutChunks: Buffer[] = []; + client.stdout!.on('data', chunk => stdoutChunks.push(chunk)); + const exitCode = await new Promise(resolve => client.on('exit', code => resolve(code))); + signal?.removeEventListener('abort', onAbort); + if (signal?.aborted) + return 'cancelled'; + if (exitCode !== 0) + return { error: `Annotation client exited with code ${exitCode}` }; + const text = Buffer.concat(stdoutChunks).toString('utf8').trim(); + if (!text) + return { annotations: [] }; + return JSON.parse(text) as AnnotateResult; +} + +async function runModalAnnotate(tab: import('./tab').Tab, signal: AbortSignal | undefined): Promise { + const payload = await annotateInContext(tab.page.context(), tab.page, signal); + if (signal?.aborted) + return 'cancelled'; + if (!payload) + return { annotations: [] }; + return { png: payload.png, ariaSnapshot: payload.ariaSnapshot, annotations: payload.annotations }; +} + export default [resume, highlight, hideHighlight, annotate]; diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 216221327b9c3..eec45f46b61d6 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -216,13 +216,6 @@ export async function program(options?: { embedderVersion?: string}) { await new Promise(resolve => child.on('exit', () => resolve())); return; } - if (args.annotate) { - const dashboard = spawn(process.execPath, daemonArgs, { detached: true, stdio: 'ignore' }); - dashboard.unref(); - const annotate = spawn(process.execPath, [...daemonArgs, '--annotate'], { stdio: 'inherit' }); - await new Promise(resolve => annotate.on('exit', () => resolve())); - return; - } const foreground = args.port !== undefined; const child = spawn(process.execPath, daemonArgs, { detached: !foreground, diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md index f9bc753e0682a..4111868657dc7 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -163,8 +163,8 @@ playwright-cli video-start video.webm playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 playwright-cli video-stop -# launch the dashboard with annotation prompt to ask the user for input -playwright-cli show --annotate +# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes +playwright-cli annotate # generate a Playwright locator for an element from its ref or selector playwright-cli generate-locator e5 --raw @@ -367,11 +367,11 @@ playwright-cli close ## Example: Interactive session -Ask the user to annotate the UI. User can provide contextual tasks or ask contextual questions using annotations: +Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean": ```bash playwright-cli open https://example.com -playwright-cli show --annotate +playwright-cli annotate ``` ## Specific tasks diff --git a/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md b/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md index f37bdb44af15b..5097a67cdf178 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md +++ b/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md @@ -88,7 +88,7 @@ playwright-cli resume # resume so that seed test runs fully playwright-cli snapshot # inventory of interactive elements playwright-cli click e5 # follow a flow playwright-cli eval "location.href" # read URL / state -playwright-cli show --annotate # ask the user to point at something +playwright-cli annotate # ask the user to point at something ``` Map out: @@ -262,7 +262,7 @@ The test is paused at the start. Step forward or run to until just before the fa playwright-cli snapshot # did the element change / move / rename? playwright-cli console # app-side errors? playwright-cli network # failed request? wrong payload? -playwright-cli show --annotate # ask the user to point somewhere +playwright-cli annotate # ask the user to point somewhere ``` Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 0a6596ad8acc8..499ceca5eb4fd 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -977,13 +977,25 @@ const dashboardShow = declareCommand({ options: z.object({ port: numberArg.optional().describe('Start as a blocking HTTP server on this port (use 0 for a random port)'), host: z.string().optional().describe('Host to bind to when using --port (defaults to localhost)'), - annotate: z.boolean().optional().describe('Switch the dashboard into annotation mode.'), kill: z.boolean().optional().describe('Kill the dashboard daemon.'), }), toolName: '', toolParams: () => ({}), }); +const annotate = declareCommand({ + name: 'annotate', + description: 'Ask the user to annotate the current page.', + category: 'devtools', + raw: true, + args: z.object({}), + options: z.object({ + modal: z.boolean().optional().describe('Open a dedicated dashboard window for this annotation, instead of reusing the singleton dashboard daemon. Experimental.'), + }), + toolName: 'browser_annotate', + toolParams: ({ modal }) => ({ modal }), +}); + const resume = declareCommand({ name: 'resume', description: 'Resume the test execution', @@ -1198,6 +1210,7 @@ const commandsArray: AnyCommandSchema[] = [ videoStop, videoChapter, dashboardShow, + annotate, pauseAt, resume, stepOver, diff --git a/packages/playwright-core/src/tools/cli-daemon/daemon.ts b/packages/playwright-core/src/tools/cli-daemon/daemon.ts index 84c61546106ee..61f576a02fc95 100644 --- a/packages/playwright-core/src/tools/cli-daemon/daemon.ts +++ b/packages/playwright-core/src/tools/cli-daemon/daemon.ts @@ -78,6 +78,8 @@ export async function startCliDaemonServer( const server = net.createServer(socket => { const connection = new SocketConnection(socket); + const abortController = new AbortController(); + connection.onclose = () => abortController.abort(); connection.onmessage = async message => { const { id, method, params } = message; try { @@ -91,7 +93,7 @@ export async function startCliDaemonServer( } else if (method === 'run') { const { toolName, toolParams } = parseCliCommand(params.args); toolParams._meta = { cwd: params.cwd, raw: params.raw || params.json, json: !!params.json }; - const response = await backend.callTool(toolName, toolParams); + const response = await backend.callTool(toolName, toolParams, abortController.signal); await connection.send({ id, result: formatResult(response) }); } else { throw new Error(`Unknown method: ${method}`); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 20dfa9fe61269..634fc8cc0976a 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -28,46 +28,55 @@ import { findChromiumChannelBestEffort, registryDirectory } from '../../server/r import { minimist } from '../cli-client/minimist'; import { saveOutputFile } from '../trace/traceUtils'; import { DashboardConnection } from './dashboardController'; +import { RegistrySessionProvider } from './registrySessionProvider'; +import { IdentitySessionProvider } from './identitySessionProvider'; import type * as api from '../../..'; import type { AnnotationData } from '@dashboard/dashboardChannel'; +import type { SessionProvider } from './sessionProvider'; // HMR: build-time flag — `true` in watch builds, `false` in release. esbuild // replaces the identifier via `define`, so the static branch pays zero runtime // cost and the dev-server code (incl. `import('vite')`) is DCE'd in release. declare const __PW_HMR__: boolean; +type AnnotationPayload = { png?: string; ariaSnapshot: string; annotations: AnnotationData[] }; +type AnnotationWaiter = { + resolve: (payload: AnnotationPayload) => void; + ondispose: () => void; +}; + type DashboardServer = { url: string; reveal: (options: DashboardOptions) => void; triggerAnnotate: () => void; registerAnnotateWaiter: (socket: net.Socket) => void; + awaitAnnotation: (signal?: AbortSignal) => Promise; + close: () => Promise; }; -async function startDashboardServer(options: DashboardOptions): Promise { +async function startDashboardServer(provider: SessionProvider, options: DashboardOptions): Promise { const httpServer = new HttpServer(); const dashboardDir = libPath('vite', 'dashboard'); const connections = new Set(); let currentReveal: DashboardOptions = options; let pendingAnnotate = false; - const waitingSockets = new Set(); + const waiters = new Set(); const submitAnnotation = (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => { - if (waitingSockets.size === 0) + if (waiters.size === 0) return; - const payload = JSON.stringify({ png: base64Png, ariaSnapshot, annotations }); - for (const socket of waitingSockets) { - socket.write(payload); - socket.end(); - } - waitingSockets.clear(); + const payload: AnnotationPayload = { png: base64Png, ariaSnapshot, annotations }; + for (const waiter of waiters) + waiter.resolve(payload); + waiters.clear(); }; httpServer.createWebSocket(() => { let connection: DashboardConnection; // eslint-disable-next-line prefer-const - connection = new DashboardConnection(() => connections.delete(connection), () => { + connection = new DashboardConnection(provider, () => connections.delete(connection), () => { if (currentReveal.pageId) connection.revealPage(currentReveal.pageId); else if (currentReveal.sessionName) @@ -120,18 +129,53 @@ async function startDashboardServer(options: DashboardOptions): Promise { - waitingSockets.add(socket); + const waiter: AnnotationWaiter = { + resolve: payload => { + socket.write(JSON.stringify(payload)); + socket.end(); + }, + ondispose: () => { + if (waiters.size === 0) + notifyAnnotateEnded(); + }, + }; + waiters.add(waiter); const cleanup = () => { - if (!waitingSockets.delete(socket)) + if (!waiters.delete(waiter)) return; - if (waitingSockets.size === 0) - notifyAnnotateEnded(); + waiter.ondispose(); }; socket.on('close', cleanup); socket.on('error', cleanup); }; - return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, registerAnnotateWaiter }; + const awaitAnnotation = (signal?: AbortSignal) => new Promise(resolve => { + if (signal?.aborted) { + resolve(undefined); + return; + } + let done = false; + const finish = (payload?: AnnotationPayload) => { + if (done) + return; + done = true; + waiters.delete(waiter); + signal?.removeEventListener('abort', onAbort); + if (waiters.size === 0) + notifyAnnotateEnded(); + resolve(payload); + }; + const waiter: AnnotationWaiter = { + resolve: payload => finish(payload), + ondispose: () => {}, + }; + const onAbort = () => finish(undefined); + waiters.add(waiter); + signal?.addEventListener('abort', onAbort); + }); + + const close = () => httpServer.stop(); + return { url: httpServer.urlPrefix('human-readable'), reveal, triggerAnnotate, registerAnnotateWaiter, awaitAnnotation, close }; } function attachDashboardStaticServer(httpServer: HttpServer, dashboardDir: string) { @@ -157,13 +201,13 @@ async function attachDashboardDevServer(httpServer: HttpServer) { // HMR end async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { - const server = await startDashboardServer(options); - const { page } = await launchApp('dashboard'); + const server = await startDashboardServer(new RegistrySessionProvider(), options); + const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) }); await page.goto(server.url); return { page, server }; } -async function launchApp(appName: string) { +async function launchApp(appName: string, options?: { onClose?: () => void }) { const channel = findChromiumChannelBestEffort('javascript'); const context = await playwright.chromium.launchPersistentContext('', { ignoreDefaultArgs: ['--enable-automation'], @@ -192,9 +236,7 @@ async function launchApp(appName: string) { }); } - page.on('close', () => { - gracefullyProcessExitDoNotHang(0); - }); + page.on('close', () => options?.onClose?.()); const image = await fs.promises.readFile(libPath('tools', 'dashboard', 'appIcon.png')); // This is local Playwright, so I can access private methods. @@ -299,7 +341,7 @@ export async function openDashboardApp() { console.error('Unhandled promise rejection:', error); }); if (options.port !== undefined) { - const { url } = await startDashboardServer(options); + const { url } = await startDashboardServer(new RegistrySessionProvider(), options); // eslint-disable-next-line no-console console.log(`Listening on ${url}`); selfDestructOnParentGone(); @@ -352,6 +394,51 @@ export async function openDashboardApp() { await statePromise; } +export async function openDashboardForContext(context: api.BrowserContext): Promise { + const server = await startDashboardServer(new IdentitySessionProvider(context), {}); + + let closed = false; + const close = async () => { + if (closed) + return; + closed = true; + await server.close(); + }; + + const { page } = await launchApp('dashboard', { onClose: () => { void close(); } }); + context.on('close', () => { void close(); }); + await page.goto(server.url); +} + +export async function annotateInContext(context: api.BrowserContext, page: api.Page, signal?: AbortSignal): Promise { + // eslint-disable-next-line no-restricted-syntax -- _guid is the cross-process page identifier. + const pageId = (page as any)._guid as string; + const server = await startDashboardServer(new IdentitySessionProvider(context), { pageId, annotate: true }); + + let closed = false; + const close = async () => { + if (closed) + return; + closed = true; + await server.close().catch(() => {}); + await dashboardContext.close({ reason: 'Annotation modal closed' }).catch(() => {}); + }; + + const { context: dashboardContext, page: dashboardPage } = await launchApp('dashboard', { onClose: () => { void close(); } }); + context.on('close', () => { void close(); }); + signal?.addEventListener('abort', () => { void close(); }); + + await dashboardPage.goto(server.url); + server.reveal({ pageId, annotate: true }); + server.triggerAnnotate(); + + try { + return await server.awaitAnnotation(signal); + } finally { + await close(); + } +} + async function runKillClient(): Promise { const socketPath = dashboardSocketPath(); await new Promise(resolve => { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 8b6305a16666e..71e2d0eec4ac2 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -21,107 +21,35 @@ import crypto from 'crypto'; import { execFile } from 'child_process'; import { Disposable } from '@isomorphic/disposable'; import { eventsHelper } from '@utils/eventsHelper'; -import { connectToBrowserAcrossVersions } from '../utils/connect'; -import { serverRegistry } from '../../serverRegistry'; import { createClientInfo } from '../cli-client/registry'; +import { SessionProviderEvent } from './sessionProvider'; + import type * as api from '../../..'; import type { Transport } from '@utils/httpServer'; import type { AnnotationData, Tab } from '@dashboard/dashboardChannel'; import type { BrowserDescriptor } from '../../serverRegistry'; - -type BrowserTrackerCallbacks = { - onTabsChanged: () => void; - onContextClosed: (context: api.BrowserContext) => void; -}; - -class BrowserTracker { - readonly descriptor: BrowserDescriptor; - readonly browser: api.Browser; - private _callbacks: BrowserTrackerCallbacks; - private _contextListeners = new Map(); - private _browserListeners: Disposable[] = []; - - static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { - try { - const browser = await connectToBrowserAcrossVersions(descriptor); - const slot = new BrowserTracker(descriptor, browser, callbacks); - for (const context of browser.contexts()) - slot._wireContext(context); - slot._browserListeners.push(eventsHelper.addEventListener(browser, 'context', (context: api.BrowserContext) => { - slot._wireContext(context); - })); - return slot; - } catch { - return undefined; - } - } - - private constructor(descriptor: BrowserDescriptor, browser: api.Browser, callbacks: BrowserTrackerCallbacks) { - this.descriptor = descriptor; - this.browser = browser; - this._callbacks = callbacks; - } - - contexts(): api.BrowserContext[] { - return this.browser.contexts(); - } - - dispose() { - this._browserListeners.forEach(d => d.dispose()); - this._browserListeners = []; - for (const listeners of this._contextListeners.values()) - listeners.forEach(d => d.dispose()); - this._contextListeners.clear(); - } - - private _wireContext(context: api.BrowserContext) { - if (this._contextListeners.has(context)) - return; - const onTabsChanged = () => this._callbacks.onTabsChanged(); - const listeners: Disposable[] = [ - eventsHelper.addEventListener(context, 'page', onTabsChanged), - eventsHelper.addEventListener(context, 'pageload', onTabsChanged), - eventsHelper.addEventListener(context, 'pageclose', onTabsChanged), - eventsHelper.addEventListener(context, 'framenavigated', (frame: api.Frame) => { - if (frame === frame.page().mainFrame()) - this._callbacks.onTabsChanged(); - }), - eventsHelper.addEventListener(context, 'close', () => { - const ls = this._contextListeners.get(context); - if (ls) { - ls.forEach(d => d.dispose()); - this._contextListeners.delete(context); - } - this._callbacks.onContextClosed(context); - this._callbacks.onTabsChanged(); - }), - ]; - this._contextListeners.set(context, listeners); - this._callbacks.onTabsChanged(); - } -} +import type { SessionProvider } from './sessionProvider'; export class DashboardConnection implements Transport { sendEvent?: (method: string, params: any) => void; close?: () => void; - private _browsers = new Map(); + private _provider: SessionProvider; private _attachedPage: AttachedPage | undefined; private _onclose: () => void; private _onconnected?: () => void; private _onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void; - private _serverRegistryDispose?: () => void; - private _pushSessionsScheduled = false; private _pushTabsScheduled = false; private _visible = true; - private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined; private _annotateWaitingForAttach = false; + private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined; _recordingDir: string; _streams = new Map(); - constructor(onclose: () => void, onconnected?: () => void, onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void) { + constructor(provider: SessionProvider, onclose: () => void, onconnected?: () => void, onAnnotationSubmit?: (base64Png: string | undefined, ariaSnapshot: string, annotations: AnnotationData[]) => void) { + this._provider = provider; this._onclose = onclose; this._onconnected = onconnected; this._onAnnotationSubmit = onAnnotationSubmit; @@ -129,20 +57,24 @@ export class DashboardConnection implements Transport { } onconnect() { - this._serverRegistryDispose = serverRegistry.watch(); - serverRegistry.on('added', this._pushSessions); - serverRegistry.on('removed', this._pushSessions); - serverRegistry.on('changed', this._pushSessions); - this._pushSessions(); + this._provider.on(SessionProviderEvent.SessionsChanged, () => { + this._pushSessions(); + this._tryRevealPending(); + }); + this._provider.on(SessionProviderEvent.TabsChanged, () => this._pushTabs()); + this._provider.on(SessionProviderEvent.ContextClosed, context => { + if (this._attachedPage?.page.context() === context) { + this._attachedPage.dispose(); + this._attachedPage = undefined; + } + }); + this._provider.on(SessionProviderEvent.AttachRequested, page => { void this._switchAttachedTo(page); }); + this._provider.start(); this._onconnected?.(); } onclose() { - serverRegistry.off('added', this._pushSessions); - serverRegistry.off('removed', this._pushSessions); - serverRegistry.off('changed', this._pushSessions); - this._serverRegistryDispose?.(); - this._serverRegistryDispose = undefined; + this._provider.dispose(); this._attachedPage?.dispose(); this._attachedPage = undefined; for (const stream of this._streams.values()) { @@ -152,9 +84,6 @@ export class DashboardConnection implements Transport { .catch(() => {}); } this._streams.clear(); - for (const tracker of this._browsers.values()) - tracker.dispose(); - this._browsers.clear(); this._onclose(); } @@ -173,14 +102,14 @@ export class DashboardConnection implements Transport { } async selectTab(params: { browser: string; context: string; page: string }) { - const page = this._findPage(params); + const page = this._provider.findPage(params); if (page) await this._switchAttachedTo(page); this._pushTabs(); } async newTab(params: { browser: string; context: string }) { - const context = this._findContext(params); + const context = this._provider.findContext(params); if (!context) return; const page = await context.newPage(); @@ -189,23 +118,12 @@ export class DashboardConnection implements Transport { } async closeTab(params: { browser: string; context: string; page: string }) { - const page = this._findPage(params); + const page = this._provider.findPage(params); await page?.close({ reason: 'Closed in Dashboard' }); } async closeSession(params: { browser: string }) { - const descriptor = serverRegistry.readDescriptor(params.browser); - const browser = await connectToBrowserAcrossVersions(descriptor); - try { - await Promise.all(browser.contexts().map(context => context.close())); - await browser.close(); - } catch { - // best-effort - } - } - - async deleteSessionData(params: { browser: string }) { - await serverRegistry.deleteUserData(params.browser); + await this._provider.closeSession(params.browser); } async setVisible(params: { visible: boolean }) { @@ -217,32 +135,31 @@ export class DashboardConnection implements Transport { revealSession(sessionName: string, workspaceDir?: string) { this._pendingReveal = { sessionName, workspaceDir }; - void this._tryRevealPending(); + this._tryRevealPending(); } - revealPage(pageId: string) { - this._pendingReveal = { pageId }; - void this._tryRevealPending(); + revealPage(pageId_: string) { + this._pendingReveal = { pageId: pageId_ }; + this._tryRevealPending(); } - private async _tryRevealPending() { + private _tryRevealPending() { const pending = this._pendingReveal; if (!pending) return; - const allPages = [...this._browsers.values()].flatMap(s => s.browser.contexts().flatMap(c => c.pages().map(p => ({ slot: s, page: p })))); - let page: api.Page | undefined; - if (pending.pageId !== undefined) { - page = allPages.find(({ page }) => pageId(page) === pending.pageId)?.page; - } else if (pending.sessionName !== undefined) { - page = allPages.find(({ slot }) => - slot.descriptor.title === pending.sessionName - && (pending.workspaceDir === undefined || slot.descriptor.workspaceDir === pending.workspaceDir))?.page; - } - if (!page) + for (const { context, descriptor } of this._provider.contextEntries()) { + let page: api.Page | undefined; + if (pending.pageId !== undefined) + page = context.pages().find(p => pageId(p) === pending.pageId); + else if (pending.sessionName !== undefined && descriptor.title === pending.sessionName + && (pending.workspaceDir === undefined || descriptor.workspaceDir === pending.workspaceDir)) + page = context.pages()[0]; + if (!page) + continue; + this._pendingReveal = undefined; + void this._switchAttachedTo(page); return; - this._pendingReveal = undefined; - await this._switchAttachedTo(page); - this._pushTabs(); + } } async submitAnnotation(params: { data: string | undefined; ariaSnapshot: string; annotations: AnnotationData[] }) { @@ -308,6 +225,14 @@ export class DashboardConnection implements Transport { this.sendEvent?.('cancelAnnotate', {}); } + artifactsDirFor(context: api.BrowserContext): string { + for (const entry of this._provider.contextEntries()) { + if (entry.context === context) + return entry.descriptor.browser.launchOptions.artifactsDir ?? this._recordingDir; + } + return this._recordingDir; + } + _pushTabs() { if (this._pushTabsScheduled) return; @@ -323,22 +248,31 @@ export class DashboardConnection implements Transport { }); } + private _pushSessions() { + void (async () => { + try { + const sessions = await this._provider.sessions(); + this.emitSessions(sessions); + } catch { + // best-effort + } + })(); + } + private async _aggregateTabs(): Promise { const attachedPage = this._attachedPage?.page; const tasks: Promise[] = []; - for (const { browser } of this._browsers.values()) { - for (const context of browser.contexts()) { - for (const page of context.pages()) { - tasks.push((async () => ({ - browser: browserId(browser), - context: contextId(context), - page: pageId(page), - title: await page.title().catch(() => ''), - url: page.url(), - selected: page === attachedPage, - faviconUrl: await faviconUrl(page), - }))()); - } + for (const { browser, context } of this._provider.contextEntries()) { + for (const page of context.pages()) { + tasks.push((async () => ({ + browser: browserId(browser), + context: contextId(context), + page: pageId(page), + title: await page.title().catch(() => ''), + url: page.url(), + selected: page === attachedPage, + faviconUrl: await faviconUrl(page), + }))()); } } return await Promise.all(tasks); @@ -348,13 +282,7 @@ export class DashboardConnection implements Transport { if (this._attachedPage?.page === page) return; this._attachedPage?.dispose(); - const browser = page.context().browser(); - const slot = browser ? [...this._browsers.values()].find(s => s.browser === browser) : undefined; - if (!slot) { - this._attachedPage = undefined; - return; - } - const attached = new AttachedPage(this, slot, page); + const attached = new AttachedPage(this, page); this._attachedPage = attached; try { await attached.init(); @@ -378,101 +306,22 @@ export class DashboardConnection implements Transport { void this._switchAttachedTo(next); this._pushTabs(); } - - private _pushSessions = () => { - if (this._pushSessionsScheduled) - return; - this._pushSessionsScheduled = true; - queueMicrotask(async () => { - this._pushSessionsScheduled = false; - try { - const byWs = await serverRegistry.list(); - const sessions: BrowserDescriptor[] = []; - for (const list of byWs.values()) { - for (const status of list) { - if (status.title.startsWith('--playwright-internal')) - continue; - sessions.push(status); - } - } - await this._reconcile(sessions); - await this._tryRevealPending(); - this.emitSessions(sessions); - this._pushTabs(); - } catch { - // best-effort - } - }); - }; - - private async _reconcile(sessions: BrowserDescriptor[]) { - const connectable = new Map(); - for (const status of sessions) - connectable.set(status.browser.guid, status); - - for (const [guid, slot] of this._browsers) { - if (connectable.has(guid)) - continue; - if (this._attachedPage && this._attachedPage.page.context().browser() === slot.browser) { - this._attachedPage.dispose(); - this._attachedPage = undefined; - } - slot.dispose(); - this._browsers.delete(guid); - } - - for (const [guid, status] of connectable) { - if (this._browsers.has(guid)) - continue; - const slot = await BrowserTracker.create(status, { - onTabsChanged: () => this._pushTabs(), - onContextClosed: context => { - if (this._attachedPage?.page.context() === context) { - this._attachedPage.dispose(); - this._attachedPage = undefined; - } - }, - }); - if (!slot) - continue; - if (this._browsers.has(guid)) { - slot.dispose(); - continue; - } - this._browsers.set(guid, slot); - } - } - - private _findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { - const slot = this._browsers.get(params.browser); - if (!slot) - return undefined; - return slot.contexts().find(c => contextId(c) === params.context); - } - - private _findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { - const context = this._findContext(params); - return context?.pages().find(p => pageId(p) === params.page); - } } class AttachedPage { private _owner: DashboardConnection; - private _slot: BrowserTracker; private _page: api.Page; private _listeners: Disposable[] = []; private _screencastRunning = false; private _recordingPath: string | null = null; private _disposed = false; - constructor(owner: DashboardConnection, slot: BrowserTracker, page: api.Page) { + constructor(owner: DashboardConnection, page: api.Page) { this._owner = owner; - this._slot = slot; this._page = page; } get page(): api.Page { return this._page; } - private get _descriptor(): BrowserDescriptor { return this._slot.descriptor; } async init() { this._listeners.push( @@ -556,7 +405,7 @@ class AttachedPage { } async startRecording() { - const artifactsDir = this._descriptor.browser.launchOptions.artifactsDir ?? this._owner._recordingDir; + const artifactsDir = this._owner.artifactsDirFor(this._page.context()); this._recordingPath = path.join(artifactsDir, `recording-${Date.now()}.webm`); if (this._screencastRunning) await this._restartScreencast(this._page); diff --git a/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts b/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts new file mode 100644 index 0000000000000..13d383f835a82 --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/identitySessionProvider.ts @@ -0,0 +1,127 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Disposable } from '@isomorphic/disposable'; +import { eventsHelper } from '@utils/eventsHelper'; +import { packageJSON, packageRoot } from '../../package'; +import { SessionProviderEvent } from './sessionProvider'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor, BrowserInfo } from '../../serverRegistry'; +import type { ContextEntry, SessionProvider, SessionProviderEventMap } from './sessionProvider'; + +export class IdentitySessionProvider extends EventEmitter implements SessionProvider { + private _context: api.BrowserContext; + private _browser: api.Browser; + private _descriptor: BrowserDescriptor; + private _listeners: Disposable[] = []; + private _closed = false; + + constructor(context: api.BrowserContext) { + super(); + const browser = context.browser(); + if (!browser) + throw new Error('SingleContextDashboardProvider requires a context with an attached browser'); + this._context = context; + this._browser = browser; + this._descriptor = synthesiseDescriptor(browser); + } + + start(): void { + const emitTabsChanged = () => this.emit(SessionProviderEvent.TabsChanged); + this._listeners.push( + eventsHelper.addEventListener(this._context, 'page', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'pageload', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'pageclose', emitTabsChanged), + eventsHelper.addEventListener(this._context, 'framenavigated', (frame: api.Frame) => { + if (frame === frame.page().mainFrame()) + this.emit(SessionProviderEvent.TabsChanged); + }), + eventsHelper.addEventListener(this._context, 'close', () => { + this._closed = true; + this.emit(SessionProviderEvent.ContextClosed, this._context); + this.emit(SessionProviderEvent.TabsChanged); + }), + ); + this.emit(SessionProviderEvent.SessionsChanged); + this.emit(SessionProviderEvent.TabsChanged); + const firstPage = this._context.pages()[0]; + if (firstPage) + this.emit(SessionProviderEvent.AttachRequested, firstPage); + } + + dispose(): void { + this._listeners.forEach(d => d.dispose()); + this._listeners = []; + this.removeAllListeners(); + } + + async sessions(): Promise { + return this._closed ? [] : [this._descriptor]; + } + + contextEntries(): ContextEntry[] { + if (this._closed) + return []; + return [{ browser: this._browser, context: this._context, descriptor: this._descriptor }]; + } + + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { + if (this._closed) + return undefined; + if (params.browser !== this._descriptor.browser.guid) + return undefined; + if (contextId(this._context) !== params.context) + return undefined; + return this._context; + } + + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { + const context = this.findContext(params); + return context?.pages().find(p => pageId(p) === params.page); + } + + async closeSession(): Promise { + // No-op: lifecycle of the user-provided context is managed by the caller. + } +} + +function synthesiseDescriptor(browser: api.Browser): BrowserDescriptor { + const browserName = browser.browserType().name() as BrowserInfo['browserName']; + const browserInfo: BrowserInfo = { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + guid: (browser as any)._guid, + browserName, + launchOptions: {}, + }; + return { + title: 'Playwright', + playwrightVersion: packageJSON.version, + playwrightLib: packageRoot, + browser: browserInfo, + }; +} + +function pageId(p: api.Page): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (p as any)._guid; +} + +function contextId(c: api.BrowserContext): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (c as any)._guid; +} diff --git a/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts b/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts new file mode 100644 index 0000000000000..3f3fa3216d352 --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/registrySessionProvider.ts @@ -0,0 +1,226 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Disposable } from '@isomorphic/disposable'; +import { eventsHelper } from '@utils/eventsHelper'; +import { connectToBrowserAcrossVersions } from '../utils/connect'; +import { serverRegistry } from '../../serverRegistry'; +import { SessionProviderEvent } from './sessionProvider'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor } from '../../serverRegistry'; +import type { ContextEntry, SessionProvider, SessionProviderEventMap } from './sessionProvider'; + +type BrowserTrackerCallbacks = { + onTabsChanged: () => void; + onContextClosed: (context: api.BrowserContext) => void; +}; + +class BrowserTracker { + readonly descriptor: BrowserDescriptor; + readonly browser: api.Browser; + private _callbacks: BrowserTrackerCallbacks; + private _contextListeners = new Map(); + private _browserListeners: Disposable[] = []; + + static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { + try { + const browser = await connectToBrowserAcrossVersions(descriptor); + const slot = new BrowserTracker(descriptor, browser, callbacks); + for (const context of browser.contexts()) + slot._wireContext(context); + slot._browserListeners.push(eventsHelper.addEventListener(browser, 'context', (context: api.BrowserContext) => { + slot._wireContext(context); + })); + return slot; + } catch { + return undefined; + } + } + + private constructor(descriptor: BrowserDescriptor, browser: api.Browser, callbacks: BrowserTrackerCallbacks) { + this.descriptor = descriptor; + this.browser = browser; + this._callbacks = callbacks; + } + + contexts(): api.BrowserContext[] { + return this.browser.contexts(); + } + + dispose() { + this._browserListeners.forEach(d => d.dispose()); + this._browserListeners = []; + for (const listeners of this._contextListeners.values()) + listeners.forEach(d => d.dispose()); + this._contextListeners.clear(); + } + + private _wireContext(context: api.BrowserContext) { + if (this._contextListeners.has(context)) + return; + const onTabsChanged = () => this._callbacks.onTabsChanged(); + const listeners: Disposable[] = [ + eventsHelper.addEventListener(context, 'page', onTabsChanged), + eventsHelper.addEventListener(context, 'pageload', onTabsChanged), + eventsHelper.addEventListener(context, 'pageclose', onTabsChanged), + eventsHelper.addEventListener(context, 'framenavigated', (frame: api.Frame) => { + if (frame === frame.page().mainFrame()) + this._callbacks.onTabsChanged(); + }), + eventsHelper.addEventListener(context, 'close', () => { + const ls = this._contextListeners.get(context); + if (ls) { + ls.forEach(d => d.dispose()); + this._contextListeners.delete(context); + } + this._callbacks.onContextClosed(context); + this._callbacks.onTabsChanged(); + }), + ]; + this._contextListeners.set(context, listeners); + this._callbacks.onTabsChanged(); + } +} + +export class RegistrySessionProvider extends EventEmitter implements SessionProvider { + private _trackers = new Map(); + private _serverRegistryDispose?: () => void; + private _pushSessionsScheduled = false; + + start(): void { + this._serverRegistryDispose = serverRegistry.watch(); + serverRegistry.on('added', this._scheduleSessions); + serverRegistry.on('removed', this._scheduleSessions); + serverRegistry.on('changed', this._scheduleSessions); + this._scheduleSessions(); + } + + dispose(): void { + serverRegistry.off('added', this._scheduleSessions); + serverRegistry.off('removed', this._scheduleSessions); + serverRegistry.off('changed', this._scheduleSessions); + this._serverRegistryDispose?.(); + this._serverRegistryDispose = undefined; + for (const tracker of this._trackers.values()) + tracker.dispose(); + this._trackers.clear(); + this.removeAllListeners(); + } + + async sessions(): Promise { + const byWs = await serverRegistry.list(); + const sessions: BrowserDescriptor[] = []; + for (const list of byWs.values()) { + for (const status of list) { + if (status.title.startsWith('--playwright-internal')) + continue; + sessions.push(status); + } + } + return sessions; + } + + contextEntries(): ContextEntry[] { + const entries: ContextEntry[] = []; + for (const tracker of this._trackers.values()) { + for (const context of tracker.contexts()) + entries.push({ browser: tracker.browser, context, descriptor: tracker.descriptor }); + } + return entries; + } + + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined { + const tracker = this._trackers.get(params.browser); + if (!tracker) + return undefined; + return tracker.contexts().find(c => contextId(c) === params.context); + } + + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined { + const context = this.findContext(params); + return context?.pages().find(p => pageId(p) === params.page); + } + + async closeSession(browserId: string): Promise { + const descriptor = serverRegistry.readDescriptor(browserId); + const browser = await connectToBrowserAcrossVersions(descriptor); + try { + await Promise.all(browser.contexts().map(context => context.close())); + await browser.close(); + } catch { + // best-effort + } + } + + private _scheduleSessions = () => { + if (this._pushSessionsScheduled) + return; + this._pushSessionsScheduled = true; + queueMicrotask(async () => { + this._pushSessionsScheduled = false; + try { + const sessions = await this.sessions(); + await this._reconcile(sessions); + this.emit(SessionProviderEvent.SessionsChanged); + this.emit(SessionProviderEvent.TabsChanged); + } catch { + // best-effort + } + }); + }; + + private async _reconcile(sessions: BrowserDescriptor[]) { + const connectable = new Map(); + for (const status of sessions) + connectable.set(status.browser.guid, status); + + for (const [guid, tracker] of this._trackers) { + if (connectable.has(guid)) + continue; + tracker.dispose(); + this._trackers.delete(guid); + } + + for (const [guid, status] of connectable) { + if (this._trackers.has(guid)) + continue; + const tracker = await BrowserTracker.create(status, { + onTabsChanged: () => this.emit(SessionProviderEvent.TabsChanged), + onContextClosed: context => this.emit(SessionProviderEvent.ContextClosed, context), + }); + if (!tracker) + continue; + if (this._trackers.has(guid)) { + tracker.dispose(); + continue; + } + this._trackers.set(guid, tracker); + } + } + +} + +function pageId(p: api.Page): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (p as any)._guid; +} + +function contextId(c: api.BrowserContext): string { + // eslint-disable-next-line no-restricted-syntax -- _guid is very conservative. + return (c as any)._guid; +} diff --git a/packages/playwright-core/src/tools/dashboard/sessionProvider.ts b/packages/playwright-core/src/tools/dashboard/sessionProvider.ts new file mode 100644 index 0000000000000..e05804f19cadd --- /dev/null +++ b/packages/playwright-core/src/tools/dashboard/sessionProvider.ts @@ -0,0 +1,50 @@ +/** + * 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 { EventEmitter } from 'events'; + +import type * as api from '../../../types/types'; +import type { BrowserDescriptor } from '../../serverRegistry'; + +export type ContextEntry = { + browser: api.Browser; + context: api.BrowserContext; + descriptor: BrowserDescriptor; +}; + +export const SessionProviderEvent = { + SessionsChanged: 'sessionsChanged', + TabsChanged: 'tabsChanged', + ContextClosed: 'contextClosed', + AttachRequested: 'attachRequested', +} as const; + +export type SessionProviderEventMap = { + [SessionProviderEvent.SessionsChanged]: []; + [SessionProviderEvent.TabsChanged]: []; + [SessionProviderEvent.ContextClosed]: [context: api.BrowserContext]; + [SessionProviderEvent.AttachRequested]: [page: api.Page]; +}; + +export interface SessionProvider extends EventEmitter { + start(): void; + sessions(): Promise; + closeSession(browserId: string): Promise; + contextEntries(): ContextEntry[]; + findContext(params: { browser: string; context: string }): api.BrowserContext | undefined; + findPage(params: { browser: string; context: string; page: string }): api.Page | undefined; + dispose(): void; +} diff --git a/packages/playwright-core/src/tools/index.ts b/packages/playwright-core/src/tools/index.ts index 000ef8036db7e..929d750668cd4 100644 --- a/packages/playwright-core/src/tools/index.ts +++ b/packages/playwright-core/src/tools/index.ts @@ -30,7 +30,7 @@ export { decorateMCPCommand } from './mcp/program'; export { program as cliProgram } from './cli-client/program'; export { generateHelp, generateHelpJSON } from './cli-daemon/helpGenerator'; export { decorateProgram as decorateCliDaemonProgram } from './cli-daemon/program'; -export { openDashboardApp } from './dashboard/dashboardApp'; +export { openDashboardApp, openDashboardForContext, annotateInContext } from './dashboard/dashboardApp'; export type { ContextConfig } from './backend/context'; export type { CallToolRequest, CallToolResult, Tool } from './backend/tool'; diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index 7b11e9e96d545..c88373d6edc93 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -153,16 +153,15 @@ async function drawAndSubmitAnnotation(dashboard: import('playwright-core').Page } function verifyAnnotateOutput(output: string, expectedText: string, outputDir: string) { - const lines = output.trim().split('\n'); - expect(lines[0]).toMatch(new RegExp(`^\\{ x: \\d+, y: \\d+, width: \\d+, height: \\d+ \\}: ${expectedText}$`)); - expect(lines[lines.length - 1]).toMatch(/^image: \.playwright-cli[\\/]annotations-.*\.png$/); - const pngRel = lines[lines.length - 1].replace(/^image: /, ''); - const pngPath = path.resolve(outputDir, pngRel); + expect(output).toMatch(new RegExp(`\\{ x: \\d+, y: \\d+, width: \\d+, height: \\d+ \\}: ${expectedText}`)); + const imageMatch = output.match(/- \[Annotation image\]\((\.playwright-cli[\\/]annotations-.*\.png)\)/); + expect(imageMatch).not.toBeNull(); + const pngPath = path.resolve(outputDir, imageMatch![1]); expect(fs.existsSync(pngPath)).toBe(true); expect(fs.statSync(pngPath).size).toBeGreaterThan(0); } -test('should capture annotations via show --annotate', async ({ connectToDashboard, cli, server }) => { +test('should capture annotations via annotate', async ({ connectToDashboard, cli, server }) => { await cli('open', server.EMPTY_PAGE); const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; await cli('show', { bindTitle }); @@ -171,7 +170,7 @@ test('should capture annotations via show --annotate', async ({ connectToDashboa const dashboard = browser.contexts()[0].pages()[0]; await dashboard.getByRole('navigation', { name: 'Sessions' }).getByRole('option').first().click(); - const annotatePromise = cli('show', '--annotate'); + const annotatePromise = cli('annotate'); let done = false; void annotatePromise.finally(() => { done = true; }); @@ -184,10 +183,10 @@ test('should capture annotations via show --annotate', async ({ connectToDashboa }); test('should start dashboard and annotate when no dashboard is running', async ({ connectToDashboard, cli, server }) => { - await cli('open', server.EMPTY_PAGE); - const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - const annotatePromise = cli('show', '--annotate', { bindTitle }); + await cli('open', server.EMPTY_PAGE, { bindTitle }); + + const annotatePromise = cli('annotate'); let done = false; void annotatePromise.finally(() => { done = true; }); @@ -205,12 +204,12 @@ test('should start dashboard and annotate when no dashboard is running', async ( verifyAnnotateOutput(output, 'hi', test.info().outputDir); }); -test('should enter annotate mode on fresh dashboard.tsx mount with -s --annotate', async ({ connectToDashboard, cli, server }) => { - await cli('-s=first', 'open', server.EMPTY_PAGE); - await cli('-s=second', 'open', server.EMPTY_PAGE); - +test('should enter annotate mode on fresh dashboard.tsx mount with -s annotate', async ({ connectToDashboard, cli, server }) => { const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; - const annotatePromise = cli('-s=second', 'show', '--annotate', { bindTitle }); + await cli('-s=first', 'open', server.EMPTY_PAGE, { bindTitle }); + await cli('-s=second', 'open', server.EMPTY_PAGE, { bindTitle }); + + const annotatePromise = cli('-s=second', 'annotate'); let done = false; void annotatePromise.finally(() => { done = true; }); @@ -321,8 +320,70 @@ test('should cancel browser_annotate when the MCP client disconnects', async ({ } }); +test('should annotate via modal browser_annotate', async ({ connectToDashboard, boundBrowser, startClient, cliEnv, server }) => { + const page = await boundBrowser.newPage(); + await page.goto(server.EMPTY_PAGE); + + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + const { client } = await startClient({ + args: ['--endpoint=default', '--caps=devtools'], + env: { + ...cliEnv, + PWTEST_DASHBOARD_APP_BIND_TITLE: bindTitle, + }, + }); + + const annotatePromise = client.callTool({ name: 'browser_annotate', arguments: { modal: true } }); + + const browser = await connectToDashboard(bindTitle); + try { + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); + await drawAndSubmitAnnotation(dashboard, 'modal-mcp'); + } finally { + await browser.close().catch(() => {}); + } + + const result = await annotatePromise; + const text = (result.content as any).map(c => c.text ?? '').join('\n'); + expect(text).toMatch(/\{ x: \d+, y: \d+, width: \d+, height: \d+ \}: modal-mcp/); + expect(text).toMatch(/- \[Annotation image\]\(.*\.png\)/); +}); + +test('should cancel modal browser_annotate when the MCP request is aborted', async ({ connectToDashboard, boundBrowser, startClient, cliEnv, server }) => { + const page = await boundBrowser.newPage(); + await page.goto(server.EMPTY_PAGE); + + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + const { client } = await startClient({ + args: ['--endpoint=default', '--caps=devtools'], + env: { + ...cliEnv, + PWTEST_DASHBOARD_APP_BIND_TITLE: bindTitle, + }, + }); + + const controller = new AbortController(); + const annotatePromise = client.callTool({ name: 'browser_annotate', arguments: { modal: true } }, undefined, { signal: controller.signal }).catch(() => {}); + + const browser = await connectToDashboard(bindTitle); + try { + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.getByRole('main', { name: 'Dashboard: annotate' })).toBeVisible(); + + controller.abort(); + + // Modal window auto-closes on abort. + await expect.poll(() => dashboard.isClosed()).toBe(true); + } finally { + await browser.close().catch(() => {}); + } + + await annotatePromise; +}); + -test('should switch screencast to -s session on show --annotate', async ({ connectToDashboard, cli, server }) => { +test('should switch screencast to -s session on annotate', async ({ connectToDashboard, cli, server }) => { server.setContent('/red', '', 'text/html'); server.setContent('/green', '', 'text/html'); @@ -353,7 +414,7 @@ test('should switch screencast to -s session on show --annotate', async ({ conne return !!(c && c.r > 200 && c.g < 50); }, { timeout: 15000 }).toBe(true); - const annotatePromise = cli('-s=second', 'show', '--annotate'); + const annotatePromise = cli('-s=second', 'annotate'); let done = false; void annotatePromise.finally(() => { done = true; }); @@ -371,7 +432,7 @@ test('should switch screencast to -s session on show --annotate', async ({ conne expect(exitCode).toBe(0); }); -test('should disengage annotate mode when --annotate client disconnects', async ({ connectToDashboard, cli, childProcess, cliEnv, mcpBrowser, mcpHeadless, server }) => { +test('should disengage annotate mode when annotate client disconnects', async ({ connectToDashboard, cli, childProcess, cliEnv, mcpBrowser, mcpHeadless, server }) => { await cli('open', server.EMPTY_PAGE); const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; await cli('show', { bindTitle }); @@ -381,7 +442,7 @@ test('should disengage annotate mode when --annotate client disconnects', async await dashboard.getByRole('navigation', { name: 'Sessions' }).getByRole('option').first().click(); const annotateClient = childProcess({ - command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), 'show', '--annotate'], + command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), 'annotate'], cwd: test.info().outputPath(), env: inheritAndCleanEnv({ ...cliEnv,