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
5 changes: 5 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2727,6 +2727,11 @@ Returns whether the element is [visible](../actionability.md#visible). [`param:

Clears all stored console messages from this page. Subsequent calls to [`method: Page.consoleMessages`] will only return messages logged after the clear.

## async method: Page.clearPageErrors
* since: v1.59

Clears all stored page errors from this page. Subsequent calls to [`method: Page.pageErrors`] will only return errors thrown after the clear.

## async method: Page.consoleMessages
* since: v1.56
- returns: <[Array]<[ConsoleMessage]>>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"check-deps": "node utils/check_deps.js",
"build-android-driver": "./utils/build_android_driver.sh",
"innerloop": "playwright run-server --reuse-browser",
"playwright-cli": "node packages/playwright/lib/mcp/terminal/cli.js",
"playwright-cli": "node packages/playwright/lib/cli/client/program.js",
"test-playwright-cli": "playwright test --config=tests/mcp/playwright.config.ts --project=chrome cli-",
"playwright-cli-readme": "node utils/generate_cli_help.js --readme"
},
Expand Down
92 changes: 44 additions & 48 deletions packages/devtools/src/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
import React from 'react';
import './devtools.css';
import { navigate } from './index';
import { DevToolsTransport } from './transport';
import { DevToolsClient } from './devtoolsClient';

type TabInfo = { id: string; title: string; url: string };
import type { DevToolsClientChannel } from './devtoolsClient';
import type { Tab } from './devtoolsChannel';

function tabFavicon(url: string): string {
try {
Expand All @@ -31,16 +32,17 @@ function tabFavicon(url: string): string {
}
}

const BUTTONS = ['left', 'middle', 'right'] as const;

export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
const [status, setStatus] = React.useState<{ text: string; cls: string }>({ text: 'Connecting', cls: '' });
const [tabs, setTabs] = React.useState<TabInfo[]>([]);
const [selectedPageId, setSelectedPageId] = React.useState<string | undefined>();
const [tabs, setTabs] = React.useState<Tab[]>([]);
const [url, setUrl] = React.useState('');
const [frameSrc, setFrameSrc] = React.useState('');
const [captured, setCaptured] = React.useState(false);
const [hintVisible, setHintVisible] = React.useState(false);

const transportRef = React.useRef<DevToolsTransport | null>(null);
const channelRef = React.useRef<DevToolsClientChannel | null>(null);
const displayRef = React.useRef<HTMLImageElement>(null);
const screenRef = React.useRef<HTMLDivElement>(null);
const omniboxRef = React.useRef<HTMLInputElement>(null);
Expand All @@ -57,34 +59,30 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
React.useEffect(() => {
if (!wsUrl)
return;
const transport = new DevToolsTransport(wsUrl);
transportRef.current = transport;
const channel = DevToolsClient.create(wsUrl);
channelRef.current = channel;

transport.onopen = () => setStatus({ text: 'Connected', cls: 'connected' });
channel.onopen = () => setStatus({ text: 'Connected', cls: 'connected' });

transport.onevent = (method: string, params: any) => {
if (method === 'selectPage') {
setSelectedPageId(params.pageId);
if (params.pageId)
omniboxRef.current?.focus();
}
if (method === 'frame') {
setFrameSrc('data:image/jpeg;base64,' + params.data);
if (params.viewportWidth)
viewportSizeRef.current.width = params.viewportWidth;
if (params.viewportHeight)
viewportSizeRef.current.height = params.viewportHeight;
resizeToFit();
}
if (method === 'url')
setUrl(params.url);
if (method === 'tabs')
setTabs(params.tabs);
};
channel.on('tabs', params => {
setTabs(params.tabs);
const selected = params.tabs.find(t => t.selected);
if (selected)
setUrl(selected.url);
});

transport.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' });
channel.on('frame', params => {
setFrameSrc('data:image/jpeg;base64,' + params.data);
if (params.viewportWidth)
viewportSizeRef.current.width = params.viewportWidth;
if (params.viewportHeight)
viewportSizeRef.current.height = params.viewportHeight;
resizeToFit();
});

return () => transport.close();
channel.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' });

return () => channel.close();
}, [wsUrl]);

function resizeToFit() {
Expand Down Expand Up @@ -134,8 +132,6 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
};
}

