/* 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 { 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 { 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([]); const [uploadedTraceNames, setUploadedTraceNames] = React.useState([]); const [model, setModel] = React.useState(emptyModel); const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 }); const [dragOver, setDragOver] = React.useState(false); const [processingErrorMessage, setProcessingErrorMessage] = React.useState(null); const [fileForLocalModeError, setFileForLocalModeError] = React.useState(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) => { 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
{ event.preventDefault(); setDragOver(true); }}>
🎭
Playwright
{model.title &&
{model.title}
}
toggleTheme()}>
{!!progress.total &&
} {fileForLocalModeError &&
Trace Viewer uses Service Workers to show traces. To view trace:
1. Click here to put your trace into the download shelf
3. Drop the trace from the download shelf into the page
} {!dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) &&
{processingErrorMessage}
Drop Playwright Trace to load
or
Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere, it opens it locally.
} {dragOver &&
{ setDragOver(false); }} onDrop={event => handleDropEvent(event)}>
Release to analyse the Playwright Trace
}
; }; export const Workbench: React.FunctionComponent<{ model: MultiTraceModel, view: 'embedded' | 'standalone' }> = ({ model, view }) => { const [selectedAction, setSelectedAction] = React.useState(); const [highlightedAction, setHighlightedAction] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState('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; const tabs = [ { id: 'logs', title: 'Call', count: 0, render: () => }, { id: 'console', title: 'Console', count: consoleCount, render: () => }, { id: 'network', title: 'Network', count: networkCount, render: () => }, ]; if (model.hasSource) tabs.push({ id: 'source', title: 'Source', count: 0, render: () => }); return
setSelectedAction(action)} />
{ setSelectedAction(action); }} onHighlighted={action => { setHighlightedAction(action); }} setSelectedTab={setSelectedPropertiesTab} /> }, { id: 'metadata', title: 'Metadata', count: 0, render: () =>
Time
{model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
}
duration:{msToString(model.endTime - model.startTime)}
Browser
engine:{model.browserName}
{model.platform &&
platform:{model.platform}
} {model.options.userAgent &&
user agent:{model.options.userAgent}
}
Viewport
{model.options.viewport &&
width:{model.options.viewport.width}
} {model.options.viewport &&
height:{model.options.viewport.height}
}
is mobile:{String(!!model.options.isMobile)}
{model.options.deviceScaleFactor &&
device scale:{String(model.options.deviceScaleFactor)}
}
Counts
pages:{model.pages.length}
actions:{model.actions.length}
events:{model.events.length}
}, ] } selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/>
; }; export const emptyModel = new MultiTraceModel([]); export async function loadSingleTraceFile(url: string): Promise { const params = new URLSearchParams(); params.set('trace', url); const response = await fetch(`context?${params.toString()}`); const contextEntry = await response.json() as ContextEntry; return new MultiTraceModel([contextEntry]); }