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
1 change: 1 addition & 0 deletions .claude/skills/playwright-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ See [CLAUDE.md](../../../CLAUDE.md) for monorepo structure, build/test/lint comm
- [Vendor Dependencies & Bundling](vendor.md) — utilsBundle, coreBundle, babelBundle; adding vendored npm packages; DEPS.list; `check_deps`
- [Updating WebKit Safari Version](webkit-safari-version.md) — update the Safari version string in the WebKit user-agent
- [Bisecting Across Published Versions](bisect-published-versions.md) — reproduce regressions side-by-side from npm and diff `node_modules/playwright/lib/` between versions
- [Dashboard](dashboard.md) - the UI powering the "playwright cli show" command, and how to work on it
37 changes: 37 additions & 0 deletions .claude/skills/playwright-dev/dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Developing Dashboard

`packages/dashboard` contains the sourcecode behind `playwright cli show`,
a dashboard that allow supervising agents while they use playwright cli.

Important code paths:

- `packages/dashboard` has the UI
- `dashboardController.ts` has the backend
- `show` section in `cli-client/program.ts`

You can use Playwright CLI to look at the dashboard:

```bash
# start the dashboard server in the background
npx playwright cli show --port=0

# open it with Playwright CLI
npx playwright cli open --session=dashboard localhost:PORT
npx playwright cli snapshot

# take screenshots to look at UI stuff
npx playwright cli screenshot

# take videos to showcase you work!
npx playwright cli video-start video.webm

# chapters are not everything - look at video-recording.md to learn about overlays, much more powerful! embrace creativity.
npx playwright cli video-chapter "Chapter Title" --description="Details" --duration=2000
npx playwright cli video-stop

# afterwards, use ffmpeg to turn the video into mp4 for sharing.
```

For using Playwright CLI, refer to the `packages/playwright-core/src/tools/cli-client/skill/SKILL.md` skill.
While developing in in this repo, it's important to use `npx playwright cli` instead of `playwright-cli`.

Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
* since: v1.9
* langs: js

Playwright has **experimental** support for Electron automation. You can access electron namespace via:
Playwright supports Electron automation, shipped as a separate package:

```js
const { _electron } = require('playwright');
```sh
npm i -D @playwright/electron
```

An example of the Electron automation script would be:

```js
const { _electron: electron } = require('playwright');
import { electron } from '@playwright/electron';