const BUTTONS: string[] = ['left', 'middle', 'right'];

function onScreenMouseDown(e: React.MouseEvent) {
e.preventDefault();
screenRef.current?.focus();
Expand All @@ -145,15 +141,15 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
return;
}
const { x, y } = imgCoords(e);
transportRef.current?.sendNoReply('mousedown', { x, y, button: BUTTONS[e.button] || 'left' });
channelRef.current?.mousedown({ x, y, button: BUTTONS[e.button] || 'left' });
}

function onScreenMouseUp(e: React.MouseEvent) {
if (!capturedRef.current)
return;
e.preventDefault();
const { x, y } = imgCoords(e);
transportRef.current?.sendNoReply('mouseup', { x, y, button: BUTTONS[e.button] || 'left' });
channelRef.current?.mouseup({ x, y, button: BUTTONS[e.button] || 'left' });
}

function onScreenMouseMove(e: React.MouseEvent) {
Expand All @@ -164,14 +160,14 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
return;
moveThrottleRef.current = now;
const { x, y } = imgCoords(e);
transportRef.current?.sendNoReply('mousemove', { x, y });
channelRef.current?.mousemove({ x, y });
}

function onScreenWheel(e: React.WheelEvent) {
if (!capturedRef.current)
return;
e.preventDefault();
transportRef.current?.sendNoReply('wheel', { deltaX: e.deltaX, deltaY: e.deltaY });
channelRef.current?.wheel({ deltaX: e.deltaX, deltaY: e.deltaY });
}

function onScreenKeyDown(e: React.KeyboardEvent) {
Expand All @@ -182,14 +178,14 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
setCaptured(false);
return;
}
transportRef.current?.sendNoReply('keydown', { key: e.key });
channelRef.current?.keydown({ key: e.key });
}

function onScreenKeyUp(e: React.KeyboardEvent) {
if (!capturedRef.current)
return;
e.preventDefault();
transportRef.current?.sendNoReply('keyup', { key: e.key });
channelRef.current?.keyup({ key: e.key });
}

function onScreenBlur() {
Expand All @@ -203,12 +199,12 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
if (!/^https?:\/\//i.test(value))
value = 'https://' + value;
setUrl(value);
transportRef.current?.send('navigate', { url: value });
channelRef.current?.navigate({ url: value });
omniboxRef.current?.blur();
}
}

const hasPages = !!selectedPageId;
const hasPages = tabs.some(t => t.selected);

