chore: trace viewer server for vscode (#23383)

This commit is contained in:
Pavel Feldman 2023-05-30 18:31:15 -07:00 committed by GitHub
parent 5cd271a2a7
commit 658b1dfea3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 91 additions and 52 deletions

View File

@ -28,6 +28,8 @@ import type { Page } from '../../page';
type Options = { app?: string, headless?: boolean, host?: string, port?: number, isServer?: boolean }; type Options = { app?: string, headless?: boolean, host?: string, port?: number, isServer?: boolean };
export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<Page> { export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<Page> {
const stdinServer = options?.isServer ? new StdinServer() : undefined;
const { headless = false, host, port, app } = options || {}; const { headless = false, host, port, app } = options || {};
for (const traceUrl of traceUrls) { for (const traceUrl of traceUrls) {
let traceFile = traceUrl; let traceFile = traceUrl;
@ -113,46 +115,59 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
page.on('close', () => process.exit()); page.on('close', () => process.exit());
} }
if (options?.isServer)
params.push('isServer');
const searchQuery = params.length ? '?' + params.join('&') : ''; const searchQuery = params.length ? '?' + params.join('&') : '';
await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/${app || 'index.html'}${searchQuery}`); await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/${app || 'index.html'}${searchQuery}`);
stdinServer?.setPage(page);
if (options?.isServer)
runServer(page);
return page; return page;
} }
function runServer(page: Page) { class StdinServer {
let liveTraceTimer: NodeJS.Timeout | undefined; private _pollTimer: NodeJS.Timeout | undefined;
const loadTrace = (url: string) => { private _traceUrl: string | undefined;
clearTimeout(liveTraceTimer); private _page: Page | undefined;
page.mainFrame().evaluateExpression(`window.setTraceURL(${JSON.stringify(url)})`, false, undefined).catch(() => {});
};
const pollLoadTrace = (url: string) => {
loadTrace(url);
liveTraceTimer = setTimeout(() => {
pollLoadTrace(url);
}, 500);
};
constructor() {
process.stdin.on('data', data => { process.stdin.on('data', data => {
const url = data.toString().trim(); const url = data.toString().trim();
if (url === this._traceUrl)
return;
this._traceUrl = url;
if (url.endsWith('.json')) if (url.endsWith('.json'))
pollLoadTrace(url); this._pollLoadTrace(url);
else else
loadTrace(url); this._loadTrace(url);
}); });
process.stdin.on('close', () => selfDestruct()); process.stdin.on('close', () => this._selfDestruct());
} }
function selfDestruct() { setPage(page: Page) {
this._page = page;
if (this._traceUrl)
this._loadTrace(this._traceUrl);
}
private _loadTrace(url: string) {
clearTimeout(this._pollTimer);
this._page?.mainFrame().evaluateExpression(`window.setTraceURL(${JSON.stringify(url)})`, false, undefined).catch(() => {});
}
private _pollLoadTrace(url: string) {
this._loadTrace(url);
this._pollTimer = setTimeout(() => {
this._pollLoadTrace(url);
}, 500);
}
private _selfDestruct() {
// Force exit after 30 seconds. // Force exit after 30 seconds.
setTimeout(() => process.exit(0), 30000); setTimeout(() => process.exit(0), 30000);
// Meanwhile, try to gracefully close all browsers. // Meanwhile, try to gracefully close all browsers.
gracefullyCloseAll().then(() => { gracefullyCloseAll().then(() => {
process.exit(0); process.exit(0);
}); });
}
} }
function traceDescriptor(traceName: string) { function traceDescriptor(traceName: string) {

View File

@ -49,8 +49,6 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
} catch (error: any) { } catch (error: any) {
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html')) if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.'); throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');
// eslint-disable-next-line no-console
console.error(error);
if (traceFileName) if (traceFileName)
throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`); throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`);
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`); throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);

View File

@ -30,9 +30,16 @@
line-height: 24px; line-height: 24px;
} }
body .drop-target {
background: rgba(255, 255, 255, 0.8);
}
body.dark-mode .drop-target {
background: rgba(0, 0, 0, 0.8);
}
.drop-target .title { .drop-target .title {
font-size: 24px; font-size: 24px;
color: #666;
font-weight: bold; font-weight: bold;
margin-bottom: 30px; margin-bottom: 30px;
} }
@ -81,7 +88,7 @@
flex-basis: 48px; flex-basis: 48px;
line-height: 48px; line-height: 48px;
font-size: 16px; font-size: 16px;
color: white; color: #cccccc;
} }
.workbench .header .toolbar-button { .workbench .header .toolbar-button {

View File

@ -24,6 +24,7 @@ import { Workbench } from './workbench';
export const WorkbenchLoader: React.FunctionComponent<{ export const WorkbenchLoader: React.FunctionComponent<{
}> = () => { }> = () => {
const [isServer, setIsServer] = React.useState<boolean>(false);
const [traceURLs, setTraceURLs] = React.useState<string[]>([]); const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]); const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]);
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel); const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
@ -32,7 +33,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null); const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
const [fileForLocalModeError, setFileForLocalModeError] = React.useState<string | null>(null); const [fileForLocalModeError, setFileForLocalModeError] = React.useState<string | null>(null);
const processTraceFiles = (files: FileList) => { const processTraceFiles = React.useCallback((files: FileList) => {
const blobUrls = []; const blobUrls = [];
const fileNames = []; const fileNames = [];
const url = new URL(window.location.href); const url = new URL(window.location.href);
@ -54,22 +55,25 @@ export const WorkbenchLoader: React.FunctionComponent<{
setUploadedTraceNames(fileNames); setUploadedTraceNames(fileNames);
setDragOver(false); setDragOver(false);
setProcessingErrorMessage(null); setProcessingErrorMessage(null);
}; }, []);
const handleDropEvent = (event: React.DragEvent<HTMLDivElement>) => { const handleDropEvent = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
processTraceFiles(event.dataTransfer.files); processTraceFiles(event.dataTransfer.files);
}; }, [processTraceFiles]);
const handleFileInputChange = (event: any) => { const handleFileInputChange = React.useCallback((event: any) => {
event.preventDefault(); event.preventDefault();
if (!event.target.files) if (!event.target.files)
return; return;
processTraceFiles(event.target.files); processTraceFiles(event.target.files);
}; }, [processTraceFiles]);
React.useEffect(() => { React.useEffect(() => {
const newTraceURLs = new URL(window.location.href).searchParams.getAll('trace'); const params = new URL(window.location.href).searchParams;
const newTraceURLs = params.getAll('trace');
setIsServer(params.has('isServer'));
// Don't accept file:// URLs - this means we re opened locally. // Don't accept file:// URLs - this means we re opened locally.
for (const url of newTraceURLs) { for (const url of newTraceURLs) {
if (url.startsWith('file:')) { if (url.startsWith('file:')) {
@ -80,11 +84,16 @@ export const WorkbenchLoader: React.FunctionComponent<{
(window as any).setTraceURL = (url: string) => { (window as any).setTraceURL = (url: string) => {
setTraceURLs([url]); setTraceURLs([url]);
setDragOver(false);
setProcessingErrorMessage(null);
}; };
if (earlyTraceURL) {
(window as any).setTraceURL(earlyTraceURL);
} else if (!newTraceURLs.some(url => url.startsWith('blob:'))) {
// Don't re-use blob file URLs on page load (results in Fetch error) // Don't re-use blob file URLs on page load (results in Fetch error)
if (!newTraceURLs.some(url => url.startsWith('blob:')))
setTraceURLs(newTraceURLs); setTraceURLs(newTraceURLs);
}, [setTraceURLs]); }
}, []);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@ -104,6 +113,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
params.set('traceFileName', uploadedTraceNames[i]); params.set('traceFileName', uploadedTraceNames[i]);
const response = await fetch(`contexts?${params.toString()}`); const response = await fetch(`contexts?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
if (!isServer)
setTraceURLs([]); setTraceURLs([]);
setProcessingErrorMessage((await response.json()).error); setProcessingErrorMessage((await response.json()).error);
return; return;
@ -118,7 +128,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
setModel(emptyModel); setModel(emptyModel);
} }
})(); })();
}, [traceURLs, uploadedTraceNames]); }, [isServer, traceURLs, uploadedTraceNames]);
return <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}> return <div className='vbox workbench' onDragOver={event => { event.preventDefault(); setDragOver(true); }}>
<div className='hbox header'> <div className='hbox header'>
@ -128,9 +138,9 @@ export const WorkbenchLoader: React.FunctionComponent<{
<div className='spacer'></div> <div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton> <ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</div> </div>
{!!progress.total && <div className='progress'> <div className='progress'>
<div className='inner-progress' style={{ width: (100 * progress.done / progress.total) + '%' }}></div> <div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>
</div>} </div>
<Workbench model={model} /> <Workbench model={model} />
{fileForLocalModeError && <div className='drop-target'> {fileForLocalModeError && <div className='drop-target'>
<div>Trace Viewer uses Service Workers to show traces. To view trace:</div> <div>Trace Viewer uses Service Workers to show traces. To view trace:</div>
@ -140,7 +150,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
<div>3. Drop the trace from the download shelf into the page</div> <div>3. Drop the trace from the download shelf into the page</div>
</div> </div>
</div>} </div>}
{!dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'> {!isServer && !dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'>
<div className='processing-error'>{processingErrorMessage}</div> <div className='processing-error'>{processingErrorMessage}</div>
<div className='title'>Drop Playwright Trace to load</div> <div className='title'>Drop Playwright Trace to load</div>
<div>or</div> <div>or</div>
@ -153,6 +163,9 @@ export const WorkbenchLoader: React.FunctionComponent<{
<div style={{ maxWidth: 400 }}>Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere, <div style={{ maxWidth: 400 }}>Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere,
it opens it locally.</div> it opens it locally.</div>
</div>} </div>}
{isServer && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'>
<div className='title'>Select test to see the trace</div>
</div>}
{dragOver && <div className='drop-target' {dragOver && <div className='drop-target'
onDragLeave={() => { setDragOver(false); }} onDragLeave={() => { setDragOver(false); }}
onDrop={event => handleDropEvent(event)}> onDrop={event => handleDropEvent(event)}>
@ -162,3 +175,9 @@ export const WorkbenchLoader: React.FunctionComponent<{
}; };
export const emptyModel = new MultiTraceModel([]); export const emptyModel = new MultiTraceModel([]);
let earlyTraceURL: string | undefined = undefined;
(window as any).setTraceURL = (url: string) => {
earlyTraceURL = url;
};