chore: render recorded action list in tv mode (#32841)

This commit is contained in:
Pavel Feldman 2024-09-26 16:46:27 -07:00 committed by GitHub
parent 5b85c71722
commit 1a3d3f699b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 537 additions and 729 deletions

View file

@ -75,7 +75,7 @@ export class ContextRecorder extends EventEmitter {
saveStorage: params.saveStorage,
};
this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
this._collection = new RecorderCollection(this._pageAliases);
this._collection.on('change', (actions: actions.ActionInContext[]) => {
this._recorderSources = [];
for (const languageGenerator of this._orderedLanguages) {

View file

@ -20,30 +20,20 @@ import type { Page } from '../page';
import type { Signal } from '../../../../recorder/src/actions';
import type * as actions from '@recorder/actions';
import { monotonicTime } from '../../utils/time';
import { callMetadataForAction, collapseActions, traceEventsToAction } from './recorderUtils';
import { callMetadataForAction, collapseActions } from './recorderUtils';
import { serializeError } from '../errors';
import { performAction } from './recorderRunner';
import type { CallMetadata } from '@protocol/callMetadata';
import { isUnderTest } from '../../utils/debug';
import type { BrowserContext } from '../browserContext';
export class RecorderCollection extends EventEmitter {
private _actions: actions.ActionInContext[] = [];
private _enabled = false;
private _pageAliases: Map<Page, string>;
private _context: BrowserContext;
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map<Page, string>) {
constructor(pageAliases: Map<Page, string>) {
super();
this._context = context;
this._pageAliases = pageAliases;
if (codegenMode === 'trace-events') {
this._context.tracing.onMemoryEvents(events => {
this._actions = traceEventsToAction(events);
this._fireChange();
});
}
}
restart() {
@ -86,7 +76,8 @@ export class RecorderCollection extends EventEmitter {
const error = await callback?.(callMetadata).catch((e: Error) => e);
callMetadata.endTime = monotonicTime();
callMetadata.error = error ? serializeError(error) : undefined;
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
// Do not wait for onAfterCall so that performAction returned immediately after the action.
mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata).catch(() => {});
}
signal(pageAlias: string, frame: Frame, signal: Signal) {

View file

@ -20,12 +20,10 @@ import type { Page } from '../page';
import type { Frame } from '../frames';
import type * as actions from '@recorder/actions';
import type * as channels from '@protocol/channels';
import type * as trace from '@trace/trace';
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
import { toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils';
import { createGuid, monotonicTime } from '../../utils';
import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
import type { SmartKeyboardModifier } from '../types';
import { createGuid } from '../../utils';
import { serializeValue } from '../../protocol/serializers';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method;
@ -201,7 +199,7 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
startTime: actionInContext.timestamp,
endTime: 0,
type: 'Frame',
method,
@ -211,281 +209,6 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
return { callMetadata, mainFrame };
}
export function traceEventsToAction(events: trace.TraceEvent[]): actions.ActionInContext[] {
const result: actions.ActionInContext[] = [];
const pageAliases = new Map<string, string>();
let lastDownloadOrdinal = 0;
let lastDialogOrdinal = 0;
const addSignal = (signal: actions.Signal) => {
const lastAction = result[result.length - 1];
if (!lastAction)
return;
lastAction.action.signals.push(signal);
};
for (const event of events) {
if (event.type === 'event' && event.class === 'BrowserContext') {
const { method, params } = event;
if (method === 'page') {
const pageAlias = 'page' + (pageAliases.size || '');
pageAliases.set(params.pageId, pageAlias);
addSignal({
name: 'popup',
popupAlias: pageAlias,
});
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'openPage',
url: '',
signals: [],
},
timestamp: event.time,
});
continue;
}
if (method === 'pageClosed') {
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'closePage',
signals: [],
},
timestamp: event.time,
});
continue;
}
if (method === 'download') {
const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : '';
++lastDownloadOrdinal;
addSignal({
name: 'download',
downloadAlias,
});
continue;
}
if (method === 'dialog') {
const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : '';
++lastDialogOrdinal;
addSignal({
name: 'dialog',
dialogAlias,
});
continue;
}
continue;
}
if (event.type !== 'before' || !event.pageId)
continue;
if (!event.stepId?.startsWith('recorder@'))
continue;
const { method, params: untypedParams, pageId } = event;
let pageAlias = pageAliases.get(pageId);
if (!pageAlias) {
pageAlias = 'page';
pageAliases.set(pageId, pageAlias);
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'openPage',
url: '',
signals: [],
},
timestamp: event.startTime,
});
}
if (method === 'goto') {
const params = untypedParams as channels.FrameGotoParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'navigate',
url: params.url,
signals: [],
},
timestamp: event.startTime,
});
continue;
}
if (method === 'click') {
const params = untypedParams as channels.FrameClickParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'click',
selector: params.selector,
signals: [],
button: params.button || 'left',
modifiers: fromKeyboardModifiers(params.modifiers),
clickCount: params.clickCount || 1,
position: params.position,
},
timestamp: event.startTime
});
continue;
}
if (method === 'fill') {
const params = untypedParams as channels.FrameFillParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'fill',
selector: params.selector,
signals: [],
text: params.value,
},
timestamp: event.startTime
});
continue;
}
if (method === 'press') {
const params = untypedParams as channels.FramePressParams;
const tokens = params.key.split('+');
const modifiers = tokens.slice(0, tokens.length - 1) as SmartKeyboardModifier[];
const key = tokens[tokens.length - 1];
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'press',
selector: params.selector,
signals: [],
key,
modifiers: fromKeyboardModifiers(modifiers),
},
timestamp: event.startTime
});
continue;
}
if (method === 'check') {
const params = untypedParams as channels.FrameCheckParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'check',
selector: params.selector,
signals: [],
},
timestamp: event.startTime
});
continue;
}
if (method === 'uncheck') {
const params = untypedParams as channels.FrameUncheckParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'uncheck',
selector: params.selector,
signals: [],
},
timestamp: event.startTime
});
continue;
}
if (method === 'selectOption') {
const params = untypedParams as channels.FrameSelectOptionParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'select',
selector: params.selector,
signals: [],
options: (params.options || []).map(option => option.value!),
},
timestamp: event.startTime
});
continue;
}
if (method === 'setInputFiles') {
const params = untypedParams as channels.FrameSetInputFilesParams;
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'setInputFiles',
selector: params.selector,
signals: [],
files: params.localPaths || [],
},
timestamp: event.startTime
});
continue;
}
if (method === 'expect') {
const params = untypedParams as channels.FrameExpectParams;
if (params.expression === 'to.have.text') {
const entry = params.expectedText?.[0];
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertText',
selector: params.selector,
signals: [],
text: entry?.string!,
substring: !!entry?.matchSubstring,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.have.value') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertValue',
selector: params.selector,
signals: [],
value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles),
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.checked') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertChecked',
selector: params.selector,
signals: [],
checked: !params.isNot,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.visible') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertVisible',
selector: params.selector,
signals: [],
},
timestamp: event.startTime
});
continue;
}
continue;
}
}
return result;
}
export function collapseActions(actions: actions.ActionInContext[]): actions.ActionInContext[] {
const result: actions.ActionInContext[] = [];
for (const action of actions) {

View file

@ -6,3 +6,7 @@ ui/
[sw-main.ts]
sw/**
[recorder.tsx]
ui/recorder/**

View file

@ -18,7 +18,7 @@ import '@web/common.css';
import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css';
import * as ReactDOM from 'react-dom/client';
import { RecorderView } from './ui/recorderView';
import { RecorderView } from './ui/recorder/recorderView';
(async () => {
applyTheme();

View file

@ -47,14 +47,12 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
}
set.add(traceUrl);
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
const traceModel = new TraceModel();
try {
// Allow 10% to hop from sw to page.
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
await traceModel.load(backend, isRecorderMode, unzipProgress);
await traceModel.load(backend, unzipProgress);
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(error);

View file

@ -15,7 +15,7 @@
*/
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
import type { ActionEntry, ContextEntry } from '../types/entries';
import type { ContextEntry } from '../types/entries';
import { SnapshotStorage } from './snapshotStorage';
import { TraceModernizer } from './traceModernizer';
@ -37,7 +37,7 @@ export class TraceModel {
constructor() {
}
async load(backend: TraceModelBackend, isRecorderMode: boolean, unzipProgress: (done: number, total: number) => void) {
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
this._backend = backend;
const ordinals: string[] = [];
@ -71,8 +71,7 @@ export class TraceModel {
modernizer.appendTrace(network);
unzipProgress(++done, total);
const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions;
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
if (!backend.isLive()) {
// Terminate actions w/o after event gracefully.
@ -134,22 +133,6 @@ function stripEncodingFromContentType(contentType: string) {
return contentType;
}
function collapseActionsForRecorder(actions: ActionEntry[]): ActionEntry[] {
const result: ActionEntry[] = [];
for (const action of actions) {
const lastAction = result[result.length - 1];
const isSameAction = lastAction && lastAction.method === action.method && lastAction.pageId === action.pageId;
const isSameSelector = lastAction && 'selector' in lastAction.params && 'selector' in action.params && action.params.selector === lastAction.params.selector;
const shouldMerge = isSameAction && (action.method === 'goto' || (action.method === 'fill' && isSameSelector));
if (!shouldMerge) {
result.push(action);
continue;
}
result[result.length - 1] = action;
}
return result;
}
function createEmptyContext(): ContextEntry {
return {
origin: 'testRunner',

View file

@ -106,9 +106,9 @@ export function useConsoleTabModel(model: modelUtil.MultiTraceModel | undefined,
export const ConsoleTab: React.FunctionComponent<{
boundaries: Boundaries,
consoleModel: ConsoleTabModel,
selectedTime: Boundaries | undefined,
selectedTime?: Boundaries | undefined,
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
onAccepted: (entry: ConsoleEntry) => void,
onAccepted?: (entry: ConsoleEntry) => void,
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
if (!consoleModel.entries.length)
return <PlaceholderPanel text='No console entries' />;

View file

@ -0,0 +1,5 @@
[*]
@isomorphic/**
@trace/**
@web/**
../**

View file

@ -0,0 +1,48 @@
/*
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 type * as actionTypes from '@recorder/actions';
import { ListView } from '@web/components/listView';
import * as React from 'react';
const ActionList = ListView<actionTypes.ActionInContext>;
export const ActionListView: React.FC<{
actions: actionTypes.ActionInContext[],
selectedAction: actionTypes.ActionInContext | undefined,
onSelectedAction: (action: actionTypes.ActionInContext | undefined) => void,
}> = ({
actions,
selectedAction,
onSelectedAction,
}) => {
return <div className='vbox'>
<ActionList
name='actions'
items={actions}
selectedItem={selectedAction}
onSelected={onSelectedAction}
render={renderAction} />
</div>;
};
export const renderAction = (action: actionTypes.ActionInContext) => {
return <>
<div title={action.action.name}>
<span>{action.action.name}</span>
</div>
</>;
};

View file

@ -0,0 +1,124 @@
/*
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 type * as actionTypes from '@recorder/actions';
import type { Mode, Source } from '@recorder/recorderTypes';
import * as React from 'react';
export const BackendContext = React.createContext<Backend | undefined>(undefined);
export const BackendProvider: React.FunctionComponent<React.PropsWithChildren<{
guid: string,
}>> = ({ guid, children }) => {
const [connection, setConnection] = React.useState<Connection | undefined>(undefined);
const [mode, setMode] = React.useState<Mode>('none');
const [actions, setActions] = React.useState<actionTypes.ActionInContext[]>([]);
const [sources, setSources] = React.useState<Source[]>([]);
const callbacks = React.useRef({ setMode, setActions, setSources });
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, callbacks.current));
return () => {
webSocket.close();
};
}, [guid]);
const backend = React.useMemo(() => {
return connection ? { mode, actions, sources, connection } : undefined;
}, [actions, mode, sources, connection]);
return <BackendContext.Provider value={backend}>
{children}
</BackendContext.Provider>;
};
export type Backend = {
actions: actionTypes.ActionInContext[],
sources: Source[],
connection: Connection,
};
type ConnectionCallbacks = {
setMode: (mode: Mode) => void;
setActions: (actions: actionTypes.ActionInContext[]) => void;
setSources: (sources: Source[]) => void;
};
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: ConnectionCallbacks;
constructor(webSocket: WebSocket, options: ConnectionCallbacks) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
this._webSocket.addEventListener('message', event => {
const message = JSON.parse(event.data);
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
}
setMode(mode: Mode) {
this._sendMessageNoReply('setMode', { mode });
}
private async _sendMessage(method: string, params?: any): Promise<any> {
const id = ++this._lastId;
const message = { id, method, params };
this._webSocket.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => { });
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
if (method === 'setSources') {
const { sources } = params as { sources: Source[] };
this._options.setSources(sources);
(window as any).playwrightSourcesEchoForTest = sources;
}
if (method === 'setActions') {
const { actions } = params as { actions: actionTypes.ActionInContext[] };
this._options.setActions(actions);
}
}
}

View file

@ -0,0 +1,70 @@
/*
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 { sha1 } from '@web/uiUtils';
import * as React from 'react';
import type { ContextEntry } from '../../types/entries';
import { MultiTraceModel } from '../modelUtil';
export const ModelContext = React.createContext<MultiTraceModel | undefined>(undefined);
export const ModelProvider: React.FunctionComponent<React.PropsWithChildren<{
trace: string,
}>> = ({ trace, children }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, sha1: string } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const result = await loadSingleTraceFile(trace);
if (result.sha1 !== model?.sha1)
setModel(result);
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [counter, model, trace]);
return <ModelContext.Provider value={model?.model}>
{children}
</ModelContext.Provider>;
};
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('|')) };
}

View file

@ -0,0 +1,269 @@
/*
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 type * as actionTypes from '@recorder/actions';
import { SourceChooser } from '@web/components/sourceChooser';
import { SplitView } from '@web/components/splitView';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
import { copy, useSetting } from '@web/uiUtils';
import * as React from 'react';
import { ConsoleTab, useConsoleTabModel } from '../consoleTab';
import type { Boundaries } from '../geometry';
import { InspectorTab } from '../inspectorTab';
import type * as modelUtil from '../modelUtil';
import type { SourceLocation } from '../modelUtil';
import { NetworkTab, useNetworkTabModel } from '../networkTab';
import { collectSnapshots, extendSnapshot, SnapshotView } from '../snapshotTab';
import { SourceTab } from '../sourceTab';
import { ModelContext, ModelProvider } from './modelContext';
import './recorderView.css';
import { ActionListView } from './actionListView';
import { BackendContext, BackendProvider } from './backendContext';
export const RecorderView: React.FunctionComponent = () => {
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws')!;
const trace = searchParams.get('trace') + '.json';
return <BackendProvider guid={guid}>
<ModelProvider trace={trace}>
<Workbench />
</ModelProvider>
</BackendProvider>;
};
export const Workbench: React.FunctionComponent = () => {
const backend = React.useContext(BackendContext);
const model = React.useContext(ModelContext);
const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedCallTime, setSelectedCallTime] = React.useState<number | undefined>(undefined);
const [isInspecting, setIsInspecting] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const setSelectedAction = React.useCallback((action: actionTypes.ActionInContext | undefined) => {
setSelectedCallTime(action?.timestamp);
}, []);
const selectedAction = React.useMemo(() => {
return backend?.actions.find(a => a.timestamp === selectedCallTime);
}, [backend?.actions, selectedCallTime]);
const source = React.useMemo(() => backend?.sources.find(s => s.id === fileId) || backend?.sources[0], [backend?.sources, fileId]);
const sourceLocation = React.useMemo(() => {
if (!source)
return undefined;
const sourceLocation: SourceLocation = {
file: '',
line: 0,
column: 0,
source: {
errors: [],
content: source.text
}
};
return sourceLocation;
}, [source]);
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 = <ActionListView
actions={backend?.actions || []}
selectedAction={selectedAction}
onSelectedAction={setSelectedAction}
/>;
const actionsTab: TabbedPaneTabModel = {
id: 'actions',
title: 'Actions',
component: actionList,
};
const toolbar = <Toolbar sidebarBackground>
<div style={{ width: 4 }}></div>
<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' disabled={!source || !source.text} onClick={() => {
if (source?.text)
copy(source.text);
}}></ToolbarButton>
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<SourceChooser fileId={fileId} sources={backend?.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 traceView = <TraceView
callTime={selectedCallTime || 0}
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator} />;
const propertiesView = <PropertiesView
boundaries={boundaries}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator}
sourceLocation={sourceLocation} />;
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}
{traceView}
</div>}
sidebar={propertiesView}
/>}
sidebar={sidebarTabbedPane}
/>
</div>;
};
const PropertiesView: React.FunctionComponent<{
boundaries: Boundaries,
setIsInspecting: (value: boolean) => void,
highlightedLocator: string,
setHighlightedLocator: (locator: string) => void,
sourceLocation: modelUtil.SourceLocation | undefined,
}> = ({
boundaries,
setIsInspecting,
highlightedLocator,
setHighlightedLocator,
sourceLocation,
}) => {
const model = React.useContext(ModelContext);
const consoleModel = useConsoleTabModel(model, boundaries);
const networkModel = useNetworkTabModel(model, boundaries);
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
const sdkLanguage = model?.sdkLanguage || 'javascript';
const inspectorTab: TabbedPaneTabModel = {
id: 'inspector',
title: 'Locator',
render: () => <InspectorTab
sdkLanguage={sdkLanguage}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator} />,
};
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab
sources={sourceModel.current}
stackFrameLocation={'right'}
fallbackLocation={sourceLocation}
/>
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',
title: 'Console',
count: consoleModel.entries.length,
render: () => <ConsoleTab boundaries={boundaries} consoleModel={consoleModel} />
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
count: networkModel.resources.length,
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
};
const tabs: TabbedPaneTabModel[] = [
sourceTab,
inspectorTab,
consoleTab,
networkTab,
];
return <TabbedPane
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={setSelectedPropertiesTab}
/>;
};
const TraceView: React.FunctionComponent<{
callTime: number;
isInspecting: boolean;
setIsInspecting: (value: boolean) => void;
highlightedLocator: string;
setHighlightedLocator: (locator: string) => void;
}> = ({
callTime,
isInspecting,
setIsInspecting,
highlightedLocator,
setHighlightedLocator,
}) => {
const model = React.useContext(ModelContext);
const action = React.useMemo(() => {
return model?.actions.find(a => a.startTime === callTime);
}, [model, callTime]);
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='javascript'
testIdAttributeName='data-testid'
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator}
snapshotUrls={snapshotUrls} />;
};

View file

@ -1,407 +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 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 '../types/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';
import type * as actions from '@recorder/actions';
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
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 [, setActions] = React.useState<actions.ActionInContext[]>([]);
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, { setMode, setSources, setActions }));
return () => {
webSocket.close();
};
}, []);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const result = await loadSingleTraceFile(traceLocation);
if (result.sha1 !== model?.sha1)
setModel({ ...result, isLive: true });
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [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 (!source)
return undefined;
const fallbackLocation: SourceLocation = {
file: '',
line: 0,
column: 0,
source: {
errors: [],
content: source.text
}
};
return fallbackLocation;
}, [source]);
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab
sources={sourceModel.current}
stackFrameLocation={'right'}
fallbackLocation={fallbackLocation}
/>
};
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} />
};
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 = {
setMode: (mode: Mode) => void;
setSources: (sources: Source[]) => void;
setActions: (actions: actions.ActionInContext[]) => void;
};
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: ConnectionOptions;
constructor(webSocket: WebSocket, options: ConnectionOptions) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
this._webSocket.addEventListener('message', event => {
const message = JSON.parse(event.data);
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
}
setMode(mode: Mode) {
this._sendMessageNoReply('setMode', { mode });
}
private async _sendMessage(method: string, params?: any): Promise<any> {
const id = ++this._lastId;
const message = { id, method, params };
this._webSocket.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => { });
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
if (method === 'setSources') {
const { sources } = params as { sources: Source[] };
this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources;
}
if (method === 'setActions') {
const { actions } = params as { actions: actions.ActionInContext[] };
this._options.setActions(actions);
}
}
}

View file

@ -160,7 +160,7 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> {
const backend = new TraceBackend(file);
const traceModel = new TraceModel();
await traceModel.load(backend, false, () => {});
await traceModel.load(backend, () => {});
const model = new MultiTraceModel(traceModel.contextEntries);
const { rootItem } = buildActionTree(model.actions);
const actionTree: string[] = [];