return (<div className='devtools-view'>
{/* Tab bar */}
Expand All @@ -221,12 +217,12 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
<div id='tabstrip' className='tabstrip' role='tablist'>
{tabs.map(tab => (
<div
key={tab.id}
className={'tab' + (tab.id === selectedPageId ? ' active' : '')}
key={tab.pageId}
className={'tab' + (tab.selected ? ' active' : '')}
role='tab'
aria-selected={tab.id === selectedPageId}
aria-selected={tab.selected}
title={tab.url || ''}
onClick={() => transportRef.current?.sendNoReply('selectTab', { id: tab.id })}
onClick={() => channelRef.current?.selectTab({ pageId: tab.pageId })}
>
<span className='tab-favicon' aria-hidden='true'>{tabFavicon(tab.url)}</span>
<span className='tab-label'>{tab.title || 'New Tab'}</span>
Expand All @@ -235,7 +231,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
title='Close tab'
onClick={e => {
e.stopPropagation();
transportRef.current?.sendNoReply('closeTab', { id: tab.id });
channelRef.current?.closeTab({ pageId: tab.pageId });
}}
>
<svg viewBox='0 0 12 12' fill='none' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round'>
Expand All @@ -246,7 +242,7 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
</div>
))}
</div>
<button id='new-tab-btn' className='new-tab-btn' title='New Tab' onClick={() => transportRef.current?.sendNoReply('newTab')}>
<button id='new-tab-btn' className='new-tab-btn' title='New Tab' onClick={() => channelRef.current?.newTab()}>
<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round'>
<line x1='12' y1='5' x2='12' y2='19'/>
<line x1='5' y1='12' x2='19' y2='12'/>
Expand All @@ -257,17 +253,17 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {

{/* Toolbar */}
<div className='toolbar'>
<button className='nav-btn' title='Back' onClick={() => transportRef.current?.sendNoReply('back')}>
<button className='nav-btn' title='Back' onClick={() => channelRef.current?.back()}>
<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
<polyline points='15 18 9 12 15 6'/>
</svg>
</button>
<button className='nav-btn' title='Forward' onClick={() => transportRef.current?.sendNoReply('forward')}>
<button className='nav-btn' title='Forward' onClick={() => channelRef.current?.forward()}>
<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
<polyline points='9 18 15 12 9 6'/>
</svg>
</button>
<button className='nav-btn' title='Reload' onClick={() => transportRef.current?.sendNoReply('reload')}>
<button className='nav-btn' title='Reload' onClick={() => channelRef.current?.reload()}>
<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
<polyline points='23 4 23 10 17 10'/>
<path d='M20.49 15a9 9 0 1 1-2.12-9.36L23 10'/>
Expand Down
42 changes: 42 additions & 0 deletions packages/devtools/src/devtoolsChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export type Tab = { pageId: string; title: string; url: string; selected: boolean };

export type DevToolsChannelEvents = {
frame: { data: string; viewportWidth: number; viewportHeight: number };
tabs: { tabs: Tab[] };
};

export interface DevToolsChannel {
tabs(): Promise<{ tabs: Tab[] }>;
selectTab(params: { pageId: string }): Promise<void>;
closeTab(params: { pageId: string }): Promise<void>;
newTab(): Promise<void>;
navigate(params: { url: string }): Promise<void>;
back(): Promise<void>;
forward(): Promise<void>;
reload(): Promise<void>;
mousemove(params: { x: number; y: number }): Promise<void>;
mousedown(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }): Promise<void>;
mouseup(params: { x: number; y: number; button?: 'left' | 'right' | 'middle' }): Promise<void>;
wheel(params: { deltaX: number; deltaY: number }): Promise<void>;
keydown(params: { key: string }): Promise<void>;
keyup(params: { key: string }): Promise<void>;

on<K extends keyof DevToolsChannelEvents>(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void;
off<K extends keyof DevToolsChannelEvents>(event: K, listener: (params: DevToolsChannelEvents[K]) => void): void;
}
86 changes: 86 additions & 0 deletions packages/devtools/src/devtoolsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Transport } from './transport';

import type { DevToolsChannel } from './devtoolsChannel';

export type DevToolsClientChannel = DevToolsChannel & {
onopen?: () => void;
onclose?: (reason?: string) => void;
close(): void;
};

export class DevToolsClient {
private _transport: Transport;
private _listeners = new Map<string, Set<Function>>();

onopen?: () => void;
onclose?: (reason?: string) => void;

private constructor(transport: Transport) {
this._transport = transport;
this._transport.onopen = () => {
this.onopen?.();
};
this._transport.onevent = (method: string, params: any) => {
this._fireEvent(method, params);
};
this._transport.onclose = (reason?: string) => {
this.onclose?.(reason);
};
}

static create(url: string): DevToolsClientChannel {
const transport = new Transport(url);
const client = new DevToolsClient(transport);
return new Proxy(client, {
get(target: DevToolsClient, prop: string | symbol, receiver: any): any {
if (typeof prop === 'symbol' || prop in target)
return Reflect.get(target, prop, receiver);
// Prevent the proxy from being treated as a thenable.
if (prop === 'then')
return undefined;
return (params?: any) => target._transport.send(prop, params);
}
}) as unknown as DevToolsClientChannel;
}

private _fireEvent(event: string, params: any) {
const set = this._listeners.get(event);
if (set) {
for (const listener of set)
listener(params);
}
}

on(event: string, listener: Function): void {
let set = this._listeners.get(event);
if (!set) {
set = new Set();
this._listeners.set(event, set);
}
set.add(listener);
}

off(event: string, listener: Function): void {
this._listeners.get(event)?.delete(listener);
}

close() {
this._transport.close();
}
}
10 changes: 10 additions & 0 deletions packages/devtools/src/grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
align-items: center;
gap: 8px;
padding: 8px 12px;
max-width: 533px;
}

.session-status-dot {
Expand All @@ -127,6 +128,15 @@
font-size: 13px;
font-weight: 600;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}

.session-chip-title {
color: var(--fg-muted);
font-weight: 400;
}

.session-chip-detail {
Expand Down
Loading
Loading