diff --git a/.claude/skills/playwright-dev/dashboard.md b/.claude/skills/playwright-dev/dashboard.md index 60d255468ea80..606ed221e22b3 100644 --- a/.claude/skills/playwright-dev/dashboard.md +++ b/.claude/skills/playwright-dev/dashboard.md @@ -32,6 +32,4 @@ npx playwright cli video-stop # afterwards, use ffmpeg to turn the video into mp4 for sharing. ``` -For more about using Playwright CLI, look at `npx playwright cli --help` and the referenced Skill. -While developing in in this repo, it's important to use `npx playwright cli` instead of `playwright-cli`. - +Full CLI reference: `packages/playwright-core/src/tools/cli-client/skill/SKILL.md`. In this repo, invoke as `npx playwright cli` instead of `playwright-cli`. diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 0c181f926b4db..b676c64dc04ac 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -72,6 +72,12 @@ await page.GotoAsync("https://www.bing.com"); await browser.CloseAsync(); ``` +## event: Browser.context +* since: v1.60 +- argument: <[BrowserContext]> + +Emitted when a new browser context is created. + ## event: Browser.disconnected * since: v1.8 - argument: <[Browser]> diff --git a/packages/dashboard/src/annotations.tsx b/packages/dashboard/src/annotations.tsx index 6b507049afe00..c3b5a73c0d36e 100644 --- a/packages/dashboard/src/annotations.tsx +++ b/packages/dashboard/src/annotations.tsx @@ -112,13 +112,43 @@ function viewportRectToScreenStyle(layout: ImageLayout, screenRect: DOMRect, vw: }; } +export async function saveAnnotationAsDownload(blob: Blob): Promise { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const suggestedName = `annotations-${stamp}.png`; + const picker = (window as any).showSaveFilePicker as undefined | ((opts: any) => Promise); + if (picker) { + try { + const handle = await picker({ + suggestedName, + startIn: 'downloads', + types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (e: any) { + if (e?.name !== 'AbortError') + throw e; + } + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = suggestedName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + export const Annotations: React.FC<{ active: boolean; displayRef: React.RefObject; screenRef: React.RefObject; viewportWidth: number; viewportHeight: number; - onSubmit?: (blob: Blob, annotations: Annotation[]) => Promise | void; + onSubmit: (blob: Blob, annotations: Annotation[]) => Promise | void; }> = ({ active, displayRef, screenRef, viewportWidth, viewportHeight, onSubmit }) => { const [annotations, setAnnotations] = React.useState([]); const [draft, setDraft] = React.useState<{ startX: number; startY: number; x: number; y: number } | null>(null); @@ -327,37 +357,10 @@ export const Annotations: React.FC<{ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); if (!blob) return; - if (onSubmit) { - await onSubmit(blob, annotations); - return; - } - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const suggestedName = `annotations-${stamp}.png`; - const picker = (window as any).showSaveFilePicker as undefined | ((opts: any) => Promise); - if (picker) { - try { - const handle = await picker({ - suggestedName, - startIn: 'downloads', - types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }], - }); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - } catch (e: any) { - if (e?.name !== 'AbortError') - throw e; - } - return; - } - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = suggestedName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + await onSubmit(blob, annotations); + setAnnotations([]); + setSelection(null); + setDraft(null); } if (!active) diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index f72c6484fbce2..417fbf09c943c 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -19,7 +19,7 @@ import './dashboard.css'; import { DashboardClientContext } from './dashboardContext'; import { asLocator } from '@isomorphic/locatorGenerators'; import { ChevronLeftIcon, ChevronRightIcon, ReloadIcon } from './icons'; -import { Annotations, getImageLayout, clientToViewport } from './annotations'; +import { Annotations, getImageLayout, clientToViewport, saveAnnotationAsDownload } from './annotations'; import type { Annotation } from './annotations'; import { ToolbarButton } from '@web/components/toolbarButton'; @@ -30,6 +30,180 @@ import type { Tab, DashboardChannelEvents } from './dashboardChannel'; const BUTTONS = ['left', 'middle', 'right'] as const; type Mode = 'readonly' | 'interactive' | 'annotate'; +type DashboardState = { + // Server-driven session state. + tabs: Tab[] | null; + url: string; + // The latest frame received from the server. Cleared on page swap; + // the server is responsible for emitting a fresh frame for the new + // page (see _startScreencast). + liveFrame: DashboardChannelEvents['frame'] | undefined; + // Snapshot of the frame at the moment we entered annotate mode. The + // overlay draws on this frozen image so the canvas does + // not jump around as new live frames arrive. + annotateFrame: DashboardChannelEvents['frame'] | undefined; + // Server requested annotate but we have no fresh frame for the active + // page. The next FRAME action will enter annotate mode. + cliAnnotatePending: boolean; + // Whether the current annotate session was initiated by CLI or by the + // user. Determines whether submit goes to the server or saves locally. + annotateInitiator: 'cli' | 'user' | null; + // Interaction mode and ephemeral UI flags. + mode: Mode; + picking: boolean; + recording: boolean; +}; + +type DashboardAction = + // Server events + | { type: 'tabs'; tabs: Tab[] } + | { type: 'frame'; frame: DashboardChannelEvents['frame'] } + | { type: 'cliAnnotate' } + | { type: 'cliCancelAnnotate' } + | { type: 'pickLocator' } + | { type: 'elementPicked' } + // User events + | { type: 'toggleInteractive' } + | { type: 'toggleAnnotate' } + | { type: 'cancelPicking' } + | { type: 'setRecording'; recording: boolean } + | { type: 'submitAnnotation' } + | { type: 'setUrl'; url: string }; + +const initialDashboardState: DashboardState = { + tabs: null, + url: '', + liveFrame: undefined, + annotateFrame: undefined, + cliAnnotatePending: false, + annotateInitiator: null, + mode: 'readonly', + picking: false, + recording: false, +}; + +function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState { + switch (action.type) { + case 'tabs': { + const newSelected = action.tabs.find(t => t.selected); + const oldSelected = state.tabs?.find(t => t.selected); + const url = newSelected ? newSelected.url : state.url; + // Page change = had a selected tab, now have a different selected + // tab. Initial selection (none -> some) is not a "change". + const pageChanged = !!oldSelected && !!newSelected && newSelected.page !== oldSelected.page; + if (!pageChanged) + return { ...state, tabs: action.tabs, url }; + // Page swap. Clear frames and rely on the server to emit a fresh + // frame for the new page (see _startScreencast which awaits a + // screenshot to force one). If we were annotating, mark pending so + // the next FRAME re-enters annotate with the new page's image. + const wasAnnotateActive = state.mode === 'annotate' || state.cliAnnotatePending; + const newTabIsBrandNew = !!state.tabs && !state.tabs.some(t => t.page === newSelected.page); + let mode: Mode = state.mode; + if (!wasAnnotateActive && newTabIsBrandNew && state.tabs) + mode = 'interactive'; + return { + ...state, + tabs: action.tabs, + url, + mode, + picking: false, + recording: false, + liveFrame: undefined, + annotateFrame: undefined, + cliAnnotatePending: wasAnnotateActive, + }; + } + case 'frame': { + const liveFrame = action.frame; + if (state.cliAnnotatePending) { + return { + ...state, + liveFrame, + annotateFrame: liveFrame, + mode: 'annotate', + cliAnnotatePending: false, + annotateInitiator: state.annotateInitiator ?? 'cli', + }; + } + return { ...state, liveFrame }; + } + case 'cliAnnotate': { + if (state.mode === 'annotate') { + // Already annotating (user-initiated). Mark CLI as the source so + // submit goes to the server. + return { ...state, annotateInitiator: 'cli' }; + } + if (state.liveFrame) { + return { + ...state, + mode: 'annotate', + annotateFrame: state.liveFrame, + cliAnnotatePending: false, + annotateInitiator: 'cli', + }; + } + return { ...state, cliAnnotatePending: true, annotateInitiator: 'cli' }; + } + case 'cliCancelAnnotate': { + const exitingAnnotate = state.mode === 'annotate'; + return { + ...state, + mode: exitingAnnotate ? 'readonly' : state.mode, + annotateFrame: undefined, + cliAnnotatePending: false, + annotateInitiator: null, + }; + } + case 'pickLocator': + return { ...state, mode: 'interactive', picking: true }; + case 'elementPicked': + return { ...state, picking: false }; + case 'toggleInteractive': { + const next: Mode = state.mode === 'interactive' ? 'readonly' : 'interactive'; + return { ...state, mode: next, picking: false }; + } + case 'toggleAnnotate': { + if (state.mode === 'annotate') { + return { + ...state, + mode: 'readonly', + annotateFrame: undefined, + annotateInitiator: null, + }; + } + if (!state.liveFrame) + return state; + // Preserve a CLI initiator across mode toggles so that CLI annotate + // sessions stay engaged when the user switches to interactive and + // back. Only set 'user' if there was no prior CLI engagement. + const initiator: 'cli' | 'user' | null = state.annotateInitiator ?? 'user'; + return { + ...state, + mode: 'annotate', + annotateFrame: state.liveFrame, + cliAnnotatePending: false, + annotateInitiator: initiator, + picking: false, + }; + } + case 'cancelPicking': + return { ...state, picking: false }; + case 'setRecording': + return { ...state, recording: action.recording }; + case 'submitAnnotation': + return { + ...state, + mode: 'readonly', + annotateFrame: undefined, + cliAnnotatePending: false, + annotateInitiator: null, + }; + case 'setUrl': + return { ...state, url: action.url }; + } +} + async function pickSaveWritable(suggestedName: string, description: string, mime: string, extension: string): Promise { try { const handle = await (window as any).showSaveFilePicker({ @@ -66,16 +240,16 @@ function smartUrl(input: string): string { export const Dashboard: React.FC = () => { const client = React.useContext(DashboardClientContext); - const [mode, setMode] = React.useState('readonly'); - const [tabs, setTabs] = React.useState(null); - const [url, setUrl] = React.useState(''); - const [frame, setFrame] = React.useState(); - const [picking, setPicking] = React.useState(false); - const [recording, setRecording] = React.useState(false); + const [state, dispatch] = React.useReducer(dashboardReducer, initialDashboardState); + const { tabs, url, mode, picking, recording, liveFrame, annotateFrame, annotateInitiator } = state; + const interactive = mode === 'interactive'; + const annotating = mode === 'annotate'; + // While annotating, the on-screen image is the frozen snapshot so the + // user can draw on a stable picture even as the page keeps moving. + const frame = annotating ? annotateFrame : liveFrame; + const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'clippy'>('device-camera'); const [flashTick, setFlashTick] = React.useState(0); - const [pendingAnnotate, setPendingAnnotate] = React.useState(false); - const [cliAnnotate, setCliAnnotate] = React.useState(false); const displayRef = React.useRef(null); const screenRef = React.useRef(null); @@ -84,7 +258,6 @@ export const Dashboard: React.FC = () => { const browserChromeRef = React.useRef(null); const interactiveBtnRef = React.useRef(null); const moveThrottleRef = React.useRef(0); - const modeRef = React.useRef('readonly'); const aspect = frame && frame.viewportWidth && frame.viewportHeight ? frame.viewportWidth / frame.viewportHeight @@ -111,13 +284,6 @@ export const Dashboard: React.FC = () => { return { width: w, height: h + chromeHeight }; }, [viewportRect, aspect]); - React.useEffect(() => { - modeRef.current = mode; - }, [mode]); - - const interactive = mode === 'interactive'; - const annotating = mode === 'annotate'; - React.useEffect(() => { if (flashTick === 0 || interactive) return; @@ -135,23 +301,11 @@ export const Dashboard: React.FC = () => { }; }, [flashTick, interactive]); - const hasFrame = !!frame; - React.useEffect(() => { - if (!pendingAnnotate || !hasFrame) - return; - setMode('annotate'); - setCliAnnotate(true); - setPendingAnnotate(false); - }, [pendingAnnotate, hasFrame]); - - React.useEffect(() => { - if (!annotating) - setCliAnnotate(false); - }, [annotating]); - - const submitAnnotationToCli = React.useCallback(async (blob: Blob, annotations: Annotation[]) => { - if (!client) + const onSubmitAnnotations = React.useCallback(async (blob: Blob, annotations: Annotation[]) => { + if (!client || annotateInitiator !== 'cli') { + await saveAnnotationAsDownload(blob); return; + } const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); @@ -163,33 +317,22 @@ export const Dashboard: React.FC = () => { data, annotations: annotations.map(a => ({ x: a.x, y: a.y, width: a.width, height: a.height, text: a.text })), }); - setMode('readonly'); - }, [client]); + dispatch({ type: 'submitAnnotation' }); + }, [client, annotateInitiator]); function flashInteractiveHint() { setFlashTick(tick => tick + 1); } - const prevTabsRef = React.useRef(null); - React.useEffect(() => { if (!client) return; let resized = false; const onTabs = (params: DashboardChannelEvents['tabs']) => { - const prev = prevTabsRef.current; - const selected = params.tabs.find(t => t.selected); - if (prev && selected && !prev.some(t => t.page === selected.page)) - setMode('interactive'); - prevTabsRef.current = params.tabs; - setTabs(params.tabs); - if (selected) - setUrl(selected.url); + dispatch({ type: 'tabs', tabs: params.tabs }); }; const onFrame = (params: DashboardChannelEvents['frame']) => { - if (modeRef.current === 'annotate') - return; - setFrame(params); + dispatch({ type: 'frame', frame: params }); const toolbar = toolbarRef.current; if (!resized && toolbar && params.viewportWidth && params.viewportHeight) { resized = true; @@ -204,35 +347,30 @@ export const Dashboard: React.FC = () => { const onElementPicked = (params: DashboardChannelEvents['elementPicked']) => { const locator = asLocator('javascript', params.selector); navigator.clipboard?.writeText(locator).catch(() => {}); - setPicking(false); - }; - const onPickLocator = () => { - setMode('interactive'); - setPicking(true); + dispatch({ type: 'elementPicked' }); }; - const onAnnotate = () => setPendingAnnotate(true); + const onPickLocator = () => dispatch({ type: 'pickLocator' }); + const onAnnotate = () => dispatch({ type: 'cliAnnotate' }); + const onCancelAnnotate = () => dispatch({ type: 'cliCancelAnnotate' }); client.on('tabs', onTabs); client.on('frame', onFrame); client.on('elementPicked', onElementPicked); client.on('pickLocator', onPickLocator); client.on('annotate', onAnnotate); + client.on('cancelAnnotate', onCancelAnnotate); return () => { client.off('tabs', onTabs); client.off('frame', onFrame); client.off('elementPicked', onElementPicked); client.off('pickLocator', onPickLocator); client.off('annotate', onAnnotate); + client.off('cancelAnnotate', onCancelAnnotate); }; }, [client]); const selectedTab = tabs?.find(t => t.selected); const ready = !!client && !!selectedTab; - React.useEffect(() => { - setRecording(false); - setPicking(false); - }, [selectedTab?.page]); - function imgCoords(e: React.MouseEvent): { x: number; y: number } { const vw = frame?.viewportWidth ?? 0; const vh = frame?.viewportHeight ?? 0; @@ -296,7 +434,7 @@ export const Dashboard: React.FC = () => { if (picking && e.key === 'Escape') { e.preventDefault(); client?.cancelPickLocator(); - setPicking(false); + dispatch({ type: 'cancelPicking' }); return; } if (!interactive || !client) @@ -315,7 +453,7 @@ export const Dashboard: React.FC = () => { function onOmniboxKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter') { const value = smartUrl((e.target as HTMLInputElement).value); - setUrl(value); + dispatch({ type: 'setUrl', url: value }); client?.navigate({ url: value }); e.currentTarget.blur(); } @@ -340,8 +478,7 @@ export const Dashboard: React.FC = () => { disabled={!ready} onClick={() => { client?.cancelPickLocator(); - setPicking(false); - setMode(interactive ? 'readonly' : 'interactive'); + dispatch({ type: 'toggleInteractive' }); }} /> { disabled={!ready || !frame} onClick={() => { client?.cancelPickLocator(); - setPicking(false); - setMode(annotating ? 'readonly' : 'annotate'); + dispatch({ type: 'toggleAnnotate' }); }} />
@@ -371,7 +507,7 @@ export const Dashboard: React.FC = () => { const writable = await pickSaveWritable(`playwright-recording-${Date.now()}.webm`, 'WebM Video', 'video/webm', '.webm'); if (!writable) return; - setRecording(false); + dispatch({ type: 'setRecording', recording: false }); const { streamId } = await client.stopRecording(); while (true) { const { data, eof } = await client.readStream({ streamId }); @@ -382,7 +518,7 @@ export const Dashboard: React.FC = () => { await writable.close(); } else { await client.startRecording(); - setRecording(true); + dispatch({ type: 'setRecording', recording: true }); } }}> {recording && Recording...} @@ -452,7 +588,7 @@ export const Dashboard: React.FC = () => { onChange={e => { if (!interactive) return; - setUrl(e.target.value); + dispatch({ type: 'setUrl', url: e.target.value }); }} onKeyDown={e => { if (!interactive) @@ -498,7 +634,7 @@ export const Dashboard: React.FC = () => { screenRef={screenRef} viewportWidth={frame?.viewportWidth ?? 0} viewportHeight={frame?.viewportHeight ?? 0} - onSubmit={cliAnnotate ? submitAnnotationToCli : undefined} + onSubmit={onSubmitAnnotations} />
{overlayText &&
{overlayText}
} diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index a6b44506752c4..d424c0911fc0b 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -37,6 +37,7 @@ export type DashboardChannelEvents = { elementPicked: { selector: string; ariaSnapshot?: string }; pickLocator: {}; annotate: {}; + cancelAnnotate: {}; }; export type MouseButton = 'left' | 'middle' | 'right'; diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index b0a45b471e42a..ba593c8b90da8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9974,6 +9974,11 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when a new browser context is created. + */ + on(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9982,11 +9987,21 @@ export interface Browser { */ on(event: 'disconnected', listener: (browser: Browser) => 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: 'context', listener: (browserContext: BrowserContext) => 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: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + addListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9995,16 +10010,31 @@ export interface Browser { */ addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + prependListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 1f19055489b37..010d16bb5f43e 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -109,6 +109,7 @@ export class Browser extends ChannelOwner implements ap // and will be configured later in `_connectToBrowserType`. if (this._browserType) this._setupBrowserContext(context); + this.emit(Events.Browser.Context, context); } private _setupBrowserContext(context: BrowserContext) { diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index f244da4dd75d3..c988ee45d02e3 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -31,6 +31,7 @@ export const Events = { }, Browser: { + Context: 'context', Disconnected: 'disconnected' }, 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 bd4fdc308a55a..10a0ca04c46c1 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -356,22 +356,13 @@ playwright-cli tracing-stop playwright-cli close ``` -## Example: Interactive element inspection +## Example: Interactive session -Ask the user to point at an element in the browser, then keep it visible while you work on it: +Ask the user to annotate the UI. User can provide contextual tasks or ask contextual questions using annotations: ```bash playwright-cli open https://example.com -# blocks until the user clicks an element; prints `ref: eN` and the locator -playwright-cli pick -# keep the picked element highlighted while iterating; style is optional -playwright-cli highlight e5 --style="outline: 3px dashed red" -playwright-cli highlight e7 -# ... inspect, generate code, etc. ... -# hide a single highlight, or drop them all in one shot -playwright-cli highlight e5 --hide -playwright-cli highlight --hide -playwright-cli close +playwright-cli show --annotate ``` ## Specific tasks diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 9267dc725dc7d..f2b9d5091b8a7 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -345,15 +345,6 @@ const snapshot = declareCommand({ toolParams: ({ filename, target, depth }) => ({ filename, target, depth }), }); -const pick = declareCommand({ - name: 'pick', - description: 'Wait for the user to pick an element in the browser and print its ref and locator', - category: 'devtools', - args: z.object({}), - toolName: 'browser_pick_locator', - toolParams: () => ({}), -}); - const generateLocator = declareCommand({ name: 'generate-locator', description: 'Generate a Playwright locator for the given element', @@ -1085,7 +1076,6 @@ const commandsArray: AnyCommandSchema[] = [ pauseAt, resume, stepOver, - pick, generateLocator, highlight, diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index f044243c98788..43f486126a215 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -106,9 +106,20 @@ async function startDashboardServer(options: DashboardOptions): Promise { + pendingAnnotate = false; + for (const connection of connections) + connection.emitCancelAnnotate(); + }; + const registerAnnotateWaiter = (socket: net.Socket) => { waitingSockets.add(socket); - const cleanup = () => waitingSockets.delete(socket); + const cleanup = () => { + if (!waitingSockets.delete(socket)) + return; + if (waitingSockets.size === 0) + notifyAnnotateEnded(); + }; socket.on('close', cleanup); socket.on('error', cleanup); }; @@ -403,7 +414,7 @@ async function runAnnotateClient(options: DashboardOptions): Promise { console.log(`{ x: ${a.x}, y: ${a.y}, width: ${a.width}, height: ${a.height} }: ${a.text}`); } // eslint-disable-next-line no-console - console.log(`image available at: ${path.relative(process.cwd(), filePath)}`); + console.log(`image: ${path.relative(process.cwd(), filePath)}`); } function selfDestructOnParentGone() { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 3460386245a5d..8593199c0c2e6 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -41,6 +41,7 @@ class BrowserTracker { readonly browser: api.Browser; private _callbacks: BrowserTrackerCallbacks; private _contextListeners = new Map(); + private _browserListeners: Disposable[] = []; static async create(descriptor: BrowserDescriptor, callbacks: BrowserTrackerCallbacks): Promise { try { @@ -48,6 +49,9 @@ class BrowserTracker { 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; @@ -65,6 +69,8 @@ class BrowserTracker { } dispose() { + this._browserListeners.forEach(d => d.dispose()); + this._browserListeners = []; for (const listeners of this._contextListeners.values()) listeners.forEach(d => d.dispose()); this._contextListeners.clear(); @@ -73,38 +79,29 @@ class BrowserTracker { private _wireContext(context: api.BrowserContext) { if (this._contextListeners.has(context)) return; - const listeners: Disposable[] = []; - this._contextListeners.set(context, listeners); - const watchPage = (page: api.Page) => { - listeners.push( - eventsHelper.addEventListener(page, 'load', () => this._callbacks.onTabsChanged()), - eventsHelper.addEventListener(page, 'framenavigated', (frame: api.Frame) => { - if (frame === page.mainFrame()) - this._callbacks.onTabsChanged(); - }), - eventsHelper.addEventListener(page, 'close', () => this._callbacks.onTabsChanged()), - ); - }; - listeners.push( - eventsHelper.addEventListener(context, 'page', (page: api.Page) => { - watchPage(page); + 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, 'picklocator', (page: api.Page) => { - this._callbacks.onPickLocator(page); - }), - 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(); - }), - ); - for (const page of context.pages()) - watchPage(page); + }), + eventsHelper.addEventListener(context, 'picklocator', (page: api.Page) => { + this._callbacks.onPickLocator(page); + }), + 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(); } } @@ -304,6 +301,10 @@ export class DashboardConnection implements Transport { this.sendEvent?.('annotate', {}); } + emitCancelAnnotate() { + this.sendEvent?.('cancelAnnotate', {}); + } + _pushTabs() { if (this._pushTabsScheduled) return; @@ -344,14 +345,22 @@ export class DashboardConnection implements Transport { if (this._attachedPage?.page === page) return; this._attachedPage?.dispose(); - this._attachedPage = undefined; const browser = page.context().browser(); const slot = browser ? [...this._browsers.values()].find(s => s.browser === browser) : undefined; - if (!slot) + if (!slot) { + this._attachedPage = undefined; return; + } const attached = new AttachedPage(this, slot, page); - await attached.init(); this._attachedPage = attached; + try { + await attached.init(); + } catch (e) { + if (this._attachedPage === attached) + this._attachedPage = undefined; + attached.dispose(); + throw e; + } } _handleAttachedPageClose(context: api.BrowserContext) { @@ -455,6 +464,7 @@ class AttachedPage { private _listeners: Disposable[] = []; private _screencastRunning = false; private _recordingPath: string | null = null; + private _disposed = false; constructor(owner: DashboardConnection, slot: BrowserTracker, page: api.Page) { this._owner = owner; @@ -483,6 +493,7 @@ class AttachedPage { } dispose() { + this._disposed = true; this._listeners.forEach(d => d.dispose()); this._listeners = []; if (this._screencastRunning) @@ -581,11 +592,17 @@ class AttachedPage { private async _startScreencast(page: api.Page) { await page.screencast.start({ - onFrame: ({ data }: { data: Buffer }) => this._owner.emitFrame(data.toString('base64'), page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0), + onFrame: ({ data }: { data: Buffer }) => { + if (this._disposed) + return; + const vp = page.viewportSize(); + this._owner.emitFrame(data.toString('base64'), vp?.width ?? 0, vp?.height ?? 0); + }, size: { width: 1280, height: 800 }, ...(this._recordingPath ? { path: this._recordingPath } : {}), }); - void page.screenshot().catch(() => {}); // TODO: this is necessary to trigger a first frame - should this be in screencast.start() implementation? + // TODO: this is necessary to trigger a first frame - should this be in screencast.start() implementation? + await page.screenshot().catch(() => {}); } private async _restartScreencast(page: api.Page) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index b0a45b471e42a..ba593c8b90da8 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9974,6 +9974,11 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when a new browser context is created. + */ + on(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9982,11 +9987,21 @@ export interface Browser { */ on(event: 'disconnected', listener: (browser: Browser) => 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: 'context', listener: (browserContext: BrowserContext) => 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: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + addListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9995,16 +10010,31 @@ export interface Browser { */ addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ off(event: 'disconnected', listener: (browser: Browser) => any): this; + /** + * Emitted when a new browser context is created. + */ + prependListener(event: 'context', listener: (browserContext: BrowserContext) => any): this; + /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: diff --git a/tests/library/browser.spec.ts b/tests/library/browser.spec.ts index 19d47f871a4ea..a3db493051925 100644 --- a/tests/library/browser.spec.ts +++ b/tests/library/browser.spec.ts @@ -63,6 +63,13 @@ test('should dispatch page.on(close) upon browser.close and reject evaluate', as expect(error.message).toContain(kTargetClosedErrorMessage); }); +test('should fire context event on newContext', async ({ browser }) => { + const events = []; + browser.on('context', ctx => events.push(ctx)); + const context = await browser.newContext(); + expect(events).toEqual([context]); +}); + test('newContext should not leave a context upon failure', async ({ browser, toImpl }) => { const error = await browser.newContext({ __testHookBeforeSetStorageState: () => Promise.reject(new Error('Oh my')), diff --git a/tests/library/multiclient.spec.ts b/tests/library/multiclient.spec.ts index e5896693b2ef9..50e1582bc2bdd 100644 --- a/tests/library/multiclient.spec.ts +++ b/tests/library/multiclient.spec.ts @@ -16,7 +16,7 @@ import { kTargetClosedErrorMessage } from '../config/errors'; import { expect, playwrightTest } from '../config/browserTest'; -import type { Browser, BrowserServer, ConnectOptions, Page } from 'playwright-core'; +import type { Browser, BrowserContext, BrowserServer, ConnectOptions, Page } from 'playwright-core'; type ExtraFixtures = { remoteServer: BrowserServer; @@ -93,6 +93,19 @@ test('should connect two clients', async ({ connect, remoteServer, server }) => await expect(pageB2).toHaveURL(server.PREFIX + '/frames/frame.html'); }); +test('should fire context event on remote newContext', async ({ connect, remoteServer }) => { + const browserA = await connect(remoteServer.wsEndpoint()); + const events: BrowserContext[] = []; + browserA.on('context', ctx => events.push(ctx)); + + const browserB = await connect(remoteServer.wsEndpoint()); + const contextB = await browserB.newContext(); + + await expect.poll(() => events).toHaveLength(1); + expect(browserA.contexts()).toEqual([events[0]]); + expect(events[0]).not.toBe(contextB); +}); + test('should have separate default timeouts', async ({ twoPages }) => { const { pageA, pageB } = twoPages; pageA.setDefaultTimeout(500); diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 69a58ba607faf..fdd40636d7bc5 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -160,49 +160,6 @@ test('video-chapter', async ({ cli, server }) => { await cli('video-stop'); }); -test('pick', async ({ boundBrowser, cli }) => { - const page = await boundBrowser.newPage(); - await page.setContent(``); - - await cli('attach', 'default'); - await cli('snapshot'); - - const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); - const pickPromise = cli('pick'); - await scriptReady; - - const box = await page.getByRole('button', { name: 'Submit' }).boundingBox(); - await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); - - const { output } = await pickPromise; - expect(output).toContain(`ref: e2`); - expect(output).toContain(`locator: getByRole('button', { name: 'Submit' })`); -}); - -test('pick activates dashboard session', async ({ boundBrowser, cli, startDashboardServer }) => { - const page = await boundBrowser.newPage(); - await page.setContent(``); - - await cli('attach', 'default'); - await cli('snapshot'); - - const dashboard = await startDashboardServer(); - await expect(dashboard.locator('div.dashboard-view')).toBeVisible(); - - const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); - const pickPromise = cli('pick'); - await scriptReady; - - await expect(dashboard.locator('div.dashboard-view.interactive')).toBeVisible(); - - const box = await page.getByRole('button', { name: 'Submit' }).boundingBox(); - await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); - - const { output } = await pickPromise; - expect(output).toContain(`ref: e2`); - expect(output).toContain(`locator: getByRole('button', { name: 'Submit' })`); -}); - test('generate-locator', async ({ cli, server }) => { server.setContent('/', ``, 'text/html'); await cli('open', server.PREFIX); diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index c670c4891e28d..2b578149c1c75 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -91,10 +91,22 @@ export const test = baseTest.extend<{ for (const pid of allPids) killProcessGroup(pid); - const daemonDir = path.join(test.info().outputDir, 'daemon'); - const userDataDirs = await fs.promises.readdir(daemonDir).catch(() => []); - for (const dir of userDataDirs.filter(f => f.startsWith('ud-'))) - await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {}); + const daemonDir = test.info().outputPath('daemon'); + for (const dir of await fs.promises.readdir(daemonDir).catch(() => [])) { + if (dir.startsWith('ud-')) { + await fs.promises.rm(path.join(daemonDir, dir), { recursive: true, force: true }).catch(() => {}); + continue; + } + const workspacePath = path.join(daemonDir, dir); + for (const entry of await fs.promises.readdir(workspacePath).catch(() => [])) { + if (!entry.endsWith('.err')) + continue; + const errPath = path.join(workspacePath, entry); + if ((await fs.promises.stat(errPath)).size === 0) + continue; + await test.info().attach(entry, { path: errPath, contentType: 'text/plain' }); + } + } }, boundBrowser: async ({ mcpBrowser, playwright }, use) => { const browserName = (mcpBrowser === 'chrome' || mcpBrowser === 'msedge') ? 'chromium' : mcpBrowser; diff --git a/tests/mcp/core.spec.ts b/tests/mcp/core.spec.ts index 15c57d21b712a..42490d8fd88c6 100644 --- a/tests/mcp/core.spec.ts +++ b/tests/mcp/core.spec.ts @@ -81,7 +81,7 @@ test('browser_navigate can navigate to file:// URLs allowUnrestrictedFileAccess name: 'browser_navigate', arguments: { url }, })).toHaveResponse({ - page: `- Page URL: ${url}`, + page: expect.stringContaining(`- Page URL: ${url}`), snapshot: `- generic [ref=e2]: Test file content`, }); }); diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index a8a5c7f39b92d..0dc262557be49 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -19,6 +19,7 @@ import os from 'os'; import path from 'path'; import { test, expect } from './cli-fixtures'; +import { inheritAndCleanEnv } from '../config/utils'; function displayPath(p: string): string { const home = os.homedir(); @@ -55,12 +56,14 @@ test('should show one row per context for a single browser', async ({ boundBrows const contextA = await boundBrowser.newContext(); const pageA = await contextA.newPage(); await pageA.goto(server.EMPTY_PAGE); - const contextB = await boundBrowser.newContext(); - const pageB = await contextB.newPage(); - await pageB.goto(server.EMPTY_PAGE); const dashboard = await startDashboardServer(); const chips = dashboard.locator('.session-chip'); + await expect(chips).toHaveCount(1); + + const contextB = await boundBrowser.newContext(); + const pageB = await contextB.newPage(); + await pageB.goto(server.EMPTY_PAGE); await expect(chips).toHaveCount(2); }); @@ -97,8 +100,7 @@ test('should show current workspace sessions first', async ({ cli, server, start }); }); -test('should activate session when show is called with -s', async ({ cli, server, startDashboardServer, mcpBrowser }) => { - test.fixme(mcpBrowser === 'firefox', 'race condition gonna be fixed through https://github.com/microsoft/playwright/pull/40315'); +test('should activate session when show is called with -s', async ({ cli, server, startDashboardServer }) => { await cli('-s=sessA', 'open', server.EMPTY_PAGE); await cli('-s=sessB', 'open', server.EMPTY_PAGE); @@ -149,8 +151,8 @@ 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 available at: \.playwright-cli[\\/]annotations-.*\.png$/); - const pngRel = lines[lines.length - 1].replace(/^image available at: /, ''); + 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(fs.existsSync(pngPath)).toBe(true); expect(fs.statSync(pngPath).size).toBeGreaterThan(0); @@ -199,28 +201,138 @@ test('should start dashboard and annotate when no dashboard is running', async ( verifyAnnotateOutput(output, 'hi', test.info().outputDir); }); -test('should pick locator from browser', async ({ cli, server, startDashboardServer }) => { - server.setContent('/', '', 'text/html'); - - await cli('open', server.PREFIX); +test('should keep CLI annotate engaged across mode switches', async ({ connectToDashboard, cli, server }) => { + await cli('open', server.EMPTY_PAGE); + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + await cli('show', { bindTitle }); + const browser = await connectToDashboard(bindTitle); - const dashboard = await startDashboardServer(); + const dashboard = browser.contexts()[0].pages()[0]; await dashboard.locator('.sidebar-tab').first().click(); - const pickPromise = cli('pick'); + const annotatePromise = cli('show', '--annotate'); + let done = false; + void annotatePromise.finally(() => { done = true; }); + + await expect(dashboard.locator('div.dashboard-view.annotate')).toBeVisible(); + + await dashboard.locator('.mode-toggle.mode-interactive').click(); + await expect(dashboard.locator('div.dashboard-view')).toHaveClass(/interactive/); + await expect(dashboard.locator('div.dashboard-view')).not.toHaveClass(/annotate/); + + const box = await dashboard.locator('img#display').boundingBox(); + await dashboard.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + + await dashboard.locator('.mode-toggle.mode-annotate').click(); + await expect(dashboard.locator('div.dashboard-view.annotate')).toBeVisible(); + + await drawAndSubmitAnnotation(dashboard, 'round-trip'); + + const { output, exitCode } = await annotatePromise; + expect(done).toBe(true); + expect(exitCode).toBe(0); + verifyAnnotateOutput(output, 'round-trip', 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); + + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + const annotatePromise = cli('-s=second', 'show', '--annotate', { bindTitle }); + let done = false; + void annotatePromise.finally(() => { done = true; }); + + const browser = await connectToDashboard(bindTitle); + try { + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.locator('div.dashboard-view.annotate')).toBeVisible(); + const activeSession = dashboard.locator('.sidebar-session:has(.sidebar-tab.active)'); + await expect(activeSession.locator('.session-chip-name')).toHaveText('second'); + await drawAndSubmitAnnotation(dashboard, 'fresh'); + } finally { + await browser.close().catch(() => {}); + } + + const { exitCode } = await annotatePromise; + expect(done).toBe(true); + expect(exitCode).toBe(0); +}); + +test('should switch screencast to -s session on show --annotate', async ({ connectToDashboard, cli, server }) => { + server.setContent('/red', '', 'text/html'); + server.setContent('/green', '', 'text/html'); + + await cli('-s=first', 'open', server.PREFIX + '/red'); + await cli('-s=second', 'open', server.PREFIX + '/green'); + + const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; + await cli('-s=first', 'show', { bindTitle }); + const browser = await connectToDashboard(bindTitle); + const dashboard = browser.contexts()[0].pages()[0]; + await expect(dashboard.locator('#display')).toBeVisible(); + + const sampleCenter = () => dashboard.evaluate(() => { + const img = document.querySelector('#display') as HTMLImageElement | null; + if (!img || !img.naturalWidth) + return null; + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, img.naturalWidth / 2, img.naturalHeight / 2, 1, 1, 0, 0, 1, 1); + const [r, g] = ctx.getImageData(0, 0, 1, 1).data; + return { r, g }; + }); + + await expect.poll(async () => { + const c = await sampleCenter(); + return !!(c && c.r > 200 && c.g < 50); + }, { timeout: 15000 }).toBe(true); + + const annotatePromise = cli('-s=second', 'show', '--annotate'); let done = false; - void pickPromise.finally(() => { done = true; }); + void annotatePromise.finally(() => { done = true; }); - await expect(dashboard.locator('div.dashboard-view.interactive')).toBeVisible(); + await expect(dashboard.locator('div.dashboard-view.annotate')).toBeVisible(); + const activeSession = dashboard.locator('.sidebar-session:has(.sidebar-tab.active)'); + await expect(activeSession.locator('.session-chip-name')).toHaveText('second'); + + await expect.poll(async () => { + const c = await sampleCenter(); + return !!(c && c.g > 200 && c.r < 50); + }, { timeout: 15000 }).toBe(true); + + await drawAndSubmitAnnotation(dashboard, 'session switch'); + const { exitCode } = await annotatePromise; + expect(done).toBe(true); + expect(exitCode).toBe(0); +}); + +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 }); + const browser = await connectToDashboard(bindTitle); + + const dashboard = browser.contexts()[0].pages()[0]; + await dashboard.locator('.sidebar-tab').first().click(); + + const annotateClient = childProcess({ + command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), 'show', '--annotate'], + cwd: test.info().outputPath(), + env: inheritAndCleanEnv({ + ...cliEnv, + PLAYWRIGHT_MCP_BROWSER: mcpBrowser, + PLAYWRIGHT_MCP_HEADLESS: String(mcpHeadless), + }), + }); + + await expect(dashboard.locator('div.dashboard-view.annotate')).toBeVisible(); - await expect(async () => { - const box = await dashboard.locator('img#display').boundingBox(); - await dashboard.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); - expect(done).toBe(true); - }).toPass(); + await annotateClient.kill(); - const { output } = await pickPromise; - expect(output).toContain(`getByRole('button', { name: 'Submit' })`); + await expect(dashboard.locator('div.dashboard-view')).not.toHaveClass(/annotate/); }); async function installSaveFilePickerMock(page: import('playwright-core').Page): Promise<() => Promise> {