diff --git a/packages/playwright-core/src/utils/traceUtils.ts b/packages/playwright-core/src/utils/traceUtils.ts index 006e1bcd92..b53d530bd4 100644 --- a/packages/playwright-core/src/utils/traceUtils.ts +++ b/packages/playwright-core/src/utils/traceUtils.ts @@ -97,9 +97,7 @@ export async function mergeTraceFiles(fileName: string, temporaryTraceFiles: str } export async function saveTraceFile(fileName: string, traceEvents: TraceEvent[], saveSources: boolean) { - const lines: string[] = traceEvents.map(e => JSON.stringify(e)); const zipFile = new yazl.ZipFile(); - zipFile.addBuffer(Buffer.from(lines.join('\n')), 'trace.trace'); if (saveSources) { const sourceFiles = new Set(); @@ -115,6 +113,25 @@ export async function saveTraceFile(fileName: string, traceEvents: TraceEvent[], }).catch(() => {}); } } + + const sha1s = new Set(); + for (const event of traceEvents.filter(e => e.type === 'after') as AfterActionTraceEvent[]) { + for (const attachment of (event.attachments || []).filter(a => !!a.path)) { + await fs.promises.readFile(attachment.path!).then(content => { + const sha1 = calculateSha1(content); + if (sha1s.has(sha1)) + return; + sha1s.add(sha1); + zipFile.addBuffer(content, 'resources/' + sha1); + attachment.sha1 = sha1; + delete attachment.path; + }).catch(); + } + } + + const traceContent = Buffer.from(traceEvents.map(e => JSON.stringify(e)).join('\n')); + zipFile.addBuffer(traceContent, 'trace.trace'); + await new Promise(f => { zipFile.end(undefined, () => { zipFile.outputStream.pipe(fs.createWriteStream(fileName)).on('close', f); @@ -136,12 +153,13 @@ export function createBeforeActionTraceEventForExpect(callId: string, apiName: s }; } -export function createAfterActionTraceEventForExpect(callId: string, error?: SerializedError['error']): AfterActionTraceEvent { +export function createAfterActionTraceEventForExpect(callId: string, attachments: AfterActionTraceEvent['attachments'], error?: SerializedError['error']): AfterActionTraceEvent { return { type: 'after', callId, endTime: monotonicTime(), log: [], + attachments, error, }; } diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index 16ad0c0279..ea1192c568 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -48,7 +48,7 @@ import { toPass } from './matchers'; import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; -import type { Expect } from '../../types/test'; +import type { Expect, TestInfo } from '../../types/test'; import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util'; import { @@ -58,6 +58,7 @@ import { printReceived, } from '../common/expectBundle'; import { zones } from 'playwright-core/lib/utils'; +import type { AfterActionTraceEvent } from '../../../trace/src/trace'; // from expect/build/types export type SyncExpectationResult = { @@ -243,6 +244,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const wallTime = Date.now(); + const initialAttachments = new Set(testInfo.attachments.slice()); const step = testInfo._addStep({ location: stackFrames[0], category: 'expect', @@ -281,7 +283,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const serializerError = serializeError(jestError); if (generateTraceEvent) { const error = { name: jestError.name, message: jestError.message, stack: jestError.stack }; - testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, error)); + testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments), error)); } step.complete({ error: serializerError }); if (this._info.isSoft) @@ -292,7 +294,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const finalizer = () => { if (generateTraceEvent) - testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`)); + testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments))); step.complete({}); }; @@ -363,4 +365,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) { return value ? `(${value})` : ''; } +function serializeAttachments(attachments: TestInfo['attachments'], initialAttachments: Set): AfterActionTraceEvent['attachments'] { + return attachments.filter(a => !initialAttachments.has(a)).map(a => { + return { + name: a.name, + contentType: a.contentType, + path: a.path, + body: a.body?.toString('base64'), + }; + }); +} + expectLibrary.extend(customMatchers); diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 7fd1966b2e..77ebf3ea75 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -170,6 +170,7 @@ export class TraceModel { existing!.log = event.log; existing!.result = event.result; existing!.error = event.error; + existing!.attachments = event.attachments; break; } case 'action': { diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 506a6e0960..b333c56cab 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -23,6 +23,8 @@ import { CopyToClipboard } from './copyToClipboard'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; import { ErrorMessage } from '@web/components/errorMessage'; +import { ImageDiffView } from '@web/components/imageDiffView'; +import type { TestAttachment } from '@web/components/imageDiffView'; export const CallTab: React.FunctionComponent<{ action: ActionTraceEvent | undefined, @@ -38,7 +40,19 @@ export const CallTab: React.FunctionComponent<{ const paramKeys = Object.keys(params); const wallTime = action.wallTime ? new Date(action.wallTime).toLocaleString() : null; const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; + + const expected = action.attachments?.find(a => a.name.endsWith('-expected.png') && (a.path || a.sha1)) as TestAttachment | undefined; + const actual = action.attachments?.find(a => a.name.endsWith('-actual.png') && (a.path || a.sha1)) as TestAttachment | undefined; + const diff = action.attachments?.find(a => a.name.endsWith('-diff.png') && (a.path || a.sha1)) as TestAttachment | undefined; + return
+ { expected && actual &&
Image diff
} + { expected && actual && } {!!error && } {!!error &&
Call
}
{action.apiName}
@@ -146,3 +160,15 @@ function parseSerializedValue(value: SerializedValue, handles: any[] | undefined } return ''; } + +function attachmentURL(attachment: { + name: string; + contentType: string; + path?: string; + sha1?: string; + body?: string; +}) { + if (attachment.sha1) + return 'sha1/' + attachment.sha1; + return 'file?path=' + attachment.path; +} diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index f7e62b7a1d..470e647a4b 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -109,6 +109,8 @@ function dedupeAndSortActions(actions: ActionTraceEvent[]) { if (callAction) { if (expectAction.error) callAction.error = expectAction.error; + if (expectAction.attachments) + callAction.attachments = expectAction.attachments; continue; } result.push(expectAction); diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index f0cdac9f5e..39bd6fb092 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -79,6 +79,13 @@ export type AfterActionTraceEvent = { afterSnapshot?: string; log: string[]; error?: SerializedError['error']; + attachments?: { + name: string; + contentType: string; + path?: string; + sha1?: string; + body?: string; // base64 + }[]; result?: any; }; diff --git a/packages/web/src/components/imageDiffView.css b/packages/web/src/components/imageDiffView.css new file mode 100644 index 0000000000..fc333bf430 --- /dev/null +++ b/packages/web/src/components/imageDiffView.css @@ -0,0 +1,46 @@ +/* + 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. +*/ + +.image-diff-view .image-wrapper img { + flex: auto; + box-shadow: var(--box-shadow-thick); + margin: 24px auto; + min-width: 200px; + max-width: 80%; +} + +.image-diff-view .image-wrapper { + flex: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.image-diff-view .image-wrapper div { + flex: none; + align-self: stretch; + height: 2em; + font-weight: 500; + padding-top: 1em; + display: flex; + flex-direction: row; +} + +.image-diff-view .modes > div { + margin: 10px; + cursor: pointer; + user-select: none; +} diff --git a/packages/web/src/components/imageDiffView.tsx b/packages/web/src/components/imageDiffView.tsx new file mode 100644 index 0000000000..2ef7e356a4 --- /dev/null +++ b/packages/web/src/components/imageDiffView.tsx @@ -0,0 +1,178 @@ +/* + 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 * as React from 'react'; +import './imageDiffView.css'; + +export type TestAttachment = { + name: string; + contentType: string; + path: string; +}; + +export type ImageDiff = { + name: string, + expected?: { attachment: TestAttachment, title: string }, + actual?: { attachment: TestAttachment }, + diff?: { attachment: TestAttachment }, +}; + +export const ImageDiffView: React.FunctionComponent<{ + imageDiff: ImageDiff, +}> = ({ imageDiff: diff }) => { + // Pre-select a tab called "diff", if any. + const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected'>(diff.diff ? 'diff' : 'actual'); + const diffElement = React.useRef(null); + const imageElement = React.useRef(null); + const [sliderPosition, setSliderPosition] = React.useState(0); + const onImageLoaded = (side?: 'left' | 'right') => { + if (diffElement.current) + diffElement.current.style.minHeight = diffElement.current.offsetHeight + 'px'; + if (side && diffElement.current && imageElement.current) { + const gap = Math.max(0, (diffElement.current.offsetWidth - imageElement.current.offsetWidth) / 2 - 20); + if (side === 'left') + setSliderPosition(gap); + else if (side === 'right') + setSliderPosition(diffElement.current.offsetWidth - gap); + } + }; + + return
+
+ {diff.diff &&
setMode('diff')}>Diff
} +
setMode('actual')}>Actual
+
setMode('expected')}>Expected
+
+
+ {diff.diff && mode === 'diff' && onImageLoaded()} />} + {diff.diff && mode === 'actual' && + onImageLoaded('right')} imageRef={imageElement} style={{ boxShadow: 'none' }} /> + + } + {diff.diff && mode === 'expected' && + onImageLoaded('left')} imageRef={imageElement} /> + + } + {!diff.diff && mode === 'actual' && onImageLoaded()} />} + {!diff.diff && mode === 'expected' && onImageLoaded()} />} +
+
; +}; + +export const ImageDiffSlider: React.FC void, +}>> = ({ children, sliderPosition, setSliderPosition }) => { + const [resizing, setResizing] = React.useState<{ offset: number, size: number } | null>(null); + const size = sliderPosition; + + const childrenArray = React.Children.toArray(children); + document.body.style.userSelect = resizing ? 'none' : 'inherit'; + + const gripStyle: React.CSSProperties = { + ...absolute, + zIndex: 100, + cursor: 'ew-resize', + left: resizing ? 0 : size - 4, + right: resizing ? 0 : undefined, + width: resizing ? 'initial' : 8, + }; + + return <> + {childrenArray[0]} +
+
+ {childrenArray[1]} +
+
setResizing({ offset: event.clientX, size })} + onMouseUp={() => setResizing(null)} + onMouseMove={event => { + if (!event.buttons) { + setResizing(null); + } else if (resizing) { + const offset = event.clientX; + const delta = offset - resizing.offset; + const newSize = resizing.size + delta; + + const splitView = (event.target as HTMLElement).parentElement!; + const rect = splitView.getBoundingClientRect(); + const size = Math.min(Math.max(0, newSize), rect.width); + setSliderPosition(size); + } + }} + >
+
+
+
+ +
+
+ ; +}; + +const ImageWithSize: React.FunctionComponent<{ + src: string, + onLoad?: () => void, + imageRef?: React.RefObject, + style?: React.CSSProperties, +}> = ({ src, onLoad, imageRef, style }) => { + const newRef = React.useRef(null); + const ref = imageRef ?? newRef; + const [size, setSize] = React.useState<{ width: number, height: number } | null>(null); + return
+
+ { size ? size.width : ''} + x + { size ? size.height : ''} +
+ { + onLoad?.(); + if (ref.current) + setSize({ width: ref.current.naturalWidth, height: ref.current.naturalHeight }); + }} ref={ref} style={style} /> +
; +}; + +const absolute: React.CSSProperties = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, +};