feat(html): link traces from html report (#9473)

This commit is contained in:
Pavel Feldman 2021-10-13 10:07:29 -08:00 committed by GitHub
parent 64a3099655
commit 8b1a887756
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 70 additions and 42 deletions

View File

@ -163,27 +163,28 @@ const TestResultView: React.FC<{
result: TestResult, result: TestResult,
}> = ({ result }) => { }> = ({ result }) => {
const { screenshots, videos, otherAttachments, attachmentsMap } = React.useMemo(() => { const { screenshots, videos, traces, otherAttachments, attachmentsMap } = React.useMemo(() => {
const attachmentsMap = new Map<string, TestAttachment>(); const attachmentsMap = new Map<string, TestAttachment>();
const attachments = result?.attachments || []; const attachments = result?.attachments || [];
const otherAttachments: TestAttachment[] = []; const otherAttachments: TestAttachment[] = [];
const screenshots = attachments.filter(a => a.name === 'screenshot'); const screenshots = attachments.filter(a => a.name === 'screenshot');
const videos = attachments.filter(a => a.name === 'video'); const videos = attachments.filter(a => a.name === 'video');
const knownNames = new Set(['screenshot', 'image', 'expected', 'actual', 'diff', 'video']); const traces = attachments.filter(a => a.name === 'trace');
const knownNames = new Set(['screenshot', 'image', 'expected', 'actual', 'diff', 'video', 'trace']);
for (const a of attachments) { for (const a of attachments) {
attachmentsMap.set(a.name, a); attachmentsMap.set(a.name, a);
if (!knownNames.has(a.name)) if (!knownNames.has(a.name))
otherAttachments.push(a); otherAttachments.push(a);
} }
return { attachmentsMap, screenshots, videos, otherAttachments }; return { attachmentsMap, screenshots, videos, otherAttachments, traces };
}, [ result ]); }, [ result ]);
const expected = attachmentsMap.get('expected'); const expected = attachmentsMap.get('expected');
const actual = attachmentsMap.get('actual'); const actual = attachmentsMap.get('actual');
const diff = attachmentsMap.get('diff'); const diff = attachmentsMap.get('diff');
return <div className='test-result'> return <div className='test-result'>
{result.error && <ErrorMessage key={-1} error={result.error}></ErrorMessage>} {result.error && <ErrorMessage key='error-message' error={result.error}></ErrorMessage>}
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>)} {result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
{expected && actual && <div className='vbox'> {expected && actual && <div className='vbox'>
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff> <ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
@ -192,24 +193,29 @@ const TestResultView: React.FC<{
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>} {diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
</div>} </div>}
{!!screenshots.length && <div className='test-overview-title'>Screenshots</div>} {!!screenshots.length && <div key='screenshots-title' className='test-overview-title'>Screenshots</div>}
{screenshots.map((a, i) => { {screenshots.map((a, i) => {
return <div className='vbox'> return <div key={`screenshot-${i}`} className='vbox'>
<img key={`screenshot-${i}`} src={a.path} /> <img src={a.path} />
<AttachmentLink key={`screenshot-link-${i}`} attachment={a}></AttachmentLink> <AttachmentLink attachment={a}></AttachmentLink>
</div>; </div>;
})} })}
{!!videos.length && <div className='test-overview-title'>Videos</div>} {!!traces.length && <div key='traces-title' className='test-overview-title'>Traces</div>}
{videos.map((a, i) => <div className='vbox'> {traces.map((a, i) => <div key={`trace-${i}`} className='vbox'>
<video key={`video-${i}`} controls> <AttachmentLink attachment={a} href={`trace/index.html?trace=${window.location.origin}/` + a.path}></AttachmentLink>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink key={`video-link-${i}`} attachment={a}></AttachmentLink>
</div>)} </div>)}
{!!otherAttachments && <div className='test-overview-title'>Attachments</div>} {!!videos.length && <div key='videos-title' className='test-overview-title'>Videos</div>}
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-${i}`} attachment={a}></AttachmentLink>)} {videos.map((a, i) => <div key={`video-${i}`} className='vbox'>
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
{!!otherAttachments && <div key='attachments-title' className='test-overview-title'>Attachments</div>}
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</div>; </div>;
}; };
@ -243,10 +249,11 @@ const StatsView: React.FC<{
export const AttachmentLink: React.FunctionComponent<{ export const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment, attachment: TestAttachment,
}> = ({ attachment }) => { href?: string,
}> = ({ attachment, href }) => {
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto' }}> return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto' }}>
<span className={'codicon codicon-cloud-download'}></span> <span className={'codicon codicon-cloud-download'}></span>
{attachment.path && <a href={attachment.path} target='_blank'>{attachment.name}</a>} {attachment.path && <a href={href || attachment.path} target='_blank'>{attachment.name}</a>}
{attachment.body && <span>{attachment.name}</span>} {attachment.body && <span>{attachment.name}</span>}
</div>} loadChildren={attachment.body ? () => { </div>} loadChildren={attachment.body ? () => {
return [<div className='attachment-body'>${attachment.body}</div>]; return [<div className='attachment-body'>${attachment.body}</div>];

View File

@ -185,7 +185,7 @@ function snapshotScript() {
iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>'); iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>');
} else { } else {
// Append query parameters to inherit ?name= or ?time= values from parent. // Append query parameters to inherit ?name= or ?time= values from parent.
iframe.setAttribute('src', window.location.origin + src + window.location.search); iframe.setAttribute('src', new URL(src + window.location.search, window.location.href).toString());
} }
} }

View File

@ -20,7 +20,9 @@ import { TraceModel } from './traceModel';
// @ts-ignore // @ts-ignore
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
self.addEventListener('install', function(event: any) {}); self.addEventListener('install', function(event: any) {
self.skipWaiting();
});
self.addEventListener('activate', function(event: any) { self.addEventListener('activate', function(event: any) {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
@ -28,6 +30,7 @@ self.addEventListener('activate', function(event: any) {
let traceModel: TraceModel | undefined; let traceModel: TraceModel | undefined;
let snapshotServer: SnapshotServer | undefined; let snapshotServer: SnapshotServer | undefined;
const scopePath = new URL(self.registration.scope).pathname;
async function loadTrace(trace: string): Promise<TraceModel> { async function loadTrace(trace: string): Promise<TraceModel> {
const traceModel = new TraceModel(); const traceModel = new TraceModel();
@ -39,13 +42,14 @@ async function loadTrace(trace: string): Promise<TraceModel> {
// @ts-ignore // @ts-ignore
async function doFetch(event: FetchEvent): Promise<Response> { async function doFetch(event: FetchEvent): Promise<Response> {
const request = event.request; const request = event.request;
const { pathname, searchParams } = new URL(request.url); const url = new URL(request.url);
const snapshotUrl = request.mode === 'navigate' ? const snapshotUrl = request.mode === 'navigate' ?
request.url : (await self.clients.get(event.clientId))!.url; request.url : (await self.clients.get(event.clientId))!.url;
if (request.url.startsWith(self.location.origin)) { if (request.url.startsWith(self.registration.scope)) {
if (pathname === '/context') { const relativePath = url.pathname.substring(scopePath.length - 1);
const trace = searchParams.get('trace')!; if (relativePath === '/context') {
const trace = url.searchParams.get('trace')!;
traceModel = await loadTrace(trace); traceModel = await loadTrace(trace);
snapshotServer = new SnapshotServer(traceModel.storage()); snapshotServer = new SnapshotServer(traceModel.storage());
return new Response(JSON.stringify(traceModel!.contextEntry), { return new Response(JSON.stringify(traceModel!.contextEntry), {
@ -53,12 +57,12 @@ async function doFetch(event: FetchEvent): Promise<Response> {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
} }
if (pathname.startsWith('/snapshotSize/')) if (relativePath.startsWith('/snapshotSize/'))
return snapshotServer!.serveSnapshotSize(pathname, searchParams); return snapshotServer!.serveSnapshotSize(relativePath, url.searchParams);
if (pathname.startsWith('/snapshot/')) if (relativePath.startsWith('/snapshot/'))
return snapshotServer!.serveSnapshot(pathname, searchParams, snapshotUrl); return snapshotServer!.serveSnapshot(relativePath, url.searchParams, snapshotUrl);
if (pathname.startsWith('/sha1/')) { if (relativePath.startsWith('/sha1/')) {
const blob = await traceModel!.resourceForSha1(pathname.slice('/sha1/'.length)); const blob = await traceModel!.resourceForSha1(relativePath.slice('/sha1/'.length));
if (blob) if (blob)
return new Response(blob, { status: 200 }); return new Response(blob, { status: 200 });
else else
@ -67,6 +71,7 @@ async function doFetch(event: FetchEvent): Promise<Response> {
return fetch(event.request); return fetch(event.request);
} }
if (!snapshotServer) if (!snapshotServer)
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
return snapshotServer!.serveResource(request.url, snapshotUrl); return snapshotServer!.serveResource(request.url, snapshotUrl);

View File

@ -61,7 +61,7 @@ export const FilmStrip: React.FunctionComponent<{
top: measure.bottom + 5, top: measure.bottom + 5,
left: Math.min(previewPoint!.x, measure.width - previewSize.width - 10), left: Math.min(previewPoint!.x, measure.width - previewSize.width - 10),
}}> }}>
<img src={`/sha1/${previewImage.sha1}`} width={previewSize.width} height={previewSize.height} /> <img src={`sha1/${previewImage.sha1}`} width={previewSize.width} height={previewSize.height} />
</div> </div>
} }
</div>; </div>;
@ -97,7 +97,7 @@ const FilmStripLane: React.FunctionComponent<{
frames.push(<div className='film-strip-frame' key={i} style={{ frames.push(<div className='film-strip-frame' key={i} style={{
width: frameSize.width, width: frameSize.width,
height: frameSize.height, height: frameSize.height,
backgroundImage: `url(/sha1/${screencastFrames[index].sha1})`, backgroundImage: `url(sha1/${screencastFrames[index].sha1})`,
backgroundSize: `${frameSize.width}px ${frameSize.height}px`, backgroundSize: `${frameSize.width}px ${frameSize.height}px`,
margin: frameMargin, margin: frameMargin,
marginRight: frameMargin, marginRight: frameMargin,
@ -107,7 +107,7 @@ const FilmStripLane: React.FunctionComponent<{
frames.push(<div className='film-strip-frame' key={i} style={{ frames.push(<div className='film-strip-frame' key={i} style={{
width: frameSize.width, width: frameSize.width,
height: frameSize.height, height: frameSize.height,
backgroundImage: `url(/sha1/${screencastFrames[screencastFrames.length - 1].sha1})`, backgroundImage: `url(sha1/${screencastFrames[screencastFrames.length - 1].sha1})`,
backgroundSize: `${frameSize.width}px ${frameSize.height}px`, backgroundSize: `${frameSize.width}px ${frameSize.height}px`,
margin: frameMargin, margin: frameMargin,
marginRight: frameMargin, marginRight: frameMargin,

View File

@ -38,7 +38,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{
const readResources = async () => { const readResources = async () => {
if (resource.request.postData) { if (resource.request.postData) {
if (resource.request.postData._sha1) { if (resource.request.postData._sha1) {
const response = await fetch(`/sha1/${resource.request.postData._sha1}`); const response = await fetch(`sha1/${resource.request.postData._sha1}`);
const requestResource = await response.text(); const requestResource = await response.text();
setRequestBody(requestResource); setRequestBody(requestResource);
} else { } else {
@ -48,7 +48,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{
if (resource.response.content._sha1) { if (resource.response.content._sha1) {
const useBase64 = resource.response.content.mimeType.includes('image'); const useBase64 = resource.response.content.mimeType.includes('image');
const response = await fetch(`/sha1/${resource.response.content._sha1}`); const response = await fetch(`sha1/${resource.response.content._sha1}`);
if (useBase64) { if (useBase64) {
const blob = await response.blob(); const blob = await response.blob();
const reader = new FileReader(); const reader = new FileReader();

View File

@ -41,8 +41,8 @@ export const SnapshotTab: React.FunctionComponent<{
if (action) { if (action) {
const snapshot = snapshots[snapshotIndex]; const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotName) { if (snapshot && snapshot.snapshotName) {
snapshotUrl = `${window.location.origin}/snapshot/${action.metadata.pageId}?name=${snapshot.snapshotName}`; snapshotUrl = new URL(`snapshot/${action.metadata.pageId}?name=${snapshot.snapshotName}`, window.location.href).toString();
snapshotSizeUrl = `${window.location.origin}/snapshotSize/${action.metadata.pageId}?name=${snapshot.snapshotName}`; snapshotSizeUrl = new URL(`snapshotSize/${action.metadata.pageId}?name=${snapshot.snapshotName}`, window.location.href).toString();
if (snapshot.snapshotName.includes('action')) { if (snapshot.snapshotName.includes('action')) {
pointX = action.metadata.point?.x; pointX = action.metadata.point?.x;
pointY = action.metadata.point?.y; pointY = action.metadata.point?.y;

View File

@ -39,9 +39,13 @@ export const Workbench: React.FunctionComponent<{
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
const contextEntry = (await fetch(`/context?trace=${traceURL}`).then(response => response.json())) as ContextEntry; if (traceURL) {
modelUtil.indexModel(contextEntry); const contextEntry = (await fetch(`context?trace=${traceURL}`).then(response => response.json())) as ContextEntry;
setContextEntry(contextEntry); modelUtil.indexModel(contextEntry);
setContextEntry(contextEntry);
} else {
setContextEntry(emptyContext);
}
})(); })();
}, [traceURL]); }, [traceURL]);

View File

@ -121,7 +121,7 @@ class HtmlReporter {
if (!process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) { if (!process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) {
const server = new HttpServer(); const server = new HttpServer();
server.routePrefix('/', (request, response) => { server.routePrefix('/', (request, response) => {
let relativePath = request.url!; let relativePath = new URL('http://localhost' + request.url).pathname;
if (relativePath === '/') if (relativePath === '/')
relativePath = '/index.html'; relativePath = '/index.html';
const absolutePath = path.join(reportFolder, ...relativePath.split('/')); const absolutePath = path.join(reportFolder, ...relativePath.split('/'));
@ -149,10 +149,22 @@ class HtmlBuilder {
this._reportFolder = path.resolve(process.cwd(), outputDir); this._reportFolder = path.resolve(process.cwd(), outputDir);
this._dataFolder = path.join(this._reportFolder, 'data'); this._dataFolder = path.join(this._reportFolder, 'data');
fs.mkdirSync(this._dataFolder, { recursive: true }); fs.mkdirSync(this._dataFolder, { recursive: true });
// Copy app.
const appFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'web', 'htmlReport'); const appFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'web', 'htmlReport');
for (const file of fs.readdirSync(appFolder)) for (const file of fs.readdirSync(appFolder))
fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file)); fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file));
// Copy trace viewer.
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'web', 'traceViewer');
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
fs.mkdirSync(traceViewerTargetFolder, { recursive: true });
// TODO (#9471): remove file filter when the babel build is fixed.
for (const file of fs.readdirSync(traceViewerFolder)) {
if (fs.statSync(path.join(traceViewerFolder, file)).isFile())
fs.copyFileSync(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
}
const projects: ProjectTreeItem[] = []; const projects: ProjectTreeItem[] = [];
for (const projectJson of rawReports) { for (const projectJson of rawReports) {
const suites: SuiteTreeItem[] = []; const suites: SuiteTreeItem[] = [];