feat(html): render image diff slider (#13257)
This commit is contained in:
parent
f9ae423eab
commit
55ee41c848
|
|
@ -17,8 +17,7 @@
|
||||||
import type { TestAttachment } from '@playwright-test/reporters/html';
|
import type { TestAttachment } from '@playwright-test/reporters/html';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AttachmentLink } from './links';
|
import { AttachmentLink } from './links';
|
||||||
import { TabbedPane } from './tabbedPane';
|
import { TabbedPane, TabbedPaneTab } from './tabbedPane';
|
||||||
import './testResultView.css';
|
|
||||||
|
|
||||||
export type ImageDiff = {
|
export type ImageDiff = {
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -31,29 +30,56 @@ export const ImageDiffView: React.FunctionComponent<{
|
||||||
imageDiff: ImageDiff,
|
imageDiff: ImageDiff,
|
||||||
}> = ({ imageDiff: diff }) => {
|
}> = ({ imageDiff: diff }) => {
|
||||||
// Pre-select a tab called "actual", if any.
|
// Pre-select a tab called "actual", if any.
|
||||||
const [selectedTab, setSelectedTab] = React.useState<string>('left');
|
const [selectedTab, setSelectedTab] = React.useState<string>('actual');
|
||||||
const diffElement = React.useRef<HTMLImageElement>(null);
|
const diffElement = React.useRef<HTMLDivElement>(null);
|
||||||
const setMinHeight = () => {
|
const imageElement = React.useRef<HTMLImageElement>(null);
|
||||||
|
const [sliderPosition, setSliderPosition] = React.useState<number>(0);
|
||||||
|
const onImageLoaded = (side?: 'left' | 'right') => {
|
||||||
if (diffElement.current)
|
if (diffElement.current)
|
||||||
diffElement.current.style.minHeight = diffElement.current.offsetHeight + 'px';
|
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 = [
|
const tabs: TabbedPaneTab[] = [];
|
||||||
{
|
if (diff.diff) {
|
||||||
id: 'left',
|
tabs.push({
|
||||||
title: diff.left!.title,
|
id: 'actual',
|
||||||
render: () => <img src={diff.left!.attachment.path!} onLoad={setMinHeight}/>
|
title: 'Actual',
|
||||||
},
|
render: () => <ImageDiffSlider sliderPosition={sliderPosition} setSliderPosition={setSliderPosition}>
|
||||||
{
|
<img src={diff.left!.attachment.path!} onLoad={() => onImageLoaded('right')} ref={imageElement} />
|
||||||
id: 'right',
|
<img src={diff.right!.attachment.path!} style={{ boxShadow: 'none' }} />
|
||||||
|
</ImageDiffSlider>,
|
||||||
|
});
|
||||||
|
tabs.push({
|
||||||
|
id: 'expected',
|
||||||
title: diff.right!.title,
|
title: diff.right!.title,
|
||||||
render: () => <img src={diff.right!.attachment.path!} onLoad={setMinHeight}/>
|
render: () => <ImageDiffSlider sliderPosition={sliderPosition} setSliderPosition={setSliderPosition}>
|
||||||
},
|
<img src={diff.left!.attachment.path!} onLoad={() => onImageLoaded('left')} ref={imageElement} />
|
||||||
];
|
<img src={diff.right!.attachment.path!} style={{ boxShadow: 'none' }} />
|
||||||
|
</ImageDiffSlider>,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tabs.push({
|
||||||
|
id: 'actual',
|
||||||
|
title: 'Actual',
|
||||||
|
render: () => <img src={diff.left!.attachment.path!} onLoad={() => onImageLoaded()} />
|
||||||
|
});
|
||||||
|
tabs.push({
|
||||||
|
id: 'expected',
|
||||||
|
title: diff.right!.title,
|
||||||
|
render: () => <img src={diff.right!.attachment.path!} onLoad={() => onImageLoaded()} />
|
||||||
|
});
|
||||||
|
}
|
||||||
if (diff.diff) {
|
if (diff.diff) {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
id: 'diff',
|
id: 'diff',
|
||||||
title: diff.diff.title,
|
title: 'Diff',
|
||||||
render: () => <img src={diff.diff!.attachment.path} onLoad={setMinHeight}/>
|
render: () => <img src={diff.diff!.attachment.path} onLoad={() => onImageLoaded()} />
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return <div className='vbox' data-testid='test-result-image-mismatch' ref={diffElement}>
|
return <div className='vbox' data-testid='test-result-image-mismatch' ref={diffElement}>
|
||||||
|
|
@ -63,3 +89,79 @@ export const ImageDiffView: React.FunctionComponent<{
|
||||||
{diff.diff && <AttachmentLink attachment={diff.diff.attachment}></AttachmentLink>}
|
{diff.diff && <AttachmentLink attachment={diff.diff.attachment}></AttachmentLink>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ImageDiffSlider: React.FC<{
|
||||||
|
sliderPosition: number,
|
||||||
|
setSliderPosition: (position: number) => 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]}
|
||||||
|
<div style={{ ...absolute }}>
|
||||||
|
<div style={{ ...absolute, display: 'flex', zIndex: 50, clip: `rect(0, ${size}px, auto, 0)` }}>
|
||||||
|
{childrenArray[1]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={gripStyle}
|
||||||
|
onMouseDown={event => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div data-testid='test-result-image-mismatch-grip' style={{
|
||||||
|
...absolute,
|
||||||
|
left: size - 1,
|
||||||
|
width: 20,
|
||||||
|
zIndex: 80,
|
||||||
|
margin: '10px -10px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 9,
|
||||||
|
width: 2,
|
||||||
|
backgroundColor: 'var(--color-diff-blob-expander-icon)',
|
||||||
|
}}>
|
||||||
|
</div>
|
||||||
|
<svg style={{ fill: 'var(--color-diff-blob-expander-icon)' }} viewBox="0 0 27 20"><path d="M9.6 0L0 9.6l9.6 9.6z"></path><path d="M17 19.2l9.5-9.6L16.9 0z"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const absolute: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,6 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabbed-pane-tab-content {
|
|
||||||
display: flex;
|
|
||||||
flex: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbed-pane-tab-strip {
|
.tabbed-pane-tab-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result > div {
|
.test-result > div {
|
||||||
|
|
|
||||||
|
|
@ -145,16 +145,35 @@ test('should include image diff', async ({ runInlineTest, page, showReport }) =>
|
||||||
await page.click('text=fails');
|
await page.click('text=fails');
|
||||||
await expect(page.locator('text=Image mismatch')).toBeVisible();
|
await expect(page.locator('text=Image mismatch')).toBeVisible();
|
||||||
await expect(page.locator('text=Snapshot mismatch')).toHaveCount(0);
|
await expect(page.locator('text=Snapshot mismatch')).toHaveCount(0);
|
||||||
|
|
||||||
|
const set = new Set();
|
||||||
|
|
||||||
const imageDiff = page.locator('data-testid=test-result-image-mismatch');
|
const imageDiff = page.locator('data-testid=test-result-image-mismatch');
|
||||||
const image = imageDiff.locator('img');
|
const expectedImage = imageDiff.locator('img').first();
|
||||||
await expect(image).toHaveAttribute('src', /.*png/);
|
const actualImage = imageDiff.locator('img').last();
|
||||||
const actualSrc = await image.getAttribute('src');
|
await expect(expectedImage).toHaveAttribute('src', /.*png/);
|
||||||
|
await expect(actualImage).toHaveAttribute('src', /.*png/);
|
||||||
|
set.add(await expectedImage.getAttribute('src'));
|
||||||
|
set.add(await actualImage.getAttribute('src'));
|
||||||
|
expect(set.size, 'Should be two images overlaid').toBe(2);
|
||||||
|
|
||||||
|
const sliderElement = imageDiff.locator('data-testid=test-result-image-mismatch-grip');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
return await sliderElement.evaluate(e => e.style.left);
|
||||||
|
}, 'Actual slider is on the right').toBe('590px');
|
||||||
|
|
||||||
await imageDiff.locator('text="Expected"').click();
|
await imageDiff.locator('text="Expected"').click();
|
||||||
const expectedSrc = await image.getAttribute('src');
|
set.add(await expectedImage.getAttribute('src'));
|
||||||
|
set.add(await actualImage.getAttribute('src'));
|
||||||
|
expect(set.size).toBe(2);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
return await sliderElement.evaluate(e => e.style.left);
|
||||||
|
}, 'Actual slider is on the right').toBe('350px');
|
||||||
|
|
||||||
await imageDiff.locator('text="Diff"').click();
|
await imageDiff.locator('text="Diff"').click();
|
||||||
const diffSrc = await image.getAttribute('src');
|
set.add(await imageDiff.locator('img').getAttribute('src'));
|
||||||
const set = new Set([expectedSrc, actualSrc, diffSrc]);
|
expect(set.size, 'Should be three images altogether').toBe(3);
|
||||||
expect(set.size).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should include multiple image diffs', async ({ runInlineTest, page, showReport }) => {
|
test('should include multiple image diffs', async ({ runInlineTest, page, showReport }) => {
|
||||||
|
|
@ -193,7 +212,7 @@ test('should include multiple image diffs', async ({ runInlineTest, page, showRe
|
||||||
await expect(page.locator('text=Screenshots')).toHaveCount(0);
|
await expect(page.locator('text=Screenshots')).toHaveCount(0);
|
||||||
for (let i = 0; i < 2; ++i) {
|
for (let i = 0; i < 2; ++i) {
|
||||||
const imageDiff = page.locator('data-testid=test-result-image-mismatch').nth(i);
|
const imageDiff = page.locator('data-testid=test-result-image-mismatch').nth(i);
|
||||||
const image = imageDiff.locator('img');
|
const image = imageDiff.locator('img').first();
|
||||||
await expect(image).toHaveAttribute('src', /.*png/);
|
await expect(image).toHaveAttribute('src', /.*png/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -259,10 +278,11 @@ test('should include image diff when screenshot failed to generate due to animat
|
||||||
await expect(page.locator('.chip-header', { hasText: 'Screenshots' })).toHaveCount(0);
|
await expect(page.locator('.chip-header', { hasText: 'Screenshots' })).toHaveCount(0);
|
||||||
const imageDiff = page.locator('data-testid=test-result-image-mismatch');
|
const imageDiff = page.locator('data-testid=test-result-image-mismatch');
|
||||||
const image = imageDiff.locator('img');
|
const image = imageDiff.locator('img');
|
||||||
await expect(image).toHaveAttribute('src', /.*png/);
|
await expect(image.first()).toHaveAttribute('src', /.*png/);
|
||||||
const actualSrc = await image.getAttribute('src');
|
await expect(image.last()).toHaveAttribute('src', /.*png/);
|
||||||
|
const previousSrc = await image.first().getAttribute('src');
|
||||||
|
const actualSrc = await image.last().getAttribute('src');
|
||||||
await imageDiff.locator('text="Previous"').click();
|
await imageDiff.locator('text="Previous"').click();
|
||||||
const previousSrc = await image.getAttribute('src');
|
|
||||||
await imageDiff.locator('text="Diff"').click();
|
await imageDiff.locator('text="Diff"').click();
|
||||||
const diffSrc = await image.getAttribute('src');
|
const diffSrc = await image.getAttribute('src');
|
||||||
const set = new Set([previousSrc, actualSrc, diffSrc]);
|
const set = new Set([previousSrc, actualSrc, diffSrc]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue