diff --git a/.claude/skills/playwright-api/SKILL.md b/.claude/skills/playwright-api/SKILL.md new file mode 100644 index 0000000000000..b88f86e7c0401 --- /dev/null +++ b/.claude/skills/playwright-api/SKILL.md @@ -0,0 +1,27 @@ +--- +name: playwright-api +description: Explains how to add playwright API methods. +--- + +# API + +## Adding and modifying APIs +- Before performing the implementation, go over the steps to understand and plan the work ahead. It is important to follow the steps in order, as some of them are prerequisites for others. +- Define (or update) API in `docs/api/class-xxx.md`. For the new methods, params and options use the version from package.json (without -next). +- Watch will kick in and re-generate types for the API +- Implement the new API in `packages/playwright/src/client/xxx.ts` +- Define (or update) channel for the API in `packages/protocol/src/protocol.yml` as needed +- Watch will kick in and re-generate types for protocol channels +- Implement dispatcher handler in `packages/playwright/src/server/dispatchers/xxxDispatcher.ts` as needed +- Handler should just route the call into the corresponding method in `packages/playwright-core/src/server/xxx.ts` +- Place new tests in `tests/page/xxx.spec.ts` or create new test file if needed + +# Build +- Assume watch is running and everything is up to date. + +# Test +- If your tests are only using page, prefer to place them in `tests/page/xxx.spec.ts` and use page fixture. If you need to use browser context, place them in `tests/library/xxx.spec.ts`. +- Run npm test as `npm run ctest ` + +# Lint +- In the end lint via `npm run flint`. diff --git a/.claude/skills/playwright-mcp-dev/SKILL.md b/.claude/skills/playwright-mcp-dev/SKILL.md index 9b706310d6aa5..30dd879c34ec4 100644 --- a/.claude/skills/playwright-mcp-dev/SKILL.md +++ b/.claude/skills/playwright-mcp-dev/SKILL.md @@ -30,6 +30,18 @@ description: Explains how to add and debug playwright MCP tools and CLI commands in `packages/playwright/src/skill/references/` - Place new tests in `tests/mcp/cli-.spec.ts` +## Adding CLI options or Config options +When you need to add something to config. + +- `packages/playwright/src/mcp/program.ts` + - add CLI option and doc +- `packages/playwright/src/mcp/config.d.ts` + - add and document the option +- `packages/playwright/src/mcp/config.ts` + - modify FullConfig if needed + - and CLIOptions if needed + - add it to configFromEnv + ## Building - Assume watch is running at all times, run lint to see type errors diff --git a/package-lock.json b/package-lock.json index 8d1425f8dbb76..d152d379aa19a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1378,6 +1378,10 @@ "resolved": "packages/playwright-client", "link": true }, + "node_modules/@playwright/devtools": { + "resolved": "packages/devtools", + "link": true + }, "node_modules/@playwright/experimental-ct-core": { "resolved": "packages/playwright-ct-core", "link": true @@ -8369,6 +8373,10 @@ "zod": "^3.25 || ^4" } }, + "packages/devtools": { + "name": "@playwright/devtools", + "version": "0.0.0" + }, "packages/html-reporter": { "version": "0.0.0" }, diff --git a/packages/devtools/.gitignore b/packages/devtools/.gitignore new file mode 100644 index 0000000000000..a547bf36d8d11 --- /dev/null +++ b/packages/devtools/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/devtools/index.html b/packages/devtools/index.html new file mode 100644 index 0000000000000..e2881e94d642b --- /dev/null +++ b/packages/devtools/index.html @@ -0,0 +1,27 @@ + + + + + + + Playwright Remote Control + + +
+ + + diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000000000..3acc8eba44626 --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,6 @@ +{ + "name": "@playwright/devtools", + "private": true, + "version": "0.0.0", + "type": "module" +} diff --git a/packages/devtools/src/devtools.css b/packages/devtools/src/devtools.css new file mode 100644 index 0000000000000..75ac4462d9854 --- /dev/null +++ b/packages/devtools/src/devtools.css @@ -0,0 +1,342 @@ +/** + * 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. + */ + +:root { + --bg: #202124; + --bg-elevated: #35363a; + --bg-tab: #292b2e; + --fg: #e8eaed; + --fg-dim: #9aa0a6; + --fg-muted: #5f6368; + --accent: #8ab4f8; + --ok: #81c995; + --err: #f28b82; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#root { + display: flex; + flex-direction: column; + height: 100%; +} + +button { + background: none; + border: none; + color: var(--fg-dim); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +button:hover { + color: var(--fg); +} + +/* -- Tab bar -- */ +.tabbar { + display: flex; + align-items: flex-end; + height: 38px; + min-height: 38px; + padding: 0 10px 0 12px; + user-select: none; +} + +.tabbar-brand span { + font-size: 12px; + color: var(--fg-dim); + font-weight: 600; + letter-spacing: 0.3px; + line-height: 34px; + margin-right: 10px; +} + +.tabstrip { + display: flex; + align-items: flex-end; + gap: 1px; + overflow-x: auto; + scrollbar-width: none; + min-width: 0; + padding-top: 8px; +} + +.tabstrip::-webkit-scrollbar { + display: none; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 10px; + background: var(--bg-tab); + color: var(--fg-dim); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + max-width: 200px; + min-width: 48px; + border-radius: 8px 8px 0 0; + user-select: none; + flex-shrink: 0; +} + +.tab:hover { + background: var(--bg-elevated); + color: var(--fg-dim); +} + +.tab.active { + background: var(--bg-elevated); + color: var(--fg); +} + +.tab-label { + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.tab-favicon { + width: 14px; + height: 14px; + flex-shrink: 0; + background: var(--fg-muted); + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; +} + +.tab-close { + width: 16px; + height: 16px; + border-radius: 50%; + opacity: 0; + margin-left: auto; +} + +.tab-close svg { + width: 10px; + height: 10px; +} + +.tab:hover .tab-close, +.tab.active .tab-close { + opacity: 1; +} + +.tab-close:hover { + background: rgba(255, 255, 255, 0.12); +} + +.new-tab-btn { + width: 28px; + height: 34px; + border-radius: 8px; + margin-left: 4px; + align-self: flex-end; +} + +.new-tab-btn:hover { + background: var(--bg-elevated); +} + +.new-tab-btn svg { + width: 16px; + height: 16px; +} + +.status { + font-size: 11px; + color: var(--fg-muted); + margin-left: auto; + padding-left: 12px; + align-self: center; +} + +.status.connected { + color: var(--ok); +} + +.status.error { + color: var(--err); +} + +/* -- Toolbar -- */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + height: 40px; + min-height: 40px; + background: var(--bg-elevated); + padding: 0 8px; +} + +.nav-btn { + width: 32px; + height: 32px; + border-radius: 50%; +} + +.nav-btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +.nav-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.nav-btn:disabled { + color: var(--fg-muted); + cursor: default; +} + +.nav-btn:disabled:hover { + background: none; +} + +.nav-btn svg { + width: 18px; + height: 18px; +} + +.omnibox { + flex: 1; + height: 30px; + padding: 0 12px; + font-size: 13px; + font-family: inherit; + background: var(--bg); + color: var(--fg); + border: 1px solid transparent; + border-radius: 16px; + outline: none; + min-width: 0; +} + +.omnibox:focus { + border-color: var(--accent); + background: var(--bg-tab); +} + +.omnibox::placeholder { + color: var(--fg-muted); +} + +.omnibox::selection { + background: #394457; +} + +/* -- Viewport -- */ +.viewport-wrapper { + flex: 1; + display: flex; + background: #000; + overflow: hidden; + position: relative; + min-height: 0; +} + +.screen { + position: relative; + outline: none; + width: 100%; + height: 100%; +} + +.screen.captured { + cursor: default; +} + +.screen.captured .display { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.display { + display: block; + width: 100%; + height: 100%; + background: #000; + object-fit: contain; +} + +.no-pages { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + color: var(--fg-muted); + font-size: 14px; + background: #000; +} + +.no-pages.visible { + display: flex; +} + +.capture-hint { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: rgba(32, 33, 36, 0.85); + backdrop-filter: blur(8px); + color: var(--fg); + font-size: 12px; + padding: 6px 16px; + border-radius: 20px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; + white-space: nowrap; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.capture-hint.visible { + opacity: 1; +} diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx new file mode 100644 index 0000000000000..cafd8bf6e5adc --- /dev/null +++ b/packages/devtools/src/devtools.tsx @@ -0,0 +1,314 @@ +/** + * 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 React from 'react'; +import './devtools.css'; +import { DevToolsTransport } from './transport'; + +type TabInfo = { id: string; title: string; url: string }; + +function tabFavicon(url: string): string { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ''); + return host ? host[0].toUpperCase() : ''; + } catch { + return ''; + } +} + +export const DevTools: React.FC = () => { + const [status, setStatus] = React.useState<{ text: string; cls: string }>({ text: 'Connecting', cls: '' }); + const [tabs, setTabs] = React.useState([]); + const [selectedPageId, setSelectedPageId] = React.useState(); + 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(null); + const displayRef = React.useRef(null); + const screenRef = React.useRef(null); + const omniboxRef = React.useRef(null); + const viewportSizeRef = React.useRef<{ width: number; height: number }>({ width: 0, height: 0 }); + const resizedRef = React.useRef(false); + const capturedRef = React.useRef(false); + const moveThrottleRef = React.useRef(0); + + // Keep capturedRef in sync with state. + React.useEffect(() => { + capturedRef.current = captured; + }, [captured]); + + React.useEffect(() => { + const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const transport = new DevToolsTransport(wsProtocol + '//' + location.host + '/ws'); + transportRef.current = transport; + + transport.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); + }; + + transport.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' }); + + return () => transport.close(); + }, []); + + function resizeToFit() { + const { width: vw, height: vh } = viewportSizeRef.current; + if (!vw || !vh || resizedRef.current) + return; + resizedRef.current = true; + const tabbar = document.querySelector('.tabbar') as HTMLElement; + const toolbar = document.querySelector('.toolbar') as HTMLElement; + if (!tabbar || !toolbar) + return; + const chromeHeight = tabbar.offsetHeight + toolbar.offsetHeight; + const extraW = window.outerWidth - window.innerWidth; + const extraH = window.outerHeight - window.innerHeight; + const targetW = Math.min(vw + extraW, screen.availWidth); + const targetH = Math.min(vh + chromeHeight + extraH, screen.availHeight); + window.resizeTo(targetW, targetH); + } + + function imgCoords(e: React.MouseEvent): { x: number; y: number } { + const { width: vw, height: vh } = viewportSizeRef.current; + if (!vw || !vh) + return { x: 0, y: 0 }; + const display = displayRef.current; + if (!display) + return { x: 0, y: 0 }; + const rect = display.getBoundingClientRect(); + const imgAspect = display.naturalWidth / display.naturalHeight; + const elemAspect = rect.width / rect.height; + let renderW: number, renderH: number, offsetX: number, offsetY: number; + if (imgAspect > elemAspect) { + renderW = rect.width; + renderH = rect.width / imgAspect; + offsetX = 0; + offsetY = (rect.height - renderH) / 2; + } else { + renderH = rect.height; + renderW = rect.height * imgAspect; + offsetX = (rect.width - renderW) / 2; + offsetY = 0; + } + const fracX = (e.clientX - rect.left - offsetX) / renderW; + const fracY = (e.clientY - rect.top - offsetY) / renderH; + return { + x: Math.round(fracX * vw), + y: Math.round(fracY * vh), + }; + } + + const BUTTONS: string[] = ['left', 'middle', 'right']; + + function onScreenMouseDown(e: React.MouseEvent) { + e.preventDefault(); + screenRef.current?.focus(); + if (!capturedRef.current) { + setCaptured(true); + setHintVisible(false); + return; + } + const { x, y } = imgCoords(e); + transportRef.current?.sendNoReply('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' }); + } + + function onScreenMouseMove(e: React.MouseEvent) { + if (!capturedRef.current) + return; + const now = Date.now(); + if (now - moveThrottleRef.current < 32) + return; + moveThrottleRef.current = now; + const { x, y } = imgCoords(e); + transportRef.current?.sendNoReply('mousemove', { x, y }); + } + + function onScreenWheel(e: React.WheelEvent) { + if (!capturedRef.current) + return; + e.preventDefault(); + transportRef.current?.sendNoReply('wheel', { deltaX: e.deltaX, deltaY: e.deltaY }); + } + + function onScreenKeyDown(e: React.KeyboardEvent) { + if (!capturedRef.current) + return; + e.preventDefault(); + if (e.key === 'Escape' && !(e.metaKey || e.ctrlKey)) { + setCaptured(false); + return; + } + transportRef.current?.sendNoReply('keydown', { key: e.key }); + } + + function onScreenKeyUp(e: React.KeyboardEvent) { + if (!capturedRef.current) + return; + e.preventDefault(); + transportRef.current?.sendNoReply('keyup', { key: e.key }); + } + + function onScreenBlur() { + if (capturedRef.current) + setCaptured(false); + } + + function onOmniboxKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + let value = (e.target as HTMLInputElement).value.trim(); + if (!/^https?:\/\//i.test(value)) + value = 'https://' + value; + setUrl(value); + transportRef.current?.send('navigate', { url: value }); + omniboxRef.current?.blur(); + } + } + + const hasPages = !!selectedPageId; + + return (<> + {/* Tab bar */} +
+
+ Playwright +
+
+ {tabs.map(tab => ( +
transportRef.current?.sendNoReply('selectTab', { id: tab.id })} + > + + {tab.title || 'New Tab'} + +
+ ))} +
+ +
{status.text}
+
+ + {/* Toolbar */} +
+ + + + setUrl(e.target.value)} + onKeyDown={onOmniboxKeyDown} + onFocus={e => e.target.select()} + /> +
+ + {/* Viewport */} +
+
e.preventDefault()} + onMouseEnter={() => { + if (!capturedRef.current) + setHintVisible(true); + }} + onMouseLeave={() => setHintVisible(false)} + > + screencast +
Click to interact · Esc to release
+
+
No tabs open
+
+ ); +}; diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx new file mode 100644 index 0000000000000..fd29c7045190e --- /dev/null +++ b/packages/devtools/src/index.tsx @@ -0,0 +1,20 @@ +/** + * 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 * as ReactDOM from 'react-dom/client'; +import { DevTools } from './devtools'; + +ReactDOM.createRoot(document.querySelector('#root')!).render(); diff --git a/packages/devtools/src/transport.ts b/packages/devtools/src/transport.ts new file mode 100644 index 0000000000000..65029b104b1c1 --- /dev/null +++ b/packages/devtools/src/transport.ts @@ -0,0 +1,79 @@ +/** + * 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 class DevToolsTransport { + private _ws: WebSocket; + private _lastId = 0; + private _pending = new Map void; reject: (error: Error) => void }>(); + + onopen?: () => void; + onevent?: (method: string, params: any) => void; + onclose?: (reason?: string) => void; + + constructor(url: string) { + this._ws = new WebSocket(url); + this._ws.onopen = () => { + if (this.onopen) + this.onopen(); + }; + this._ws.onmessage = (event: MessageEvent) => { + let msg: any; + try { + msg = JSON.parse(event.data); + } catch { + this._ws.close(); + return; + } + if (msg.id !== undefined) { + const pending = this._pending.get(msg.id); + if (pending) { + this._pending.delete(msg.id); + if (msg.error) + pending.reject(new Error(msg.error)); + else + pending.resolve(msg.result); + } + } else if (msg.method) { + if (this.onevent) + this.onevent(msg.method, msg.params); + } + }; + this._ws.onclose = (event: CloseEvent) => { + for (const { reject } of this._pending.values()) + reject(new Error('Connection closed')); + this._pending.clear(); + if (this.onclose) + this.onclose(event.reason); + }; + this._ws.onerror = () => {}; + } + + sendNoReply(method: string, params?: any) { + this.send(method, params).catch(() => {}); + } + + send(method: string, params?: any): Promise { + const id = ++this._lastId; + this._ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => { + this._pending.set(id, { resolve, reject }); + }); + } + + close() { + this._ws.close(); + } +} diff --git a/packages/devtools/src/vite-env.d.ts b/packages/devtools/src/vite-env.d.ts new file mode 100644 index 0000000000000..11f02fe2a0061 --- /dev/null +++ b/packages/devtools/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000000000..6a5e455746a26 --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": true, + "skipLibCheck": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "useUnknownInCatchVariables": false, + "paths": { + "@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"], + "@protocol/*": ["../protocol/src/*"], + "@web/*": ["../web/src/*"], + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/devtools/tsconfig.node.json b/packages/devtools/tsconfig.node.json new file mode 100644 index 0000000000000..a336f895aab52 --- /dev/null +++ b/packages/devtools/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/devtools/vite.config.ts b/packages/devtools/vite.config.ts new file mode 100644 index 0000000000000..a10d62d865897 --- /dev/null +++ b/packages/devtools/vite.config.ts @@ -0,0 +1,43 @@ +/** + * 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 path from 'path'; + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react() + ], + resolve: { + alias: { + '@isomorphic': path.resolve(__dirname, '../playwright-core/src/utils/isomorphic'), + '@protocol': path.resolve(__dirname, '../protocol/src'), + '@web': path.resolve(__dirname, '../web/src'), + }, + }, + build: { + outDir: path.resolve(__dirname, '../playwright-core/lib/vite/devtools'), + emptyOutDir: true, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + } +}); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 554fc7a6744c5..94766db711abd 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -585,6 +585,14 @@ export class BrowserContext extends ChannelOwner return; throw new Error(`File access denied: ${filePath} is outside allowed roots. Allowed roots: ${this._allowedDirectories.length ? this._allowedDirectories.join(', ') : 'none'}`); } + + async _devtoolsStart(options: { size?: { width: number, height: number }, port?: number, host?: string } = {}): Promise<{ url: string }> { + return await this._channel.devtoolsStart(options); + } + + async _devtoolsStop(): Promise { + await this._channel.devtoolsStop(); + } } async function prepareStorageState(platform: Platform, storageState: string | SetStorageState): Promise> { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index abfaaf90a29e0..1626cdec9767c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1157,6 +1157,19 @@ scheme.BrowserContextClockSetSystemTimeParams = tObject({ timeString: tOptional(tString), }); scheme.BrowserContextClockSetSystemTimeResult = tOptional(tObject({})); +scheme.BrowserContextDevtoolsStartParams = tObject({ + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + port: tOptional(tInt), + host: tOptional(tString), +}); +scheme.BrowserContextDevtoolsStartResult = tObject({ + url: tString, +}); +scheme.BrowserContextDevtoolsStopParams = tOptional(tObject({})); +scheme.BrowserContextDevtoolsStopResult = tOptional(tObject({})); scheme.PageInitializer = tObject({ mainFrame: tChannel(['Frame']), viewportSize: tOptional(tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 1bad49f02279a..d08e547c4740a 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -35,6 +35,7 @@ import { Page, PageBinding } from './page'; import { RecorderApp } from './recorder/recorderApp'; import { Selectors } from './selectors'; import { Tracing } from './trace/recorder/tracing'; +import { DevToolsController } from './devtoolsController'; import * as rawStorageSource from '../generated/storageScriptSource'; import type { Artifact } from './artifact'; @@ -64,6 +65,8 @@ const BrowserContextEvent = { RequestContinued: 'requestcontinued', BeforeClose: 'beforeclose', RecorderEvent: 'recorderevent', + PageClosed: 'pageclosed', + InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', } as const; export type BrowserContextEventMap = { @@ -80,6 +83,8 @@ export type BrowserContextEventMap = { [BrowserContextEvent.RequestContinued]: [request: network.Request]; [BrowserContextEvent.BeforeClose]: []; [BrowserContextEvent.RecorderEvent]: [event: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }]; + [BrowserContextEvent.PageClosed]: [page: Page]; + [BrowserContextEvent.InternalFrameNavigatedToNewDocument]: [frame: frames.Frame, page: Page]; }; export abstract class BrowserContext extends SdkObject { @@ -114,6 +119,7 @@ export abstract class BrowserContext extends Sdk private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; + private _devtools: DevToolsController | undefined; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -509,6 +515,22 @@ export abstract class BrowserContext extends Sdk await this.doUpdateRequestInterception(); } + async devtoolsStart(options: { size?: types.Size, port?: number, host?: string } = {}): Promise { + if (this._devtools) + throw new Error('DevTools is already running'); + const size = validateVideoSize(options.size, undefined); + this._devtools = new DevToolsController(this); + const url = await this._devtools.start({ width: size.width, height: size.height, quality: 90, port: options.port, host: options.host }); + return url; + } + + async devtoolsStop(): Promise { + if (!this._devtools) + throw new Error('DevTools is not running'); + await this._devtools.stop(); + this._devtools = undefined; + } + isClosingOrClosed() { return this._closedStatus !== 'open'; } @@ -532,6 +554,9 @@ export abstract class BrowserContext extends Sdk this.emit(BrowserContext.Events.BeforeClose); this._closedStatus = 'closing'; + await this._devtools?.stop(); + this._devtools = undefined; + for (const harRecorder of this._harRecorders.values()) await harRecorder.flush(); await this.tracing.flush(); diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts new file mode 100644 index 0000000000000..b9ba8856629ba --- /dev/null +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -0,0 +1,242 @@ +/** + * 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 path from 'path'; +import { eventsHelper } from '../utils'; +import { HttpServer } from './utils/httpServer'; +import { BrowserContext } from './browserContext'; +import { Page } from './page'; +import { ProgressController } from './progress'; + +import type { RegisteredListener } from '../utils'; +import type { Transport } from './utils/httpServer'; +import type http from 'http'; + +export class DevToolsController { + private _context: BrowserContext; + private _screencastOptions: { width: number, height: number, quality: number } = { width: 800, height: 600, quality: 90 }; + private _httpServer: HttpServer; + + constructor(context: BrowserContext) { + this._context = context; + this._httpServer = new HttpServer(); + } + + async start(options: { width: number, height: number, quality: number, port?: number, host?: string }): Promise { + this._screencastOptions = options; + + const devtoolsDir = path.join(__dirname, '..', 'vite', 'devtools'); + this._httpServer.routePrefix('/', (request: http.IncomingMessage, response: http.ServerResponse) => { + const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; + const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); + const resolved = path.join(devtoolsDir, filePath); + if (!resolved.startsWith(devtoolsDir)) + return false; + return this._httpServer.serveFile(request, response, resolved); + }); + + this._httpServer.createWebSocket(() => new DevToolsConnection(this._context, this._screencastOptions), 'ws'); + + await this._httpServer.start({ port: options.port, host: options.host }); + return this._httpServer.urlPrefix('human-readable'); + } + + async stop() { + await this._httpServer.stop(); + } +} + + +class DevToolsConnection implements Transport { + sendEvent?: (method: string, params: any) => void; + close?: () => void; + + selectedPage: Page | null = null; + private _lastFrameData: string | null = null; + private _lastViewportSize: { width: number, height: number } | null = null; + private _pageListeners: RegisteredListener[] = []; + private _contextListeners: RegisteredListener[] = []; + private _context: BrowserContext; + private _screencastOptions: { width: number, height: number, quality: number }; + + constructor(context: BrowserContext, screencastOptions: { width: number, height: number, quality: number }) { + this._context = context; + this._screencastOptions = screencastOptions; + } + + onconnect() { + const context = this._context; + + this._contextListeners.push( + eventsHelper.addEventListener(context, BrowserContext.Events.Page, (page: Page) => { + this._sendTabList(); + if (!this.selectedPage) + this._selectPage(page); + }), + eventsHelper.addEventListener(context, BrowserContext.Events.PageClosed, (page: Page) => { + if (this.selectedPage === page) { + this._deselectPage(); + const pages = context.pages(); + if (pages.length > 0) + this._selectPage(pages[0]); + else + this.sendEvent?.('selectPage', { pageId: undefined }); + } + this._sendTabList(); + }), + eventsHelper.addEventListener(context, BrowserContext.Events.InternalFrameNavigatedToNewDocument, (frame, page) => { + if (frame === page.mainFrame()) { + this._sendTabList(); + if (page === this.selectedPage) + this.sendEvent?.('url', { url: frame.url() }); + } + }), + ); + + // Auto-select first page. + const pages = context.pages(); + if (pages.length > 0) + this._selectPage(pages[0]); + + this._sendCachedState(); + } + + onclose() { + this._deselectPage(); + eventsHelper.removeEventListeners(this._contextListeners); + this._contextListeners = []; + } + + async dispatch(method: string, params: any): Promise { + if (method === 'selectTab') { + const page = this._context.pages().find(p => p.guid === params.id); + if (page) + await this._selectPage(page); + return; + } + + if (method === 'closeTab') { + const page = this._context.pages().find(p => p.guid === params.id); + if (page) + await page.close({ reason: 'Closed from devtools' }); + return; + } + + if (method === 'newTab') { + await ProgressController.runInternalTask(async progress => { + const page = await this._context.newPage(progress); + await this._selectPage(page); + }); + return; + } + + if (!this.selectedPage) + return; + + const page = this.selectedPage; + if (method === 'navigate' && params.url) + await ProgressController.runInternalTask(async progress => { await page.mainFrame().goto(progress, params.url); }); + else if (method === 'back') + await ProgressController.runInternalTask(async progress => { await page.goBack(progress, {}); }); + else if (method === 'forward') + await ProgressController.runInternalTask(async progress => { await page.goForward(progress, {}); }); + else if (method === 'reload') + await ProgressController.runInternalTask(async progress => { await page.reload(progress, {}); }); + else if (method === 'mousemove') + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); }); + else if (method === 'mousedown') + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.down(progress, { button: params.button || 'left' }); }); + else if (method === 'mouseup') + await ProgressController.runInternalTask(async progress => { await page.mouse.move(progress, params.x, params.y); await page.mouse.up(progress, { button: params.button || 'left' }); }); + else if (method === 'wheel') + await ProgressController.runInternalTask(async progress => { await page.mouse.wheel(progress, params.deltaX, params.deltaY); }); + else if (method === 'keydown') + await ProgressController.runInternalTask(async progress => { await page.keyboard.down(progress, params.key); }); + else if (method === 'keyup') + await ProgressController.runInternalTask(async progress => { await page.keyboard.up(progress, params.key); }); + } + + private async _selectPage(page: Page) { + if (this.selectedPage === page) + return; + + // Stop screencast on old page. + if (this.selectedPage) { + eventsHelper.removeEventListeners(this._pageListeners); + this._pageListeners = []; + await this.selectedPage.screencast.stopScreencast(this); + } + + this.selectedPage = page; + this._lastFrameData = null; + this._lastViewportSize = null; + this.sendEvent?.('selectPage', { pageId: page.guid }); + + // Start screencast on new page. + this._pageListeners.push( + eventsHelper.addEventListener(page, Page.Events.ScreencastFrame, frame => this._writeFrame(frame.buffer, frame.width, frame.height)) + ); + + await page.screencast.startScreencast(this, this._screencastOptions); + + // Send URL to this client. + const url = page.mainFrame().url(); + if (url) + this.sendEvent?.('url', { url }); + } + + private _deselectPage() { + if (!this.selectedPage) + return; + eventsHelper.removeEventListeners(this._pageListeners); + this._pageListeners = []; + this.selectedPage.screencast.stopScreencast(this); + this.selectedPage = null; + this._lastFrameData = null; + this._lastViewportSize = null; + } + + private _sendCachedState() { + this.sendEvent?.('selectPage', { pageId: this.selectedPage?.guid }); + if (this._lastFrameData) + this.sendEvent?.('frame', { data: this._lastFrameData, viewportWidth: this._lastViewportSize?.width, viewportHeight: this._lastViewportSize?.height }); + if (this.selectedPage) { + const url = this.selectedPage.mainFrame().url(); + if (url) + this.sendEvent?.('url', { url }); + } + this._sendTabList(); + } + + private async _tabList(): Promise<{ id: string, title: string, url: string }[]> { + return await Promise.all(this._context.pages().map(async page => ({ + id: page.guid, + title: await page.mainFrame().title().catch(() => '') || page.mainFrame().url(), + url: page.mainFrame().url(), + }))); + } + + private _sendTabList() { + this._tabList().then(tabs => this.sendEvent?.('tabs', { tabs })); + } + + private _writeFrame(frame: Buffer, viewportWidth: number, viewportHeight: number) { + const data = frame.toString('base64'); + this._lastFrameData = data; + this._lastViewportSize = { width: viewportWidth, height: viewportHeight }; + this.sendEvent?.('frame', { data, viewportWidth, viewportHeight }); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index b742af1561496..7f619c77f971a 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -401,6 +401,15 @@ export class BrowserContextDispatcher extends Dispatcher { + const url = await this._context.devtoolsStart(params); + return { url }; + } + + async devtoolsStop(params: channels.BrowserContextDevtoolsStopParams, progress: Progress): Promise { + await this._context.devtoolsStop(); + } + async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams, progress: Progress): Promise { if (params.enabled) this._subscriptions.add(params.event); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 19cb1be63e208..5333595ac53b9 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -279,6 +279,7 @@ export class Page extends SdkObject { assert(this._closedState !== 'closed', 'Page closed twice'); this._closedState = 'closed'; this.emit(Page.Events.Close); + this.browserContext.emit(BrowserContext.Events.PageClosed, this); this._closedPromise.resolve(); this.instrumentation.onPageClose(this); this.openScope.close(new TargetClosedError(this.closeReason())); @@ -830,6 +831,7 @@ export class Page extends SdkObject { frameNavigatedToNewDocument(frame: frames.Frame) { this.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame); + this.browserContext.emit(BrowserContext.Events.InternalFrameNavigatedToNewDocument, frame, this); const origin = frame.origin(); if (origin) this.browserContext.addVisitedOrigin(origin); diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 58001fefb7f14..b94ef285fdfb1 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -47,6 +47,11 @@ export class ProgressController { }); } + static runInternalTask(task: (progress: Progress) => Promise, timeout?: number) { + const progress = new ProgressController(); + return progress.run(task, timeout); + } + async abort(error: Error) { if (this._state === 'running') { (error as any)[kAbortErrorSymbol] = true; diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index e1a2c7d796438..38de70b9e9a75 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -92,7 +92,7 @@ export class Screencast { const videoId = this._videoId; assert(videoId); this._page.once(Page.Events.Close, () => this.stopVideoRecording().catch(() => {})); - await this._startScreencast(this._videoRecorder, { + await this.startScreencast(this._videoRecorder, { quality: 90, width: options.width, height: options.height, @@ -110,7 +110,7 @@ export class Screencast { this._videoId = null; const videoRecorder = this._videoRecorder!; this._videoRecorder = null; - await this._stopScreencast(videoRecorder); + await this.stopScreencast(videoRecorder); await videoRecorder.stop(); // Keep the video artifact in the map until encoding is fully finished, if the context // starts closing before the video is fully written to disk it will wait for it. @@ -134,12 +134,12 @@ export class Screencast { private async _setOptions(options: { width: number, height: number, quality: number } | null): Promise { if (options) - await this._startScreencast(this, options); + await this.startScreencast(this, options); else - await this._stopScreencast(this); + await this.stopScreencast(this); } - private async _startScreencast(client: unknown, options: { width: number, height: number, quality: number }) { + async startScreencast(client: unknown, options: { width: number, height: number, quality: number }) { this._screencastClients.add(client); if (this._screencastClients.size === 1) { await this._page.delegate.startScreencast({ @@ -150,7 +150,7 @@ export class Screencast { } } - private async _stopScreencast(client: unknown) { + async stopScreencast(client: unknown) { this._screencastClients.delete(client); if (!this._screencastClients.size) await this._page.delegate.stopScreencast(); diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 39ea55ee0f026..392fe08eeb744 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -112,7 +112,7 @@ export async function startTraceViewerServer(options?: TraceViewerServerOptions) const transport = options?.transport || (options?.isServer ? new StdinServer() : undefined); if (transport) - server.createWebSocket(transport); + server.createWebSocket(() => transport); const { host, port } = options || {}; await server.start({ preferredPort: port, host }); diff --git a/packages/playwright-core/src/server/utils/httpServer.ts b/packages/playwright-core/src/server/utils/httpServer.ts index cb08f27eb2e36..c001b0f0ad72c 100644 --- a/packages/playwright-core/src/server/utils/httpServer.ts +++ b/packages/playwright-core/src/server/utils/httpServer.ts @@ -63,14 +63,15 @@ export class HttpServer { return this._port; } - createWebSocket(transport: Transport, guid?: string) { + createWebSocket(transportFactory: () => Transport, guid?: string) { assert(!this._wsGuid, 'can only create one main websocket transport per server'); this._wsGuid = guid || createGuid(); const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); wss.on('connection', ws => { - transport.onconnect(); - transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); + const transport = transportFactory(); + transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); transport.close = () => ws.close(); + transport.onconnect(); ws.on('message', async message => { const { id, method, params } = JSON.parse(String(message)); try { diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 651c3246ecdde..8d6dea7fc9933 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -102,6 +102,8 @@ export const methodMetainfo = new Map'); } catch (error) { diff --git a/packages/playwright/src/mcp/terminal/program.ts b/packages/playwright/src/mcp/terminal/program.ts index a4c33333ffefa..dabfb22d413c3 100644 --- a/packages/playwright/src/mcp/terminal/program.ts +++ b/packages/playwright/src/mcp/terminal/program.ts @@ -486,9 +486,6 @@ const booleanOptions: (keyof (GlobalOptions & OpenOptions & { all?: boolean }))[ 'all', 'help', 'version', - 'extension', - 'headed', - 'persistent' ]; export async function program(packageLocation: string) { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index f2ac7b85e410b..3e062a28fb555 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1624,6 +1624,8 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT clockRunFor(params: BrowserContextClockRunForParams, progress?: Progress): Promise; clockSetFixedTime(params: BrowserContextClockSetFixedTimeParams, progress?: Progress): Promise; clockSetSystemTime(params: BrowserContextClockSetSystemTimeParams, progress?: Progress): Promise; + devtoolsStart(params: BrowserContextDevtoolsStartParams, progress?: Progress): Promise; + devtoolsStop(params?: BrowserContextDevtoolsStopParams, progress?: Progress): Promise; } export type BrowserContextBindingCallEvent = { binding: BindingCallChannel, @@ -2016,6 +2018,28 @@ export type BrowserContextClockSetSystemTimeOptions = { timeString?: string, }; export type BrowserContextClockSetSystemTimeResult = void; +export type BrowserContextDevtoolsStartParams = { + size?: { + width: number, + height: number, + }, + port?: number, + host?: string, +}; +export type BrowserContextDevtoolsStartOptions = { + size?: { + width: number, + height: number, + }, + port?: number, + host?: string, +}; +export type BrowserContextDevtoolsStartResult = { + url: string, +}; +export type BrowserContextDevtoolsStopParams = {}; +export type BrowserContextDevtoolsStopOptions = {}; +export type BrowserContextDevtoolsStopResult = void; export interface BrowserContextEvents { 'bindingCall': BrowserContextBindingCallEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index a4f5f62df653e..0db5adb34d4a7 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1466,6 +1466,22 @@ BrowserContext: timeNumber: float? timeString: string? + devtoolsStart: + internal: true + parameters: + size: + type: object? + properties: + width: int + height: int + port: int? + host: string? + returns: + url: string + + devtoolsStop: + internal: true + events: bindingCall: diff --git a/tests/library/browsercontext-devtools.spec.ts b/tests/library/browsercontext-devtools.spec.ts new file mode 100644 index 0000000000000..d05fc182517b7 --- /dev/null +++ b/tests/library/browsercontext-devtools.spec.ts @@ -0,0 +1,137 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 { contextTest, expect } from '../config/browserTest'; + +import type { Page } from 'playwright-core'; + +const it = contextTest.extend<{ rcPage: Page }>({ + rcPage: async ({ context, browserType }, use) => { + const { url } = await (context as any)._devtoolsStart(); + const rcBrowser = await browserType.launch(); + const rcPage = await rcBrowser.newPage(); + await rcPage.goto(url); + await use(rcPage); + await rcBrowser.close(); + await (context as any)._devtoolsStop(); + }, +}); + +it('should show connected status', async ({ rcPage }) => { + await expect(rcPage.locator('#status')).toHaveText('Connected'); + await expect(rcPage.locator('#status')).toHaveClass(/connected/); +}); + +it('should show tab title after navigation', async ({ rcPage, page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected] + `); +}); + +it('should show multiple tabs', async ({ rcPage, context, page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + const page2 = await context.newPage(); + await page2.goto(server.EMPTY_PAGE); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected] + - tab /.*/ [selected=false] + `); +}); + +it('should switch active tab on click', async ({ rcPage, context, page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + const page2 = await context.newPage(); + await page2.goto(server.EMPTY_PAGE); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected] + - tab /.*/ [selected=false] + `); + // Click the second (unselected) tab. + await rcPage.locator('#tabstrip [role="tab"]').last().click(); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected=false] + - tab /.*/ [selected] + `); +}); + +it('should close tab via close button', async ({ rcPage, context, page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + const page2 = await context.newPage(); + await page2.goto(server.EMPTY_PAGE); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected] + - tab /.*/ [selected=false] + `); + // Close the second tab. + await rcPage.locator('#tabstrip [role="tab"]').last().locator('.tab-close').click(); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab "Woof-Woof" [selected] + `); +}); + +it('should show no-pages placeholder when all tabs are closed', async ({ rcPage, page }) => { + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab /.*/ [selected] + `); + await page.close(); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - /children: deep-equal + - tablist + `); + await expect(rcPage.locator('#no-pages')).toBeVisible(); + await expect(rcPage.locator('#no-pages')).toHaveText('No tabs open'); +}); + +it('should open new tab via new-tab button', async ({ rcPage }) => { + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - /children: deep-equal + - tablist + `); + await rcPage.locator('#new-tab-btn').click(); + await expect(rcPage.locator('#tabstrip')).toMatchAriaSnapshot(` + - tablist: + - /children: equal + - tab /.*/ [selected] + `); +}); + +it('should update omnibox on navigation', async ({ rcPage, page, server }) => { + await page.goto(server.PREFIX + '/title.html'); + await expect(rcPage.locator('#omnibox')).toMatchAriaSnapshot(` + - /children: deep-equal + - textbox "Search or enter URL": /.*\/title\.html/ + `); +}); + +it('should display screencast image', async ({ rcPage, page }) => { + await page.goto('data:text/html,'); + await expect(rcPage.locator('#display')).toHaveAttribute('src', /^data:image\/jpeg;base64,/); +}); diff --git a/utils/build/build.js b/utils/build/build.js index 605ebb7d558df..4746aa92ffbc4 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -535,7 +535,7 @@ steps.push(new ProgramStep({ })); // Build/watch web packages. -for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { +for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer', 'devtools']) { steps.push(new ProgramStep({ command: 'npx', args: [ @@ -634,6 +634,7 @@ copyFiles.push({ to: 'packages/playwright-core/lib', }); + copyFiles.push({ files: 'packages/playwright/src/agents/*.md', from: 'packages/playwright/src', @@ -660,7 +661,7 @@ if (watchMode) { shell: true, concurrent: true, })); - for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { + for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer', 'devtools']) { steps.push(new ProgramStep({ command: 'npx', args: ['tsc', ...(watchMode ? ['-w'] : []), '--preserveWatchOutput', '-p', quotePath(filePath(`packages/${webPackage}`))],