From 51fb24225d56a04a9ce42f32490c847d49dac192 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 17 Sep 2026 09:53:56 -0700 Subject: [PATCH] feat(screencast): style action decorations, keep cursor visible - `showActions({ style: { point, highlight, title } })` takes CSS declarations for each decoration - point marker and target highlight are opt-in, rendered via dedicated screencast elements - deprecate `fontSize` in favor of `style.title` - cursor stays at the last action point, survives navigations and travels along a slightly curved path - macOS-style cursor - `--cursor` for `video-start`, `--point-style`, `--highlight-style`, `--title-style` for `video-show-actions` --- docs/src/api/class-screencast.md | 23 +++ docs/src/api/params.md | 6 +- docs/src/test-api/class-testoptions.md | 6 +- packages/injected/src/highlight.css | 32 +++- packages/injected/src/highlight.ts | 136 +++++++++++----- packages/injected/src/injectedScript.ts | 36 +++-- packages/playwright-client/types/types.d.ts | 150 +++++++++++++++++- .../playwright-core/src/client/channels.d.ts | 65 ++++++++ .../playwright-core/src/client/screencast.ts | 4 +- .../playwright-core/src/server/channels.d.ts | 65 ++++++++ .../src/server/dispatchers/pageDispatcher.ts | 2 +- .../playwright-core/src/server/screencast.ts | 40 ++++- .../src/tools/backend/context.ts | 12 +- .../src/tools/backend/video.ts | 11 +- .../src/tools/cli-daemon/commands.ts | 12 +- .../src/tools/skills/playwright-cli/SKILL.md | 4 +- .../references/video-recording.md | 67 +++++++- packages/playwright-core/types/types.d.ts | 150 +++++++++++++++++- packages/playwright/types/test.d.ts | 2 +- packages/protocol/spec/mixins.yml | 6 + packages/protocol/src/validator.ts | 35 ++++ tests/library/screencast-actions.spec.ts | 88 ++++++---- tests/mcp/cli-devtools.spec.ts | 15 ++ utils/generate_types/overrides-test.d.ts | 2 +- 24 files changed, 846 insertions(+), 123 deletions(-) diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index 03efa44a39322..410dbdd246329 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -139,6 +139,7 @@ Position of the action title overlay. Defaults to `"top-right"`. ### option: Screencast.showActions.fontSize * since: v1.59 +* deprecated: Use `title` in [`option: Screencast.showActions.style`] instead, for example `style: { title: 'font-size: 32px' }`. - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. @@ -151,6 +152,28 @@ Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from the previous action point to the next one. `"none"` disables the cursor decoration. +### option: Screencast.showActions.style +* since: v1.64 +- `style` ?<[Object]> + * alias-csharp: ScreencastActionStyle + - `point` ?<[string]> CSS declarations for the marker at the action point. The marker is positioned at the action point, has zero size and is centered on the point, so its size and look come from this style. Not shown when omitted. + - `highlight` ?<[string]> CSS declarations for the box that covers the target element. The box is positioned and sized to the element bounds. Not shown when omitted. + - `title` ?<[string]> CSS declarations for the action title, for example `'font-size: 32px; background: #333'`. The title is placed according to [`option: Screencast.showActions.position`]. + +Styles of the action decorations. All decorations fade out over [`option: Screencast.showActions.duration`]. + +**Usage** + +```js +await page.screencast.showActions({ + style: { + point: 'width: 20px; height: 20px; border-radius: 50%; background: red', + highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', + title: 'font-size: 16px', + }, +}); +``` + ## async method: Screencast.showOverlays * since: v1.59 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 3b284b5ad6277..4bb5b6bd99763 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -862,8 +862,12 @@ When set to `minimal`, only record information necessary for routing from HAR. T - `showActions` ?<[Object]> If specified, enables visual annotations on interacted elements during video recording. - `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`. - `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the action title overlay. Defaults to `"top-right"`. - - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. + - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. - `cursor` ?<[ScreencastCursor]<"none"|"pointer">> Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from the previous action point to the next one. `"none"` disables the cursor decoration. + - `style` ?<[Object]> Styles of the action decorations. + - `point` ?<[string]> CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + - `highlight` ?<[string]> CSS declarations for the box that covers the target element. Not shown when omitted. + - `title` ?<[string]> CSS declarations for the action title. Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. Make sure to await [`method: BrowserContext.close`] for videos to be saved. diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 5aa6c5cde0e9c..900638a95514c 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -730,8 +730,12 @@ export default defineConfig({ - `actions` ?<[Object]> Controls visual annotations on interacted elements. - `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`. - `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the action title overlay. Defaults to `"top-right"`. - - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. + - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. - `cursor` ?<[ScreencastCursor]<"none"|"pointer">> Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from the previous action point to the next one. `"none"` disables the cursor decoration. + - `style` ?<[Object]> Styles of the action decorations. + - `point` ?<[string]> CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + - `highlight` ?<[string]> CSS declarations for the box that covers the target element. Not shown when omitted. + - `title` ?<[string]> CSS declarations for the action title. - `test` ?<[Object]> Controls test information displayed as a status overlay in the video. - `level` ?<[TestAnnotationLevel]<"file"|"test"|"step">> Level of the detail to include about the current test. - `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the test information overlay. Defaults to `"top-left"`. diff --git a/packages/injected/src/highlight.css b/packages/injected/src/highlight.css index 61a376f4eb502..2a83b23985ed7 100644 --- a/packages/injected/src/highlight.css +++ b/packages/injected/src/highlight.css @@ -108,19 +108,45 @@ x-pw-action-point { z-index: 2; } +x-pw-screencast-highlight, +x-pw-screencast-point, +x-pw-title { + animation: pw-fade-out var(--pw-fade-duration) ease-out var(--pw-fade-delay, 0ms) forwards; +} + +x-pw-screencast-highlight, +x-pw-screencast-point { + position: absolute; + pointer-events: none; +} + +x-pw-screencast-highlight { + z-index: 1; +} + +/* Zero-sized and centered on the action point, user style draws around it. */ +x-pw-screencast-point { + width: 0; + height: 0; + overflow: visible; + translate: -50% -50%; + z-index: 2; +} + x-pw-action-cursor { position: absolute; - width: 18px; - height: 22px; + width: 14px; + height: 21px; pointer-events: none; z-index: 4; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4)); + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35)); } x-pw-action-cursor svg { width: 100%; height: 100%; position: static; + overflow: visible; } x-pw-title { diff --git a/packages/injected/src/highlight.ts b/packages/injected/src/highlight.ts index 02577683e6150..47198f3fa1bee 100644 --- a/packages/injected/src/highlight.ts +++ b/packages/injected/src/highlight.ts @@ -29,8 +29,6 @@ type Rect = { x: number, y: number, width: number, height: number }; type RenderedHighlightEntry = { targetElement?: Element, color: string, - borderColor?: string, - fadeDuration?: number, highlightElement: HTMLElement, tooltipElement?: HTMLElement, box?: DOMRect, @@ -44,8 +42,6 @@ export type HighlightEntry = { element?: Element, box?: Rect, color: string, - borderColor?: string, - fadeDuration?: number, tooltipText?: string, cssStyle?: string, }; @@ -56,6 +52,8 @@ export class Highlight { private _renderedEntries: RenderedHighlightEntry[] = []; private _actionPointElement: HTMLElement | undefined; private _actionCursorElement: HTMLElement; + private _actionCursorAnimation: Animation | undefined; + private _screencastDecorations: HTMLElement[] = []; private _titleElement: HTMLElement; private _userOverlayContainer: HTMLElement; private _userOverlays = new Map(); @@ -179,7 +177,7 @@ export class Highlight { this._glassPaneElement.remove(); } - showActionPoint(x: number, y: number, fadeDuration?: number) { + showActionPoint(x: number, y: number) { if (!this._actionPointElement) { this._actionPointElement = this._injectedScript.document.createElement('x-pw-action-point'); this._glassPaneShadow.appendChild(this._actionPointElement); @@ -187,10 +185,6 @@ export class Highlight { this._actionPointElement.style.top = y + 'px'; this._actionPointElement.style.left = x + 'px'; this._actionPointElement.hidden = false; - if (fadeDuration) - this._actionPointElement.style.animation = `pw-fade-out ${fadeDuration}ms ease-out forwards`; - else - this._actionPointElement.style.animation = ''; } hideActionPoint() { @@ -198,12 +192,59 @@ export class Highlight { this._actionPointElement.hidden = true; } + showScreencastHighlight(box: Rect, style: string, fadeDuration: number) { + const element = this._createScreencastDecoration('x-pw-screencast-highlight', style, fadeDuration); + element.style.left = box.x + 'px'; + element.style.top = box.y + 'px'; + element.style.width = box.width + 'px'; + element.style.height = box.height + 'px'; + } + + showScreencastPoint(x: number, y: number, style: string, fadeDuration: number) { + const element = this._createScreencastDecoration('x-pw-screencast-point', style, fadeDuration); + element.style.left = x + 'px'; + element.style.top = y + 'px'; + } + + hideScreencastDecorations() { + for (const element of this._screencastDecorations) + element.remove(); + this._screencastDecorations = []; + } + + private _createScreencastDecoration(name: string, style: string, fadeDuration: number): HTMLElement { + const element = this._injectedScript.document.createElement(name); + // User style goes first so that the geometry assigned by the caller wins. + element.style.cssText = style; + if (fadeDuration) + element.style.setProperty('--pw-fade-duration', fadeDuration + 'ms'); + this._glassPaneShadow.appendChild(element); + this._screencastDecorations.push(element); + return element; + } + moveActionCursor(x: number, y: number, fadeDuration?: number) { - const moveDuration = fadeDuration ? Math.max(80, Math.min(fadeDuration * 0.6, 400)) : 0; - this._actionCursorElement.style.transition = `top ${moveDuration}ms ease, left ${moveDuration}ms ease`; - this._actionCursorElement.style.left = x + 'px'; - this._actionCursorElement.style.top = y + 'px'; - this._actionCursorElement.style.visibility = 'visible'; + const element = this._actionCursorElement; + // Start where the cursor is rendered, it might be in the middle of the previous move. + const from = element.getBoundingClientRect(); + const wasVisible = element.style.visibility === 'visible'; + this._actionCursorAnimation?.cancel(); + this._actionCursorAnimation = undefined; + element.style.left = x + 'px'; + element.style.top = y + 'px'; + element.style.visibility = 'visible'; + const dx = from.left - x; + const dy = from.top - y; + if (!wasVisible || !fadeDuration || Math.hypot(dx, dy) < 1) + return; + const duration = Math.max(80, Math.min(fadeDuration * 0.6, 400)); + // Start slow, speed up in the middle and land gently. + this._actionCursorAnimation = element.animate(cursorPathKeyframes(dx, dy, x + y + dx + dy), { duration, easing: 'cubic-bezier(0.5, 0, 0.3, 1)' }); + } + + restoreActionCursor(x: number, y: number) { + if (this._actionCursorElement.style.visibility !== 'visible') + this.moveActionCursor(x, y); } hideActionCursor() { @@ -213,34 +254,30 @@ export class Highlight { private _createCursorSvg(document: Document): SVGSVGElement { const svgNs = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNs, 'svg'); - svg.setAttribute('viewBox', '0 0 18 22'); + // macOS-style arrow with the tip at the origin, the border is drawn outside of the fill. + svg.setAttribute('viewBox', '0 0 12 18'); const path = document.createElementNS(svgNs, 'path'); - path.setAttribute('d', 'M1 1 L1 17 L5.5 13 L8 20.5 L11 19.5 L8.5 12 L15 12 Z'); - path.setAttribute('fill', 'white'); - path.setAttribute('stroke', 'black'); - path.setAttribute('stroke-width', '1.5'); + path.setAttribute('d', 'M0 0 L0 15.8 L3.9 12.4 L6.6 18.6 L8.9 17.6 L6.2 11.4 L11.2 11.4 Z'); + path.setAttribute('fill', 'black'); + path.setAttribute('stroke', 'white'); + path.setAttribute('stroke-width', '1.6'); path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('paint-order', 'stroke'); svg.appendChild(path); return svg; } - showActionTitle(text: string, fadeDuration: number, position?: string, fontSize?: number) { + showActionTitle(text: string, fadeDuration: number, position?: string, style?: string) { this._titleElement.textContent = text; this._titleElement.hidden = false; + // User style goes first so that the position assigned below wins. + this._titleElement.style.cssText = style ?? ''; if (fadeDuration) { const fadeTime = fadeDuration / 4; - this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`; - } else { - this._titleElement.style.animation = ''; + this._titleElement.style.setProperty('--pw-fade-duration', fadeTime + 'ms'); + this._titleElement.style.setProperty('--pw-fade-delay', (fadeDuration - fadeTime) + 'ms'); } - // Reset positioning - this._titleElement.style.top = ''; - this._titleElement.style.bottom = ''; - this._titleElement.style.left = ''; - this._titleElement.style.right = ''; - this._titleElement.style.transform = ''; - switch (position) { case 'top-left': this._titleElement.style.top = '6px'; @@ -270,9 +307,6 @@ export class Highlight { this._titleElement.style.right = '6px'; break; } - - if (fontSize) - this._titleElement.style.fontSize = fontSize + 'px'; } hideActionTitle() { @@ -377,7 +411,7 @@ export class Highlight { lineElement.textContent = entry.tooltipText; tooltipElement.appendChild(lineElement); } - this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement }); + this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, cssStyle: entry.cssStyle, tooltipElement, highlightElement }); } // 2. Trigger layout while positioning tooltips and computing bounding boxes. @@ -407,10 +441,6 @@ export class Highlight { entry.highlightElement.style.width = box.width + 'px'; entry.highlightElement.style.height = box.height + 'px'; entry.highlightElement.style.display = 'block'; - if (entry.borderColor) - entry.highlightElement.style.border = '2px solid ' + entry.borderColor; - if (entry.fadeDuration) - entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`; if (entry.cssStyle) entry.highlightElement.style.cssText += ';' + entry.cssStyle; @@ -504,6 +534,36 @@ export class Highlight { } } +// Keyframes that bring the cursor from (dx, dy) relative to its destination to the destination. +// The path bows to one side, more at the start than at the end, the way a hand moves a mouse. +function cursorPathKeyframes(dx: number, dy: number, seed: number): Keyframe[] { + const random = (salt: number) => { + const value = Math.sin(seed * 12.9898 + salt * 78.233) * 43758.5453; + return value - Math.floor(value); + }; + const distance = Math.hypot(dx, dy); + const normalX = -dy / distance; + const normalY = dx / distance; + const side = random(1) < 0.5 ? -1 : 1; + const startBow = side * distance * (0.03 + 0.03 * random(2)); + const endBow = side * distance * (0.01 + 0.02 * random(3)); + // Control points at one and two thirds of the way. + const c1x = dx * 2 / 3 + normalX * startBow; + const c1y = dy * 2 / 3 + normalY * startBow; + const c2x = dx / 3 + normalX * endBow; + const c2y = dy / 3 + normalY * endBow; + const keyframes: Keyframe[] = []; + const steps = 30; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const u = 1 - t; + const px = u * u * u * dx + 3 * u * u * t * c1x + 3 * u * t * t * c2x; + const py = u * u * u * dy + 3 * u * u * t * c1y + 3 * u * t * t * c2y; + keyframes.push({ transform: `translate(${px}px, ${py}px)` }); + } + return keyframes; +} + function toDOMRect(box: Rect): DOMRect; function toDOMRect(box: Rect | undefined): DOMRect | undefined; function toDOMRect(box: Rect | undefined): DOMRect | undefined { diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index e8c3bc17ea1d4..85963d3a37696 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1351,32 +1351,38 @@ export class InjectedScript { highlight.setElementHighlights(highlights); } - setScreencastAnnotation(annotation: { point?: Point, box?: Rect, actionTitle?: string, duration?: number, position?: string, fontSize?: number, cursor?: 'none' | 'pointer' } | null) { + setScreencastAnnotation(annotation: { point?: Point, box?: Rect, actionTitle?: string, duration?: number, position?: string, cursor?: 'none' | 'pointer', style?: { point?: string, highlight?: string, title?: string }, lastCursorPoint?: Point } | null) { const highlight = this._ensureHighlight(); + highlight.hideScreencastDecorations(); + // The cursor outlives the annotation, it stays at the last action point. if (!annotation) { - highlight.updateHighlight([]); - highlight.hideActionPoint(); highlight.hideActionTitle(); - highlight.hideActionCursor(); return; } const fadeDuration = annotation.duration ?? 500; - if (annotation.box) { - highlight.updateHighlight([{ - box: annotation.box, - color: 'rgba(0, 128, 255, 0.15)', - borderColor: 'rgba(0, 128, 255, 0.6)', - fadeDuration, - }]); - } + if (annotation.box && annotation.style?.highlight) + highlight.showScreencastHighlight(annotation.box, annotation.style.highlight, fadeDuration); if (annotation.point) { - if (annotation.cursor !== 'none') + if (annotation.cursor !== 'none') { + // After navigation, animate from where the cursor was in the previous document. + if (annotation.lastCursorPoint) + highlight.restoreActionCursor(annotation.lastCursorPoint.x, annotation.lastCursorPoint.y); highlight.moveActionCursor(annotation.point.x, annotation.point.y, fadeDuration); - highlight.showActionPoint(annotation.point.x, annotation.point.y, fadeDuration); + } + if (annotation.style?.point) + highlight.showScreencastPoint(annotation.point.x, annotation.point.y, annotation.style.point, fadeDuration); } if (annotation.actionTitle) - highlight.showActionTitle(annotation.actionTitle, fadeDuration, annotation.position, annotation.fontSize); + highlight.showActionTitle(annotation.actionTitle, fadeDuration, annotation.position, annotation.style?.title); + } + + restoreScreencastCursor(point: Point) { + this._ensureHighlight().restoreActionCursor(point.x, point.y); + } + + hideScreencastCursor() { + this._highlight?.hideActionCursor(); } addUserOverlay(id: string, html: string) { diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 9c1d214199f18..cd297c4c18d49 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -11612,7 +11612,7 @@ export interface Browser { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -11621,6 +11621,26 @@ export interface Browser { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -17981,7 +18001,7 @@ export interface BrowserType { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -17990,6 +18010,26 @@ export interface BrowserType { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -18735,6 +18775,8 @@ export interface Screencast { /** * Font size of the action title in pixels. Defaults to `24`. + * @deprecated Use `title` in [`style`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-style) + * instead, for example `style: { title: 'font-size: 32px' }`. */ fontSize?: number; @@ -18742,6 +18784,44 @@ export interface Screencast { * Position of the action title overlay. Defaults to `"top-right"`. */ position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; + + /** + * Styles of the action decorations. All decorations fade out over + * [`duration`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-duration). + * + * **Usage** + * + * ```js + * await page.screencast.showActions({ + * style: { + * point: 'width: 20px; height: 20px; border-radius: 50%; background: red', + * highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', + * title: 'font-size: 16px', + * }, + * }); + * ``` + * + */ + style?: { + /** + * CSS declarations for the marker at the action point. The marker is positioned at the action point, has zero size + * and is centered on the point, so its size and look come from this style. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. The box is positioned and sized to the element bounds. + * Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title, for example `'font-size: 32px; background: #333'`. The title is placed + * according to + * [`position`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-position). + */ + title?: string; + }; }): Promise; /** @@ -24167,7 +24247,7 @@ export interface Electron { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -24176,6 +24256,26 @@ export interface Electron { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -24888,7 +24988,7 @@ export interface AndroidDevice { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -24897,6 +24997,26 @@ export interface AndroidDevice { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -26113,7 +26233,7 @@ export interface BrowserContextOptions { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -26122,6 +26242,26 @@ export interface BrowserContextOptions { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 68da621e77eb4..954dd9dcb3f6c 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -427,6 +427,11 @@ export type AndroidDeviceLaunchBrowserParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -496,6 +501,11 @@ export type AndroidDeviceLaunchBrowserOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -880,6 +890,11 @@ export type BrowserNewContextParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -952,6 +967,11 @@ export type BrowserNewContextOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1027,6 +1047,11 @@ export type BrowserNewContextForReuseParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1099,6 +1124,11 @@ export type BrowserNewContextForReuseOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1213,6 +1243,11 @@ export type BrowserContextInitializer = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1866,6 +1901,11 @@ export type BrowserTypeLaunchPersistentContextParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1951,6 +1991,11 @@ export type BrowserTypeLaunchPersistentContextOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -2054,6 +2099,11 @@ export type ElectronLaunchParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -2094,6 +2144,11 @@ export type ElectronLaunchOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -4492,12 +4547,22 @@ export type PageScreencastShowActionsParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }; export type PageScreencastShowActionsOptions = { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }; export type PageScreencastShowActionsResult = void; export type PageScreencastHideActionsParams = {}; diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts index 96244d1f7f5ec..8ceeeef52ba51 100644 --- a/packages/playwright-core/src/client/screencast.ts +++ b/packages/playwright-core/src/client/screencast.ts @@ -71,8 +71,8 @@ export class Screencast implements api.Screencast { }); } - async showActions(options?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }): Promise { - await this._page._channel.screencastShowActions({ duration: options?.duration, position: options?.position, fontSize: options?.fontSize, cursor: options?.cursor }, kNoTimeout); + async showActions(options?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', style?: { point?: string, highlight?: string, title?: string } }): Promise { + await this._page._channel.screencastShowActions({ duration: options?.duration, position: options?.position, fontSize: options?.fontSize, cursor: options?.cursor, style: options?.style }, kNoTimeout); return new DisposableStub(() => this._page._channel.screencastHideActions({}, kNoTimeout)); } diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 81d9c734987d2..f9493cf87dfa1 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -428,6 +428,11 @@ export type AndroidDeviceLaunchBrowserParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -497,6 +502,11 @@ export type AndroidDeviceLaunchBrowserOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -881,6 +891,11 @@ export type BrowserNewContextParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -953,6 +968,11 @@ export type BrowserNewContextOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1028,6 +1048,11 @@ export type BrowserNewContextForReuseParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1100,6 +1125,11 @@ export type BrowserNewContextForReuseOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1214,6 +1244,11 @@ export type BrowserContextInitializer = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1867,6 +1902,11 @@ export type BrowserTypeLaunchPersistentContextParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -1952,6 +1992,11 @@ export type BrowserTypeLaunchPersistentContextOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -2055,6 +2100,11 @@ export type ElectronLaunchParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -2095,6 +2145,11 @@ export type ElectronLaunchOptions = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }, }, strictSelectors?: boolean, @@ -4493,12 +4548,22 @@ export type PageScreencastShowActionsParams = { position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }; export type PageScreencastShowActionsOptions = { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer', + style?: { + point?: string, + highlight?: string, + title?: string, + }, }; export type PageScreencastShowActionsResult = void; export type PageScreencastHideActionsParams = {}; diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index caa9359e43667..e94bc3964a52f 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -387,7 +387,7 @@ export class PageDispatcher extends Dispatcher { - this._page.screencast.showActions({ duration: params.duration, position: params.position, fontSize: params.fontSize, cursor: params.cursor }); + this._page.screencast.showActions({ duration: params.duration, position: params.position, fontSize: params.fontSize, cursor: params.cursor, style: params.style }); } async screencastHideActions(): Promise { diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index f1add3433a624..4982dcf8f018d 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -40,6 +40,7 @@ type ActionOptions = { position?: AnnotatePosition, fontSize?: number, cursor?: 'none' | 'pointer', + style?: { point?: string, highlight?: string, title?: string }, }; export class Screencast implements InstrumentationListener { @@ -48,10 +49,15 @@ export class Screencast implements InstrumentationListener { private _actions: ActionOptions | undefined; private _size: types.Size | undefined; private _lastFrame: types.ScreencastFrame | undefined; + private _cursorPoint: types.Point | undefined; constructor(page: Page) { this.page = page; this.page.instrumentation.addListener(this, this.page.browserContext); + this.page.on(Page.Events.InternalFrameNavigatedToNewDocument, frame => { + if (!frame.parentFrame() && this._cursorPoint) + this._restoreCursor(this._cursorPoint).catch(e => debugLogger.log('error', e)); + }); } async handlePageOrContextClose() { @@ -75,11 +81,32 @@ export class Screencast implements InstrumentationListener { } showActions(options: ActionOptions) { - this._actions = options; + // Deprecated fontSize is folded into the title style, explicit title style wins. + const title = options.fontSize !== undefined ? `font-size: ${options.fontSize}px; ${options.style?.title ?? ''}` : options.style?.title; + this._actions = { ...options, style: { ...options.style, title } }; + if (options.cursor === 'none') + this._hideCursor(); } hideActions() { this._actions = undefined; + this._hideCursor(); + } + + private async _restoreCursor(point: types.Point) { + const utility = await this.page.mainFrame().utilityContext(); + await utility.evaluate(({ injected, point }) => injected.restoreScreencastCursor(point), { injected: await utility.injectedScript(), point }); + } + + private _hideCursor() { + if (!this._cursorPoint) + return; + this._cursorPoint = undefined; + const frame = this.page.mainFrame(); + frame.raceAgainstEvaluationStallingEvents(async () => { + const injectedScript = await frame.existingContext('utility')?.injectedScript(); + await injectedScript?.evaluate(injected => injected.hideScreencastCursor()); + }).catch(e => debugLogger.log('error', e)); } addClient(client: ScreencastClient): { size: types.Size } { @@ -166,10 +193,14 @@ export class Screencast implements InstrumentationListener { if (page !== this.page) return; - if (!box && (sdkObject instanceof ElementHandle)) + if (!box && this._actions.style?.highlight && (sdkObject instanceof ElementHandle)) box = await sdkObject.boundingBox(nullProgress) || undefined; const actionTitle = renderFullTitleForCall(progress.metadata, this.page.browserContext._browser.sdkLanguage()); + const cursor = this._actions?.cursor ?? 'pointer'; + const lastCursorPoint = this._cursorPoint; + if (point && cursor !== 'none') + this._cursorPoint = point; const utility = await progress.race(page.mainFrame().utilityContext()); // Run this outside of the progress timer. @@ -186,8 +217,9 @@ export class Screencast implements InstrumentationListener { box, actionTitle, position: this._actions?.position, - fontSize: this._actions?.fontSize, - cursor: this._actions?.cursor ?? 'pointer', + cursor, + style: this._actions?.style, + lastCursorPoint, }).catch(e => debugLogger.log('error', e))); } } diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index fbf45c9c8b923..d552483543fde 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -94,7 +94,10 @@ export type FilenameTemplate = { date?: Date; }; -type VideoParams = { size?: { width: number; height: number }, fps?: number }; +type VideoParams = { size?: { width: number; height: number }, fps?: number, cursor?: boolean }; + +// Actions are paced by this delay when the cursor is shown, giving it time to travel. +const kCursorDuration = 800; export class Context { readonly config: ContextConfig; @@ -292,7 +295,12 @@ export class Context { fileName = path.join(dir, path.basename(fileName, ext) + suffix + ext); } this._video.fileNames.push(fileName); - await page.screencast.start({ path: fileName, ...this._video.params }); + const { cursor, ...startParams } = this._video.params; + await page.screencast.start({ path: fileName, ...startParams }); + if (cursor) { + // Show the cursor only, the action title is what `browser_video_show_actions` is for. + await page.screencast.showActions({ cursor: 'pointer', duration: kCursorDuration, style: { title: 'display: none' } }); + } } private _onPageCreated(page: playwrightTypes.Page) { diff --git a/packages/playwright-core/src/tools/backend/video.ts b/packages/playwright-core/src/tools/backend/video.ts index 8d5b0dce441b5..7b72e3e4adadd 100644 --- a/packages/playwright-core/src/tools/backend/video.ts +++ b/packages/playwright-core/src/tools/backend/video.ts @@ -31,13 +31,14 @@ const videoStart = defineTool({ height: z.number().describe('Video height'), }).optional().describe('Video size'), fps: z.number().optional().describe('Video frame rate in frames per second, defaults to 25'), + cursor: z.boolean().optional().describe('Render an animated mouse cursor that travels to each action point. Paces actions by 800ms so that the cursor has time to travel.'), }), type: 'readOnly', }, handle: async (context, params, response) => { const resolvedFile = await response.resolveClientOutputFile({ prefix: 'video', ext: 'webm', suggestedFilename: params.filename }, 'Video'); - await context.startVideoRecording(resolvedFile.fileName, { size: params.size, fps: params.fps }); + await context.startVideoRecording(resolvedFile.fileName, { size: params.size, fps: params.fps, cursor: params.cursor }); response.addTextResult('Video recording started.'); }, }); @@ -104,11 +105,16 @@ const videoShowActions = defineTool({ schema: { name: 'browser_video_show_actions', title: 'Show action overlays', - description: 'Annotate subsequent actions performed on the page with a callout that names the action and highlights the target element. Useful while video recording or screencasting.', + description: 'Annotate subsequent actions performed on the page with a callout that names the action and, when styled, marks the action point and highlights the target element. Useful while video recording or screencasting.', inputSchema: z.object({ duration: z.number().optional().describe('How long each action annotation stays on screen, in milliseconds. Defaults to 500.'), position: actionPosition.optional().describe('Where to place the action title relative to the page. Defaults to top-right.'), cursor: actionCursor.optional().describe('Cursor decoration for pointer actions. "pointer" (default) animates a mouse pointer from the previous action point to the next one; "none" disables the cursor decoration.'), + style: z.object({ + point: z.string().optional().describe('CSS declarations for the zero-sized marker centered on the action point, e.g. "width: 20px; height: 20px; border-radius: 50%; background: red". Not shown when omitted.'), + highlight: z.string().optional().describe('CSS declarations for the box that covers the target element, e.g. "outline: 2px solid #333". Not shown when omitted.'), + title: z.string().optional().describe('CSS declarations for the action title, e.g. "font-size: 16px".'), + }).optional().describe('Styles of the action decorations.'), }), type: 'readOnly', }, @@ -119,6 +125,7 @@ const videoShowActions = defineTool({ duration: params.duration, position: params.position, cursor: params.cursor, + style: params.style, }); response.addTextResult('Action annotations enabled.'); }, diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 724b2d15f16b4..81cc77c7a7d3d 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -1078,11 +1078,12 @@ const videoStart = declareCommand({ options: z.object({ size: z.string().optional().describe('Video frame size, e.g. "800x600". If not specified, the size of the recorded video will fit 800x800.'), fps: numberArg.optional().describe('Video frame rate in frames per second, defaults to 25.'), + cursor: z.boolean().optional().describe('Render an animated mouse cursor that travels to each action point. Paces actions by 800ms so that the cursor has time to travel.'), }), toolName: 'browser_start_video', - toolParams: ({ filename, size, fps }) => { + toolParams: ({ filename, size, fps, cursor }) => { const parsedSize = size ? size.split('x').map(Number) : undefined; - return { filename, size: parsedSize ? { width: parsedSize[0], height: parsedSize[1] } : undefined, fps }; + return { filename, size: parsedSize ? { width: parsedSize[0], height: parsedSize[1] } : undefined, fps, cursor }; } }); @@ -1114,16 +1115,19 @@ const actionCursorArg = z.enum(['none', 'pointer']); const videoShowActions = declareCommand({ name: 'video-show-actions', - description: 'Annotate subsequent CLI/MCP actions on the page with a callout that names the action and highlights the target element', + description: 'Annotate subsequent CLI/MCP actions on the page with a callout that names the action and, when styled, marks the action point and highlights the target element', category: 'devtools', args: z.object({}), options: z.object({ duration: numberArg.optional().describe('How long each action annotation stays on screen, in milliseconds. Defaults to 500.'), position: actionPositionArg.optional().describe('Where to place the action title: top-left, top, top-right, bottom-left, bottom, bottom-right. Defaults to top-right.'), cursor: actionCursorArg.optional().describe('Cursor decoration: "pointer" (default) animates a mouse pointer between action points; "none" disables it.'), + ['point-style']: z.string().optional().describe('CSS declarations for the zero-sized marker centered on the action point, e.g. "width: 20px; height: 20px; border-radius: 50%; background: red". Not shown when omitted.'), + ['highlight-style']: z.string().optional().describe('CSS declarations for the box that covers the target element, e.g. "outline: 2px solid #333". Not shown when omitted.'), + ['title-style']: z.string().optional().describe('CSS declarations for the action title, e.g. "font-size: 16px".'), }), toolName: 'browser_video_show_actions', - toolParams: ({ duration, position, cursor }) => ({ duration, position, cursor }), + toolParams: ({ duration, position, cursor, ['point-style']: point, ['highlight-style']: highlight, ['title-style']: title }) => ({ duration, position, cursor, style: point || highlight || title ? { point, highlight, title } : undefined }), }); const videoHideActions = declareCommand({ diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md index c312fa8fde9e2..1c90039039dc0 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md @@ -189,8 +189,8 @@ playwright-cli video-start video.webm playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 playwright-cli video-stop -# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target -playwright-cli video-show-actions --duration=600 --position=top-right +# annotate each subsequent action (click, type, ...) with a callout naming the action, optionally styling the action point and target highlight +playwright-cli video-show-actions --duration=600 --position=top-right --highlight-style="outline: 2px solid #333" playwright-cli video-hide-actions # launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/references/video-recording.md b/packages/playwright-core/src/tools/skills/playwright-cli/references/video-recording.md index aae2f9b43bba6..f0b528ab1a70f 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/references/video-recording.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/references/video-recording.md @@ -8,8 +8,9 @@ Capture browser automation sessions as video for debugging, documentation, or ve # Open browser first playwright-cli open -# Start recording -playwright-cli video-start demo.webm +# Start recording, --cursor renders an animated mouse cursor that travels to each action point +# and paces actions by 800ms so that it has time to travel +playwright-cli video-start demo.webm --cursor --fps=60 # Add a chapter marker for section transitions playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000 @@ -27,6 +28,56 @@ playwright-cli fill e2 "test input" playwright-cli video-stop ``` +## Cursor, Target Highlight and Click Point + +Three decorations can be drawn for each action: the mouse **cursor**, a **highlight** box around the +target element and a **point** marker at the click point. A **title** callout naming the action comes +with `video-show-actions`. The cursor is the only one `video-start --cursor` turns on; the rest are +opt-in and styled with plain CSS declarations, so they look exactly the way you want. + +```bash +# Cursor only, nothing else on screen +playwright-cli video-start demo.webm --cursor + +# Action callout, plus a red click point and a dark frame around the target +playwright-cli video-show-actions --duration=800 --position=top-right \ + --point-style="width: 20px; height: 20px; border-radius: 50%; background: rgba(255,0,0,.7)" \ + --highlight-style="outline: 2px solid #333; background: rgba(0,128,255,.15)" \ + --title-style="font-size: 16px" + +# Stop annotating actions +playwright-cli video-hide-actions +``` + +The same options are available programmatically, which is the better choice for hero scripts: + +```js +await page.screencast.showActions({ + // 'pointer' (default) animates the cursor from the previous action point, 'none' hides it. + cursor: 'pointer', + // How long decorations stay on screen. Actions are paced by this delay, 500ms by default. + duration: 800, + // Where the action title goes: top-left, top, top-right, bottom-left, bottom, bottom-right. + position: 'top-right', + style: { + // Marker at the click point. The element is zero-sized and centered on the point, + // so give it a size, or draw around the point with box-shadow. Hidden when omitted. + point: 'width: 20px; height: 20px; border-radius: 50%; background: rgba(255, 0, 0, .7)', + // Box that covers the target element. Hidden when omitted. + // Prefer `outline` over `border`, it does not shrink the box. + highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', + // The action title. Use 'display: none' to keep the cursor but drop the callout. + title: 'font-size: 16px', + }, +}); +``` + +Notes: +- All decorations fade out over `duration`. Override `animation` in a style to do something else. +- The cursor stays on screen at the last action point between actions and across navigations, + and travels along a slightly curved path, so it reads as a hand moving a mouse. +- Call `page.screencast.hideActions()` to stop annotating and hide the cursor. + ## Best Practices ### 1. Use Descriptive Filenames @@ -50,7 +101,15 @@ It allows inserting appropriate pauses between the actions and annotating the vi ```js async page => { - await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } }); + await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 }, fps: 60 }); + // Show the cursor and mark the click point, and pace actions by 800ms. + await page.screencast.showActions({ + duration: 800, + style: { + point: 'width: 20px; height: 20px; border-radius: 50%; background: rgba(255, 0, 0, .7)', + title: 'display: none', + }, + }); await page.goto('https://demo.playwright.dev/todomvc'); // Show a chapter card — blurs the page and shows a dialog. @@ -127,6 +186,8 @@ Embrace creativity, overlays are powerful. | `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | | `disposable.dispose()` | Remove a sticky overlay added without duration | | `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | +| `page.screencast.showActions({ cursor, duration, position, style })` | Cursor, click point, target highlight and action title | +| `page.screencast.hideActions()` | Stop annotating actions and hide the cursor | ### 3. Attach the recording to the pull request diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 9c1d214199f18..cd297c4c18d49 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -11612,7 +11612,7 @@ export interface Browser { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -11621,6 +11621,26 @@ export interface Browser { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -17981,7 +18001,7 @@ export interface BrowserType { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -17990,6 +18010,26 @@ export interface BrowserType { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -18735,6 +18775,8 @@ export interface Screencast { /** * Font size of the action title in pixels. Defaults to `24`. + * @deprecated Use `title` in [`style`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-style) + * instead, for example `style: { title: 'font-size: 32px' }`. */ fontSize?: number; @@ -18742,6 +18784,44 @@ export interface Screencast { * Position of the action title overlay. Defaults to `"top-right"`. */ position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; + + /** + * Styles of the action decorations. All decorations fade out over + * [`duration`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-duration). + * + * **Usage** + * + * ```js + * await page.screencast.showActions({ + * style: { + * point: 'width: 20px; height: 20px; border-radius: 50%; background: red', + * highlight: 'outline: 2px solid #333; background: rgba(0, 128, 255, .15)', + * title: 'font-size: 16px', + * }, + * }); + * ``` + * + */ + style?: { + /** + * CSS declarations for the marker at the action point. The marker is positioned at the action point, has zero size + * and is centered on the point, so its size and look come from this style. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. The box is positioned and sized to the element bounds. + * Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title, for example `'font-size: 32px; background: #333'`. The title is placed + * according to + * [`position`](https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-position). + */ + title?: string; + }; }): Promise; /** @@ -24167,7 +24247,7 @@ export interface Electron { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -24176,6 +24256,26 @@ export interface Electron { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -24888,7 +24988,7 @@ export interface AndroidDevice { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -24897,6 +24997,26 @@ export interface AndroidDevice { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; @@ -26113,7 +26233,7 @@ export interface BrowserContextOptions { position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; /** - * Font size of the action title in pixels. Defaults to `24`. + * Font size of the action title in pixels. Defaults to `24`. Deprecated, use `style.title` instead. */ fontSize?: number; @@ -26122,6 +26242,26 @@ export interface BrowserContextOptions { * the previous action point to the next one. `"none"` disables the cursor decoration. */ cursor?: "none"|"pointer"; + + /** + * Styles of the action decorations. + */ + style?: { + /** + * CSS declarations for the zero-sized marker centered on the action point. Not shown when omitted. + */ + point?: string; + + /** + * CSS declarations for the box that covers the target element. Not shown when omitted. + */ + highlight?: string; + + /** + * CSS declarations for the action title. + */ + title?: string; + }; }; }; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index d43af541d68f4..35d0a0b9a1873 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7282,7 +7282,7 @@ export interface PlaywrightWorkerOptions { * * Learn more about [recording video](https://playwright.dev/docs/test-use-options#recording-options). */ - video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, fps?: number, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; + video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, fps?: number, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', /** @deprecated Use `style.title` instead. */ fontSize?: number, cursor?: 'none' | 'pointer', style?: { point?: string, highlight?: string, title?: string } }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; diff --git a/packages/protocol/spec/mixins.yml b/packages/protocol/spec/mixins.yml index f76b3e5e21c47..f6e3141bb2be6 100644 --- a/packages/protocol/spec/mixins.yml +++ b/packages/protocol/spec/mixins.yml @@ -31,6 +31,12 @@ ShowActionsOptions: literals: - none - pointer + style: + type: object? + properties: + point: string? + highlight: string? + title: string? CommonScreenshotOptions: type: mixin diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 6ac8c14883eb7..a861f10817719 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -195,6 +195,11 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -479,6 +484,11 @@ scheme.BrowserNewContextParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -554,6 +564,11 @@ scheme.BrowserNewContextForReuseParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -651,6 +666,11 @@ scheme.BrowserContextInitializer = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -1053,6 +1073,11 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -1143,6 +1168,11 @@ scheme.ElectronLaunchParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), })), })), strictSelectors: tOptional(tBoolean), @@ -2607,6 +2637,11 @@ scheme.PageScreencastShowActionsParams = tObject({ position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), + style: tOptional(tObject({ + point: tOptional(tString), + highlight: tOptional(tString), + title: tOptional(tString), + })), }); scheme.PageScreencastShowActionsResult = tOptional(tObject({})); scheme.PageScreencastHideActionsParams = tOptional(tObject({})); diff --git a/tests/library/screencast-actions.spec.ts b/tests/library/screencast-actions.spec.ts index abd57834bfeb9..64b6c486867eb 100644 --- a/tests/library/screencast-actions.spec.ts +++ b/tests/library/screencast-actions.spec.ts @@ -18,6 +18,11 @@ import { expect, browserTest as test } from '../config/browserTest'; test.skip(({ mode }) => mode !== 'default', 'Annotations use an open shadow root only in default mode'); +const style = { + point: 'width: 20px; height: 20px; border-radius: 50%; background: red', + highlight: 'outline: 2px solid #333', +}; + test('should show annotation on click', async ({ browser, server }) => { const context = await browser.newContext(); const page = await context.newPage(); @@ -26,10 +31,11 @@ test('should show annotation on click', async ({ browser, server }) => { await page.screencast.showActions({ duration: 5000 }); page.click('button').catch(() => {}); - await expect(page.locator('x-pw-highlight')).toBeVisible(); - await expect(page.locator('x-pw-action-point')).toBeVisible(); await expect(page.locator('x-pw-title')).toBeVisible(); await expect(page.locator('x-pw-title')).toHaveText(/click/i); + // Point and highlight are only shown when styled. + await expect(page.locator('x-pw-screencast-highlight')).toHaveCount(0); + await expect(page.locator('x-pw-screencast-point')).toHaveCount(0); await context.close(); }); @@ -39,44 +45,37 @@ test('should render annotation styles', async ({ browser, server }) => { const page = await context.newPage(); await page.goto(server.PREFIX + '/input/button.html'); - await page.screencast.showActions({ duration: 5000, fontSize: 32 }); + await page.screencast.showActions({ duration: 5000, fontSize: 32, style: { ...style, title: 'top: 100px; background-color: rgb(1, 2, 3)' } }); + const buttonBox = (await page.locator('button').boundingBox())!; page.click('button').catch(() => {}); - // Highlight box: blue overlay with non-zero dimensions. - const highlight = page.locator('x-pw-highlight'); + // Highlight: styled box that covers the target. + const highlight = page.locator('x-pw-screencast-highlight'); await expect(highlight).toBeVisible(); - const highlightStyle = await highlight.evaluate((el: HTMLElement) => ({ - backgroundColor: el.style.backgroundColor, - borderColor: el.style.borderColor, - })); - expect(highlightStyle.backgroundColor).toBe('rgba(0, 128, 255, 0.15)'); - expect(highlightStyle.borderColor).toBe('rgba(0, 128, 255, 0.6)'); - const box = await highlight.boundingBox(); - expect(box!.width).toBeGreaterThan(0); - expect(box!.height).toBeGreaterThan(0); - - // Action point: 20x20 red circle. - const actionPoint = page.locator('x-pw-action-point'); - await expect(actionPoint).toBeVisible(); - const apStyle = await actionPoint.evaluate((el: HTMLElement) => { - const cs = getComputedStyle(el); - return { width: cs.width, height: cs.height, background: cs.backgroundColor, borderRadius: cs.borderRadius }; - }); - expect(apStyle.width).toBe('20px'); - expect(apStyle.height).toBe('20px'); - expect(apStyle.background).toBe('rgb(255, 0, 0)'); - expect(apStyle.borderRadius).toBe('10px'); - - // Title: white text, dark background, positioned top-right by default, custom fontSize. + expect(await highlight.evaluate((el: HTMLElement) => getComputedStyle(el).outlineWidth)).toBe('2px'); + const box = (await highlight.boundingBox())!; + expect(box.x).toBeCloseTo(buttonBox.x, 0); + expect(box.y).toBeCloseTo(buttonBox.y, 0); + expect(box.width).toBeCloseTo(buttonBox.width, 0); + expect(box.height).toBeCloseTo(buttonBox.height, 0); + + // Point: styled marker centered on the click point, Firefox rounds the click point. + const pointBox = (await page.locator('x-pw-screencast-point').boundingBox())!; + expect(pointBox.width).toBe(20); + expect(Math.abs(pointBox.x + 10 - (buttonBox.x + buttonBox.width / 2))).toBeLessThanOrEqual(1); + expect(Math.abs(pointBox.y + 10 - (buttonBox.y + buttonBox.height / 2))).toBeLessThanOrEqual(1); + + // Title: style applies on top of deprecated fontSize, position wins over style. const title = page.locator('x-pw-title'); await expect(title).toBeVisible(); const titleStyle = await title.evaluate((el: HTMLElement) => { const cs = getComputedStyle(el); return { - color: cs.color, borderRadius: cs.borderRadius, padding: cs.padding, + color: cs.color, borderRadius: cs.borderRadius, padding: cs.padding, backgroundColor: cs.backgroundColor, top: el.style.top, right: el.style.right, fontSize: el.style.fontSize, }; }); + expect(titleStyle.backgroundColor).toBe('rgb(1, 2, 3)'); expect(titleStyle.color).toBe('rgb(255, 255, 255)'); expect(titleStyle.borderRadius).toBe('6px'); expect(titleStyle.padding).toBe('6px'); @@ -124,10 +123,11 @@ test('should clear annotation after duration', async ({ browser, server }) => { const page = await context.newPage(); await page.goto(server.PREFIX + '/input/button.html'); - await page.screencast.showActions({ duration: 1000 }); + await page.screencast.showActions({ duration: 1000, style }); await page.click('button'); - await expect(page.locator('x-pw-action-point')).toBeHidden(); + await expect(page.locator('x-pw-screencast-point')).toHaveCount(0); + await expect(page.locator('x-pw-screencast-highlight')).toHaveCount(0); await expect(page.locator('x-pw-title')).toBeHidden(); await context.close(); @@ -234,13 +234,35 @@ test('cursor: "none" suppresses the action cursor decoration', async ({ browser, await page.screencast.showActions({ duration: 5000, cursor: 'none' }); page.click('button').catch(() => {}); - // The click marker still renders, but the cursor does not. - await expect(page.locator('x-pw-action-point')).toBeVisible(); + // The title still renders, but the cursor does not. + await expect(page.locator('x-pw-title')).toBeVisible(); await expect(page.locator('x-pw-action-cursor')).toBeHidden(); await context.close(); }); +test('cursor stays at the last action point until hideActions', async ({ browser, server }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.PREFIX + '/input/button.html'); + + await page.screencast.showActions({ duration: 100 }); + await page.click('button'); + await expect(page.locator('x-pw-title')).toBeHidden(); + const cursor = page.locator('x-pw-action-cursor'); + await expect(cursor).toBeVisible(); + const position = await cursor.evaluate((el: HTMLElement) => ({ top: el.style.top, left: el.style.left })); + + await page.goto(server.EMPTY_PAGE); + await expect(cursor).toBeVisible(); + expect(await cursor.evaluate((el: HTMLElement) => ({ top: el.style.top, left: el.style.left }))).toEqual(position); + + await page.screencast.hideActions(); + await expect(cursor).toBeHidden(); + + await context.close(); +}); + test('should survive navigation', async ({ browser, server }) => { const context = await browser.newContext(); const page = await context.newPage(); diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 6bc852c897063..07605bea8c2ed 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -259,6 +259,21 @@ test('video-start with fps', async ({ cli, server }, testInfo) => { expect(stderr.toString()).toContain(', 60 fps,'); }); +test('video-start --cursor', async ({ boundBrowser, cli }) => { + const page = await boundBrowser.newPage(); + await page.setContent(``); + + await cli('attach', 'default'); + await cli('snapshot'); + await cli('video-start', 'video.webm', '--cursor'); + await cli('click', 'e2'); + + // The cursor travels to the action point, the action title stays out of the video. + await expect(page.locator('x-pw-action-cursor')).toBeVisible(); + await expect(page.locator('x-pw-title')).toBeHidden(); + await cli('video-stop'); +}); + test('video-chapter', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); await cli('video-start', 'video.webm'); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index c5081e02886ed..b95c661759778 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -273,7 +273,7 @@ export interface PlaywrightWorkerOptions { reuseContext: boolean; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, coverage?: boolean, sources?: boolean, attachments?: boolean }; - video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, fps?: number, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; + video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, fps?: number, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', /** @deprecated Use `style.title` instead. */ fontSize?: number, cursor?: 'none' | 'pointer', style?: { point?: string, highlight?: string, title?: string } }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';