chore: start putting tv-recorder ui together (#32776)
This commit is contained in:
parent
7c3dd70bf6
commit
8649b13f25
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActionListProps> = ({
|
|||
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)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Sorting | undefined>(undefined);
|
||||
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(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}
|
||||
|
|
|
|||
|
|
@ -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<Connection | null>(null);
|
||||
const [sources, setSources] = React.useState<Source[]>([]);
|
||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>();
|
||||
const [mode, setMode] = React.useState<Mode>('none');
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(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 <div className='vbox workbench-loader'>
|
||||
<TraceView
|
||||
traceLocation={trace}
|
||||
sources={sources} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
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<NodeJS.Timeout | null>(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 <div className='vbox workbench-loader'>
|
||||
<Workbench
|
||||
key='workbench'
|
||||
mode={mode}
|
||||
setMode={mode => connection?.setMode(mode)}
|
||||
model={model?.model}
|
||||
sources={sources}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
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<string | undefined>();
|
||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
|
||||
const [isInspecting, setIsInspectingState] = React.useState(false);
|
||||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
|
||||
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
|
||||
|
||||
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: () => <InspectorTab
|
||||
sdkLanguage={sdkLanguage}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={setHighlightedLocator} />,
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
model={model?.model}
|
||||
showSourcesFirst={true}
|
||||
const sourceTab: TabbedPaneTabModel = {
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
render: () => <SourceTab
|
||||
sources={sourceModel.current}
|
||||
stackFrameLocation={'right'}
|
||||
fallbackLocation={fallbackLocation}
|
||||
isLive={true}
|
||||
hideTimeline={true}
|
||||
/>;
|
||||
/>
|
||||
};
|
||||
const consoleTab: TabbedPaneTabModel = {
|
||||
id: 'console',
|
||||
title: 'Console',
|
||||
count: consoleModel.entries.length,
|
||||
render: () => <ConsoleTab
|
||||
consoleModel={consoleModel}
|
||||
boundaries={boundaries}
|
||||
selectedTime={selectedTime}
|
||||
onAccepted={m => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })}
|
||||
/>
|
||||
};
|
||||
const networkTab: TabbedPaneTabModel = {
|
||||
id: 'network',
|
||||
title: 'Network',
|
||||
count: networkModel.resources.length,
|
||||
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
|
||||
};
|
||||
|
||||
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
||||
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 tabs: TabbedPaneTabModel[] = [
|
||||
sourceTab,
|
||||
inspectorTab,
|
||||
consoleTab,
|
||||
networkTab,
|
||||
];
|
||||
|
||||
const { boundaries } = React.useMemo(() => {
|
||||
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 = <ActionList
|
||||
sdkLanguage={sdkLanguage}
|
||||
actions={model?.actions || []}
|
||||
selectedAction={model ? selectedAction : undefined}
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
onSelected={onActionSelected}
|
||||
revealConsole={() => selectPropertiesTab('console')}
|
||||
isLive={true}
|
||||
/>;
|
||||
|
||||
const actionsTab: TabbedPaneTabModel = {
|
||||
id: 'actions',
|
||||
title: 'Actions',
|
||||
component: actionList,
|
||||
};
|
||||
|
||||
const toolbar = <Toolbar sidebarBackground>
|
||||
<div style={{ width: 4 }}></div>
|
||||
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording'} onClick={() => {
|
||||
setMode(mode === 'recording' ? 'standby' : 'recording');
|
||||
}}>Record</ToolbarButton>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
|
||||
setIsInspecting(!isInspecting);
|
||||
}} />
|
||||
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
|
||||
}} />
|
||||
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
|
||||
}} />
|
||||
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
|
||||
}} />
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon='files' title='Copy' onClick={() => {
|
||||
}} />
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
||||
</Toolbar>;
|
||||
|
||||
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
|
||||
|
||||
const propertiesTabbedPane = <TabbedPane
|
||||
tabs={tabs}
|
||||
selectedTab={selectedPropertiesTab}
|
||||
setSelectedTab={selectPropertiesTab}
|
||||
/>;
|
||||
|
||||
const snapshotView = <SnapshotContainer
|
||||
sdkLanguage={sdkLanguage}
|
||||
action={selectedAction}
|
||||
testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
|
||||
isInspecting={isInspecting}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
locatorPicked={locatorPicked} />;
|
||||
|
||||
return <div className='vbox workbench'>
|
||||
<SplitView
|
||||
sidebarSize={250}
|
||||
orientation={'horizontal'}
|
||||
settingName='recorderActionListSidebar'
|
||||
sidebarIsFirst
|
||||
main={<SplitView
|
||||
sidebarSize={250}
|
||||
orientation='vertical'
|
||||
settingName='recorderPropertiesSidebar'
|
||||
main={<div className='vbox'>
|
||||
{toolbar}
|
||||
{snapshotView}
|
||||
</div>}
|
||||
sidebar={propertiesTabbedPane}
|
||||
/>}
|
||||
sidebar={sidebarTabbedPane}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
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 <SnapshotView
|
||||
sdkLanguage={sdkLanguage}
|
||||
testIdAttributeName={testIdAttributeName || 'data-testid'}
|
||||
isInspecting={isInspecting}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={locatorPicked}
|
||||
snapshotUrls={snapshotUrls} />;
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, SourceModel>,
|
||||
rootDir?: string,
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(undefined);
|
||||
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
|
||||
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
|
||||
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
|
||||
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
|
||||
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
|
||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('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<string, modelUtil.SourceModel>(), [model]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
|
|||
20
packages/web/src/components/sourceChooser.css
Normal file
20
packages/web/src/components/sourceChooser.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
58
packages/web/src/components/sourceChooser.tsx
Normal file
58
packages/web/src/components/sourceChooser.tsx
Normal 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: []
|
||||
};
|
||||
}
|
||||
|
|
@ -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 <div className='tabbed-pane' data-testid={dataTestId}>
|
||||
|
|
@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
|
|||
</div>}
|
||||
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
|
||||
setSelectedTab(tabs[e.currentTarget.selectedIndex].id);
|
||||
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
let suffix = '';
|
||||
|
|
@ -95,10 +97,10 @@ export const TabbedPaneTab: React.FunctionComponent<{
|
|||
count?: number,
|
||||
errorCount?: number,
|
||||
selected?: boolean,
|
||||
onSelect: (id: string) => void
|
||||
onSelect?: (id: string) => void
|
||||
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
|
||||
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
|
||||
onClick={() => onSelect(id)}
|
||||
onClick={() => onSelect?.(id)}
|
||||
title={title}
|
||||
key={id}>
|
||||
<div className='tabbed-pane-tab-label'>{title}</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar.toolbar-sidebar-background {
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.toolbar:after {
|
||||
content: '';
|
||||
display: block;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import * as React from 'react';
|
|||
type ToolbarProps = {
|
||||
noShadow?: boolean;
|
||||
noMinHeight?: boolean;
|
||||
sidebarBackground?: boolean;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
|
@ -30,7 +31,8 @@ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
|
|||
children,
|
||||
noMinHeight,
|
||||
className,
|
||||
sidebarBackground,
|
||||
onClick,
|
||||
}) => {
|
||||
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} onClick={onClick}>{children}</div>;
|
||||
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className, sidebarBackground && 'toolbar-sidebar-background')} onClick={onClick}>{children}</div>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -203,5 +203,10 @@ export function clsx(...classes: (string | undefined | false)[]) {
|
|||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export async function sha1(str: string): Promise<string> {
|
||||
const buffer = new TextEncoder().encode(str);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer))).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
|
||||
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
|
||||
|
|
|
|||
Loading…
Reference in a new issue