diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 214f7e35b3865..5910a41c335d1 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -28,6 +28,22 @@ import type { Tab, DashboardChannelEvents } from './dashboardChannel'; const BUTTONS = ['left', 'middle', 'right'] as const; type Mode = 'readonly' | 'interactive' | 'annotate'; +async function pickSaveWritable(suggestedName: string, description: string, mime: string, extension: string): Promise { + try { + const handle = await (window as any).showSaveFilePicker({ + suggestedName, + types: [{ description, accept: { [mime]: [extension] } }], + }); + return await handle.createWritable(); + } catch { + return null; + } +} + +function base64ToBlob(base64: string, mime: string): Blob { + return new Blob([(Uint8Array as any).fromBase64(base64)], { type: mime }); +} + function smartUrl(input: string): string { const value = input.trim(); if (!value) @@ -312,9 +328,18 @@ export const Dashboard: React.FC = () => { if (!client) return; if (recording) { - const { path } = await client.stopRecording(); - await client.reveal({ path }); + const writable = await pickSaveWritable(`playwright-recording-${Date.now()}.webm`, 'WebM Video', 'video/webm', '.webm'); + if (!writable) + return; setRecording(false); + const { streamId } = await client.stopRecording(); + while (true) { + const { data, eof } = await client.readStream({ streamId }); + if (eof) + break; + await writable.write(base64ToBlob(data, 'video/webm')); + } + await writable.close(); } else { await client.startRecording(); setRecording(true); @@ -324,15 +349,18 @@ export const Dashboard: React.FC = () => { { if (!client) return; - const screenshot = await client.screenshot(); - const blob = await (await fetch('data:image/png;base64,' + screenshot)).blob(); - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + const writable = await pickSaveWritable(`playwright-screenshot-${Date.now()}.png`, 'PNG Image', 'image/png', '.png'); + if (!writable) + return; + const data = await client.screenshot(); + await writable.write(base64ToBlob(data, 'image/png')); + await writable.close(); setScreenshotIcon('clippy'); setTimeout(() => setScreenshotIcon('device-camera'), 3000); }} diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index 3f82ec25ca87b..bbde6fbb0ed36 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -60,7 +60,8 @@ export interface DashboardChannel { pickLocator(): Promise; cancelPickLocator(): Promise; startRecording(): Promise; - stopRecording(): Promise<{ path: string }>; + stopRecording(): Promise<{ streamId: string }>; + readStream(params: { streamId: string }): Promise<{ data: string; eof: boolean }>; screenshot(): Promise; on(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index b13c2173b08a7..3f47aae614f55 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -17,6 +17,7 @@ import path from 'path'; import os from 'os'; import fs from 'fs'; +import crypto from 'crypto'; import { execFile } from 'child_process'; import { eventsHelper } from '@utils/eventsHelper'; import { connectToBrowserAcrossVersions } from '../utils/connect'; @@ -52,6 +53,7 @@ export class DashboardConnection implements Transport { private _pendingReveal: { sessionName: string; workspaceDir?: string } | undefined; _recordingDir: string; + _streams = new Map(); constructor(onclose: () => void) { this._onclose = onclose; @@ -74,6 +76,13 @@ export class DashboardConnection implements Transport { this._serverRegistryDispose = undefined; this._attachedBrowser?.dispose(); this._attachedBrowser = undefined; + for (const stream of this._streams.values()) { + void stream.handle.close() + .catch(() => {}) + .then(() => fs.promises.unlink(stream.path)) + .catch(() => {}); + } + this._streams.clear(); for (const slot of this._browsers.values()) slot.listeners.forEach(d => d.dispose()); this._browsers.clear(); @@ -173,6 +182,21 @@ export class DashboardConnection implements Transport { } } + async readStream(params: { streamId: string }): Promise<{ data: string; eof: boolean }> { + const stream = this._streams.get(params.streamId); + if (!stream) + throw new Error(`Unknown stream: ${params.streamId}`); + const buffer = Buffer.alloc(256 * 1024); + const { bytesRead } = await stream.handle.read(buffer, 0, buffer.length); + if (bytesRead === 0) { + this._streams.delete(params.streamId); + await stream.handle.close().catch(() => {}); + await fs.promises.unlink(stream.path).catch(() => {}); + return { data: '', eof: true }; + } + return { data: buffer.subarray(0, bytesRead).toString('base64'), eof: false }; + } + visible(): boolean { return this._visible; } @@ -482,14 +506,17 @@ class AttachedBrowser { await this._restartScreencast(page); } - async stopRecording(): Promise<{ path: string }> { + async stopRecording(): Promise<{ streamId: string }> { const p = this._recordingPath; if (!p) throw new Error('No recording in progress'); this._recordingPath = null; if (this._selectedPage && this._screencastRunning) await this._restartScreencast(this._selectedPage); - return { path: p }; + const handle = await fs.promises.open(p, 'r'); + const streamId = crypto.randomUUID(); + this._owner._streams.set(streamId, { handle, path: p }); + return { streamId }; } async screenshot(): Promise { diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index be1dc2f985a94..33bd405f4e6ff 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -131,3 +131,78 @@ test('should pick locator from browser', async ({ cli, server, openDashboard }) const { output } = await pickPromise; expect(output).toContain(`getByRole('button', { name: 'Submit' })`); }); + +async function installSaveFilePickerMock(page: import('playwright-core').Page): Promise<() => Promise> { + let captured: string | undefined; + let resolveCaptured: ((b64: string) => void) | undefined; + const waitForCapture = new Promise(resolve => { + resolveCaptured = resolve; + }); + await page.exposeBinding('__testCaptureBytes', (_, b64: string) => { + captured = b64; + resolveCaptured!(b64); + }); + await page.addInitScript(() => { + (window as any).showSaveFilePicker = async () => ({ + createWritable: async () => { + const chunks: Uint8Array[] = []; + return { + write: async (chunk: Blob | BufferSource) => { + const buf = chunk instanceof Blob + ? new Uint8Array(await chunk.arrayBuffer()) + : new Uint8Array(chunk instanceof ArrayBuffer ? chunk : (chunk as ArrayBufferView).buffer); + chunks.push(buf); + }, + close: async () => { + const total = chunks.reduce((n, c) => n + c.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + await (window as any).__testCaptureBytes((merged as any).toBase64()); + }, + }; + }, + }); + }); + return async () => { + const b64 = captured ?? await waitForCapture; + return Buffer.from(b64, 'base64'); + }; +} + +test('screenshot writes PNG bytes to the chosen file', async ({ cli, server, page, openDashboard }) => { + await cli('open', server.EMPTY_PAGE); + const awaitBytes = await installSaveFilePickerMock(page); + + const dashboard = await openDashboard(); + await dashboard.locator('.sidebar-tab').first().click(); + await expect(dashboard.locator('img#display')).toBeVisible(); + await expect(dashboard.locator('.screenshot')).toBeEnabled(); + + await dashboard.locator('.screenshot').click(); + + const bytes = await awaitBytes(); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); +}); + +test('stop recording streams WebM bytes to the chosen file', async ({ cli, server, page, openDashboard }) => { + await cli('open', server.EMPTY_PAGE); + const awaitBytes = await installSaveFilePickerMock(page); + + const dashboard = await openDashboard(); + await dashboard.locator('.sidebar-tab').first().click(); + await expect(dashboard.locator('img#display')).toBeVisible(); + + const recordBtn = dashboard.locator('.recording'); + await expect(recordBtn).toBeEnabled(); + await recordBtn.click(); + await expect(dashboard.locator('.recording-label')).toBeVisible(); + await recordBtn.click(); + + const bytes = await awaitBytes(); + // WebM files start with the EBML magic bytes. + expect(bytes.subarray(0, 4)).toEqual(Buffer.from([0x1a, 0x45, 0xdf, 0xa3])); +});