diff --git a/README.md b/README.md index 06d07f46e8a0c..4059d2441801e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-145.0.7632.26-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-146.0.1-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-146.0.7680.0-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-146.0.1-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium1 145.0.7632.26 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium1 146.0.7680.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 26.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 146.0.1 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 23a4750116e96..8b91c0a67d8ba 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,8 @@ title: "Release notes" toc_max_heading_level: 2 --- +import LiteYouTube from '@site/src/components/LiteYouTube'; + ## Version 1.58 ### UI Mode and Trace Viewer Improvements @@ -1264,9 +1266,10 @@ This version was also tested against the following stable channels: ## Version 1.24 -
- -
+ ### 🐂 Debian 11 Bullseye Support diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index f907564084806..62d9a84369be9 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,8 @@ title: "Release notes" toc_max_heading_level: 2 --- +import LiteYouTube from '@site/src/components/LiteYouTube'; + ## Version 1.58 ### UI Mode and Trace Viewer Improvements @@ -1297,9 +1299,10 @@ This version was also tested against the following stable channels: ## Version 1.24 -
- -
+ ### 🐂 Debian 11 Bullseye Support diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index da3b69fa866a0..806d7621eafd4 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,8 @@ title: "Release notes" toc_max_heading_level: 2 --- +import LiteYouTube from '@site/src/components/LiteYouTube'; + ## Version 1.58 ### UI Mode and Trace Viewer Improvements @@ -1208,9 +1210,10 @@ This version was also tested against the following stable channels: ## Version 1.24 -
- -
+ ### 🐂 Debian 11 Bullseye Support diff --git a/package-lock.json b/package-lock.json index d152d379aa19a..4980057315871 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6581,9 +6581,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/devtools/src/devtools.css b/packages/devtools/src/devtools.css index ca5346758a1aa..6e88fe1674df1 100644 --- a/packages/devtools/src/devtools.css +++ b/packages/devtools/src/devtools.css @@ -255,6 +255,14 @@ min-height: 0; } +.viewport-main { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + min-width: 0; +} + .screen { position: relative; outline: none; @@ -315,3 +323,58 @@ .capture-hint.visible { opacity: 1; } + +.capture-hint code { + color: var(--accent); + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + margin-left: 4px; +} + +.inspector-panel { + width: 50%; + min-width: 300px; + max-width: calc(100% - 320px); + display: flex; + flex-direction: column; + border-left: 1px solid var(--bg-elevated); + flex: none; + overflow: hidden; + position: relative; +} + +.inspector-grip { + position: absolute; + left: -4px; + top: 0; + bottom: 0; + width: 8px; + cursor: col-resize; + z-index: 10; + touch-action: none; +} + +.inspector-grip::before { + content: ''; + position: absolute; + left: 3px; + top: 0; + bottom: 0; + width: 1px; + background: var(--bg-elevated); +} + +.inspector-grip:hover::before { + background: var(--accent); +} + +.inspector-frame { + width: 100%; + height: 100%; + border: none; + background: var(--bg); +} + +.nav-btn.active-toggle { + color: var(--accent); + background: rgba(138, 180, 248, 0.12); +} diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index f4f8719400e9a..5d886e16f3f05 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -18,6 +18,7 @@ import React from 'react'; import './devtools.css'; import { navigate } from './index'; import { DevToolsClient } from './devtoolsClient'; +import { asLocator } from '@isomorphic/locatorGenerators'; import type { DevToolsClientChannel } from './devtoolsClient'; import type { Tab } from './devtoolsChannel'; @@ -41,21 +42,32 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const [frameSrc, setFrameSrc] = React.useState(''); const [captured, setCaptured] = React.useState(false); const [hintVisible, setHintVisible] = React.useState(false); + const [showInspector, setShowInspector] = React.useState(false); + const [picking, setPicking] = React.useState(false); + const [toast, setToast] = React.useState(null); const channelRef = React.useRef(null); const displayRef = React.useRef(null); const screenRef = React.useRef(null); + const viewportWrapperRef = 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); + const pickingRef = React.useRef(false); + const toastTimerRef = React.useRef>(0 as any); + const [inspectorWidth, setInspectorWidth] = React.useState(); // Keep capturedRef in sync with state. React.useEffect(() => { capturedRef.current = captured; }, [captured]); + React.useEffect(() => { + pickingRef.current = picking; + }, [picking]); + React.useEffect(() => { if (!wsUrl) return; @@ -69,6 +81,8 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const selected = params.tabs.find(t => t.selected); if (selected) setUrl(selected.url); + if (!selected?.inspectorUrl) + setShowInspector(false); }); channel.on('frame', params => { @@ -80,9 +94,21 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { resizeToFit(); }); + channel.on('elementPicked', params => { + const locator = asLocator('javascript', params.selector); + navigator.clipboard?.writeText(locator).catch(() => {}); + setPicking(false); + setToast(locator); + clearTimeout(toastTimerRef.current); + toastTimerRef.current = setTimeout(() => setToast(null), 3000); + }); + channel.onclose = () => setStatus({ text: 'Disconnected', cls: 'error' }); - return () => channel.close(); + return () => { + clearTimeout(toastTimerRef.current); + channel.close(); + }; }, [wsUrl]); function resizeToFit() { @@ -132,28 +158,33 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { }; } + const isForwardingInput = () => showInspector || capturedRef.current; + + function sendMouseEvent(method: 'mousedown' | 'mouseup', e: React.MouseEvent) { + const { x, y } = imgCoords(e); + channelRef.current?.[method]({ x, y, button: BUTTONS[e.button] || 'left' }); + } + function onScreenMouseDown(e: React.MouseEvent) { e.preventDefault(); screenRef.current?.focus(); - if (!capturedRef.current) { + if (!pickingRef.current && !isForwardingInput()) { setCaptured(true); setHintVisible(false); return; } - const { x, y } = imgCoords(e); - channelRef.current?.mousedown({ x, y, button: BUTTONS[e.button] || 'left' }); + sendMouseEvent('mousedown', e); } function onScreenMouseUp(e: React.MouseEvent) { - if (!capturedRef.current) + if (!pickingRef.current && !isForwardingInput()) return; e.preventDefault(); - const { x, y } = imgCoords(e); - channelRef.current?.mouseup({ x, y, button: BUTTONS[e.button] || 'left' }); + sendMouseEvent('mouseup', e); } function onScreenMouseMove(e: React.MouseEvent) { - if (!capturedRef.current) + if (!pickingRef.current && !isForwardingInput()) return; const now = Date.now(); if (now - moveThrottleRef.current < 32) @@ -164,14 +195,20 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } function onScreenWheel(e: React.WheelEvent) { - if (!capturedRef.current) + if (!isForwardingInput()) return; e.preventDefault(); channelRef.current?.wheel({ deltaX: e.deltaX, deltaY: e.deltaY }); } function onScreenKeyDown(e: React.KeyboardEvent) { - if (!capturedRef.current) + if (pickingRef.current && e.key === 'Escape') { + e.preventDefault(); + channelRef.current?.cancelPickLocator(); + setPicking(false); + return; + } + if (!isForwardingInput()) return; e.preventDefault(); if (e.key === 'Escape' && !(e.metaKey || e.ctrlKey)) { @@ -182,15 +219,46 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } function onScreenKeyUp(e: React.KeyboardEvent) { - if (!capturedRef.current) + if (!isForwardingInput()) return; e.preventDefault(); channelRef.current?.keyup({ key: e.key }); } function onScreenBlur() { - if (capturedRef.current) - setCaptured(false); + setCaptured(false); + } + + function onInspectorGripPointerDown(e: React.PointerEvent) { + if (e.button !== 0) + return; + const wrapperRect = viewportWrapperRef.current?.getBoundingClientRect(); + if (!wrapperRect) + return; + e.preventDefault(); + const grip = e.currentTarget; + grip.setPointerCapture(e.pointerId); + const minWidth = 300; + const maxWidth = Math.max(minWidth, wrapperRect.width - 320); + const startX = e.clientX; + const startWidth = inspectorWidth ?? wrapperRect.width * 0.5; + const onPointerMove = (event: PointerEvent) => { + const delta = startX - event.clientX; + const nextWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta)); + setInspectorWidth(nextWidth); + }; + const onPointerUp = () => { + grip.removeEventListener('pointermove', onPointerMove); + grip.removeEventListener('pointerup', onPointerUp); + grip.removeEventListener('lostpointercapture', onPointerUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + grip.addEventListener('pointermove', onPointerMove); + grip.addEventListener('pointerup', onPointerUp); + grip.addEventListener('lostpointercapture', onPointerUp); } function onOmniboxKeyDown(e: React.KeyboardEvent) { @@ -204,7 +272,8 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } } - const hasPages = tabs.some(t => t.selected); + const selectedTab = tabs.find(t => t.selected); + const hasPages = !!selectedTab; return (
{/* Tab bar */} @@ -282,33 +351,79 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { onKeyDown={onOmniboxKeyDown} onFocus={e => e.target.select()} /> + + {selectedTab?.inspectorUrl && ( + + )}
{/* Viewport */} -
-
e.preventDefault()} - onMouseEnter={() => { - if (!capturedRef.current) - setHintVisible(true); - }} - onMouseLeave={() => setHintVisible(false)} - > - screencast -
Click to interact · Esc to release
+
+
+
e.preventDefault()} + onMouseEnter={() => { + if (!showInspector && !capturedRef.current) + setHintVisible(true); + }} + onMouseLeave={() => setHintVisible(false)} + > + screencast + {toast + ?
Copied: {toast}
+ : picking + ?
Click an element to pick its locator
+ : !showInspector &&
Click to interact · Esc to release
+ } +
+
No tabs open
-
No tabs open
+ {showInspector && selectedTab?.inspectorUrl && ( +
+
+