chore: extract shared trace viewer components (#21433)
This commit is contained in:
parent
7c44eab644
commit
04b35089be
|
|
@ -19,7 +19,7 @@ import { applyTheme } from '@web/theme';
|
||||||
import '@web/third_party/vscode/codicon.css';
|
import '@web/third_party/vscode/codicon.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import { WorkbenchLoader } from './ui/workbench';
|
import { WorkbenchLoader } from './ui/workbenchLoader';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ import type { MultiTraceModel } from './modelUtil';
|
||||||
const tileSize = { width: 200, height: 45 };
|
const tileSize = { width: 200, height: 45 };
|
||||||
|
|
||||||
export const FilmStrip: React.FunctionComponent<{
|
export const FilmStrip: React.FunctionComponent<{
|
||||||
context: MultiTraceModel,
|
model: MultiTraceModel,
|
||||||
boundaries: Boundaries,
|
boundaries: Boundaries,
|
||||||
previewPoint?: { x: number, clientY: number },
|
previewPoint?: { x: number, clientY: number },
|
||||||
}> = ({ context, boundaries, previewPoint }) => {
|
}> = ({ model, boundaries, previewPoint }) => {
|
||||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||||
|
|
||||||
let pageIndex = 0;
|
let pageIndex = 0;
|
||||||
|
|
@ -37,7 +37,7 @@ export const FilmStrip: React.FunctionComponent<{
|
||||||
pageIndex = ((previewPoint.clientY - bounds.top) / tileSize.height) | 0;
|
pageIndex = ((previewPoint.clientY - bounds.top) / tileSize.height) | 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const screencastFrames = context.pages[pageIndex]?.screencastFrames;
|
const screencastFrames = model.pages[pageIndex]?.screencastFrames;
|
||||||
let previewImage = undefined;
|
let previewImage = undefined;
|
||||||
let previewSize = undefined;
|
let previewSize = undefined;
|
||||||
if (previewPoint !== undefined && screencastFrames) {
|
if (previewPoint !== undefined && screencastFrames) {
|
||||||
|
|
@ -48,7 +48,7 @@ export const FilmStrip: React.FunctionComponent<{
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className='film-strip' ref={ref}>{
|
return <div className='film-strip' ref={ref}>{
|
||||||
context.pages.filter(p => p.screencastFrames.length).map((page, index) => <FilmStripLane
|
model.pages.filter(p => p.screencastFrames.length).map((page, index) => <FilmStripLane
|
||||||
boundaries={boundaries}
|
boundaries={boundaries}
|
||||||
page={page}
|
page={page}
|
||||||
width={measure.width}
|
width={measure.width}
|
||||||
|
|
|
||||||
43
packages/trace-viewer/src/ui/metadataView.tsx
Normal file
43
packages/trace-viewer/src/ui/metadataView.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
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 { msToString } from '@web/uiUtils';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { MultiTraceModel } from './modelUtil';
|
||||||
|
import './callTab.css';
|
||||||
|
|
||||||
|
export const MetadataView: React.FunctionComponent<{
|
||||||
|
model: MultiTraceModel,
|
||||||
|
}> = ({ model }) => {
|
||||||
|
return <div className='vbox'>
|
||||||
|
<div className='call-section' style={{ paddingTop: 2 }}>Time</div>
|
||||||
|
{model.wallTime && <div className='call-line'>start time:<span className='call-value datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>}
|
||||||
|
<div className='call-line'>duration:<span className='call-value number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div>
|
||||||
|
<div className='call-section'>Browser</div>
|
||||||
|
<div className='call-line'>engine:<span className='call-value string' title={model.browserName}>{model.browserName}</span></div>
|
||||||
|
{model.platform && <div className='call-line'>platform:<span className='call-value string' title={model.platform}>{model.platform}</span></div>}
|
||||||
|
{model.options.userAgent && <div className='call-line'>user agent:<span className='call-value datetime' title={model.options.userAgent}>{model.options.userAgent}</span></div>}
|
||||||
|
<div className='call-section'>Viewport</div>
|
||||||
|
{model.options.viewport && <div className='call-line'>width:<span className='call-value number' title={String(!!model.options.viewport?.width)}>{model.options.viewport.width}</span></div>}
|
||||||
|
{model.options.viewport && <div className='call-line'>height:<span className='call-value number' title={String(!!model.options.viewport?.height)}>{model.options.viewport.height}</span></div>}
|
||||||
|
<div className='call-line'>is mobile:<span className='call-value boolean' title={String(!!model.options.isMobile)}>{String(!!model.options.isMobile)}</span></div>
|
||||||
|
{model.options.deviceScaleFactor && <div className='call-line'>device scale:<span className='call-value number' title={String(model.options.deviceScaleFactor)}>{String(model.options.deviceScaleFactor)}</span></div>}
|
||||||
|
<div className='call-section'>Counts</div>
|
||||||
|
<div className='call-line'>pages:<span className='call-value number'>{model.pages.length}</span></div>
|
||||||
|
<div className='call-line'>actions:<span className='call-value number'>{model.actions.length}</span></div>
|
||||||
|
<div className='call-line'>events:<span className='call-value number'>{model.events.length}</span></div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
@ -38,24 +38,26 @@ type TimelineBar = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Timeline: React.FunctionComponent<{
|
export const Timeline: React.FunctionComponent<{
|
||||||
context: MultiTraceModel,
|
model: MultiTraceModel,
|
||||||
boundaries: Boundaries,
|
|
||||||
selectedAction: ActionTraceEvent | undefined,
|
selectedAction: ActionTraceEvent | undefined,
|
||||||
onSelected: (action: ActionTraceEvent) => void,
|
onSelected: (action: ActionTraceEvent) => void,
|
||||||
}> = ({ context, boundaries, selectedAction, onSelected }) => {
|
}> = ({ model, selectedAction, onSelected }) => {
|
||||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||||
const barsRef = React.useRef<HTMLDivElement | null>(null);
|
const barsRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [previewPoint, setPreviewPoint] = React.useState<{ x: number, clientY: number } | undefined>();
|
const [previewPoint, setPreviewPoint] = React.useState<{ x: number, clientY: number } | undefined>();
|
||||||
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
|
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
|
||||||
|
|
||||||
const offsets = React.useMemo(() => {
|
const { boundaries, offsets } = React.useMemo(() => {
|
||||||
return calculateDividerOffsets(measure.width, boundaries);
|
const boundaries = { minimum: model.startTime, maximum: model.endTime };
|
||||||
}, [measure.width, boundaries]);
|
// Leave some nice free space on the right hand side.
|
||||||
|
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
|
||||||
|
return { boundaries, offsets: calculateDividerOffsets(measure.width, boundaries) };
|
||||||
|
}, [measure.width, model]);
|
||||||
|
|
||||||
const bars = React.useMemo(() => {
|
const bars = React.useMemo(() => {
|
||||||
const bars: TimelineBar[] = [];
|
const bars: TimelineBar[] = [];
|
||||||
for (const entry of context.actions) {
|
for (const entry of model.actions) {
|
||||||
let detail = trimRight(entry.params.selector || '', 50);
|
let detail = trimRight(entry.params.selector || '', 50);
|
||||||
if (entry.method === 'goto')
|
if (entry.method === 'goto')
|
||||||
detail = trimRight(entry.params.url || '', 50);
|
detail = trimRight(entry.params.url || '', 50);
|
||||||
|
|
@ -72,7 +74,7 @@ export const Timeline: React.FunctionComponent<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of context.events) {
|
for (const event of model.events) {
|
||||||
const startTime = event.time;
|
const startTime = event.time;
|
||||||
bars.push({
|
bars.push({
|
||||||
event,
|
event,
|
||||||
|
|
@ -87,7 +89,7 @@ export const Timeline: React.FunctionComponent<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return bars;
|
return bars;
|
||||||
}, [context, boundaries, measure.width]);
|
}, [model, boundaries, measure.width]);
|
||||||
|
|
||||||
const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined;
|
const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined;
|
||||||
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction);
|
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction);
|
||||||
|
|
@ -185,7 +187,7 @@ export const Timeline: React.FunctionComponent<{
|
||||||
></div>;
|
></div>;
|
||||||
})
|
})
|
||||||
}</div>
|
}</div>
|
||||||
<FilmStrip context={context} boundaries={boundaries} previewPoint={previewPoint} />
|
<FilmStrip model={model} boundaries={boundaries} previewPoint={previewPoint} />
|
||||||
<div className='timeline-marker timeline-marker-hover' style={{
|
<div className='timeline-marker timeline-marker-hover' style={{
|
||||||
display: (previewPoint !== undefined) ? 'block' : 'none',
|
display: (previewPoint !== undefined) ? 'block' : 'none',
|
||||||
left: (previewPoint?.x || 0) + 'px',
|
left: (previewPoint?.x || 0) + 'px',
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import '@web/third_party/vscode/codicon.css';
|
import '@web/third_party/vscode/codicon.css';
|
||||||
import { loadSingleTraceFile, Workbench } from './workbench';
|
import { Workbench } from './workbench';
|
||||||
import '@web/common.css';
|
import '@web/common.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ListView } from '@web/components/listView';
|
import { ListView } from '@web/components/listView';
|
||||||
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
|
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
|
||||||
import type { FullConfig, Suite, TestCase, TestStep } from '../../../playwright-test/types/testReporter';
|
import type { FullConfig, Suite, TestCase, TestStep } from '../../../playwright-test/types/testReporter';
|
||||||
import { SplitView } from '@web/components/splitView';
|
import { SplitView } from '@web/components/splitView';
|
||||||
import type { MultiTraceModel } from './modelUtil';
|
import { MultiTraceModel } from './modelUtil';
|
||||||
import './watchMode.css';
|
import './watchMode.css';
|
||||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||||
import { Toolbar } from '@web/components/toolbar';
|
import { Toolbar } from '@web/components/toolbar';
|
||||||
import { toggleTheme } from '@web/theme';
|
import { toggleTheme } from '@web/theme';
|
||||||
|
import type { ContextEntry } from '../entries';
|
||||||
|
|
||||||
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
|
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
|
||||||
let updateStepsProgress: () => void = () => {};
|
let updateStepsProgress: () => void = () => {};
|
||||||
|
|
@ -277,8 +278,7 @@ export const TraceView: React.FC<{
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Workbench model={model} view='embedded'></Workbench>;
|
return <Workbench model={model} />;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -485,3 +485,11 @@ function flattenTree(fileItems: FileItem[], expandedItems: Map<string, boolean |
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,173 +16,29 @@
|
||||||
|
|
||||||
import type { ActionTraceEvent } from '@trace/trace';
|
import type { ActionTraceEvent } from '@trace/trace';
|
||||||
import { SplitView } from '@web/components/splitView';
|
import { SplitView } from '@web/components/splitView';
|
||||||
import { msToString } from '@web/uiUtils';
|
|
||||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { ContextEntry } from '../entries';
|
|
||||||
import { ActionList } from './actionList';
|
import { ActionList } from './actionList';
|
||||||
import { CallTab } from './callTab';
|
import { CallTab } from './callTab';
|
||||||
import { ConsoleTab } from './consoleTab';
|
import { ConsoleTab } from './consoleTab';
|
||||||
import * as modelUtil from './modelUtil';
|
import * as modelUtil from './modelUtil';
|
||||||
import { MultiTraceModel } from './modelUtil';
|
import type { MultiTraceModel } from './modelUtil';
|
||||||
import { NetworkTab } from './networkTab';
|
import { NetworkTab } from './networkTab';
|
||||||
import { SnapshotTab } from './snapshotTab';
|
import { SnapshotTab } from './snapshotTab';
|
||||||
import { SourceTab } from './sourceTab';
|
import { SourceTab } from './sourceTab';
|
||||||
import { TabbedPane } from '@web/components/tabbedPane';
|
import { TabbedPane } from '@web/components/tabbedPane';
|
||||||
import { Timeline } from './timeline';
|
import { Timeline } from './timeline';
|
||||||
import './workbench.css';
|
import './workbench.css';
|
||||||
import { toggleTheme } from '@web/theme';
|
import { MetadataView } from './metadataView';
|
||||||
|
|
||||||
export const WorkbenchLoader: React.FunctionComponent<{
|
|
||||||
}> = () => {
|
|
||||||
const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
|
|
||||||
const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]);
|
|
||||||
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
|
|
||||||
const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 });
|
|
||||||
const [dragOver, setDragOver] = React.useState<boolean>(false);
|
|
||||||
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
|
|
||||||
const [fileForLocalModeError, setFileForLocalModeError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const processTraceFiles = (files: FileList) => {
|
|
||||||
const blobUrls = [];
|
|
||||||
const fileNames = [];
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files.item(i);
|
|
||||||
if (!file)
|
|
||||||
continue;
|
|
||||||
const blobTraceURL = URL.createObjectURL(file);
|
|
||||||
blobUrls.push(blobTraceURL);
|
|
||||||
fileNames.push(file.name);
|
|
||||||
url.searchParams.append('trace', blobTraceURL);
|
|
||||||
url.searchParams.append('traceFileName', file.name);
|
|
||||||
}
|
|
||||||
const href = url.toString();
|
|
||||||
// Snapshot loaders will inherit the trace url from the query parameters,
|
|
||||||
// so set it here.
|
|
||||||
window.history.pushState({}, '', href);
|
|
||||||
setTraceURLs(blobUrls);
|
|
||||||
setUploadedTraceNames(fileNames);
|
|
||||||
setDragOver(false);
|
|
||||||
setProcessingErrorMessage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDropEvent = (event: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
processTraceFiles(event.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileInputChange = (event: any) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!event.target.files)
|
|
||||||
return;
|
|
||||||
processTraceFiles(event.target.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const newTraceURLs = new URL(window.location.href).searchParams.getAll('trace');
|
|
||||||
// Don't accept file:// URLs - this means we re opened locally.
|
|
||||||
for (const url of newTraceURLs) {
|
|
||||||
if (url.startsWith('file:')) {
|
|
||||||
setFileForLocalModeError(url || null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't re-use blob file URLs on page load (results in Fetch error)
|
|
||||||
if (!newTraceURLs.some(url => url.startsWith('blob:')))
|
|
||||||
setTraceURLs(newTraceURLs);
|
|
||||||
}, [setTraceURLs]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (traceURLs.length) {
|
|
||||||
const swListener = (event: any) => {
|
|
||||||
if (event.data.method === 'progress')
|
|
||||||
setProgress(event.data.params);
|
|
||||||
};
|
|
||||||
navigator.serviceWorker.addEventListener('message', swListener);
|
|
||||||
setProgress({ done: 0, total: 1 });
|
|
||||||
const contextEntries: ContextEntry[] = [];
|
|
||||||
for (let i = 0; i < traceURLs.length; i++) {
|
|
||||||
const url = traceURLs[i];
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('trace', url);
|
|
||||||
if (uploadedTraceNames.length)
|
|
||||||
params.set('traceFileName', uploadedTraceNames[i]);
|
|
||||||
const response = await fetch(`contexts?${params.toString()}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
setTraceURLs([]);
|
|
||||||
setProcessingErrorMessage((await response.json()).error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
contextEntries.push(...(await response.json()));
|
|
||||||
}
|
|
||||||
navigator.serviceWorker.removeEventListener('message', swListener);
|
|
||||||
const model = new MultiTraceModel(contextEntries);
|
|
||||||
setProgress({ done: 0, total: 0 });
|
|
||||||
setModel(model);
|
|
||||||
} else {
|
|
||||||
setModel(emptyModel);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [traceURLs, uploadedTraceNames]);
|
|
||||||
|
|
||||||
return <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
|
|
||||||
<div className='hbox header'>
|
|
||||||
<div className='logo'>🎭</div>
|
|
||||||
<div className='product'>Playwright</div>
|
|
||||||
{model.title && <div className='title'>{model.title}</div>}
|
|
||||||
<div className='spacer'></div>
|
|
||||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
|
||||||
</div>
|
|
||||||
<Workbench model={model} view='standalone'></Workbench>
|
|
||||||
{!!progress.total && <div className='progress'>
|
|
||||||
<div className='inner-progress' style={{ width: (100 * progress.done / progress.total) + '%' }}></div>
|
|
||||||
</div>}
|
|
||||||
{fileForLocalModeError && <div className='drop-target'>
|
|
||||||
<div>Trace Viewer uses Service Workers to show traces. To view trace:</div>
|
|
||||||
<div style={{ paddingTop: 20 }}>
|
|
||||||
<div>1. Click <a href={fileForLocalModeError}>here</a> to put your trace into the download shelf</div>
|
|
||||||
<div>2. Go to <a href='https://trace.playwright.dev'>trace.playwright.dev</a></div>
|
|
||||||
<div>3. Drop the trace from the download shelf into the page</div>
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{!dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'>
|
|
||||||
<div className='processing-error'>{processingErrorMessage}</div>
|
|
||||||
<div className='title'>Drop Playwright Trace to load</div>
|
|
||||||
<div>or</div>
|
|
||||||
<button onClick={() => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.click();
|
|
||||||
input.addEventListener('change', e => handleFileInputChange(e));
|
|
||||||
}}>Select file</button>
|
|
||||||
<div style={{ maxWidth: 400 }}>Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere,
|
|
||||||
it opens it locally.</div>
|
|
||||||
</div>}
|
|
||||||
{dragOver && <div className='drop-target'
|
|
||||||
onDragLeave={() => { setDragOver(false); }}
|
|
||||||
onDrop={event => handleDropEvent(event)}>
|
|
||||||
<div className='title'>Release to analyse the Playwright Trace</div>
|
|
||||||
</div>}
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Workbench: React.FunctionComponent<{
|
export const Workbench: React.FunctionComponent<{
|
||||||
model: MultiTraceModel,
|
model: MultiTraceModel,
|
||||||
view: 'embedded' | 'standalone'
|
}> = ({ model }) => {
|
||||||
}> = ({ model, view }) => {
|
|
||||||
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
|
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
|
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs');
|
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs');
|
||||||
|
|
||||||
const activeAction = highlightedAction || selectedAction;
|
const activeAction = highlightedAction || selectedAction;
|
||||||
const boundaries = { minimum: model.startTime, maximum: model.endTime };
|
|
||||||
|
|
||||||
// Leave some nice free space on the right hand side.
|
|
||||||
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
|
|
||||||
const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 };
|
const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 };
|
||||||
const consoleCount = errors + warnings;
|
const consoleCount = errors + warnings;
|
||||||
const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0;
|
const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0;
|
||||||
|
|
@ -194,13 +50,12 @@ export const Workbench: React.FunctionComponent<{
|
||||||
];
|
];
|
||||||
|
|
||||||
if (model.hasSource)
|
if (model.hasSource)
|
||||||
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={selectedAction} /> });
|
tabs.push({ id: 'source', title: 'Source', count: 0, render: () => <SourceTab action={activeAction} /> });
|
||||||
|
|
||||||
return <div className='vbox'>
|
return <div className='vbox'>
|
||||||
<div style={{ paddingLeft: '20px', flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
|
<div style={{ paddingLeft: '20px', flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
|
||||||
<Timeline
|
<Timeline
|
||||||
context={model}
|
model={model}
|
||||||
boundaries={boundaries}
|
|
||||||
selectedAction={activeAction}
|
selectedAction={activeAction}
|
||||||
onSelected={action => setSelectedAction(action)}
|
onSelected={action => setSelectedAction(action)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -224,36 +79,13 @@ export const Workbench: React.FunctionComponent<{
|
||||||
}}
|
}}
|
||||||
setSelectedTab={setSelectedPropertiesTab}
|
setSelectedTab={setSelectedPropertiesTab}
|
||||||
/> },
|
/> },
|
||||||
{ id: 'metadata', title: 'Metadata', count: 0, render: () => <div className='vbox'>
|
{ id: 'metadata',
|
||||||
<div className='call-section' style={{ paddingTop: 2 }}>Time</div>
|
title: 'Metadata',
|
||||||
{model.wallTime && <div className='call-line'>start time:<span className='call-value datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>}
|
count: 0,
|
||||||
<div className='call-line'>duration:<span className='call-value number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div>
|
render: () => <MetadataView model={model} />
|
||||||
<div className='call-section'>Browser</div>
|
},
|
||||||
<div className='call-line'>engine:<span className='call-value string' title={model.browserName}>{model.browserName}</span></div>
|
|
||||||
{model.platform && <div className='call-line'>platform:<span className='call-value string' title={model.platform}>{model.platform}</span></div>}
|
|
||||||
{model.options.userAgent && <div className='call-line'>user agent:<span className='call-value datetime' title={model.options.userAgent}>{model.options.userAgent}</span></div>}
|
|
||||||
<div className='call-section'>Viewport</div>
|
|
||||||
{model.options.viewport && <div className='call-line'>width:<span className='call-value number' title={String(!!model.options.viewport?.width)}>{model.options.viewport.width}</span></div>}
|
|
||||||
{model.options.viewport && <div className='call-line'>height:<span className='call-value number' title={String(!!model.options.viewport?.height)}>{model.options.viewport.height}</span></div>}
|
|
||||||
<div className='call-line'>is mobile:<span className='call-value boolean' title={String(!!model.options.isMobile)}>{String(!!model.options.isMobile)}</span></div>
|
|
||||||
{model.options.deviceScaleFactor && <div className='call-line'>device scale:<span className='call-value number' title={String(model.options.deviceScaleFactor)}>{String(model.options.deviceScaleFactor)}</span></div>}
|
|
||||||
<div className='call-section'>Counts</div>
|
|
||||||
<div className='call-line'>pages:<span className='call-value number'>{model.pages.length}</span></div>
|
|
||||||
<div className='call-line'>actions:<span className='call-value number'>{model.actions.length}</span></div>
|
|
||||||
<div className='call-line'>events:<span className='call-value number'>{model.events.length}</span></div>
|
|
||||||
</div> },
|
|
||||||
]
|
]
|
||||||
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
|
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
|
||||||
</SplitView>
|
</SplitView>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyModel = new MultiTraceModel([]);
|
|
||||||
|
|
||||||
export 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
161
packages/trace-viewer/src/ui/workbenchLoader.tsx
Normal file
161
packages/trace-viewer/src/ui/workbenchLoader.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/*
|
||||||
|
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 { ToolbarButton } from '@web/components/toolbarButton';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ContextEntry } from '../entries';
|
||||||
|
import { MultiTraceModel } from './modelUtil';
|
||||||
|
import './workbench.css';
|
||||||
|
import { toggleTheme } from '@web/theme';
|
||||||
|
import { Workbench } from './workbench';
|
||||||
|
|
||||||
|
export const WorkbenchLoader: React.FunctionComponent<{
|
||||||
|
}> = () => {
|
||||||
|
const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
|
||||||
|
const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]);
|
||||||
|
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
|
||||||
|
const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 });
|
||||||
|
const [dragOver, setDragOver] = React.useState<boolean>(false);
|
||||||
|
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
|
||||||
|
const [fileForLocalModeError, setFileForLocalModeError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const processTraceFiles = (files: FileList) => {
|
||||||
|
const blobUrls = [];
|
||||||
|
const fileNames = [];
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files.item(i);
|
||||||
|
if (!file)
|
||||||
|
continue;
|
||||||
|
const blobTraceURL = URL.createObjectURL(file);
|
||||||
|
blobUrls.push(blobTraceURL);
|
||||||
|
fileNames.push(file.name);
|
||||||
|
url.searchParams.append('trace', blobTraceURL);
|
||||||
|
url.searchParams.append('traceFileName', file.name);
|
||||||
|
}
|
||||||
|
const href = url.toString();
|
||||||
|
// Snapshot loaders will inherit the trace url from the query parameters,
|
||||||
|
// so set it here.
|
||||||
|
window.history.pushState({}, '', href);
|
||||||
|
setTraceURLs(blobUrls);
|
||||||
|
setUploadedTraceNames(fileNames);
|
||||||
|
setDragOver(false);
|
||||||
|
setProcessingErrorMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropEvent = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
processTraceFiles(event.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = (event: any) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.target.files)
|
||||||
|
return;
|
||||||
|
processTraceFiles(event.target.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const newTraceURLs = new URL(window.location.href).searchParams.getAll('trace');
|
||||||
|
// Don't accept file:// URLs - this means we re opened locally.
|
||||||
|
for (const url of newTraceURLs) {
|
||||||
|
if (url.startsWith('file:')) {
|
||||||
|
setFileForLocalModeError(url || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't re-use blob file URLs on page load (results in Fetch error)
|
||||||
|
if (!newTraceURLs.some(url => url.startsWith('blob:')))
|
||||||
|
setTraceURLs(newTraceURLs);
|
||||||
|
}, [setTraceURLs]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (traceURLs.length) {
|
||||||
|
const swListener = (event: any) => {
|
||||||
|
if (event.data.method === 'progress')
|
||||||
|
setProgress(event.data.params);
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener('message', swListener);
|
||||||
|
setProgress({ done: 0, total: 1 });
|
||||||
|
const contextEntries: ContextEntry[] = [];
|
||||||
|
for (let i = 0; i < traceURLs.length; i++) {
|
||||||
|
const url = traceURLs[i];
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('trace', url);
|
||||||
|
if (uploadedTraceNames.length)
|
||||||
|
params.set('traceFileName', uploadedTraceNames[i]);
|
||||||
|
const response = await fetch(`contexts?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
setTraceURLs([]);
|
||||||
|
setProcessingErrorMessage((await response.json()).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextEntries.push(...(await response.json()));
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.removeEventListener('message', swListener);
|
||||||
|
const model = new MultiTraceModel(contextEntries);
|
||||||
|
setProgress({ done: 0, total: 0 });
|
||||||
|
setModel(model);
|
||||||
|
} else {
|
||||||
|
setModel(emptyModel);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [traceURLs, uploadedTraceNames]);
|
||||||
|
|
||||||
|
return <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
|
||||||
|
<div className='hbox header'>
|
||||||
|
<div className='logo'>🎭</div>
|
||||||
|
<div className='product'>Playwright</div>
|
||||||
|
{model.title && <div className='title'>{model.title}</div>}
|
||||||
|
<div className='spacer'></div>
|
||||||
|
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
||||||
|
</div>
|
||||||
|
<Workbench model={model} />
|
||||||
|
{!!progress.total && <div className='progress'>
|
||||||
|
<div className='inner-progress' style={{ width: (100 * progress.done / progress.total) + '%' }}></div>
|
||||||
|
</div>}
|
||||||
|
{fileForLocalModeError && <div className='drop-target'>
|
||||||
|
<div>Trace Viewer uses Service Workers to show traces. To view trace:</div>
|
||||||
|
<div style={{ paddingTop: 20 }}>
|
||||||
|
<div>1. Click <a href={fileForLocalModeError}>here</a> to put your trace into the download shelf</div>
|
||||||
|
<div>2. Go to <a href='https://trace.playwright.dev'>trace.playwright.dev</a></div>
|
||||||
|
<div>3. Drop the trace from the download shelf into the page</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{!dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'>
|
||||||
|
<div className='processing-error'>{processingErrorMessage}</div>
|
||||||
|
<div className='title'>Drop Playwright Trace to load</div>
|
||||||
|
<div>or</div>
|
||||||
|
<button onClick={() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.click();
|
||||||
|
input.addEventListener('change', e => handleFileInputChange(e));
|
||||||
|
}}>Select file</button>
|
||||||
|
<div style={{ maxWidth: 400 }}>Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere,
|
||||||
|
it opens it locally.</div>
|
||||||
|
</div>}
|
||||||
|
{dragOver && <div className='drop-target'
|
||||||
|
onDragLeave={() => { setDragOver(false); }}
|
||||||
|
onDrop={event => handleDropEvent(event)}>
|
||||||
|
<div className='title'>Release to analyse the Playwright Trace</div>
|
||||||
|
</div>}
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyModel = new MultiTraceModel([]);
|
||||||
Loading…
Reference in a new issue