diff --git a/packages/html-reporter/src/imageDiffView.css b/packages/html-reporter/src/imageDiffView.css deleted file mode 100644 index 0f2ffe85d3d1bc..00000000000000 --- a/packages/html-reporter/src/imageDiffView.css +++ /dev/null @@ -1,47 +0,0 @@ -/* - 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 .tabbed-pane .tab-content { - display: flex; - align-items: center; - justify-content: center; - position: relative; -} - -.image-diff-view .image-wrapper img { - flex: auto; - box-shadow: none; - 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; -} diff --git a/packages/html-reporter/src/imageDiffView.tsx b/packages/html-reporter/src/imageDiffView.tsx deleted file mode 100644 index 705a8277167e0d..00000000000000 --- a/packages/html-reporter/src/imageDiffView.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/* - 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 type { TestAttachment } from './types'; -import * as React from 'react'; -import { AttachmentLink } from './links'; -import type { TabbedPaneTab } from './tabbedPane'; -import { TabbedPane } from './tabbedPane'; -import './imageDiffView.css'; -import './tabbedPane.css'; - -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 [selectedTab, setSelectedTab] = React.useState('diff'); - 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); - } - }; - const tabs: TabbedPaneTab[] = []; - if (diff.diff) { - tabs.push({ - id: 'diff', - title: 'Diff', - render: () => onImageLoaded()} /> - }); - tabs.push({ - id: 'actual', - title: 'Actual', - render: () => - onImageLoaded('right')} imageRef={imageElement} style={{ boxShadow: 'none' }} /> - - , - }); - tabs.push({ - id: 'expected', - title: diff.expected!.title, - render: () => - onImageLoaded('left')} imageRef={imageElement} /> - - , - }); - } else { - tabs.push({ - id: 'actual', - title: 'Actual', - render: () => onImageLoaded()} /> - }); - tabs.push({ - id: 'expected', - title: diff.expected!.title, - render: () => onImageLoaded()} /> - }); - } - return
- - {diff.diff && } - - -
; -}; - -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, -}; diff --git a/packages/html-reporter/src/testResultView.css b/packages/html-reporter/src/testResultView.css index 79773b4302e0b2..81fd61453d4edd 100644 --- a/packages/html-reporter/src/testResultView.css +++ b/packages/html-reporter/src/testResultView.css @@ -26,7 +26,7 @@ } .test-result video, -.test-result img { +.test-result img.screenshot { flex: none; box-shadow: var(--box-shadow-thick); margin: 24px auto; diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 43842aabcf3274..53c8d19d92cf25 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -22,8 +22,8 @@ import { AutoChip } from './chip'; import { traceImage } from './images'; import { AttachmentLink, generateTraceUrl } from './links'; import { statusIcon } from './statusIcon'; -import type { ImageDiff } from './imageDiffView'; -import { ImageDiffView } from './imageDiffView'; +import type { ImageDiff } from '@web/shared/imageDiffView'; +import { ImageDiffView } from '@web/shared/imageDiffView'; import { TestErrorView } from './testErrorView'; import './testResultView.css'; @@ -102,7 +102,7 @@ export const TestResultView: React.FC<{ {diffs.map((diff, index) => - + )} @@ -110,7 +110,7 @@ export const TestResultView: React.FC<{ {screenshots.map((a, i) => { return ; @@ -120,7 +120,7 @@ export const TestResultView: React.FC<{ {!!traces.length && {
- + {traces.map((a, i) => )}
} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 481f08fe0a2df4..179e6e5c91f4af 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import './attachmentsTab.css'; -import { ImageDiffView } from '@web/components/imageDiffView'; +import { ImageDiffView } from '@web/shared/imageDiffView'; import type { MultiTraceModel } from './modelUtil'; import { PlaceholderPanel } from './placeholderPanel'; import type { AfterActionTraceEventAttachment } from '@trace/trace'; @@ -63,7 +63,7 @@ export const AttachmentsTab: React.FunctionComponent<{ {[...diffMap.values()].map(({ expected, actual, diff }) => { return <> {expected && actual &&
Image diff
} - {expected && actual && div { - margin: 10px; - cursor: pointer; - user-select: none; -} diff --git a/packages/web/src/components/imageDiffView.tsx b/packages/web/src/components/imageDiffView.tsx deleted file mode 100644 index 525338f357c8e0..00000000000000 --- a/packages/web/src/components/imageDiffView.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* - 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, -}; diff --git a/packages/web/src/shared/DEPS.list b/packages/web/src/shared/DEPS.list new file mode 100644 index 00000000000000..82081a1d2fafc8 --- /dev/null +++ b/packages/web/src/shared/DEPS.list @@ -0,0 +1,5 @@ +[*] +../uiUtils.ts + +[imageDiffView.spec.tsx] +*** diff --git a/packages/web/src/components/glassPane.tsx b/packages/web/src/shared/glassPane.tsx similarity index 97% rename from packages/web/src/components/glassPane.tsx rename to packages/web/src/shared/glassPane.tsx index 6919cb30d33a76..c9430826c22e7a 100644 --- a/packages/web/src/components/glassPane.tsx +++ b/packages/web/src/shared/glassPane.tsx @@ -28,7 +28,7 @@ export const GlassPane: React.FC<{ return; const glassPaneDiv = document.createElement('div'); - glassPaneDiv.style.position = 'absolute'; + glassPaneDiv.style.position = 'fixed'; glassPaneDiv.style.top = '0'; glassPaneDiv.style.right = '0'; glassPaneDiv.style.bottom = '0'; diff --git a/packages/html-reporter/src/imageDiffView.spec.tsx b/packages/web/src/shared/imageDiffView.spec.tsx similarity index 69% rename from packages/html-reporter/src/imageDiffView.spec.tsx rename to packages/web/src/shared/imageDiffView.spec.tsx index d2339919b0a8cf..79d256096d08ae 100644 --- a/packages/html-reporter/src/imageDiffView.spec.tsx +++ b/packages/web/src/shared/imageDiffView.spec.tsx @@ -36,7 +36,7 @@ const imageDiff: ImageDiff = { }; test('should render links', async ({ mount }) => { - const component = await mount(); + const component = await mount(); await expect(component.locator('a')).toHaveText([ 'screenshot-diff.png', 'screenshot-actual.png', @@ -44,40 +44,10 @@ test('should render links', async ({ mount }) => { ]); }); -test('should switch to actual', async ({ mount }) => { - const component = await mount(); - await component.getByText('Actual', { exact: true }).click(); - const sliderElement = component.locator('data-testid=test-result-image-mismatch-grip'); - await expect.poll(() => sliderElement.evaluate(e => e.style.left), 'Actual slider is on the right').toBe('611px'); - - const images = component.locator('img'); - const imageCount = await component.locator('img').count(); - for (let i = 0; i < imageCount; ++i) { - const image = images.nth(i); - const box = await image.boundingBox(); - expect(box).toEqual({ x: 400, y: 108, width: 200, height: 200 }); - } -}); - -test('should switch to expected', async ({ mount }) => { - const component = await mount(); - await component.getByText('Expected', { exact: true }).click(); - const sliderElement = component.locator('data-testid=test-result-image-mismatch-grip'); - await expect.poll(() => sliderElement.evaluate(e => e.style.left), 'Expected slider is on the left').toBe('371px'); - - const images = component.locator('img'); - const imageCount = await component.locator('img').count(); - for (let i = 0; i < imageCount; ++i) { - const image = images.nth(i); - const box = await image.boundingBox(); - expect(box).toEqual({ x: 400, y: 108, width: 200, height: 200 }); - } -}); - test('should show diff by default', async ({ mount }) => { - const component = await mount(); + const component = await mount(); const image = component.locator('img'); const box = await image.boundingBox(); - expect(box).toEqual({ x: 400, y: 108, width: 200, height: 200 }); + expect(box).toEqual(expect.objectContaining({ width: 48, height: 48 })); }); diff --git a/packages/web/src/shared/imageDiffView.tsx b/packages/web/src/shared/imageDiffView.tsx new file mode 100644 index 00000000000000..7c7a761e408727 --- /dev/null +++ b/packages/web/src/shared/imageDiffView.tsx @@ -0,0 +1,194 @@ +/* + 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 { GlassPane } from './glassPane'; +import { useMeasure } from '../uiUtils'; + +type TestAttachment = { + name: string; + body?: string; + path?: string; + contentType: string; +}; + +export type ImageDiff = { + name: string, + expected?: { attachment: TestAttachment, title: string }, + actual?: { attachment: TestAttachment }, + diff?: { attachment: TestAttachment }, +}; + +async function loadImage(src?: string): Promise { + const image = new Image(); + if (src) { + image.src = src; + await new Promise((f, r) => { + image.onload = f; + image.onerror = f; + }); + } + return image; +} + +export const ImageDiffView: React.FC<{ + diff: ImageDiff, +}> = ({ diff }) => { + const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual'); + const [showSxsDiff, setShowSxsDiff] = React.useState(false); + + const [expectedImage, setExpectedImage] = React.useState(null); + const [actualImage, setActualImage] = React.useState(null); + const [diffImage, setDiffImage] = React.useState(null); + const [measure, ref] = useMeasure(); + + React.useEffect(() => { + (async () => { + setExpectedImage(await loadImage(diff.expected?.attachment.path)); + setActualImage(await loadImage(diff.actual?.attachment.path)); + setDiffImage(await loadImage(diff.diff?.attachment.path)); + })(); + }, [diff]); + + const isLoaded = expectedImage && actualImage && diffImage; + + const imageWidth = isLoaded ? Math.max(expectedImage.naturalWidth, actualImage.naturalWidth) : 500; + const fitWidth = Math.min(measure.width, imageWidth || 500); + const scale = fitWidth / imageWidth; + const fitHeight = Math.max(expectedImage?.naturalHeight || 0, actualImage?.naturalHeight || 0) * scale; + + const modeStyle: React.CSSProperties = { + flex: 'none', + margin: '0 10px', + cursor: 'pointer', + userSelect: 'none', + }; + return
+ {isLoaded && <> +
+ {diff.diff &&
setMode('diff')}>Diff
} +
setMode('actual')}>Actual
+
setMode('expected')}>Expected
+
setMode('slider')}>Slider
+
setMode('sxs')}>Side by side
+
+
+ {diff.diff && mode === 'diff' && } + {diff.diff && mode === 'actual' && } + {diff.diff && mode === 'expected' && } + {diff.diff && mode === 'slider' && } + {diff.diff && mode === 'sxs' &&
+ +
+ setShowSxsDiff(!showSxsDiff)} scale={scale / 2} /> +
} + {!diff.diff && mode === 'actual' && } + {!diff.diff && mode === 'expected' && } + {!diff.diff && mode === 'sxs' &&
+ +
+ +
} +
+ + } +
; +}; + +export const ImageDiffSlider: React.FC<{ + expectedImage: HTMLImageElement, + actualImage: HTMLImageElement, + scale: number, + fitWidth: number, +}> = ({ expectedImage, actualImage, scale, fitWidth }) => { + const absoluteStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + }; + + const [slider, setSlider] = React.useState(5); + const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight; + const maxSize = { + width: Math.max(expectedImage.naturalWidth, actualImage.naturalWidth), + height: Math.max(expectedImage.naturalHeight, actualImage.naturalHeight), + }; + const [resizing, setResizing] = React.useState<{ offset: number, slider: number } | null>(null); + + return
+ setResizing(null)} + onPaneMouseMove={event => { + if (!event.buttons) { + setResizing(null); + } else if (resizing) { + const offset = event.clientX; + const delta = offset - resizing.offset; + const newSlider = resizing.slider + delta; + const slider = Math.min(Math.max(0, newSlider), fitWidth); + setSlider(slider); + } + }} + /> +
+ {!sameSize && Expected } + {expectedImage.naturalWidth} + x + {expectedImage.naturalHeight} + {!sameSize && Actual } + {!sameSize && {actualImage.naturalWidth}} + {!sameSize && x} + {!sameSize && {actualImage.naturalHeight}} +
+
setResizing({ offset: event.clientX, slider: slider })}> + +
+ +
+
+ +
+
; +
; +}; + +const ImageWithSize: React.FunctionComponent<{ + image: HTMLImageElement, + scale: number, + onClick?: () => void; +}> = ({ image, scale, onClick }) => { + return
+
+ {image.naturalWidth} + x + {image.naturalHeight} +
+
+ +
+
; +};