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.css b/packages/recorder/src/recorder.css index fa7ce623db315..bb34b2aa1ca53 100644 --- a/packages/recorder/src/recorder.css +++ b/packages/recorder/src/recorder.css @@ -20,14 +20,6 @@ flex: auto; } -.recorder-chooser { - border: none; - background: none; - outline: none; - color: var(--vscode-sideBarTitle-foreground); - min-width: 100px; -} - .recorder .codicon { font-size: 16px; } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 31bad2b70b3f5..35b8af90df82c 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -17,6 +17,7 @@ import type { CallLog, Mode, Source } from './recorderTypes'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; +import { emptySource, SourceChooser } from '@web/components/sourceChooser'; import { TabbedPane } from '@web/components/tabbedPane'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; @@ -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/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx index 945ac86fc038f..2b46152565ab9 100644 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -21,6 +21,10 @@ import type { SourceLocation } from './modelUtil'; import { Workbench } from './workbench'; import type { Mode, Source } from '@recorder/recorderTypes'; import type { ContextEntry } from '../entries'; +import { emptySource, SourceChooser } from '@web/components/sourceChooser'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; +import { toggleTheme } from '@web/theme'; const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); @@ -29,33 +33,81 @@ const trace = searchParams.get('trace') + '.json'; export const RecorderView: React.FunctionComponent = () => { const [connection, setConnection] = React.useState(null); const [sources, setSources] = React.useState([]); + const [mode, setMode] = React.useState('none'); + const [fileId, setFileId] = React.useState(); + + React.useEffect(() => { + if (!fileId && sources.length > 0) + setFileId(sources[0].id); + }, [fileId, sources]); + + const source = React.useMemo(() => { + if (fileId) { + const source = sources.find(s => s.id === fileId); + if (source) + return source; + } + return emptySource(); + }, [sources, fileId]); + 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, { setSources, setMode })); return () => { webSocket.close(); }; }, []); - React.useEffect(() => { - if (!connection) - return; - connection.setMode('recording'); - }, [connection]); - return
+ + { + connection?.setMode(mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby'); + }}>Record + + { + const newMode = ({ + 'inspecting': 'standby', + 'none': 'inspecting', + 'standby': 'inspecting', + 'recording': 'recording-inspecting', + 'recording-inspecting': 'recording', + 'assertingText': 'recording-inspecting', + 'assertingVisibility': 'recording-inspecting', + 'assertingValue': 'recording-inspecting', + } as Record)[mode]; + connection?.setMode(newMode); + }}> + { + connection?.setMode(mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility'); + }}> + { + connection?.setMode(mode === 'assertingText' ? 'recording' : 'assertingText'); + }}> + { + connection?.setMode(mode === 'assertingValue' ? 'recording' : 'assertingValue'); + }}> + +
+
Target:
+ { + setFileId(fileId); + }} /> + { + }}> + toggleTheme()}> +
+ source={source} />
; }; export const TraceView: React.FC<{ traceLocation: string, - sources: Source[], -}> = ({ traceLocation, sources }) => { + source: Source | undefined, +}> = ({ traceLocation, source }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -82,7 +134,7 @@ export const TraceView: React.FC<{ }, [counter, traceLocation]); const fallbackLocation = React.useMemo(() => { - if (!sources.length) + if (!source) return undefined; const fallbackLocation: SourceLocation = { file: '', @@ -90,11 +142,11 @@ export const TraceView: React.FC<{ column: 0, source: { errors: [], - content: sources[0].text + content: source.text } }; return fallbackLocation; - }, [sources]); + }, [source]); return ; }; @@ -114,13 +167,19 @@ async function loadSingleTraceFile(url: string): Promise { return new MultiTraceModel(contextEntries); } + +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; @@ -157,7 +216,7 @@ class Connection { } private _sendMessageNoReply(method: string, params?: any) { - this._sendMessage(method, params).catch(() => { }); + this._sendMessage(method, params); } private _dispatchEvent(method: string, params?: any) { @@ -166,5 +225,10 @@ 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/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index 2677cfe53a2cc..4eb94817c3a6a 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -28,10 +28,6 @@ background-color: var(--vscode-sideBar-background); } -.snapshot-tab .toolbar .pick-locator { - margin: 0 4px; -} - .snapshot-controls { flex: none; background-color: var(--vscode-sideBar-background); diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 9dafa10b965fa..dc742cc60b09b 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -211,7 +211,8 @@ export const SnapshotTab: React.FunctionComponent<{ iframe={iframeRef1.current} iteration={loadingRef.current.iteration} /> - setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> + setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> +
{['action', 'before', 'after'].map(tab => { return - { fileName && + {fileName &&
{shortFileName}
{location && } -
} +
} + {!fileName &&
+ { + copy(source.content || ''); + }} /> +
} } sidebar={} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index f10d4831fd7af..9877c02a347c9 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -50,7 +50,6 @@ export const Workbench: React.FunctionComponent<{ rootDir?: string, fallbackLocation?: modelUtil.SourceLocation, isLive?: boolean, - hideTimeline?: boolean, status?: UITestStatus, annotations?: { type: string; description?: string; }[]; inert?: boolean, @@ -58,7 +57,9 @@ export const Workbench: React.FunctionComponent<{ onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, showSettings?: boolean, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { + hideTimeline?: boolean, + hideMetatada?: boolean, +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, hideMetatada, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); @@ -280,42 +281,46 @@ export const Workbench: React.FunctionComponent<{ else if (model && model.wallTime) time = Date.now() - model.wallTime; + const actionsView =
+ {status &&
+ +
{testStatusText(status)}
+
+
{time ? msToString(time) : ''}
+
} + selectPropertiesTab('console')} + isLive={isLive} + /> +
; + const metadataView = hideMetatada ? null : ; + const settingsView = showSettings ? : null; + const actionsTab: TabbedPaneTabModel = { id: 'actions', title: 'Actions', - component:
- {status &&
- -
{testStatusText(status)}
-
-
{time ? msToString(time) : ''}
-
} - selectPropertiesTab('console')} - isLive={isLive} - /> -
+ component: actionsView, }; - const metadataTab: TabbedPaneTabModel = { + const metadataTab: TabbedPaneTabModel | null = metadataView ? { id: 'metadata', title: 'Metadata', - component: - }; - const settingsTab: TabbedPaneTabModel = { + component: metadataView, + } : null; + const settingsTab: TabbedPaneTabModel | null = settingsView ? { id: 'settings', title: 'Settings', - component: , - }; + component: settingsView, + } : null; return
{!hideTimeline && } - sidebar={ - - } + sidebar={} />} sidebar={ 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: [] + }; +} diff --git a/packages/web/src/components/toolbar.css b/packages/web/src/components/toolbar.css index 8c58f96f65fb3..5ef285205a400 100644 --- a/packages/web/src/components/toolbar.css +++ b/packages/web/src/components/toolbar.css @@ -21,7 +21,7 @@ min-height: 35px; align-items: center; flex: none; - padding-right: 4px; + padding: 0 4px; } .toolbar:after {