chore: start building tv-recorder toolbar

This commit is contained in:
Pavel 2024-09-20 18:19:22 -07:00
parent 17ed944a84
commit c9215adc03
11 changed files with 222 additions and 102 deletions

View file

@ -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<void>();
readonly eventSink = new ManualPromise<EventEmitter>();
constructor() {
}
@ -103,6 +105,8 @@ class RecorderTransport implements Transport {
}
async dispatch(method: string, params: any): Promise<any> {
const eventSink = await this.eventSink;
eventSink.emit('event', { event: method, params });
}
onclose() {

View file

@ -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;
}

View file

@ -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<RecorderProps> = ({
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<RecorderProps> = ({
}}></ToolbarButton>
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => {
setFileId(event.target.selectedOptions[0].value);
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } });
}}>{renderSourceOptions(sources)}</select>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
}} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' });
}}></ToolbarButton>
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
/>
</div>;
};
function renderSourceOptions(sources: Source[]): React.ReactNode {
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
const renderOption = (source: Source): React.ReactNode => (
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
);
const hasGroup = sources.some(s => s.group);
if (hasGroup) {
const groups = new Set(sources.map(s => s.group));
return [...groups].filter(Boolean).map(group => (
<optgroup label={group} key={group}>
{sources.filter(s => s.group === group).map(source => renderOption(source))}
</optgroup>
));
}
return sources.map(source => renderOption(source));
}

View file

@ -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<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
const [mode, setMode] = React.useState<Mode>('none');
const [fileId, setFileId] = React.useState<string | undefined>();
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 <div className='vbox workbench-loader'>
<Toolbar>
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText' || mode === 'assertingVisibility'} onClick={() => {
connection?.setMode(mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby');
}}>Record</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='inspect' title='Pick locator' toggled={mode === 'inspecting' || mode === 'recording-inspecting'} onClick={() => {
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<string, Mode>)[mode];
connection?.setMode(newMode);
}}></ToolbarButton>
<ToolbarButton icon='eye' title='Assert visibility' toggled={mode === 'assertingVisibility'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility');
}}></ToolbarButton>
<ToolbarButton icon='whole-word' title='Assert text' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingText' ? 'recording' : 'assertingText');
}}></ToolbarButton>
<ToolbarButton icon='symbol-constant' title='Assert value' toggled={mode === 'assertingValue'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingValue' ? 'recording' : 'assertingValue');
}}></ToolbarButton>
<ToolbarSeparator />
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
}} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
}}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar>
<TraceView
traceLocation={trace}
sources={sources} />
source={source} />
</div>;
};
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<NodeJS.Timeout | null>(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 <Workbench
key='workbench'
@ -103,6 +155,7 @@ export const TraceView: React.FC<{
fallbackLocation={fallbackLocation}
isLive={true}
hideTimeline={true}
hideMetatada={true}
/>;
};
@ -114,13 +167,19 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
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<number, { resolve: (arg: any) => 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);
}
}
}

View file

@ -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);

View file

@ -211,7 +211,8 @@ export const SnapshotTab: React.FunctionComponent<{
iframe={iframeRef1.current}
iteration={loadingRef.current.iteration} />
<Toolbar>
<ToolbarButton className='pick-locator' title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
<ToolbarButton title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
<div style={{ width: 4 }}></div>
{['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab
key={tab}

View file

@ -16,7 +16,7 @@
import { SplitView } from '@web/components/splitView';
import * as React from 'react';
import { useAsyncMemo } from '@web/uiUtils';
import { copy, useAsyncMemo } from '@web/uiUtils';
import './sourceTab.css';
import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
@ -104,13 +104,18 @@ export const SourceTab: React.FunctionComponent<{
orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'}
sidebarHidden={!showStackFrames}
main={<div className='vbox' data-testid='source-code'>
{ fileName && <Toolbar>
{fileName && <Toolbar>
<div className='source-tab-file-name' title={fileName}>
<div>{shortFileName}</div>
</div>
<CopyToClipboard description='Copy filename' value={shortFileName}/>
{location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
</Toolbar> }
</Toolbar>}
{!fileName && <div style={{ position: 'absolute', right: 5, top: 5 }}>
<ToolbarButton icon='files' title='Copy' onClick={() => {
copy(source.content || '');
}} />
</div>}
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div>}
sidebar={<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />}

View file

@ -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<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
@ -280,10 +281,7 @@ export const Workbench: React.FunctionComponent<{
else if (model && model.wallTime)
time = Date.now() - model.wallTime;
const actionsTab: TabbedPaneTabModel = {
id: 'actions',
title: 'Actions',
component: <div className='vbox'>
const actionsView = <div className='vbox'>
{status && <div className='workbench-run-status'>
<span className={clsx('codicon', testStatusIcon(status))}></span>
<div>{testStatusText(status)}</div>
@ -301,21 +299,28 @@ export const Workbench: React.FunctionComponent<{
revealConsole={() => selectPropertiesTab('console')}
isLive={isLive}
/>
</div>
};
const metadataTab: TabbedPaneTabModel = {
id: 'metadata',
title: 'Metadata',
component: <MetadataView model={model}/>
};
const settingsTab: TabbedPaneTabModel = {
id: 'settings',
title: 'Settings',
component: <SettingsView settings={[
</div>;
const metadataView = hideMetatada ? null : <MetadataView model={model}/>;
const settingsView = showSettings ? <SettingsView settings={[
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
]}/>,
]}/> : null;
const actionsTab: TabbedPaneTabModel = {
id: 'actions',
title: 'Actions',
component: actionsView,
};
const metadataTab: TabbedPaneTabModel | null = metadataView ? {
id: 'metadata',
title: 'Metadata',
component: metadataView,
} : null;
const settingsTab: TabbedPaneTabModel | null = settingsView ? {
id: 'settings',
title: 'Settings',
component: settingsView,
} : null;
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
{!hideTimeline && <Timeline
@ -348,13 +353,11 @@ export const Workbench: React.FunctionComponent<{
highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked}
openPage={openPage} />}
sidebar={
<TabbedPane
tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]}
sidebar={<TabbedPane
tabs={[actionsTab, metadataTab, settingsTab].filter(Boolean) as TabbedPaneTabModel[]}
selectedTab={selectedNavigatorTab}
setSelectedTab={setSelectedNavigatorTab}
/>
}
/>}
/>}
sidebar={<TabbedPane
tabs={tabs}

View file

@ -0,0 +1,23 @@
/*
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;
}

View file

@ -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 <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
setFileId(event.target.selectedOptions[0].value);
}}>{renderSourceOptions(sources)}</select>;
};
function renderSourceOptions(sources: Source[]): React.ReactNode {
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
const renderOption = (source: Source): React.ReactNode => (
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
);
const hasGroup = sources.some(s => s.group);
if (hasGroup) {
const groups = new Set(sources.map(s => s.group));
return [...groups].filter(Boolean).map(group => (
<optgroup label={group} key={group}>
{sources.filter(s => s.group === group).map(source => renderOption(source))}
</optgroup>
));
}
return sources.map(source => renderOption(source));
}
export function emptySource(): Source {
return {
id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
}

View file

@ -21,7 +21,7 @@
min-height: 35px;
align-items: center;
flex: none;
padding-right: 4px;
padding: 0 4px;
}
.toolbar:after {