Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions packages/dashboard/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSystemWritableFileStream | null> {
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)
Expand Down Expand Up @@ -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);
Expand All @@ -324,15 +349,18 @@ export const Dashboard: React.FC = () => {
</ToolbarButton>
<ToolbarButton
className='screenshot'
title='Copy screenshot to clipboard'
title='Save screenshot'
icon={screenshotIcon}
disabled={!ready}
onClick={async () => {
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);
}}
Expand Down
3 changes: 2 additions & 1 deletion packages/dashboard/src/dashboardChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export interface DashboardChannel {
pickLocator(): Promise<void>;
cancelPickLocator(): Promise<void>;
startRecording(): Promise<void>;
stopRecording(): Promise<{ path: string }>;
stopRecording(): Promise<{ streamId: string }>;
readStream(params: { streamId: string }): Promise<{ data: string; eof: boolean }>;
screenshot(): Promise<string>;

on<K extends keyof DashboardChannelEvents>(event: K, listener: (params: DashboardChannelEvents[K]) => void): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,7 @@ export class DashboardConnection implements Transport {
private _pendingReveal: { sessionName: string; workspaceDir?: string } | undefined;

_recordingDir: string;
_streams = new Map<string, { handle: fs.promises.FileHandle; path: string }>();

constructor(onclose: () => void) {
this._onclose = onclose;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string> {
Expand Down
75 changes: 75 additions & 0 deletions tests/mcp/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer>> {
let captured: string | undefined;
let resolveCaptured: ((b64: string) => void) | undefined;
const waitForCapture = new Promise<string>(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();
Comment thread
pavelfeldman marked this conversation as resolved.
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]));
});
Loading