diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index ad6b9d0f87..04bdee3735 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -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) { diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index ad1c65f6d9..c0fe19441a 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -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; - private _context: BrowserContext; - constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map) { + constructor(pageAliases: Map) { 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) { diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index 9605d21ba6..be8a04a9c3 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -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, 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, actionInCo return { callMetadata, mainFrame }; } -export function traceEventsToAction(events: trace.TraceEvent[]): actions.ActionInContext[] { - const result: actions.ActionInContext[] = []; - const pageAliases = new Map(); - 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) { diff --git a/packages/trace-viewer/src/DEPS.list b/packages/trace-viewer/src/DEPS.list index f52c0a024e..3d486b5452 100644 --- a/packages/trace-viewer/src/DEPS.list +++ b/packages/trace-viewer/src/DEPS.list @@ -6,3 +6,7 @@ ui/ [sw-main.ts] sw/** + + +[recorder.tsx] +ui/recorder/** diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx index 4de705d4fc..5e6b9764e3 100644 --- a/packages/trace-viewer/src/recorder.tsx +++ b/packages/trace-viewer/src/recorder.tsx @@ -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(); diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index 43029ed5bb..7888aa6a30 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -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); diff --git a/packages/trace-viewer/src/sw/traceModel.ts b/packages/trace-viewer/src/sw/traceModel.ts index dfd417bef8..602ff4e075 100644 --- a/packages/trace-viewer/src/sw/traceModel.ts +++ b/packages/trace-viewer/src/sw/traceModel.ts @@ -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', diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 881b1c05f2..cdc3cb7e93 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -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 ; diff --git a/packages/trace-viewer/src/ui/recorder/DEPS.list b/packages/trace-viewer/src/ui/recorder/DEPS.list new file mode 100644 index 0000000000..a504a7dba1 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorder/DEPS.list @@ -0,0 +1,5 @@ +[*] +@isomorphic/** +@trace/** +@web/** +../** diff --git a/packages/trace-viewer/src/ui/recorder/actionListView.tsx b/packages/trace-viewer/src/ui/recorder/actionListView.tsx new file mode 100644 index 0000000000..71a7c05c7e --- /dev/null +++ b/packages/trace-viewer/src/ui/recorder/actionListView.tsx @@ -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; + +export const ActionListView: React.FC<{ + actions: actionTypes.ActionInContext[], + selectedAction: actionTypes.ActionInContext | undefined, + onSelectedAction: (action: actionTypes.ActionInContext | undefined) => void, +}> = ({ + actions, + selectedAction, + onSelectedAction, +}) => { + return
+ +
; +}; + +export const renderAction = (action: actionTypes.ActionInContext) => { + return <> +
+ {action.action.name} +
+ ; +}; diff --git a/packages/trace-viewer/src/ui/recorder/backendContext.tsx b/packages/trace-viewer/src/ui/recorder/backendContext.tsx new file mode 100644 index 0000000000..b65dfa2686 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorder/backendContext.tsx @@ -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(undefined); + +export const BackendProvider: React.FunctionComponent> = ({ guid, children }) => { + const [connection, setConnection] = React.useState(undefined); + const [mode, setMode] = React.useState('none'); + const [actions, setActions] = React.useState([]); + const [sources, setSources] = React.useState([]); + 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 + {children} + ; +}; + +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 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 { + 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); + } + } +} diff --git a/packages/trace-viewer/src/ui/recorder/modelContext.tsx b/packages/trace-viewer/src/ui/recorder/modelContext.tsx new file mode 100644 index 0000000000..9db52e1964 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorder/modelContext.tsx @@ -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(undefined); + +export const ModelProvider: React.FunctionComponent> = ({ trace, children }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, sha1: string } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(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 + {children} + ; +}; + +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('|')) }; +} diff --git a/packages/trace-viewer/src/ui/recorderView.css b/packages/trace-viewer/src/ui/recorder/recorderView.css similarity index 100% rename from packages/trace-viewer/src/ui/recorderView.css rename to packages/trace-viewer/src/ui/recorder/recorderView.css diff --git a/packages/trace-viewer/src/ui/recorder/recorderView.tsx b/packages/trace-viewer/src/ui/recorder/recorderView.tsx new file mode 100644 index 0000000000..3df4c0e7ba --- /dev/null +++ b/packages/trace-viewer/src/ui/recorder/recorderView.tsx @@ -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 + + + + ; +}; + +export const Workbench: React.FunctionComponent = () => { + const backend = React.useContext(BackendContext); + const model = React.useContext(ModelContext); + const [fileId, setFileId] = React.useState(); + const [selectedCallTime, setSelectedCallTime] = React.useState(undefined); + const [isInspecting, setIsInspecting] = React.useState(false); + const [highlightedLocator, setHighlightedLocator] = React.useState(''); + + 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 = ; + + const actionsTab: TabbedPaneTabModel = { + id: 'actions', + title: 'Actions', + component: actionList, + }; + + const toolbar = +
+ { + setIsInspecting(!isInspecting); + }} /> + { + }} /> + { + }} /> + { + }} /> + + { + if (source?.text) + copy(source.text); + }}> +
+
Target:
+ { + setFileId(fileId); + }} /> + { + }}> + toggleTheme()}> +
; + + const sidebarTabbedPane = ; + const traceView = ; + const propertiesView = ; + + return
+ + {toolbar} + {traceView} +
} + sidebar={propertiesView} + />} + sidebar={sidebarTabbedPane} + /> + ; +}; + +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()); + const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('recorderPropertiesTab', 'source'); + + const sdkLanguage = model?.sdkLanguage || 'javascript'; + + const inspectorTab: TabbedPaneTabModel = { + id: 'inspector', + title: 'Locator', + render: () => , + }; + + const sourceTab: TabbedPaneTabModel = { + id: 'source', + title: 'Source', + render: () => + }; + const consoleTab: TabbedPaneTabModel = { + id: 'console', + title: 'Console', + count: consoleModel.entries.length, + render: () => + }; + const networkTab: TabbedPaneTabModel = { + id: 'network', + title: 'Network', + count: networkModel.resources.length, + render: () => + }; + + const tabs: TabbedPaneTabModel[] = [ + sourceTab, + inspectorTab, + consoleTab, + networkTab, + ]; + + return ; +}; + +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 ; +}; diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx deleted file mode 100644 index 329deb5568..0000000000 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ /dev/null @@ -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(null); - const [sources, setSources] = React.useState([]); - const [, setActions] = React.useState([]); - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>(); - const [mode, setMode] = React.useState('none'); - const [counter, setCounter] = React.useState(0); - const pollTimer = React.useRef(null); - - React.useEffect(() => { - const wsURL = new URL(`../${guid}`, window.location.toString()); - wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); - const webSocket = new WebSocket(wsURL.toString()); - setConnection(new Connection(webSocket, { 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
- connection?.setMode(mode)} - model={model?.model} - sources={sources} - /> -
; -}; - -async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> { - const params = new URLSearchParams(); - params.set('trace', url); - const response = await fetch(`contexts?${params.toString()}`); - const contextEntries = await response.json() as ContextEntry[]; - - const tokens: string[] = []; - for (const entry of contextEntries) { - entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime)); - entry.events.forEach(e => tokens.push(e.type + '@' + e.time)); - } - return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) }; -} - -export const Workbench: React.FunctionComponent<{ - mode: Mode, - setMode: (mode: Mode) => void, - model?: modelUtil.MultiTraceModel, - sources: Source[], -}> = ({ mode, setMode, model, sources }) => { - const [fileId, setFileId] = React.useState(); - const [selectedCallId, setSelectedCallId] = React.useState(undefined); - const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('recorderPropertiesTab', 'source'); - const [isInspecting, setIsInspectingState] = React.useState(false); - const [highlightedLocator, setHighlightedLocator] = React.useState(''); - const [selectedTime, setSelectedTime] = React.useState(); - const sourceModel = React.useRef(new Map()); - - const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { - setSelectedCallId(action?.callId); - }, []); - - const selectedAction = React.useMemo(() => { - return model?.actions.find(a => a.callId === selectedCallId); - }, [model, selectedCallId]); - - const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { - setSelectedAction(action); - }, [setSelectedAction]); - - const selectPropertiesTab = React.useCallback((tab: string) => { - setSelectedPropertiesTab(tab); - if (tab !== 'inspector') - setIsInspectingState(false); - }, [setSelectedPropertiesTab]); - - const setIsInspecting = React.useCallback((value: boolean) => { - if (!isInspecting && value) - selectPropertiesTab('inspector'); - setIsInspectingState(value); - }, [setIsInspectingState, selectPropertiesTab, isInspecting]); - - const locatorPicked = React.useCallback((locator: string) => { - setHighlightedLocator(locator); - selectPropertiesTab('inspector'); - }, [selectPropertiesTab]); - - const consoleModel = useConsoleTabModel(model, selectedTime); - const networkModel = useNetworkTabModel(model, selectedTime); - const sdkLanguage = model?.sdkLanguage || 'javascript'; - - const inspectorTab: TabbedPaneTabModel = { - id: 'inspector', - title: 'Locator', - render: () => , - }; - - const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]); - - const fallbackLocation = React.useMemo(() => { - if (!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: () => - }; - const consoleTab: TabbedPaneTabModel = { - id: 'console', - title: 'Console', - count: consoleModel.entries.length, - render: () => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })} - /> - }; - const networkTab: TabbedPaneTabModel = { - id: 'network', - title: 'Network', - count: networkModel.resources.length, - render: () => - }; - - 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 = selectPropertiesTab('console')} - isLive={true} - />; - - const actionsTab: TabbedPaneTabModel = { - id: 'actions', - title: 'Actions', - component: actionList, - }; - - const toolbar = -
- { - setMode(mode === 'recording' ? 'standby' : 'recording'); - }}>Record - - { - setIsInspecting(!isInspecting); - }} /> - { - }} /> - { - }} /> - { - }} /> - - { - }} /> -
-
Target:
- { - setFileId(fileId); - }} /> - { - }}> - toggleTheme()}> -
; - - const sidebarTabbedPane = ; - - const propertiesTabbedPane = ; - - const snapshotView = ; - - return
- - {toolbar} - {snapshotView} -
} - sidebar={propertiesTabbedPane} - />} - sidebar={sidebarTabbedPane} - /> - ; -}; - -const SnapshotContainer: React.FunctionComponent<{ - sdkLanguage: Language, - action: modelUtil.ActionTraceEventInContext | undefined, - testIdAttributeName?: string, - isInspecting: boolean, - highlightedLocator: string, - setIsInspecting: (value: boolean) => void, - locatorPicked: (locator: string) => void, -}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => { - const snapshot = React.useMemo(() => { - const snapshot = collectSnapshots(action); - return snapshot.action || snapshot.after || snapshot.before; - }, [action]); - const snapshotUrls = React.useMemo(() => { - return snapshot ? extendSnapshot(snapshot) : undefined; - }, [snapshot]); - return ; -}; - -type ConnectionOptions = { - 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 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 { - 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); - } - } -} diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 9955b5d755..74158aeeae 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -160,7 +160,7 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso export async function parseTrace(file: string): Promise<{ resources: Map, 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[] = [];