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
2 changes: 0 additions & 2 deletions packages/dashboard/src/dashboardChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ export interface DashboardChannel {
closeTab(params: { browser: string; context: string; page: string }): Promise<void>;
newTab(params: { browser: string; context: string }): Promise<void>;
closeSession(params: { browser: string }): Promise<void>;
deleteSessionData(params: { browser: string }): Promise<void>;
setVisible(params: { visible: boolean }): Promise<void>;
reveal(params: { path: string }): Promise<void>;

navigate(params: { url: string }): Promise<void>;
back(): Promise<void>;
Expand Down
4 changes: 0 additions & 4 deletions packages/dashboard/src/dashboardModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/backend/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[*]
../../..
../..
../dashboard/dashboardApp.ts
@utils/**
@utils/**
@isomorphic/**
Expand Down
89 changes: 58 additions & 31 deletions packages/playwright-core/src/tools/backend/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<number | null>(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<AnnotateResult> {
// 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<number | null>(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<AnnotateResult> {
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];
7 changes: 0 additions & 7 deletions packages/playwright-core/src/tools/cli-client/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,6 @@ export async function program(options?: { embedderVersion?: string}) {
await new Promise<void>(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<void>(resolve => annotate.on('exit', () => resolve()));
return;
}
const foreground = args.port !== undefined;
const child = spawn(process.execPath, daemonArgs, {
detached: !foreground,
Expand Down
8 changes: 4 additions & 4 deletions packages/playwright-core/src/tools/cli-client/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -1198,6 +1210,7 @@ const commandsArray: AnyCommandSchema[] = [
videoStop,
videoChapter,
dashboardShow,
annotate,
pauseAt,
resume,
stepOver,
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/tools/cli-daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}`);
Expand Down
Loading
Loading