-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
US152247 & US152272 - Add pixelmatch and handle images of different sizes #46
Changes from all commits
70bc105
731aeab
11cbfac
7a05602
e3363ad
36ffa98
baecb3b
56906f4
3d44569
9b78b9d
0cb83f4
f97053b
9224c1a
0f05804
1cbd9d0
5c6bb05
a9e75fc
5a2201a
828a341
9723915
4328f4a
77435e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,9 +1,12 @@ | ||||||||||||||||||
import { access, constants, mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; | ||||||||||||||||||
import { access, constants, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; | ||||||||||||||||||
import { basename, dirname, join } from 'node:path'; | ||||||||||||||||||
import { env } from 'node:process'; | ||||||||||||||||||
import pixelmatch from 'pixelmatch'; | ||||||||||||||||||
import { PNG } from 'pngjs'; | ||||||||||||||||||
|
||||||||||||||||||
const isCI = !!env['CI']; | ||||||||||||||||||
const DEFAULT_MARGIN = 10; | ||||||||||||||||||
const DEFAULT_TOLERANCE = 0; // TODO: Support tolerance override? | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We currently allow this to be overridden and a few places have, but I don't think we have a story for it |
||||||||||||||||||
const PATHS = { | ||||||||||||||||||
FAIL: 'fail', | ||||||||||||||||||
GOLDEN: 'golden', | ||||||||||||||||||
|
@@ -56,6 +59,31 @@ async function clearDiffPaths(dir) { | |||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
async function createComparisonPNGs(original, newSize) { | ||||||||||||||||||
const resizedPNGs = []; | ||||||||||||||||||
[ | ||||||||||||||||||
{ name: 'top', coord: 0 }, | ||||||||||||||||||
{ name: 'center', coord: Math.floor((newSize.height - original.height) / 2) }, | ||||||||||||||||||
{ name: 'bottom', coord: newSize.height - original.height } | ||||||||||||||||||
].forEach(y => { | ||||||||||||||||||
[ | ||||||||||||||||||
{ name: 'left', coord: 0 }, | ||||||||||||||||||
{ name: 'center', coord: Math.floor((newSize.width - original.width) / 2) }, | ||||||||||||||||||
{ name: 'right', coord: newSize.width - original.width } | ||||||||||||||||||
].forEach(x => { // TODO: position added for reports, remove/adjust as needed | ||||||||||||||||||
if (original.width === newSize.width && original.height === newSize.height) { | ||||||||||||||||||
resizedPNGs.push({ png: original, position: `${y.name}-${x.name}` }); | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The position can be adjusted to whatever if we want to use it for the report, I don't actually use it anywhere. I used it for testing, and because it made these nested There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think the reports are going to need it, since they're going to use the resized images anyway which will always both be the same size. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah it was only if we wanted to call it out for extra clarity. I could remove this and switch to just comments for each value, like
? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess keep it for now and I'll see if it can be used in a useful way. Your TODO can remind us to remove it later if not! |
||||||||||||||||||
} else { | ||||||||||||||||||
const resized = new PNG(newSize); | ||||||||||||||||||
PNG.bitblt(original, resized, 0, 0, original.width, original.height, x.coord, y.coord); | ||||||||||||||||||
resizedPNGs.push({ png: resized, position: `${y.name}-${x.name}` }); | ||||||||||||||||||
} | ||||||||||||||||||
}); | ||||||||||||||||||
}); | ||||||||||||||||||
|
||||||||||||||||||
return resizedPNGs; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
async function tryMoveFile(srcFileName, destFileName) { | ||||||||||||||||||
await mkdir(dirname(destFileName), { recursive: true }); | ||||||||||||||||||
try { | ||||||||||||||||||
|
@@ -101,9 +129,6 @@ export function visualDiff({ updateGoldens = false, runSubset = false } = {}) { | |||||||||||||||||
}, | ||||||||||||||||||
async executeCommand({ command, payload, session }) { | ||||||||||||||||||
|
||||||||||||||||||
if (command !== 'brightspace-visual-diff') { | ||||||||||||||||||
return; | ||||||||||||||||||
} | ||||||||||||||||||
if (session.browser.type !== 'playwright') { | ||||||||||||||||||
throw new Error('Visual-diff is only supported for browser type Playwright.'); | ||||||||||||||||||
} | ||||||||||||||||||
|
@@ -114,56 +139,100 @@ export function visualDiff({ updateGoldens = false, runSubset = false } = {}) { | |||||||||||||||||
const newPath = join(rootDir, PATHS.VDIFF_ROOT, testPath, dir); | ||||||||||||||||||
const goldenFileName = `${join(newPath, PATHS.GOLDEN, browser, newName)}.png`; | ||||||||||||||||||
const passFileName = `${join(newPath, PATHS.PASS, browser, newName)}.png`; | ||||||||||||||||||
const screenshotFileName = `${join(newPath, PATHS.FAIL, browser, newName)}.png`; | ||||||||||||||||||
|
||||||||||||||||||
if (!isCI) { // CI will be a fresh .vdiff folder each time and only one run | ||||||||||||||||||
if (session.testRun !== currentRun) { | ||||||||||||||||||
currentRun = session.testRun; | ||||||||||||||||||
clearedDirs.clear(); | ||||||||||||||||||
} | ||||||||||||||||||
const screenshotFile = join(newPath, PATHS.FAIL, browser, newName); | ||||||||||||||||||
const screenshotFileName = `${screenshotFile}.png`; | ||||||||||||||||||
|
||||||||||||||||||
if (command === 'brightspace-visual-diff-compare') { | ||||||||||||||||||
if (!isCI) { // CI will be a fresh .vdiff folder each time and only one run | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought about putting this whole cleanup in it's own command to make this cleaner, but then we're running testing/src/server/visual-diff-plugin.js Lines 136 to 143 in 9723915
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah... was thinking we could also refactor this a bit at this point to be like: if (command === 'brightspace-visual-diff-compare') {
doCompare();
} else if (command === 'brightspace-visual-diff-compare-resize') {
doCompareResize();
} At the very least that'd reduce a lot of the nesting, and would also let us move things out into separate files if that made sense. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I started doing that and then there were way too many things I was passing around as parameters without knowing what you'd need and where, so I figured that might be better once the skeleton of the report stuff is in |
||||||||||||||||||
if (session.testRun !== currentRun) { | ||||||||||||||||||
currentRun = session.testRun; | ||||||||||||||||||
clearedDirs.clear(); | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if (runSubset || currentRun > 0) { | ||||||||||||||||||
if (!clearedDirs.has(newPath)) { | ||||||||||||||||||
clearedDirs.set(newPath, clearDir(updateGoldens, newPath)); | ||||||||||||||||||
if (runSubset || currentRun > 0) { | ||||||||||||||||||
if (!clearedDirs.has(newPath)) { | ||||||||||||||||||
clearedDirs.set(newPath, clearDir(updateGoldens, newPath)); | ||||||||||||||||||
} | ||||||||||||||||||
await clearedDirs.get(newPath); | ||||||||||||||||||
} | ||||||||||||||||||
await clearedDirs.get(newPath); | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
const opts = { margin: DEFAULT_MARGIN, ...payload.opts }; | ||||||||||||||||||
|
||||||||||||||||||
const page = session.browser.getPage(session.id); | ||||||||||||||||||
await page.screenshot({ | ||||||||||||||||||
animations: 'disabled', | ||||||||||||||||||
clip: { | ||||||||||||||||||
x: payload.rect.x - opts.margin, | ||||||||||||||||||
y: payload.rect.y - opts.margin, | ||||||||||||||||||
width: payload.rect.width + (opts.margin * 2), | ||||||||||||||||||
height: payload.rect.height + (opts.margin * 2) | ||||||||||||||||||
}, | ||||||||||||||||||
path: updateGoldens ? goldenFileName : screenshotFileName | ||||||||||||||||||
}); | ||||||||||||||||||
|
||||||||||||||||||
if (updateGoldens) { | ||||||||||||||||||
return { pass: true }; | ||||||||||||||||||
} | ||||||||||||||||||
const opts = { margin: DEFAULT_MARGIN, ...payload.opts }; | ||||||||||||||||||
|
||||||||||||||||||
const page = session.browser.getPage(session.id); | ||||||||||||||||||
await page.screenshot({ | ||||||||||||||||||
animations: 'disabled', | ||||||||||||||||||
clip: { | ||||||||||||||||||
x: payload.rect.x - opts.margin, | ||||||||||||||||||
y: payload.rect.y - opts.margin, | ||||||||||||||||||
width: payload.rect.width + (opts.margin * 2), | ||||||||||||||||||
height: payload.rect.height + (opts.margin * 2) | ||||||||||||||||||
}, | ||||||||||||||||||
path: updateGoldens ? goldenFileName : screenshotFileName | ||||||||||||||||||
}); | ||||||||||||||||||
|
||||||||||||||||||
if (updateGoldens) { | ||||||||||||||||||
return { pass: true }; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
const goldenExists = await checkFileExists(goldenFileName); | ||||||||||||||||||
if (!goldenExists) { | ||||||||||||||||||
return { pass: false, message: 'No golden exists. Use the "--golden" CLI flag to re-run and re-generate goldens.' }; | ||||||||||||||||||
} | ||||||||||||||||||
const goldenExists = await checkFileExists(goldenFileName); | ||||||||||||||||||
if (!goldenExists) { | ||||||||||||||||||
return { pass: false, message: 'No golden exists. Use the "--golden" CLI flag to re-run and re-generate goldens.' }; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
const screenshotInfo = await stat(screenshotFileName); | ||||||||||||||||||
const goldenInfo = await stat(goldenFileName); | ||||||||||||||||||
const screenshotImage = PNG.sync.read(await readFile(screenshotFileName)); | ||||||||||||||||||
const goldenImage = PNG.sync.read(await readFile(goldenFileName)); | ||||||||||||||||||
|
||||||||||||||||||
if (screenshotImage.width === goldenImage.width && screenshotImage.height === goldenImage.height) { | ||||||||||||||||||
const diff = new PNG({ width: screenshotImage.width, height: screenshotImage.height }); | ||||||||||||||||||
const pixelsDiff = pixelmatch( | ||||||||||||||||||
screenshotImage.data, goldenImage.data, diff.data, screenshotImage.width, screenshotImage.height, { diffMask: true, threshold: DEFAULT_TOLERANCE } | ||||||||||||||||||
); | ||||||||||||||||||
|
||||||||||||||||||
if (pixelsDiff !== 0) { | ||||||||||||||||||
await writeFile(`${screenshotFile}-diff.png`, PNG.sync.write(diff)); | ||||||||||||||||||
return { pass: false, message: `Image does not match golden. ${pixelsDiff} pixels are different.` }; | ||||||||||||||||||
} else { | ||||||||||||||||||
const success = await tryMoveFile(screenshotFileName, passFileName); | ||||||||||||||||||
if (!success) return { pass: false, message: 'Problem moving file to "pass" directory.' }; | ||||||||||||||||||
return { pass: true }; | ||||||||||||||||||
} | ||||||||||||||||||
} else { | ||||||||||||||||||
return { resizeRequired: true }; | ||||||||||||||||||
} | ||||||||||||||||||
} else if (command === 'brightspace-visual-diff-compare-resize') { | ||||||||||||||||||
const screenshotImage = PNG.sync.read(await readFile(screenshotFileName)); | ||||||||||||||||||
const goldenImage = PNG.sync.read(await readFile(goldenFileName)); | ||||||||||||||||||
|
||||||||||||||||||
const newWidth = Math.max(screenshotImage.width, goldenImage.width); | ||||||||||||||||||
const newHeight = Math.max(screenshotImage.height, goldenImage.height); | ||||||||||||||||||
const newSize = { width: newWidth, height: newHeight }; | ||||||||||||||||||
|
||||||||||||||||||
const newScreenshots = await createComparisonPNGs(screenshotImage, newSize); | ||||||||||||||||||
const newGoldens = await createComparisonPNGs(goldenImage, newSize); | ||||||||||||||||||
|
||||||||||||||||||
let bestIndex = -1; | ||||||||||||||||||
let bestDiffImage = null; | ||||||||||||||||||
let pixelsDiff = Number.MAX_SAFE_INTEGER; | ||||||||||||||||||
for (let i = 0; i < newScreenshots.length; i++) { | ||||||||||||||||||
const currentDiff = new PNG(newSize); | ||||||||||||||||||
const currentPixelsDiff = pixelmatch( | ||||||||||||||||||
newScreenshots[i].png.data, newGoldens[i].png.data, currentDiff.data, currentDiff.width, currentDiff.height, { diffMask: true, threshold: DEFAULT_TOLERANCE } | ||||||||||||||||||
); | ||||||||||||||||||
|
||||||||||||||||||
if (currentPixelsDiff < pixelsDiff) { | ||||||||||||||||||
bestIndex = i; | ||||||||||||||||||
bestDiffImage = currentDiff; | ||||||||||||||||||
pixelsDiff = currentPixelsDiff; | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// TODO: obviously this isn't how to diff against the golden! Use pixelmatch here. | ||||||||||||||||||
const same = (screenshotInfo.size === goldenInfo.size); | ||||||||||||||||||
await writeFile(`${screenshotFile}-resized-screenshot.png`, PNG.sync.write(newScreenshots[bestIndex].png)); | ||||||||||||||||||
await writeFile(`${screenshotFile}-resized-golden.png`, PNG.sync.write(newGoldens[bestIndex].png)); | ||||||||||||||||||
await writeFile(`${screenshotFile}-diff.png`, PNG.sync.write(bestDiffImage)); | ||||||||||||||||||
|
||||||||||||||||||
if (same) { | ||||||||||||||||||
const success = await tryMoveFile(screenshotFileName, passFileName); | ||||||||||||||||||
if (!success) return { pass: false, message: 'Problem moving file to pass directory.' }; | ||||||||||||||||||
return { pass: false, message: `Images are not the same size. When resized, ${pixelsDiff} pixels are different.` }; | ||||||||||||||||||
} | ||||||||||||||||||
return { pass: same, message: 'Does not match golden.' }; // TODO: Add more details once actually diff-ing | ||||||||||||||||||
|
||||||||||||||||||
} | ||||||||||||||||||
}; | ||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -56,14 +56,24 @@ describe('element-different', () => { | |
elem.style.borderColor = 'black'; | ||
elem.text = 'Different Text'; | ||
} }, | ||
/*{ name: 'smaller', action: elem => { | ||
{ name: 'smaller', action: elem => { | ||
elem.style.width = '200px'; | ||
elem.style.height = '50px'; | ||
} }, | ||
{ name: 'larger', action: elem => { | ||
elem.style.width = '350px'; | ||
elem.style.height = '70px'; | ||
} }*/ | ||
} }, | ||
{ name: 'slimer-taller', action: elem => { | ||
elem.style.width = '200px'; | ||
elem.style.height = '70px'; | ||
elem.style.textAlign = 'end'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding these |
||
} }, | ||
{ name: 'wider-shorter', action: elem => { | ||
elem.style.width = '350px'; | ||
elem.style.height = '50px'; | ||
elem.style.textAlign = 'end'; | ||
} } | ||
].forEach(({ name, action }) => { | ||
it(name, async() => { | ||
const elem = await fixture(`<${elementTag} text="Visual Difference"></${elementTag}>`); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
import '../element.vdiff.js'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've found having this second nested test file was really helpful for all the file manipulation testing, so figured I'd just add it. Could also help with the deleting/creating/rolling up reports testing! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're worried about turning off the timeout, we could also pick a large number. We'll only get to this point after the screenshot is taken, so we don't need to worry about bad tests timing out - those will fail properly. It'd only be if pixelmatch or the file operations never completed that we'd be stuck.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not worried about it -- if we run into these cases we should be able to handle them separately anyway.