(async () => {
// Launch Electron app.
Expand Down Expand Up @@ -89,59 +89,5 @@ Specifies environment variables that will be visible to Electron. Defaults to `p

Maximum time in milliseconds to wait for the application to start. Defaults to `30000` (30 seconds). Pass `0` to disable timeout.

### option: Electron.launch.acceptdownloads = %%-context-option-acceptdownloads-%%
* since: v1.12

### option: Electron.launch.bypassCSP = %%-context-option-bypasscsp-%%
* since: v1.12

### option: Electron.launch.colorScheme = %%-context-option-colorscheme-%%
* since: v1.12

### option: Electron.launch.extraHTTPHeaders = %%-context-option-extrahttpheaders-%%
* since: v1.12

### option: Electron.launch.geolocation = %%-context-option-geolocation-%%
* since: v1.12

### option: Electron.launch.httpcredentials = %%-context-option-httpcredentials-%%
* since: v1.12

### option: Electron.launch.ignoreHTTPSErrors = %%-context-option-ignorehttpserrors-%%
* since: v1.12

### option: Electron.launch.locale = %%-context-option-locale-%%
* since: v1.12

### option: Electron.launch.offline = %%-context-option-offline-%%
* since: v1.12

### option: Electron.launch.recordhar = %%-context-option-recordhar-%%
* since: v1.12

### option: Electron.launch.recordharpath = %%-context-option-recordhar-path-%%
* since: v1.12

### option: Electron.launch.recordHarOmitContent = %%-context-option-recordhar-omit-content-%%
* since: v1.12

### option: Electron.launch.recordvideo = %%-context-option-recordvideo-%%
* since: v1.12

### option: Electron.launch.recordvideodir = %%-context-option-recordvideo-dir-%%
* since: v1.12

### option: Electron.launch.recordvideosize = %%-context-option-recordvideo-size-%%
* since: v1.12

### option: Electron.launch.timezoneId = %%-context-option-timezoneid-%%
* since: v1.12

### option: Electron.launch.tracesDir = %%-browser-option-tracesdir-%%
* since: v1.36

### option: Electron.launch.artifactsDir = %%-browser-option-artifactsdir-%%
* since: v1.59

### option: Electron.launch.chromiumSandbox = %%-browser-option-chromiumsandbox-%%
* since: v1.59
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions packages/dashboard/src/annotations.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
cursor: default;
}

:root.dark-mode .annotation-toolbar {
border-color: var(--color-border-default);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}

:root.dark-mode .annotate-action-btn {
background: var(--color-neutral-muted);
border-color: var(--color-border-default);
}

.annotate-action-btn {
height: 26px;
padding: 0 12px;
Expand Down
65 changes: 44 additions & 21 deletions packages/dashboard/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,33 @@ export const Dashboard: React.FC = () => {
const [frame, setFrame] = React.useState<DashboardChannelEvents['frame']>();
const [picking, setPicking] = React.useState(false);
const [recording, setRecording] = React.useState(false);
const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'clippy'>('device-camera');
const [showInteractiveHint, setShowInteractiveHint] = React.useState(false);
const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'check'>('device-camera');
const [flashTick, setFlashTick] = React.useState(0);

const downloadArtifact = React.useCallback(async (id: string) => {
const appMode = window.matchMedia('(display-mode: standalone)').matches
|| (window as any).__dashboardAppModeForTest === true;
if (appMode)
return await client!.saveAndReveal({ id });

const a = document.createElement('a');
a.href = `/artifact/${encodeURIComponent(id)}`;
a.style.display = 'none';
document.body.appendChild(a);
try {
a.click();
} finally {
a.remove();
}
}, [client]);

const displayRef = React.useRef<HTMLImageElement>(null);
const screenRef = React.useRef<HTMLDivElement>(null);
const toolbarRef = React.useRef<HTMLDivElement>(null);
const viewportMainRef = React.useRef<HTMLDivElement>(null);
const browserChromeRef = React.useRef<HTMLDivElement>(null);
const interactiveBtnRef = React.useRef<HTMLButtonElement>(null);
const moveThrottleRef = React.useRef(0);
const hintTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const modeRef = React.useRef<Mode>('readonly');

const aspect = frame && frame.viewportWidth && frame.viewportHeight
Expand Down Expand Up @@ -81,18 +98,24 @@ export const Dashboard: React.FC = () => {
const annotating = mode === 'annotate';

React.useEffect(() => {
if (interactive)
setShowInteractiveHint(false);
}, [interactive]);

React.useEffect(() => {
return () => clearTimeout(hintTimerRef.current);
}, []);
if (flashTick === 0 || interactive)
return;
const btn = interactiveBtnRef.current;
if (!btn)
return;
btn.classList.remove('flash');
// Force a reflow so that re-adding the class restarts the animation.
void btn.offsetWidth;
btn.classList.add('flash');
const timer = setTimeout(() => btn.classList.remove('flash'), 2000);
return () => {
clearTimeout(timer);
btn.classList.remove('flash');
};
}, [flashTick, interactive]);

function flashInteractiveHint() {
clearTimeout(hintTimerRef.current);
setShowInteractiveHint(true);
hintTimerRef.current = setTimeout(() => setShowInteractiveHint(false), 2000);
setFlashTick(tick => tick + 1);
}

const prevTabsRef = React.useRef<Tab[] | null>(null);
Expand Down Expand Up @@ -256,7 +279,8 @@ export const Dashboard: React.FC = () => {
{/* Toolbar */}
<div ref={toolbarRef} className='toolbar'>
<ToolbarButton
className={'mode-toggle mode-interactive' + (showInteractiveHint ? ' flash' : '')}
ref={interactiveBtnRef}
className='mode-toggle mode-interactive'
title={interactive ? 'Disable interactive mode' : 'Enable interactive mode'}
icon='person'
toggled={interactive}
Expand Down Expand Up @@ -291,9 +315,9 @@ export const Dashboard: React.FC = () => {
if (!client)
return;
if (recording) {
const { path } = await client.stopRecording();
await client.reveal({ path });
const { id } = await client.stopRecording();
setRecording(false);
await downloadArtifact(id);
} else {
await client.startRecording();
setRecording(true);
Expand All @@ -303,16 +327,15 @@ export const Dashboard: React.FC = () => {
</ToolbarButton>
<ToolbarButton
className='screenshot'
title='Copy screenshot to clipboard'
title='Take 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 })]);
setScreenshotIcon('clippy');
const { id } = await client.screenshot();
await downloadArtifact(id);
setScreenshotIcon('check');
setTimeout(() => setScreenshotIcon('device-camera'), 3000);
}}
/>
Expand Down
6 changes: 3 additions & 3 deletions packages/dashboard/src/dashboardChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface DashboardChannel {
closeSession(params: { browser: string }): Promise<void>;
deleteSessionData(params: { browser: string }): Promise<void>;
setVisible(params: { visible: boolean }): Promise<void>;
reveal(params: { path: string }): Promise<void>;
saveAndReveal(params: { id: string }): Promise<void>;

navigate(params: { url: string }): Promise<void>;
back(): Promise<void>;
Expand All @@ -60,8 +60,8 @@ export interface DashboardChannel {
pickLocator(): Promise<void>;
cancelPickLocator(): Promise<void>;
startRecording(): Promise<void>;
stopRecording(): Promise<{ path: string }>;
screenshot(): Promise<string>;
stopRecording(): Promise<{ id: string }>;
screenshot(): Promise<{ id: string }>;

on<K extends keyof DashboardChannelEvents>(event: K, listener: (params: DashboardChannelEvents[K]) => void): void;
off<K extends keyof DashboardChannelEvents>(event: K, listener: (params: DashboardChannelEvents[K]) => void): void;
Expand Down
13 changes: 11 additions & 2 deletions packages/dashboard/src/sessionSidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@

.sidebar-tab-url {
font-size: 11px;
color: var(--color-fg-subtle);
color: var(--color-fg-muted);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
Expand All @@ -293,12 +293,21 @@
.sidebar-tab-close {
margin-left: auto;
align-self: center;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}

.sidebar-tab:hover .sidebar-tab-close,
.sidebar-tab-close:focus-visible {
opacity: 1;
pointer-events: auto;
}

.sidebar-tab-close .codicon {
font-size: 12px;
}

.sidebar-tab-close:hover {
.sidebar-tab-close:not(:disabled):hover {
background: var(--color-neutral-muted);
}
6 changes: 0 additions & 6 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['Worker.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['WebSocket.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['Debugger.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['ElectronApplication.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['AndroidDevice.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }],
['BrowserContext.addCookies', { title: 'Add cookies', group: 'configuration', }],
['BrowserContext.addInitScript', { title: 'Add init script', group: 'configuration', }],
Expand Down Expand Up @@ -306,11 +305,6 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['WritableStream.close', { internal: true, }],
['CDPSession.send', { title: 'Send CDP command', group: 'configuration', }],
['CDPSession.detach', { title: 'Detach CDP session', group: 'configuration', }],
['Electron.launch', { title: 'Launch electron', }],
['ElectronApplication.browserWindow', { internal: true, }],
['ElectronApplication.evaluateExpression', { title: 'Evaluate', }],
['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }],
['ElectronApplication.updateSubscription', { internal: true, }],
['Android.devices', { internal: true, }],
['AndroidSocket.write', { internal: true, }],
['AndroidSocket.close', { internal: true, }],
Expand Down
Loading
Loading