mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	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) => { |  | ||||||
|     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) { | ||||||
|  | |||||||
| @ -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); | ||||||
|     }; |     }; | ||||||
|  |     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; | ||||||
|  | }; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Pavel Feldman
						Pavel Feldman