diff --git a/packages/dashboard/src/dashboard.css b/packages/dashboard/src/dashboard.css index 037ba9e86ca12..3f719afa40723 100644 --- a/packages/dashboard/src/dashboard.css +++ b/packages/dashboard/src/dashboard.css @@ -24,22 +24,6 @@ overflow: hidden; } -/* -- Tab bar -- */ -.tabbar { - display: flex; - align-items: flex-end; - height: 39px; - min-height: 39px; - padding: 0 4px; - user-select: none; - position: relative; - z-index: 110; -} - -:root.light-mode .tabbar { - background: var(--color-canvas-overlay); -} - .dashboard-view.interactive .toolbar::after { content: ''; position: absolute; @@ -108,31 +92,6 @@ } } -.tabbar-back { - display: flex; - align-items: center; - justify-content: center; - height: 34px; - align-self: flex-end; - color: var(--color-fg-muted); - text-decoration: none; - border-radius: 8px; - margin-right: 4px; - font-size: 11px; - text-transform: uppercase; - font-weight: 600; -} - -.tabbar-back:hover { - background: var(--color-canvas-overlay); - color: var(--color-fg-default); -} - -.tabbar-back svg { - width: 16px; - height: 16px; -} - .interactive .toolbar .toolbar-button > .codicon { color: var(--color-fg-on-emphasis); } @@ -141,242 +100,6 @@ color: var(--color-fg-on-emphasis); } -.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(--color-canvas-subtle); - color: var(--color-fg-muted); - font-size: 13px; - 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(--color-canvas-overlay); - color: var(--color-fg-muted); -} - -.tab.active { - background: var(--color-canvas-overlay); - color: var(--color-fg-default); -} - -:root.light-mode .tab { - background: transparent; -} -:root.light-mode .tab:hover { - background: var(--color-canvas-subtle); -} -:root.light-mode .tab.active { - background: var(--color-canvas-subtle); -} - -.dashboard-view.interactive .tab.active { - background: rgb(var(--interactive-orange)); - color: var(--color-fg-on-emphasis); -} - -.dashboard-view.annotate .tab.active { - background: rgb(var(--annotate-blue)); - color: var(--color-fg-on-emphasis); -} - -.tab-label { - overflow: hidden; - text-overflow: ellipsis; - pointer-events: none; -} - -.tab-favicon { - width: 14px; - height: 14px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 9px; - font-weight: 700; -} - -.tab-favicon.placeholder { - background: var(--color-fg-subtle); - border-radius: 2px; -} - -.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: var(--color-neutral-muted); -} - -.new-tab-btn { - width: 28px; - height: 34px; - border-radius: 8px; - margin-left: 4px; - align-self: flex-end; -} - -.new-tab-btn:hover { - background: var(--color-canvas-overlay); -} - -.new-tab-btn svg { - width: 16px; - height: 16px; -} - -.interactive-controls { - margin-left: auto; - margin-right: 0; - align-self: center; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 4px 2px 4px 8px; -} - -.segmented-control { - position: relative; - height: 28px; - border-radius: 999px; - padding: 2px; - display: inline-flex; - align-items: center; - gap: 0; - background: var(--color-neutral-subtle); -} - -.segmented-control::before { - content: ''; - position: absolute; - top: 2px; - bottom: 2px; - left: 2px; - width: calc(50% - 2px); - border-radius: 999px; - background: var(--color-neutral-muted); - transform: translateX(0); - transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms ease; -} - -.segmented-control.interactive::before { - transform: translateX(100%); - background: rgb(var(--interactive-orange) / 0.95); -} - -.segmented-control-option { - position: relative; - z-index: 1; - width: 96px; - height: 24px; - border-radius: 999px; - padding: 0; - border: none; - background: transparent; - color: var(--color-fg-muted); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.03em; - text-transform: uppercase; - white-space: nowrap; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.segmented-control-option:hover:not(:disabled) { - background: transparent; -} - -.segmented-control-option.active { - color: var(--color-fg-default); -} - -.segmented-control-option:disabled { - opacity: 0.7; - cursor: default; -} - -.dashboard-view.interactive .segmented-control { - background: rgb(255 255 255 / 0.2); -} - -.dashboard-view.interactive .segmented-control::before { - background: rgb(255 255 255 / 0.32); -} - -:root.light-mode .dashboard-view.interactive .segmented-control { - background: rgb(0 0 0 / 0.1); -} -:root.light-mode .dashboard-view.interactive .segmented-control::before { - background: rgb(0 0 0 / 0.12); -} -:root.light-mode .dashboard-view.interactive .toolbar .nav-btn:hover { - background: rgba(255, 255, 255, 0.18); -} -:root.light-mode .dashboard-view.interactive .toolbar .nav-btn:active { - background: rgba(255, 255, 255, 0.25); -} - -.dashboard-view.interactive .segmented-control.interactive::before { - background: rgb(var(--interactive-orange) / 0.95); -} - -.dashboard-view.interactive .segmented-control-option { - color: rgb(255 255 255 / 0.74); -} - -.dashboard-view.interactive .segmented-control-option.active { - color: var(--color-fg-on-emphasis); -} - -:root.light-mode .dashboard-view.interactive .segmented-control-option { - color: var(--color-fg-muted); -} -:root.light-mode .dashboard-view.interactive .segmented-control-option.active { - color: var(--color-fg-on-emphasis); -} - /* -- Toolbar -- */ .toolbar { display: flex; diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 6e6687de0c33e..68e88ed39608e 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -16,29 +16,22 @@ import React from 'react'; import './dashboard.css'; -import { navigate, DashboardClientContext } from './index'; +import { DashboardClientContext } from './index'; import { asLocator } from '@isomorphic/locatorGenerators'; +import { TraceModel } from '@isomorphic/trace/traceModel'; import { SplitView } from '@web/components/splitView'; -import { ChevronLeftIcon, ChevronRightIcon, CloseIcon, PlusIcon, ReloadIcon } from './icons'; -import { SettingsButton } from './settingsView'; +import { ChevronLeftIcon, ChevronRightIcon, ReloadIcon } from './icons'; import { Annotations, getImageLayout, clientToViewport } from './annotations'; import { ToolbarButton } from '@web/components/toolbarButton'; import { TabbedPaneTabModel, TabbedPane } from '@web/components/tabbedPane'; +import { ConsoleTab, useConsoleTabModel } from '@trace-viewer/ui/consoleTab'; import { InspectorTab } from '@trace-viewer/ui/inspectorTab'; +import { NetworkTab, useNetworkTabModel } from '@trace-viewer/ui/networkTab'; import { useSetting } from '@web/uiUtils'; import type { Tab, DashboardChannelEvents } from './dashboardChannel'; import { HighlightedElement } from '@trace-viewer/ui/snapshotTab'; - -function tabFavicon(url: string): string { - try { - const u = new URL(url); - const host = u.hostname.replace(/^www\./, ''); - return host ? host[0].toUpperCase() : ''; - } catch { - return ''; - } -} +import { TraceModelContext } from '@trace-viewer/ui/traceModelContext'; const BUTTONS = ['left', 'middle', 'right'] as const; type Mode = 'readonly' | 'interactive' | 'annotate'; @@ -48,7 +41,7 @@ export const Dashboard: React.FC<{ autoInteractive?: boolean; onAutoInteractiveConsumed?: () => void; }> = ({ browser, autoInteractive, onAutoInteractiveConsumed }) => { - const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); + const [sidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const client = React.useContext(DashboardClientContext); const [mode, setMode] = React.useState('readonly'); const [sidebarVisible, setSidebarVisible] = useSetting('propertiesSidebarVisible', false); @@ -68,10 +61,11 @@ export const Dashboard: React.FC<{ const [recording, setRecording] = React.useState(false); const [screenshotIcon, setScreenshotIcon] = React.useState<'device-camera' | 'clippy'>('device-camera'); const [showInteractiveHint, setShowInteractiveHint] = React.useState(false); + const [traceModel, setTraceModel] = React.useState(); + const [selectedSidebarTab, setSelectedSidebarTab] = useSetting('dashboardPropertiesTab', 'inspector'); const displayRef = React.useRef(null); const screenRef = React.useRef(null); - const tabbarRef = React.useRef(null); const toolbarRef = React.useRef(null); const moveThrottleRef = React.useRef(0); const hintTimerRef = React.useRef>(undefined); @@ -119,11 +113,10 @@ export const Dashboard: React.FC<{ if (modeRef.current === 'annotate') return; setFrame(params); - const tabbar = tabbarRef.current; const toolbar = toolbarRef.current; - if (!resized && tabbar && toolbar && params.viewportWidth && params.viewportHeight) { + if (!resized && toolbar && params.viewportWidth && params.viewportHeight) { resized = true; - const chromeHeight = tabbar.offsetHeight + toolbar.offsetHeight; + const chromeHeight = toolbar.offsetHeight; const extraW = window.outerWidth - window.innerWidth; const extraH = window.outerHeight - window.innerHeight; const targetW = Math.min(params.viewportWidth + extraW, screen.availWidth); @@ -154,10 +147,10 @@ export const Dashboard: React.FC<{ client.off('tabs', onTabs); client.off('frame', onFrame); client.off('elementPicked', onElementPicked); - client.detach({ browser }).catch(() => {}); setContext(undefined); setTabs(null); setFrame(undefined); + setTraceModel(undefined); setMode('readonly'); setPickingPage(null); setRecording(false); @@ -174,6 +167,43 @@ export const Dashboard: React.FC<{ setRecording(false); }, [selectedTab?.page]); + React.useEffect(() => { + if (!client || !context || !sidebarVisible) + return; + client.startTracing({ browser }).catch(() => {}); + }, [client, browser, context, sidebarVisible]); + + React.useEffect(() => { + if (!client || !context || !sidebarVisible) { + setTraceModel(undefined); + return; + } + + let disposed = false; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const { tracesDir, contextEntries } = await client.traceContextEntries({ browser }); + if (disposed) + return; + setTraceModel(new TraceModel(tracesDir, contextEntries)); + } catch { + if (!disposed) + setTraceModel(undefined); + } finally { + if (!disposed) + timer = setTimeout(poll, 500); + } + }; + + poll().catch(() => {}); + return () => { + disposed = true; + if (timer) + clearTimeout(timer); + }; + }, [client, browser, context, sidebarVisible]); + function imgCoords(e: React.MouseEvent): { x: number; y: number } { const vw = frame?.viewportWidth ?? 0; const vh = frame?.viewportHeight ?? 0; @@ -267,6 +297,9 @@ export const Dashboard: React.FC<{ } const picking = selectedTab?.page === pickingPage; + const boundaries = React.useMemo(() => ({ minimum: 0, maximum: Number.POSITIVE_INFINITY }), []); + const consoleModel = useConsoleTabModel(traceModel, undefined, selectedTab?.page); + const networkModel = useNetworkTabModel(traceModel, undefined, selectedTab?.page); const inspectorTabs: TabbedPaneTabModel[] = [ { @@ -293,6 +326,25 @@ export const Dashboard: React.FC<{ setHighlightedElement={setHighlightedElement} />, }, + { + id: 'console', + title: 'Console', + count: consoleModel.entries.length, + render: () => , + }, + { + id: 'network', + title: 'Network', + count: networkModel.resources.length, + render: () => , + }, ]; let overlayText: string | undefined; @@ -304,244 +356,201 @@ export const Dashboard: React.FC<{ overlayText = 'No tabs open'; return ( -
- - {/* Tab bar */} -
- { e.preventDefault(); navigate('#'); }}> - - Sessions - -
- {tabs?.map(tab => ( -
client?.selectTab({ browser, context: tab.context, page: tab.page })} - > - {tab.faviconUrl - ? - : } - {tab.title || 'New Tab'} - -
- ))} -
- -
-
- - {/* Toolbar */} -
- - - - { - if (!interactive) - return; - setUrl(e.target.value); - }} - onKeyDown={e => { - if (!interactive) - return; - onOmniboxKeyDown(e); - }} - onFocus={e => { + +
+ + {/* Toolbar */} +
+ + + + { + if (!interactive) + return; + setUrl(e.target.value); + }} + onKeyDown={e => { + if (!interactive) + return; + onOmniboxKeyDown(e); + }} + onFocus={e => { + if (!interactive) { + flashInteractiveHint(); + e.target.blur(); + return; + } + e.target.select(); + }} + aria-disabled={!interactive || undefined} + readOnly={!interactive} + /> + { + if (!client || !pageTarget) + return; + if (recording) { + const { path } = await client.stopRecording(pageTarget); + await client.reveal({ path }); + setRecording(false); + } else { + await client.startRecording(pageTarget); + setRecording(true); + } + }}> + {recording && Recording...} + + { + if (!client || !pageTarget) + return; + const screenshot = await client.screenshot(pageTarget); + const blob = await (await fetch('data:image/png;base64,' + screenshot)).blob(); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + setScreenshotIcon('clippy'); + setTimeout(() => setScreenshotIcon('device-camera'), 3000); + }} + /> +
+
+ { + if (interactive) { + if (pageTarget) + client?.cancelPickLocator(pageTarget); + setPickingPage(null); + setMode('readonly'); + return; + } if (pageTarget) client?.cancelPickLocator(pageTarget); setPickingPage(null); + setMode('interactive'); + }} + /> + {showInteractiveHint &&
Enable interactive mode
} +
+ { + if (annotating) { setMode('readonly'); return; } if (pageTarget) client?.cancelPickLocator(pageTarget); setPickingPage(null); - setMode('interactive'); + setMode('annotate'); }} /> - {showInteractiveHint &&
Enable interactive mode
} + setSidebarVisible(!sidebarVisible)} + disabled={!ready} + />
- { - if (annotating) { - setMode('readonly'); - return; - } - if (pageTarget) - client?.cancelPickLocator(pageTarget); - setPickingPage(null); - setMode('annotate'); - }} - /> - setSidebarVisible(!sidebarVisible)} - disabled={!ready} - />
-
- - {/* Viewport */} -
-
-
e.preventDefault()} - > - screencast - + + {/* Viewport */} +
+
+
e.preventDefault()} + > + screencast + +
+ {overlayText &&
{overlayText}
}
- {overlayText &&
{overlayText}
}
-
-
} - sidebar={ {}} - rightToolbar={[ - , - ]} - mode='default' - />} - /> -
+
} + sidebar={} + /> +
+ ); }; diff --git a/packages/dashboard/src/dashboardChannel.ts b/packages/dashboard/src/dashboardChannel.ts index 44e2e948148cc..7d801d14bec21 100644 --- a/packages/dashboard/src/dashboardChannel.ts +++ b/packages/dashboard/src/dashboardChannel.ts @@ -16,6 +16,7 @@ import type { ClientInfo } from '../../playwright-core/src/tools/cli-client/registry'; import type { SessionStatus } from './sessionModel'; +import type { ContextEntry } from '@isomorphic/trace/entries'; export type BrowserTarget = { browser: string }; export type ContextTarget = { browser: string; context: string }; @@ -66,6 +67,8 @@ export interface DashboardChannel { keyup(params: PageTarget & { key: string }): Promise; pickLocator(params: PageTarget): Promise; cancelPickLocator(params: PageTarget): Promise; + startTracing(params: BrowserTarget): Promise; + traceContextEntries(params: BrowserTarget): Promise<{ contextEntries: ContextEntry[], tracesDir: string }>; startRecording(params: PageTarget): Promise; stopRecording(params: PageTarget): Promise<{ path: string }>; screenshot(params: PageTarget): Promise; diff --git a/packages/dashboard/src/grid.tsx b/packages/dashboard/src/grid.tsx deleted file mode 100644 index fe505e80cae43..0000000000000 --- a/packages/dashboard/src/grid.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** - * 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 './grid.css'; -import { navigate, DashboardClientContext } from './index'; -import { Screencast } from './screencast'; -import { SettingsButton } from './settingsView'; - -import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; -import type { Tab, DashboardChannelEvents } from './dashboardChannel'; -import type { SessionModel, SessionStatus } from './sessionModel'; - -export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { - const [expandedWorkspaces, setExpandedWorkspaces] = React.useState>(new Set()); - const sessions = model.sessions; - const clientInfo = model.clientInfo; - - function toggleWorkspace(workspace: string) { - setExpandedWorkspaces(prev => { - const next = new Set(prev); - if (next.has(workspace)) - next.delete(workspace); - else - next.add(workspace); - return next; - }); - } - - const workspaceGroups = React.useMemo(() => { - const groups = new Map(); - for (const session of sessions) { - const key = session.workspaceDir || 'Global'; - let list = groups.get(key); - if (!list) { - list = []; - groups.set(key, list); - } - list.push(session); - } - for (const list of groups.values()) - list.sort((a, b) => a.title.localeCompare(b.title)); - - // Current workspace first, then alphabetical. - const currentWorkspace = clientInfo?.workspaceDir || 'Global'; - const entries = [...groups.entries()]; - const current = entries.filter(([key]) => key === currentWorkspace); - const other = entries.filter(([key]) => key !== currentWorkspace).sort((a, b) => a[0].localeCompare(b[0])); - return [...current, ...other]; - }, [sessions, clientInfo?.workspaceDir]); - - return (
-
- -
-
- {model.loading &&
Loading sessions...
} - {!model.loading && sessions.length === 0 &&
No sessions found.
} - -
- {workspaceGroups.map(([workspace, entries], index) => { - const isFirst = index === 0; - const isExpanded = isFirst || expandedWorkspaces.has(workspace); - return ( -
-
toggleWorkspace(workspace)} - > - {!isFirst && ( - - - - )} - {workspace.split('/').pop() || workspace} - — {workspace} -
- {isExpanded && ( -
- {entries.map(session => )} -
- )} -
- ); - })} -
-
-
); -}; - -const SessionChip: React.FC<{ descriptor: BrowserDescriptor; canConnect: boolean; visible: boolean; model: SessionModel }> = ({ descriptor, canConnect, visible, model }) => { - const href = '#session=' + encodeURIComponent(descriptor.browser.guid); - const client = React.useContext(DashboardClientContext); - const browser = descriptor.browser.guid; - const attached = canConnect && visible && !!client; - const [selectedTab, setSelectedTab] = React.useState(); - - React.useEffect(() => { - if (!attached || !client) - return; - const onTabs = (params: DashboardChannelEvents['tabs']) => { - if (params.target.browser !== browser) - return; - setSelectedTab(params.tabs.find(t => t.selected)); - }; - client.on('tabs', onTabs); - client.attach({ browser }).catch(() => {}); - return () => { - client.off('tabs', onTabs); - client.detach({ browser }).catch(() => {}); - }; - }, [attached, client, browser]); - - const chipTitle = selectedTab ? `[${descriptor.title}] ${selectedTab.url} \u2014 ${selectedTab.title}` : descriptor.title; - - return ( - { - e.preventDefault(); - if (canConnect) - navigate(href); - }}> -
-
- - {selectedTab ? <>[{descriptor.title}] {selectedTab.url} — {selectedTab.title} : descriptor.title} - - {canConnect && ( - - )} - {!canConnect && ( - - )} -
-
- {attached && client && } - {!canConnect &&
Session closed
} -
-
- ); -}; diff --git a/packages/dashboard/src/index.tsx b/packages/dashboard/src/index.tsx index 95e3c8abbb1f4..6915398202b11 100644 --- a/packages/dashboard/src/index.tsx +++ b/packages/dashboard/src/index.tsx @@ -21,9 +21,10 @@ import '@web/common.css'; import './common.css'; import { applyTheme } from '@web/theme'; import { Dashboard } from './dashboard'; -import { Grid } from './grid'; import { SessionModel } from './sessionModel'; import { DashboardClient } from './dashboardClient'; +import { SessionSidebar } from './sessionSidebar'; +import { SplitView } from '@web/components/splitView'; import type { DashboardChannelEvents } from './dashboardChannel'; import type { DashboardClientChannel } from './dashboardClient'; @@ -54,8 +55,8 @@ if (document.hidden) pushVisibility(); const App: React.FC = () => { - const [, setRevision] = React.useState(0); - const [sessionGuid, setSessionGuid] = React.useState(parseHash); + const [revision, setRevision] = React.useState(0); + const [sessionGuid, setSessionGuid] = React.useState(parseHash()); const [autoInteractiveBrowser, setAutoInteractiveBrowser] = React.useState(); React.useEffect(() => model.subscribe(() => setRevision(r => r + 1)), []); @@ -76,15 +77,58 @@ const App: React.FC = () => { return () => client.off('pickLocator', onPickLocator); }, []); - const content = sessionGuid - ? setAutoInteractiveBrowser(undefined)} - /> - : ; + React.useEffect(() => { + if (!sessionGuid || model.loading) + return; + const session = model.sessionByGuid(sessionGuid); + if (!session || !session.canConnect) + navigate('#'); + }, [sessionGuid, revision]); + + React.useEffect(() => { + if (sessionGuid || model.loading) + return; + const firstOpenSession = model.sessions.find(session => session.canConnect); + if (firstOpenSession) + navigate('#session=' + encodeURIComponent(firstOpenSession.browser.guid)); + }, [sessionGuid, revision]); + + const activeSession = sessionGuid ? model.sessionByGuid(sessionGuid) : undefined; + const activeBrowser = activeSession?.canConnect ? activeSession.browser.guid : undefined; - return {content}; + return + { + if (sessionGuid !== tab.browser) + navigate('#session=' + encodeURIComponent(tab.browser)); + void client.selectTab({ browser: tab.browser, context: tab.context, page: tab.page }); + }} + onCloseTab={tab => { void client.closeTab({ browser: tab.browser, context: tab.context, page: tab.page }); }} + onNewTab={(browser, context) => { + if (sessionGuid !== browser) + navigate('#session=' + encodeURIComponent(browser)); + void client.newTab({ browser, context }); + }} + />} + main={
+ {activeBrowser + ? setAutoInteractiveBrowser(undefined)} + /> + :
Select an open tab in the sidebar.
} +
} + /> +
; }; ReactDOM.createRoot(document.querySelector('#root')!).render(); diff --git a/packages/dashboard/src/sessionSidebar.css b/packages/dashboard/src/sessionSidebar.css new file mode 100644 index 0000000000000..681f8e9033349 --- /dev/null +++ b/packages/dashboard/src/sessionSidebar.css @@ -0,0 +1,302 @@ +/** + * 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. + */ + +.dashboard-shell-sidebar { + width: 100%; + background: var(--color-canvas-default); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dashboard-shell-sidebar-header { + height: 40px; + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + border-bottom: 1px solid var(--color-border-muted); +} + +.dashboard-shell-sidebar-title { + margin: 0; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--color-fg-muted); + letter-spacing: 0.03em; +} + +.dashboard-shell-sidebar-content { + flex: 1; + overflow: auto; + padding: 0; +} + +.sidebar-empty { + color: var(--color-fg-subtle); + font-size: 13px; + padding: 8px 12px; +} + +.dashboard-shell-main { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + min-height: 0; +} + +.dashboard-shell-main > .vbox { + flex: 1; + min-height: 0; +} + +.dashboard-shell-empty { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-fg-muted); + font-size: 14px; +} + +.workspace-group { + margin: 0; +} + +.workspace-header { + margin: 0; + padding: 8px 12px 4px; + font-size: 12px; + color: var(--color-fg-muted); + font-weight: 400; +} + +.workspace-path-full { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + +.sidebar-session-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.session-chip.sidebar-session { + border: none; + border-radius: 0; + background: transparent; + overflow: hidden; + cursor: default; + position: relative; +} + +.sidebar-session-row { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + text-align: left; + padding: 8px 4px 4px 8px; +} + +.session-browser-icon-wrap { + position: relative; + width: 18px; + height: 18px; + border-radius: 50%; + flex-shrink: 0; +} + +.session-browser-icon { + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--color-fg-subtle); + color: var(--color-canvas-default); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + transition: opacity 120ms ease; +} + +.session-browser-close { + position: absolute; + inset: 0; + border-radius: 50%; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.session-browser-close .codicon { + font-size: 10px; +} + +.session-browser-icon-wrap:hover .session-browser-icon { + opacity: 0; +} + +.session-browser-icon-wrap:hover .session-browser-close { + opacity: 1; + pointer-events: auto; +} + +.session-browser-close:hover { + background: var(--color-neutral-muted); +} + +.session-chip-name { + font-size: 13px; + font-weight: 600; + color: var(--color-fg-default); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.sidebar-session-row-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; +} + +.sidebar-session-new-tab .codicon { + font-size: 12px; +} + +.sidebar-session-new-tab:hover:not(:disabled) { + background: var(--color-neutral-muted); +} + +.sidebar-tab-list { + padding: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.sidebar-tabs-loading, +.sidebar-tabs-empty { + color: var(--color-fg-subtle); + font-size: 12px; + padding: 4px 12px 6px 38px; +} + +.sidebar-tab { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-fg-muted); + border-radius: 0; + padding: 4px 4px 4px 24px; +} + +.sidebar-tab:hover { + background: var(--color-neutral-muted); +} + +.sidebar-tab.active { + background: var(--color-neutral-subtle); + color: var(--color-fg-default); +} + +.sidebar-tab-favicon { + width: 14px; + height: 14px; + flex-shrink: 0; + margin-top: 0; + align-self: center; +} + +.sidebar-tab-select { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + cursor: pointer; +} + +.sidebar-tab-select:focus-visible { + outline: 1px solid var(--color-accent-emphasis); + outline-offset: 2px; +} + +.sidebar-tab-favicon.placeholder { + border-radius: 2px; + background: var(--color-fg-subtle); + color: var(--color-canvas-default); + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; +} + +.sidebar-tab-text { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; + min-width: 0; +} + +.sidebar-tab-title { + color: var(--color-fg-default); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-tab-url { + font-size: 11px; + color: var(--color-fg-subtle); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-tab-close { + margin-left: auto; + align-self: center; +} + +.sidebar-tab-close .codicon { + font-size: 12px; +} + +.sidebar-tab-close:hover { + background: var(--color-neutral-muted); +} diff --git a/packages/dashboard/src/sessionSidebar.tsx b/packages/dashboard/src/sessionSidebar.tsx new file mode 100644 index 0000000000000..1c92a8e46370e --- /dev/null +++ b/packages/dashboard/src/sessionSidebar.tsx @@ -0,0 +1,209 @@ +/** + * 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 './sessionSidebar.css'; +import { DashboardClientContext } from './index'; +import { SettingsButton } from './settingsView'; +import { useSetting } from '@web/uiUtils'; +import { ToolbarButton } from '@web/components/toolbarButton'; + +import type { Tab, DashboardChannelEvents } from './dashboardChannel'; +import type { SessionModel, SessionStatus } from './sessionModel'; + +type SessionSidebarProps = { + model: SessionModel; + activeBrowser?: string; + onSelectTab: (tab: Tab) => void; + onCloseTab: (tab: Tab) => void; + onNewTab: (browser: string, context: string) => void; +}; + +function tabFavicon(url: string): string { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ''); + return host ? host[0].toUpperCase() : ''; + } catch { + return ''; + } +} + +function normalizeWorkspacePath(workspace: string): string { + if (!workspace || workspace === 'Global') + return workspace; + if (workspace.startsWith('/') || workspace.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(workspace)) + return workspace; + if (workspace.includes('/')) + return '/' + workspace; + return workspace; +} + +export const SessionSidebar: React.FC = ({ model, activeBrowser, onSelectTab, onCloseTab, onNewTab }) => { + const client = React.useContext(DashboardClientContext); + const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); + const openSessions = React.useMemo(() => model.sessions.filter(session => session.canConnect), [model.sessions]); + const clientInfo = model.clientInfo; + const [browserState, setBrowserState] = React.useState>({}); + + React.useEffect(() => { + if (!client) + return; + + const onTabs = (params: DashboardChannelEvents['tabs']) => { + setBrowserState(prev => ({ + ...prev, + [params.target.browser]: { + context: params.target.context, + tabs: params.tabs, + }, + })); + }; + + client.on('tabs', onTabs); + + for (const session of openSessions) { + void client.attach({ browser: session.browser.guid }).then(result => { + setBrowserState(prev => ({ + ...prev, + [session.browser.guid]: { + ...prev[session.browser.guid], + context: result.context, + }, + })); + }).catch(() => {}); + } + + return () => client.off('tabs', onTabs); + }, [client, openSessions]); + + const workspaceGroups = React.useMemo(() => { + const groups = new Map(); + for (const session of openSessions) { + const key = session.workspaceDir || 'Global'; + let list = groups.get(key); + if (!list) { + list = []; + groups.set(key, list); + } + list.push(session); + } + for (const list of groups.values()) + list.sort((a, b) => a.title.localeCompare(b.title)); + + const currentWorkspace = clientInfo?.workspaceDir || 'Global'; + const entries = [...groups.entries()]; + const current = entries.filter(([key]) => key === currentWorkspace); + const other = entries.filter(([key]) => key !== currentWorkspace).sort((a, b) => a[0].localeCompare(b[0])); + return [...current, ...other]; + }, [openSessions, clientInfo?.workspaceDir]); + + return ; +}; diff --git a/packages/playwright-core/src/tools/dashboard/DEPS.list b/packages/playwright-core/src/tools/dashboard/DEPS.list index b5330959fa65a..7005c5cfbeac8 100644 --- a/packages/playwright-core/src/tools/dashboard/DEPS.list +++ b/packages/playwright-core/src/tools/dashboard/DEPS.list @@ -2,8 +2,9 @@ ../../.. ../../ ../../server/registry/index.ts +@isomorphic/** @utils/** ../../serverRegistry.ts -@utils/** ../cli-client/registry.ts +../trace/traceParser.ts ../utils/** diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index bb7c88b15c5d4..3f95c2a837de5 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -22,6 +22,7 @@ import http from 'http'; import { HttpServer } from '@utils/httpServer'; import { makeSocketPath } from '@utils/fileUtils'; import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher'; +import { TraceLoader } from '@isomorphic/trace/traceLoader'; import { libPath } from '../../package'; import { playwright } from '../../inprocess'; import { findChromiumChannelBestEffort, registryDirectory } from '../../server/registry/index'; @@ -33,16 +34,43 @@ async function innerOpenDashboardApp(): Promise { const httpServer = new HttpServer(); const dashboardDir = libPath('vite', 'dashboard'); + const traces = new Map(); const connections = new Set(); httpServer.createWebSocket(() => { let connection: DashboardConnection; // eslint-disable-next-line prefer-const - connection = new DashboardConnection(() => connections.delete(connection)); + connection = new DashboardConnection(() => connections.delete(connection), traces); connections.add(connection); return connection; }, 'ws'); + httpServer.routePrefix('/sha1/', (request: http.IncomingMessage, response: http.ServerResponse) => { + const url = new URL(request.url!, `http://${request.headers.host}`); + const tracesDir = url.searchParams.get('trace'); + if (!tracesDir) + throw new Error('Traces directory is missing'); + const sha1 = url.pathname.split('/sha1/')[1]; + const loader = traces.get(tracesDir); + if (!loader) + throw new Error('Trace is not loaded'); + loader.resourceForSha1(sha1).then(async blob => { + if (!blob) { + response.statusCode = 404; + response.end('Not found'); + return; + } + const buffer = await blob.arrayBuffer(); + response.setHeader('Content-Type', blob.type); + response.end(Buffer.from(buffer)); + }).catch(e => { + response.statusCode = 500; + response.setHeader('error', String(e)); + response.end(String(e)); + }); + return true; + }); + 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); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index f88c375d39813..bbc7a18b196b7 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -19,13 +19,16 @@ import os from 'os'; import fs from 'fs'; import { execFile } from 'child_process'; import { eventsHelper } from '@utils/eventsHelper'; +import { TraceLoader } from '@isomorphic/trace/traceLoader'; import { connectToBrowserAcrossVersions } from '../utils/connect'; import { serverRegistry } from '../../serverRegistry'; import { createClientInfo } from '../cli-client/registry'; +import { DirTraceLoaderBackend } from '../trace/traceParser'; import type * as api from '../../..'; import type { Transport } from '@utils/httpServer'; import type { Tab } from '@dashboard/dashboardChannel'; +import type { ContextEntry } from '@isomorphic/trace/entries'; import type { BrowserDescriptor, BrowserStatus } from '../../serverRegistry'; type Disposable = { dispose: () => Promise }; @@ -43,9 +46,11 @@ export class DashboardConnection implements Transport { private _visible = true; _recordingDir: string; + _traceMap: Map; - constructor(onclose: () => void) { + constructor(onclose: () => void, traceMap: Map) { this._onclose = onclose; + this._traceMap = traceMap; this._recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-recordings-')); } @@ -209,6 +214,7 @@ class AttachedBrowser { private _selectedPage: api.Page | null = null; private _screencastRunning = false; private _recordingPath: string | null = null; + private _tracingStarted = false; private _pageListeners: Disposable[] = []; private _contextListeners: Disposable[] = []; @@ -249,7 +255,13 @@ class AttachedBrowser { this._selectedPage.screencast.stop().catch(() => {}); this._screencastRunning = false; this._recordingPath = null; + if (this._tracingStarted) + this._context.tracing.stop().catch(() => {}); + this._tracingStarted = false; this._selectedPage = null; + const tracesDir = this._descriptor.browser.launchOptions.tracesDir; + if (tracesDir) + this._owner._traceMap.delete(tracesDir); } async setScreencastActive(active: boolean) { @@ -352,6 +364,28 @@ class AttachedBrowser { await this.pageForId(params.page)?.cancelPickLocator(); } + async startTracing() { + if (this._tracingStarted) + return; + await this._context.tracing.start({ + snapshots: true, + live: true, + }); + this._tracingStarted = true; + } + + async traceContextEntries(): Promise<{ contextEntries: ContextEntry[]; tracesDir: string }> { + const tracesDir = this._descriptor.browser.launchOptions.tracesDir; + if (!tracesDir) + throw new Error('Tracing requires launchOptions.tracesDir'); + const backend = new DirTraceLoaderBackend(tracesDir); + const loader = new TraceLoader(); + await loader.load(backend); + this._owner._traceMap.set(tracesDir, loader); + const contextEntries = loader.contextEntries.filter(entry => entry.contextId === this.contextGuid); + return { contextEntries, tracesDir }; + } + async startRecording(params: { page: string }) { const page = this.pageForId(params.page); if (!page) @@ -423,6 +457,7 @@ class AttachedBrowser { size: { width: 1280, height: 800 }, ...(this._recordingPath ? { path: this._recordingPath } : {}), }); + void page.screenshot().catch(() => {}); // TODO: this is necessary to trigger a first frame - should this be in screencast.start() implementation? } private async _restartScreencast(page: api.Page) { diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 974c2fc5e24b6..9f24c2cbaef95 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -258,19 +258,22 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s if (cliOptions.sandbox !== undefined) launchOptions.chromiumSandbox = cliOptions.sandbox; - if (cliOptions.proxyServer) { - launchOptions.proxy = { - server: cliOptions.proxyServer - }; - if (cliOptions.proxyBypass) - launchOptions.proxy.bypass = cliOptions.proxyBypass; - } - if (cliOptions.device && cliOptions.cdpEndpoint) throw new Error('Device emulation is not supported with cdpEndpoint.'); // Context options const contextOptions: playwrightTypes.BrowserContextOptions = cliOptions.device ? playwright.devices[cliOptions.device] : {}; + + if (cliOptions.proxyServer) { + const proxy: playwrightTypes.LaunchOptions['proxy'] = { server: cliOptions.proxyServer }; + if (cliOptions.proxyBypass) + proxy.bypass = cliOptions.proxyBypass; + // Set on both to ensure CLI takes precedence over any proxy set in the config file + // (launchOptions.proxy applies at browser launch, contextOptions.proxy at context creation). + launchOptions.proxy = proxy; + contextOptions.proxy = proxy; + } + if (cliOptions.storageState) contextOptions.storageState = cliOptions.storageState; diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 3e35af381a21d..b9e91218787a0 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -59,7 +59,7 @@ const ansiColours = { } }; -export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): ConsoleTabModel { +export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined, pageId?: string): ConsoleTabModel { const { entries } = React.useMemo(() => { if (!model) return { entries: [] }; @@ -87,6 +87,8 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: }); for (const event of logEvents) { if (event.type === 'console') { + if (pageId && event.pageId !== pageId) + continue; const colours = event.messageType === 'error' ? ansiColours.error : event.messageType === 'warning' ? ansiColours.warning : ansiColours.log; const body = event.args && event.args.length ? format(event.args, colours) : formatAnsi(event.text, colours); const url = event.location.url; @@ -105,6 +107,8 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: }); } if (event.type === 'event' && event.method === 'pageError') { + if (pageId && event.pageId !== pageId) + continue; addEntry({ browserError: event.params.error, isError: true, @@ -129,7 +133,7 @@ export function useConsoleTabModel(model: TraceModel | undefined, selectedTime: } } return { entries }; - }, [model]); + }, [model, pageId]); const filteredEntries = React.useMemo(() => { if (!selectedTime) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index f3c32a97489ff..01ad15a62b10c 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -49,16 +49,18 @@ type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; const NetworkGridView = GridView; -export function useNetworkTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { +export function useNetworkTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined, pageId?: string): NetworkTabModel { const resources = React.useMemo(() => { const resources = model?.resources || []; const filtered = resources.filter(resource => { + if (pageId && resource.pageref !== pageId) + return false; if (!selectedTime) return true; return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum); }); return filtered; - }, [model, selectedTime]); + }, [model, selectedTime, pageId]); const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]); return { resources, contextIdMap }; } diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 98af761d8aa7d..4716a3dd92319 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -191,7 +191,7 @@ test('pick activates dashboard session', async ({ cdpServer, cli, server, openDa await cli('snapshot'); const dashboard = await openDashboard(); - await expect(dashboard.locator('.session-chip')).toHaveCount(1); + await expect(dashboard.locator('div.dashboard-view')).toBeVisible(); const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); const pickPromise = cli('pick'); diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index ca5e0cc21da9f..5dc6f67ecbb74 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -46,13 +46,13 @@ test('should show current workspace sessions first', async ({ cli, server, openD const workspaceGroups = dashboard.locator('.workspace-group'); await expect(workspaceGroups).toHaveCount(2); - // Current workspace (first) should be first and expanded. - await expect(workspaceGroups.nth(0).locator('.workspace-path')).toContainText(first); - await expect(workspaceGroups.nth(0).locator('.session-chips')).toBeVisible(); + // Current workspace (first) should be first. + await expect(workspaceGroups.nth(0).locator('.workspace-path-full')).toContainText(first); + await expect(workspaceGroups.nth(0).locator('.session-chip')).toHaveCount(1); - // Other workspace (second) should be second and collapsed. - await expect(workspaceGroups.nth(1).locator('.workspace-path')).toContainText(second); - await expect(workspaceGroups.nth(1).locator('.session-chips')).not.toBeVisible(); + // Other workspace (second) should be second. + await expect(workspaceGroups.nth(1).locator('.workspace-path-full')).toContainText(second); + await expect(workspaceGroups.nth(1).locator('.session-chip')).toHaveCount(1); await dashboard.close(); }; @@ -67,12 +67,12 @@ test('should show current workspace sessions first', async ({ cli, server, openD }); test('should pick locator from browser', async ({ cli, server, openDashboard }) => { - server.setContent('/', '', 'text/html'); + server.setContent('/', '', 'text/html'); await cli('open', server.PREFIX); const dashboard = await openDashboard(); - await dashboard.locator('.session-chip').click(); + await dashboard.locator('.sidebar-tab').first().click(); await dashboard.getByRole('button', { name: 'Show sidebar' }).click(); await dashboard.getByRole('button', { name: 'Pick locator' }).click(); @@ -80,7 +80,67 @@ test('should pick locator from browser', async ({ cli, server, openDashboard }) await expect(dashboard.locator('div.dashboard-view')).toContainClass('interactive'); await expect(async () => { - await dashboard.locator('img#display').click({ position: { x: 500, y: 25 } }); + const box = await dashboard.locator('img#display').boundingBox(); + await dashboard.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); await expect(dashboard.locator('.cm-wrapper').first()).toContainText(`getByRole('button', { name: 'Submit' })`); }).toPass(); }); + +test('should show console and network tabs in sidebar', async ({ cli, server, openDashboard }) => { + server.setContent('/dashboard-network-marker', JSON.stringify({ marker: 'dashboard-response-payload-marker' }), 'application/json'); + await cli('open', server.PREFIX); + + const dashboard = await openDashboard(); + await dashboard.locator('.session-chip').click(); + await dashboard.getByRole('button', { name: 'Show sidebar' }).click(); + + await cli('run-code', `async (page) => { + await page.evaluate(async () => { + console.log('dashboard-console-marker'); + await fetch('${server.PREFIX}/dashboard-network-marker'); + }); + }`); + + await dashboard.getByRole('tab', { name: 'Console' }).click(); + await expect(dashboard.locator('.console-tab')).toContainText('dashboard-console-marker'); + + await dashboard.getByRole('tab', { name: 'Network' }).click(); + await expect(dashboard.getByLabel('Network requests')).toContainText('dashboard-network-marker'); + + await dashboard.getByLabel('Network requests').getByText('dashboard-network-marker').click(); + await dashboard.getByRole('tab', { name: 'Response' }).click(); + await expect(dashboard.locator('.network-response-body')).toContainText('dashboard-response-payload-marker'); +}); + +test('sidebar', async ({ cli, server, openDashboard, mcpBrowser }) => { + test.fixme(mcpBrowser === 'firefox', 'firefox has bug around context creation that breaks this test'); + await cli('open', server.PREFIX); + + const dashboard = await openDashboard(); + const sidebar = dashboard.getByRole('navigation', { name: 'Sessions' }); + await expect(sidebar).toMatchAriaSnapshot(` +- heading "Sessions" +- list: + - listitem: + - text: default + - list: + - listitem: + - button "New Tab ${server.PREFIX}/" + `); + + await cli('open', '--session=foo', server.PREFIX); + await expect(sidebar).toMatchAriaSnapshot(` +- heading "Sessions" +- list: + - listitem: + - text: default + - list "default tabs": + - listitem: + - button "New Tab ${server.PREFIX}/" + - listitem: + - text: foo + - list: + - listitem: + - button "New Tab ${server.PREFIX}/" + `); +}); diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts index f5bc62c9b85b8..4019b530e74a7 100644 --- a/tests/mcp/launch.spec.ts +++ b/tests/mcp/launch.spec.ts @@ -160,6 +160,45 @@ test('isolated context with storage state', async ({ startClient, server }, test }); }); +test('--proxy-server routes browser traffic through the proxy', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40204' }, +}, async ({ startClient, server }) => { + server.setRoute('/target.html', (_req, res) => { + res.end('Served by the proxy'); + }); + const { client } = await startClient({ + args: [`--proxy-server=${server.HOST}`], + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: 'http://non-existent.com/target.html' }, + })).toHaveResponse({ + page: expect.stringContaining('Served by the proxy'), + }); +}); + +test('--proxy-server overrides contextOptions.proxy from config file', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40204' }, +}, async ({ startClient, server }) => { + server.setRoute('/target.html', (_req, res) => { + res.end('Served by CLI proxy'); + }); + const { client } = await startClient({ + config: { + browser: { + contextOptions: { proxy: { server: 'http://unreachable-config-proxy:1' } }, + }, + }, + args: [`--proxy-server=${server.HOST}`], + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: 'http://non-existent.com/target.html' }, + })).toHaveResponse({ + page: expect.stringContaining('Served by CLI proxy'), + }); +}); + test('proper launch error message for broken browser and persistent context', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1305' } }, async ({ startClient, server, mcpBrowser }, testInfo) => {