chore: trace viewer server for vscode (#23383)
This commit is contained in:
parent
5cd271a2a7
commit
658b1dfea3
|
|
@ -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) => {
|
constructor() {
|
||||||
loadTrace(url);
|
process.stdin.on('data', data => {
|
||||||
liveTraceTimer = setTimeout(() => {
|
const url = data.toString().trim();
|
||||||
pollLoadTrace(url);
|
if (url === this._traceUrl)
|
||||||
|
return;
|
||||||
|
this._traceUrl = url;
|
||||||
|
if (url.endsWith('.json'))
|
||||||
|
this._pollLoadTrace(url);
|
||||||
|
else
|
||||||
|
this._loadTrace(url);
|
||||||
|
});
|
||||||
|
process.stdin.on('close', () => this._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);
|
}, 500);
|
||||||
};
|
}
|
||||||
|
|
||||||
process.stdin.on('data', data => {
|
private _selfDestruct() {
|
||||||
const url = data.toString().trim();
|
// Force exit after 30 seconds.
|
||||||
if (url.endsWith('.json'))
|
setTimeout(() => process.exit(0), 30000);
|
||||||
pollLoadTrace(url);
|
// Meanwhile, try to gracefully close all browsers.
|
||||||
else
|
gracefullyCloseAll().then(() => {
|
||||||
loadTrace(url);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
process.stdin.on('close', () => selfDestruct());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function selfDestruct() {
|
|
||||||
// Force exit after 30 seconds.
|
|
||||||
setTimeout(() => process.exit(0), 30000);
|
|
||||||
// Meanwhile, try to gracefully close all browsers.
|
|
||||||
gracefullyCloseAll().then(() => {
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function traceDescriptor(traceName: string) {
|
function traceDescriptor(traceName: string) {
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
// Don't re-use blob file URLs on page load (results in Fetch error)
|
if (earlyTraceURL) {
|
||||||
if (!newTraceURLs.some(url => url.startsWith('blob:')))
|
(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)
|
||||||
setTraceURLs(newTraceURLs);
|
setTraceURLs(newTraceURLs);
|
||||||
}, [setTraceURLs]);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -104,7 +113,8 @@ 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) {
|
||||||
setTraceURLs([]);
|
if (!isServer)
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue