diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index 8da0896497829..4c84547ade373 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) { super(); this._transport = transport; + this._transport.eventSink.resolve(this); this._tracePage = tracePage; this._traceServer = traceServer; this.wsEndpointForTest = wsEndpointForTest; @@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea class RecorderTransport implements Transport { private _connected = new ManualPromise(); + readonly eventSink = new ManualPromise(); constructor() { } @@ -103,6 +105,8 @@ class RecorderTransport implements Transport { } async dispatch(method: string, params: any): Promise { + const eventSink = await this.eventSink; + eventSink.emit('event', { event: method, params }); } onclose() { diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 31bad2b70b3f5..9d5c0feebac94 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; import { TabbedPane } from '@web/components/tabbedPane'; import { Toolbar } from '@web/components/toolbar'; +import { emptySource, SourceChooser } from '@web/components/sourceChooser'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; import * as React from 'react'; import { CallLogView } from './callLog'; @@ -54,15 +55,7 @@ export const Recorder: React.FC = ({ if (source) return source; } - const source: Source = { - id: 'default', - isRecorded: false, - text: '', - language: 'javascript', - label: '', - highlight: [] - }; - return source; + return emptySource(); }, [sources, fileId]); const [locator, setLocator] = React.useState(''); @@ -152,10 +145,10 @@ export const Recorder: React.FC = ({ }}>
Target:
- + { + setFileId(fileId); + window.dispatch({ event: 'fileChanged', params: { file: fileId } }); + }} /> { window.dispatch({ event: 'clear' }); }}> @@ -184,22 +177,3 @@ export const Recorder: React.FC = ({ /> ; }; - -function renderSourceOptions(sources: Source[]): React.ReactNode { - const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); - const renderOption = (source: Source): React.ReactNode => ( - - ); - - const hasGroup = sources.some(s => s.group); - if (hasGroup) { - const groups = new Set(sources.map(s => s.group)); - return [...groups].filter(Boolean).map(group => ( - - {sources.filter(s => s.group === group).map(source => renderOption(source))} - - )); - } - - return sources.map(source => renderOption(source)); -} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 3935447e7dd04..55f4f4028a6d2 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -32,9 +32,9 @@ export interface ActionListProps { selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language | undefined; - onSelected: (action: ActionTraceEventInContext) => void, - onHighlighted: (action: ActionTraceEventInContext | undefined) => void, - revealConsole: () => void, + onSelected?: (action: ActionTraceEventInContext) => void, + onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, + revealConsole?: () => void, isLive?: boolean, } @@ -67,8 +67,8 @@ export const ActionList: React.FC = ({ treeState={treeState} setTreeState={setTreeState} selectedItem={selectedItem} - onSelected={item => onSelected(item.action!)} - onHighlighted={item => onHighlighted(item?.action)} + onSelected={item => onSelected?.(item.action!)} + onHighlighted={item => onHighlighted?.(item?.action)} onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })} isError={item => !!item.action?.error?.message} isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)} diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index b2947f501199d..3ae847ef5060b 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{ boundaries: Boundaries, consoleModel: ConsoleTabModel, selectedTime: Boundaries | undefined, - onEntryHovered: (entry: ConsoleEntry | undefined) => void, + onEntryHovered?: (entry: ConsoleEntry | undefined) => void, onAccepted: (entry: ConsoleEntry) => void, }> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => { if (!consoleModel.entries.length) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 207dd3354759f..62b139be0d958 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT export const NetworkTab: React.FunctionComponent<{ boundaries: Boundaries, networkModel: NetworkTabModel, - onEntryHovered: (entry: Entry | undefined) => void, + onEntryHovered?: (entry: Entry | undefined) => void, }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); @@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{ items={renderedEntries} selectedItem={selectedEntry} onSelected={item => setSelectedEntry(item)} - onHighlighted={item => onEntryHovered(item?.resource)} + onHighlighted={item => onEntryHovered?.(item?.resource)} columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} columnWidths={columnWidths} diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx index 945ac86fc038f..159536d925b31 100644 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -14,52 +14,52 @@ limitations under the License. */ -import * as React from 'react'; -import './recorderView.css'; -import { MultiTraceModel } from './modelUtil'; -import type { SourceLocation } from './modelUtil'; -import { Workbench } from './workbench'; +import type { Language } from '@isomorphic/locatorGenerators'; import type { Mode, Source } from '@recorder/recorderTypes'; +import { SplitView } from '@web/components/splitView'; +import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; +import { TabbedPane } from '@web/components/tabbedPane'; +import { sha1, useSetting } from '@web/uiUtils'; +import * as React from 'react'; import type { ContextEntry } from '../entries'; +import type { Boundaries } from '../geometry'; +import { ActionList } from './actionList'; +import { ConsoleTab, useConsoleTabModel } from './consoleTab'; +import { InspectorTab } from './inspectorTab'; +import type * as modelUtil from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { MultiTraceModel } from './modelUtil'; +import { NetworkTab, useNetworkTabModel } from './networkTab'; +import './recorderView.css'; +import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab'; +import { SourceTab } from './sourceTab'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; +import { toggleTheme } from '@web/theme'; +import { SourceChooser } from '@web/components/sourceChooser'; const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); -const trace = searchParams.get('trace') + '.json'; +const traceLocation = searchParams.get('trace') + '.json'; export const RecorderView: React.FunctionComponent = () => { const [connection, setConnection] = React.useState(null); const [sources, setSources] = React.useState([]); + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>(); + const [mode, setMode] = React.useState('none'); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + React.useEffect(() => { const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); const webSocket = new WebSocket(wsURL.toString()); - setConnection(new Connection(webSocket, { setSources })); + setConnection(new Connection(webSocket, { setMode, setSources })); return () => { webSocket.close(); }; }, []); - React.useEffect(() => { - if (!connection) - return; - connection.setMode('recording'); - }, [connection]); - - return
- -
; -}; - -export const TraceView: React.FC<{ - traceLocation: string, - sources: Source[], -}> = ({ traceLocation, sources }) => { - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); - const [counter, setCounter] = React.useState(0); - const pollTimer = React.useRef(null); - React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -67,8 +67,9 @@ export const TraceView: React.FC<{ // Start polling running test. pollTimer.current = setTimeout(async () => { try { - const model = await loadSingleTraceFile(traceLocation); - setModel({ model, isLive: true }); + const result = await loadSingleTraceFile(traceLocation); + if (result.sha1 !== model?.sha1) + setModel({ ...result, isLive: true }); } catch { setModel(undefined); } finally { @@ -79,10 +80,94 @@ export const TraceView: React.FC<{ if (pollTimer.current) clearTimeout(pollTimer.current); }; - }, [counter, traceLocation]); + }, [counter, model]); + + return
+ connection?.setMode(mode)} + model={model?.model} + sources={sources} + /> +
; +}; + +async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + + const tokens: string[] = []; + for (const entry of contextEntries) { + entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime)); + entry.events.forEach(e => tokens.push(e.type + '@' + e.time)); + } + return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) }; +} + +export const Workbench: React.FunctionComponent<{ + mode: Mode, + setMode: (mode: Mode) => void, + model?: modelUtil.MultiTraceModel, + sources: Source[], +}> = ({ mode, setMode, model, sources }) => { + const [fileId, setFileId] = React.useState(); + const [selectedCallId, setSelectedCallId] = React.useState(undefined); + const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('recorderPropertiesTab', 'source'); + const [isInspecting, setIsInspectingState] = React.useState(false); + const [highlightedLocator, setHighlightedLocator] = React.useState(''); + const [selectedTime, setSelectedTime] = React.useState(); + const sourceModel = React.useRef(new Map()); + + const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { + setSelectedCallId(action?.callId); + }, []); + + const selectedAction = React.useMemo(() => { + return model?.actions.find(a => a.callId === selectedCallId); + }, [model, selectedCallId]); + + const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { + setSelectedAction(action); + }, [setSelectedAction]); + + const selectPropertiesTab = React.useCallback((tab: string) => { + setSelectedPropertiesTab(tab); + if (tab !== 'inspector') + setIsInspectingState(false); + }, [setSelectedPropertiesTab]); + + const setIsInspecting = React.useCallback((value: boolean) => { + if (!isInspecting && value) + selectPropertiesTab('inspector'); + setIsInspectingState(value); + }, [setIsInspectingState, selectPropertiesTab, isInspecting]); + + const locatorPicked = React.useCallback((locator: string) => { + setHighlightedLocator(locator); + selectPropertiesTab('inspector'); + }, [selectPropertiesTab]); + + const consoleModel = useConsoleTabModel(model, selectedTime); + const networkModel = useNetworkTabModel(model, selectedTime); + const sdkLanguage = model?.sdkLanguage || 'javascript'; + + const inspectorTab: TabbedPaneTabModel = { + id: 'inspector', + title: 'Locator', + render: () => , + }; + + const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]); const fallbackLocation = React.useMemo(() => { - if (!sources.length) + if (!source) return undefined; const fallbackLocation: SourceLocation = { file: '', @@ -90,37 +175,178 @@ export const TraceView: React.FC<{ column: 0, source: { errors: [], - content: sources[0].text + content: source.text } }; return fallbackLocation; - }, [sources]); + }, [source]); + + const sourceTab: TabbedPaneTabModel = { + id: 'source', + title: 'Source', + render: () => + }; + const consoleTab: TabbedPaneTabModel = { + id: 'console', + title: 'Console', + count: consoleModel.entries.length, + render: () => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })} + /> + }; + const networkTab: TabbedPaneTabModel = { + id: 'network', + title: 'Network', + count: networkModel.resources.length, + render: () => + }; - return { + const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; + if (boundaries.minimum > boundaries.maximum) { + boundaries.minimum = 0; + boundaries.maximum = 30000; + } + // Leave some nice free space on the right hand side. + boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + return { boundaries }; + }, [model]); + + const actionList = selectPropertiesTab('console')} isLive={true} - hideTimeline={true} />; + + const actionsTab: TabbedPaneTabModel = { + id: 'actions', + title: 'Actions', + component: actionList, + }; + + const toolbar = +
+ { + setMode(mode === 'recording' ? 'standby' : 'recording'); + }}>Record + + { + setIsInspecting(!isInspecting); + }} /> + { + }} /> + { + }} /> + { + }} /> + + { + }} /> +
+
Target:
+ { + setFileId(fileId); + }} /> + { + }}> + toggleTheme()}> +
; + + const sidebarTabbedPane = ; + + const propertiesTabbedPane = ; + + const snapshotView = ; + + return
+ + {toolbar} + {snapshotView} +
} + sidebar={propertiesTabbedPane} + />} + sidebar={sidebarTabbedPane} + /> + ; }; -async function loadSingleTraceFile(url: string): Promise { - const params = new URLSearchParams(); - params.set('trace', url); - const response = await fetch(`contexts?${params.toString()}`); - const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(contextEntries); -} +const SnapshotContainer: React.FunctionComponent<{ + sdkLanguage: Language, + action: modelUtil.ActionTraceEventInContext | undefined, + testIdAttributeName?: string, + isInspecting: boolean, + highlightedLocator: string, + setIsInspecting: (value: boolean) => void, + locatorPicked: (locator: string) => void, +}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => { + const snapshot = React.useMemo(() => { + const snapshot = collectSnapshots(action); + return snapshot.action || snapshot.after || snapshot.before; + }, [action]); + const snapshotUrls = React.useMemo(() => { + return snapshot ? extendSnapshot(snapshot) : undefined; + }, [snapshot]); + return ; +}; + +type ConnectionOptions = { + setSources: (sources: Source[]) => void; + setMode: (mode: Mode) => void; +}; class Connection { private _lastId = 0; private _webSocket: WebSocket; private _callbacks = new Map void, reject: (arg: Error) => void }>(); - private _options: { setSources: (sources: Source[]) => void; }; + private _options: ConnectionOptions; - constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + constructor(webSocket: WebSocket, options: ConnectionOptions) { this._webSocket = webSocket; this._callbacks = new Map(); this._options = options; @@ -166,5 +392,9 @@ class Connection { this._options.setSources(sources); window.playwrightSourcesEchoForTest = sources; } + if (method === 'setMode') { + const { mode } = params as { mode: Mode }; + this._options.setMode(mode); + } } } diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index ce54b34d539b1..d130499207a07 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; export const SourceTab: React.FunctionComponent<{ - stack: StackFrame[] | undefined, + stack?: StackFrame[], stackFrameLocation: 'bottom' | 'right', sources: Map, rootDir?: string, diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 13c5f5fd0e1f6..2905bb052fd4a 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{ }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); - - const [highlightedAction, setHighlightedAction] = React.useState(); + const [highlightedCallId, setHighlightedCallId] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{ setRevealedError(undefined); }, []); + const highlightedAction = React.useMemo(() => { + return model?.actions.find(a => a.callId === highlightedCallId); + }, [model, highlightedCallId]); + + const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => { + setHighlightedCallId(highlightedAction?.callId); + }, []); + const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { diff --git a/packages/web/src/components/sourceChooser.css b/packages/web/src/components/sourceChooser.css new file mode 100644 index 0000000000000..60ac74bf85445 --- /dev/null +++ b/packages/web/src/components/sourceChooser.css @@ -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. +*/ + +.source-chooser { + border: none; + background: none; + outline: none; + color: var(--vscode-sideBarTitle-foreground); + min-width: 100px; +} diff --git a/packages/web/src/components/sourceChooser.tsx b/packages/web/src/components/sourceChooser.tsx new file mode 100644 index 0000000000000..0645480a03c65 --- /dev/null +++ b/packages/web/src/components/sourceChooser.tsx @@ -0,0 +1,58 @@ +/** + * 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 React from 'react'; +import type { Source } from '@recorder/recorderTypes'; + +export const SourceChooser: React.FC<{ + sources: Source[], + fileId: string | undefined, + setFileId: (fileId: string) => void, +}> = ({ sources, fileId, setFileId }) => { + return ; +}; + +function renderSourceOptions(sources: Source[]): React.ReactNode { + const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); + const renderOption = (source: Source): React.ReactNode => ( + + ); + + const hasGroup = sources.some(s => s.group); + if (hasGroup) { + const groups = new Set(sources.map(s => s.group)); + return [...groups].filter(Boolean).map(group => ( + + {sources.filter(s => s.group === group).map(source => renderOption(source))} + + )); + } + + return sources.map(source => renderOption(source)); +} + +export function emptySource(): Source { + return { + id: 'default', + isRecorded: false, + text: '', + language: 'javascript', + label: '', + highlight: [] + }; +} \ No newline at end of file diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index b9872294df6c6..5df94ec4c3c23 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{ tabs: TabbedPaneTabModel[], leftToolbar?: React.ReactElement[], rightToolbar?: React.ReactElement[], - selectedTab: string, - setSelectedTab: (tab: string) => void, + selectedTab?: string, + setSelectedTab?: (tab: string) => void, dataTestId?: string, mode?: 'default' | 'select', }> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => { + if (!selectedTab) + selectedTab = tabs[0].id; if (!mode) mode = 'default'; return
@@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
} {mode === 'select' &&