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 React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { WorkbenchLoader } from './ui/workbench';
|
||||
import { WorkbenchLoader } from './ui/workbenchLoader';
|
||||
|
||||
(async () => {
|
||||
applyTheme();
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ import type { MultiTraceModel } from './modelUtil';
|
|||
const tileSize = { width: 200, height: 45 };
|
||||
|
||||
export const FilmStrip: React.FunctionComponent<{
|
||||
context: MultiTraceModel,
|
||||
model: MultiTraceModel,
|
||||
boundaries: Boundaries,
|
||||
previewPoint?: { x: number, clientY: number },
|
||||
}> = ({ context, boundaries, previewPoint }) => {
|
||||
}> = ({ model, boundaries, previewPoint }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
||||
let pageIndex = 0;
|
||||
|
|
@ -37,7 +37,7 @@ export const FilmStrip: React.FunctionComponent<{
|
|||
pageIndex = ((previewPoint.clientY - bounds.top) / tileSize.height) | 0;
|
||||
}
|
||||
|
||||
const screencastFrames = context.pages[pageIndex]?.screencastFrames;
|
||||
const screencastFrames = model.pages[pageIndex]?.screencastFrames;
|
||||
let previewImage = undefined;
|
||||
let previewSize = undefined;
|
||||
if (previewPoint !== undefined && screencastFrames) {
|
||||
|
|
@ -48,7 +48,7 @@ export const FilmStrip: React.FunctionComponent<{
|
|||
}
|
||||
|
||||
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}
|
||||
page={page}
|
||||
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<{
|
||||
context: MultiTraceModel,
|
||||
boundaries: Boundaries,
|
||||
model: MultiTraceModel,
|
||||
selectedAction: ActionTraceEvent | undefined,
|
||||
onSelected: (action: ActionTraceEvent) => void,
|
||||
}> = ({ context, boundaries, selectedAction, onSelected }) => {
|
||||
}> = ({ model, selectedAction, onSelected }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
const barsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [previewPoint, setPreviewPoint] = React.useState<{ x: number, clientY: number } | undefined>();
|
||||
const [hoveredBarIndex, setHoveredBarIndex] = React.useState<number | undefined>();
|
||||
|
||||
const offsets = React.useMemo(() => {
|
||||
return calculateDividerOffsets(measure.width, boundaries);
|
||||
}, [measure.width, boundaries]);
|
||||
const { boundaries, offsets } = React.useMemo(() => {
|
||||
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;
|
||||
return { boundaries, offsets: calculateDividerOffsets(measure.width, boundaries) };
|
||||
}, [measure.width, model]);
|
||||
|
||||
const bars = React.useMemo(() => {
|
||||
const bars: TimelineBar[] = [];
|
||||
for (const entry of context.actions) {
|
||||
for (const entry of model.actions) {
|
||||
let detail = trimRight(entry.params.selector || '', 50);
|
||||
if (entry.method === 'goto')
|
||||
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;
|
||||
bars.push({
|
||||
event,
|
||||
|
|
@ -87,7 +89,7 @@ export const Timeline: React.FunctionComponent<{
|
|||
});
|
||||
}
|
||||
return bars;
|
||||
}, [context, boundaries, measure.width]);
|
||||
}, [model, boundaries, measure.width]);
|
||||
|
||||
const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined;
|
||||
let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction);
|
||||
|
|
@ -185,7 +187,7 @@ export const Timeline: React.FunctionComponent<{
|
|||
></div>;
|
||||
})
|
||||
}</div>
|
||||
<FilmStrip context={context} boundaries={boundaries} previewPoint={previewPoint} />
|
||||
<FilmStrip model={model} boundaries={boundaries} previewPoint={previewPoint} />
|
||||
<div className='timeline-marker timeline-marker-hover' style={{
|
||||
display: (previewPoint !== undefined) ? 'block' : 'none',
|
||||
left: (previewPoint?.x || 0) + 'px',
|
||||
|
|
|
|||
|
|
@ -15,18 +15,19 @@
|
|||
*/
|
||||
|
||||
import '@web/third_party/vscode/codicon.css';
|
||||
import { loadSingleTraceFile, Workbench } from './workbench';
|
||||
import { Workbench } from './workbench';
|
||||
import '@web/common.css';
|
||||
import React from 'react';
|
||||
import { ListView } from '@web/components/listView';
|
||||
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
|
||||
import type { FullConfig, Suite, TestCase, TestStep } from '../../../playwright-test/types/testReporter';
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import type { MultiTraceModel } from './modelUtil';
|
||||
import { MultiTraceModel } from './modelUtil';
|
||||
import './watchMode.css';
|
||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { toggleTheme } from '@web/theme';
|
||||
import type { ContextEntry } from '../entries';
|
||||
|
||||
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
|
||||
let updateStepsProgress: () => void = () => {};
|
||||
|
|
@ -277,8 +278,7 @@ export const TraceView: React.FC<{
|
|||
</div>;
|
||||
}
|
||||
|
||||
return <Workbench model={model} view='embedded'></Workbench>;
|
||||
|
||||
return <Workbench model={model} />;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
|
@ -485,3 +485,11 @@ function flattenTree(fileItems: FileItem[], expandedItems: Map<string, boolean |
|
|||
}
|
||||
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 { SplitView } from '@web/components/splitView';
|
||||
import { msToString } from '@web/uiUtils';
|
||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||
import * as React from 'react';
|
||||
import type { ContextEntry } from '../entries';
|
||||
import { ActionList } from './actionList';
|
||||
import { CallTab } from './callTab';
|
||||
import { ConsoleTab } from './consoleTab';
|
||||
import * as modelUtil from './modelUtil';
|
||||
import { MultiTraceModel } from './modelUtil';
|
||||
import type { MultiTraceModel } from './modelUtil';
|
||||
import { NetworkTab } from './networkTab';
|
||||
import { SnapshotTab } from './snapshotTab';
|
||||
import { SourceTab } from './sourceTab';
|
||||
import { TabbedPane } from '@web/components/tabbedPane';
|
||||
import { Timeline } from './timeline';
|
||||
import './workbench.css';
|
||||
import { toggleTheme } from '@web/theme';
|
||||
|
||||
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>;
|
||||
};
|
||||
import { MetadataView } from './metadataView';
|
||||
|
||||
export const Workbench: React.FunctionComponent<{
|
||||
model: MultiTraceModel,
|
||||
view: 'embedded' | 'standalone'
|
||||
}> = ({ model, view }) => {
|
||||
}> = ({ model }) => {
|
||||
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>('logs');
|
||||
|
||||
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 consoleCount = errors + warnings;
|
||||
const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0;
|
||||
|
|
@ -194,13 +50,12 @@ export const Workbench: React.FunctionComponent<{
|
|||
];
|
||||
|
||||
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'>
|
||||
<div style={{ paddingLeft: '20px', flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
|
||||
<Timeline
|
||||
context={model}
|
||||
boundaries={boundaries}
|
||||
model={model}
|
||||
selectedAction={activeAction}
|
||||
onSelected={action => setSelectedAction(action)}
|
||||
/>
|
||||
|
|
@ -224,36 +79,13 @@ export const Workbench: React.FunctionComponent<{
|
|||
}}
|
||||
setSelectedTab={setSelectedPropertiesTab}
|
||||
/> },
|
||||
{ id: 'metadata', title: 'Metadata', count: 0, render: () => <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> },
|
||||
{ id: 'metadata',
|
||||
title: 'Metadata',
|
||||
count: 0,
|
||||
render: () => <MetadataView model={model} />
|
||||
},
|
||||
]
|
||||
} selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
|
||||
</SplitView>
|
||||
</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