diff --git a/README.md b/README.md index c9885ef7..f057958e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,16 @@ cy.matchImage(); ## Example -Still got troubles with installation? Have a look at [example directory of this repo](./example) to see how this plugin can be used in e2e or component-testing Cypress within your project. +Still got troubles with installation? Have a look at [example directory of this repo](./example) to see how this plugin can be used in e2e or component-testing Cypress environment within your project. + +## Automatic clean up of unused images + +It's useful to remove screenshots generated by the visual regression plugin that are not used by any test anymore. +Enable this feature via env variable and enjoy freed up storage space 🚀: + +```bash +npx cypress run --env "pluginVisualRegressionCleanupUnusedImages=true" +``` ## Configuration @@ -178,6 +187,10 @@ cy.matchImage({ // title used for naming the image file // default: Cypress.currentTest.titlePath (your test title) title: `${Cypress.currentTest.titlePath.join(' ')} (${Cypress.browser.displayName})` + // pass a path to custom image that should be used for comparison + // instead of checking against the image from previous run + // default: undefined + matchAgainstPath: '/path/to/reference-image.png' }) ``` diff --git a/__tests__/fixtures/prepare-screenshot-for-cleanup.spec.cy.js b/__tests__/fixtures/prepare-screenshot-for-cleanup.spec.cy.js new file mode 100644 index 00000000..a6d416ee --- /dev/null +++ b/__tests__/fixtures/prepare-screenshot-for-cleanup.spec.cy.js @@ -0,0 +1,6 @@ +describe('Cleanup test', () => { + it('Create screenshot to be removed', () => { + cy.visit('/'); + cy.get('[data-testid="description"]').matchImage(); + }); +}); diff --git a/__tests__/fixtures/screenshot.actual.png b/__tests__/fixtures/screenshot.actual.png index a505598f..9342c2a7 100644 Binary files a/__tests__/fixtures/screenshot.actual.png and b/__tests__/fixtures/screenshot.actual.png differ diff --git a/__tests__/fixtures/screenshot.diff.png b/__tests__/fixtures/screenshot.diff.png index 7030d78f..15de2c6e 100644 Binary files a/__tests__/fixtures/screenshot.diff.png and b/__tests__/fixtures/screenshot.diff.png differ diff --git a/__tests__/fixtures/screenshot.png b/__tests__/fixtures/screenshot.png index 12b69148..ccb685a5 100644 Binary files a/__tests__/fixtures/screenshot.png and b/__tests__/fixtures/screenshot.png differ diff --git a/example/cypress/e2e/spec.cy.js b/example/cypress/e2e/spec.cy.js index 8e41043d..4671caf8 100644 --- a/example/cypress/e2e/spec.cy.js +++ b/example/cypress/e2e/spec.cy.js @@ -3,5 +3,9 @@ describe('My First Test', () => { cy.visit('/') cy.contains('h1', 'Welcome to Your Vue.js App') cy.matchImage() + .then(({ imgNewPath }) => { + // match against image from custom path + cy.matchImage({ matchAgainstPath: imgNewPath }); + }) }) }) diff --git a/example/yarn.lock b/example/yarn.lock index 4ec448d9..5fd0a986 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -145,6 +145,8 @@ __metadata: resolution: "@frsource/cypress-plugin-visual-regression-diff@portal:..::locator=example%40workspace%3A." dependencies: "@frsource/base64": 1.0.3 + glob: ^8.0.3 + meta-png: ^1.0.3 move-file: 2.1.0 pixelmatch: 5.3.0 pngjs: 6.0.0 @@ -3380,7 +3382,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.0.1": +"glob@npm:^8.0.1, glob@npm:^8.0.3": version: 8.0.3 resolution: "glob@npm:8.0.3" dependencies: @@ -4502,6 +4504,13 @@ __metadata: languageName: node linkType: hard +"meta-png@npm:^1.0.3": + version: 1.0.3 + resolution: "meta-png@npm:1.0.3" + checksum: cc7e1e0950b149273eb127622d8079725855ca14fb5e0175a4f1a7946d7f4a1c92e78de9f44eb1b9fa339c60f43b099c5135dc06b218cf77879fbd0a7f6ecddb + languageName: node + linkType: hard + "methods@npm:~1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" diff --git a/package.json b/package.json index dd6ac603..2b7b0136 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@semantic-release/git": "10.0.1", "@semantic-release/npm": "9.0.1", "@semantic-release/release-notes-generator": "10.0.3", + "@types/glob": "^8.0.0", "@types/pixelmatch": "5.2.4", "@types/pngjs": "6.0.1", "@types/sharp": "0.31.0", @@ -106,6 +107,8 @@ ], "dependencies": { "@frsource/base64": "1.0.3", + "glob": "^8.0.3", + "meta-png": "^1.0.3", "move-file": "2.1.0", "pixelmatch": "5.3.0", "pngjs": "6.0.0", diff --git a/src/commands.ts b/src/commands.ts index 1cf24c3e..96e16326 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -13,6 +13,22 @@ declare global { imagesDir?: string; maxDiffThreshold?: number; title?: string; + matchAgainstPath?: string; + // IDEA: to be implemented if support for files NOT from filesystem needed + // matchAgainst?: string | Buffer; + }; + + type MatchImageReturn = { + diffValue: number | undefined; + imgNewPath: string; + imgPath: string; + imgDiffPath: string; + imgNewBase64: string | undefined; + imgBase64: string | undefined; + imgDiffBase64: string | undefined; + imgNew: InstanceType | undefined; + img: InstanceType | undefined; + imgDiff: InstanceType | undefined; }; interface Chainable { @@ -21,13 +37,11 @@ declare global { * @memberof Cypress.Chainable * @example cy.get('.my-element').matchImage(); */ - matchImage(options?: Cypress.MatchImageOptions): Chainable; + matchImage(options?: Cypress.MatchImageOptions): Chainable; } } } -const nameCacheCounter: Record = {}; - const constructCypressError = (log: Cypress.Log, err: Error) => { // only way to throw & log the message properly in Cypress // https://github.com/cypress-io/cypress/blob/5f94cad3cb4126e0567290b957050c33e3a78e3c/packages/driver/src/cypress/error_utils.ts#L214-L216 @@ -69,6 +83,7 @@ export const getConfig = (options: Cypress.MatchImageOptions) => ({ | Partial | undefined) || {}, + matchAgainstPath: options.matchAgainstPath || undefined, }); Cypress.Commands.add( @@ -76,10 +91,7 @@ Cypress.Commands.add( { prevSubject: "optional" }, (subject, options = {}) => { const $el = subject as JQuery | undefined; - let title = options.title || Cypress.currentTest.titlePath.join(" "); - if (typeof nameCacheCounter[title] === "undefined") - nameCacheCounter[title] = -1; - title += ` #${++nameCacheCounter[title]}`; + let title: string; const { scaleFactor, @@ -88,21 +100,24 @@ Cypress.Commands.add( maxDiffThreshold, diffConfig, screenshotConfig, + matchAgainstPath, } = getConfig(options); return cy .then(() => - cy.task( - TASK.getScreenshotPath, + cy.task<{ screenshotPath: string; title: string }>( + TASK.getScreenshotPathInfo, { - title, + titleFromOptions: + options.title || Cypress.currentTest.titlePath.join(" "), imagesDir, specPath: Cypress.spec.relative, }, { log: false } ) ) - .then((screenshotPath) => { + .then(({ screenshotPath, title: titleFromTask }) => { + title = titleFromTask; let imgPath: string; return ($el ? cy.wrap($el) : cy) .screenshot(screenshotPath as string, { @@ -115,14 +130,14 @@ Cypress.Commands.add( }) .then(() => imgPath); }) - .then((imgPath) => - cy + .then((imgPath) => { + return cy .task( TASK.compareImages, { scaleFactor, imgNew: imgPath, - imgOld: imgPath.replace(FILE_SUFFIX.actual, ""), + imgOld: matchAgainstPath || imgPath.replace(FILE_SUFFIX.actual, ""), updateImages, maxDiffThreshold, diffConfig, @@ -133,7 +148,7 @@ Cypress.Commands.add( res, imgPath, })) - ) + }) .then(({ res, imgPath }) => { const log = Cypress.log({ name: "log", @@ -170,6 +185,19 @@ Cypress.Commands.add( log.set("consoleProps", () => res); throw constructCypressError(log, new Error(res.message)); } + + return { + diffValue: res.imgDiff, + imgNewPath: imgPath, + imgPath: imgPath.replace(FILE_SUFFIX.actual, ""), + imgDiffPath: imgPath.replace(FILE_SUFFIX.actual, FILE_SUFFIX.diff), + imgNewBase64: res.imgNewBase64, + imgBase64: res.imgOldBase64, + imgDiffBase64: res.imgDiffBase64, + imgNew: typeof res.imgNewBase64 === 'string' ? Cypress.Buffer.from(res.imgNewBase64, 'base64') : undefined, + img: typeof res.imgOldBase64 === 'string' ? Cypress.Buffer.from(res.imgOldBase64, 'base64') : undefined, + imgDiff: typeof res.imgDiffBase64 === 'string' ? Cypress.Buffer.from(res.imgDiffBase64, 'base64') : undefined, + } }); } ); diff --git a/src/constants.ts b/src/constants.ts index 51db9139..39b47244 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ +/* c8 ignore start */ const PLUGIN_NAME = "cp-visual-regression-diff"; export const LINK_PREFIX = `#${PLUGIN_NAME}-`; export const OVERLAY_CLASS = `${PLUGIN_NAME}-overlay`; @@ -9,9 +10,13 @@ export enum FILE_SUFFIX { } export const TASK = { - getScreenshotPath: `${PLUGIN_NAME}-getScreenshotPath`, + getScreenshotPathInfo: `${PLUGIN_NAME}-getScreenshotPathInfo`, compareImages: `${PLUGIN_NAME}-compareImages`, approveImage: `${PLUGIN_NAME}-approveImage`, + cleanupImages: `${PLUGIN_NAME}-cleanupImages`, doesFileExist: `${PLUGIN_NAME}-doesFileExist`, /* c8 ignore next */ }; + +export const METADATA_KEY = "FRSOURCE_CPVRD_V"; +/* c8 ignore stop */ diff --git a/src/image.utils.ts b/src/image.utils.ts index 4fe4d59f..25a2fc2d 100644 --- a/src/image.utils.ts +++ b/src/image.utils.ts @@ -1,6 +1,27 @@ +import path from "path"; import fs from "fs"; import { PNG, PNGWithMetadata } from "pngjs"; import sharp from "sharp"; +import { addMetadata, getMetadata } from "meta-png"; +import glob from "glob"; +import { version } from "../package.json"; +import { wasScreenshotUsed } from "./screenshotPath.utils"; +import { METADATA_KEY } from "./constants"; + +export const addPNGMetadata = (png: Buffer) => + addMetadata(png, METADATA_KEY, version /* c8 ignore next */); +export const getPNGMetadata = (png: Buffer) => + getMetadata(png, METADATA_KEY /* c8 ignore next */); +export const isImageCurrentVersion = (png: Buffer) => + getPNGMetadata(png) === version; +export const isImageGeneratedByPlugin = (png: Buffer) => + !!getPNGMetadata(png /* c8 ignore next */); + +export const writePNG = (name: string, png: PNG | Buffer) => + fs.writeFileSync( + name, + addPNGMetadata(png instanceof PNG ? PNG.sync.write(png) : png) + ); const inArea = (x: number, y: number, height: number, width: number) => y > height || x > width; @@ -33,19 +54,22 @@ export const createImageResizer = /* c8 ignore next */ }; -export const importAndScaleImage = async (cfg: { +export const scaleImageAndWrite = async ({ + scaleFactor, + path, +}: { scaleFactor: number; path: string; }) => { - const imgBuffer = fs.readFileSync(cfg.path); - const rawImgNew = PNG.sync.read(imgBuffer); - if (cfg.scaleFactor === 1) return rawImgNew; + const imgBuffer = fs.readFileSync(path); + if (scaleFactor === 1) return imgBuffer; - const newImageWidth = Math.ceil(rawImgNew.width * cfg.scaleFactor); - const newImageHeight = Math.ceil(rawImgNew.height * cfg.scaleFactor); - await sharp(imgBuffer).resize(newImageWidth, newImageHeight).toFile(cfg.path); + const rawImgNew = PNG.sync.read(imgBuffer); + const newImageWidth = Math.ceil(rawImgNew.width * scaleFactor); + const newImageHeight = Math.ceil(rawImgNew.height * scaleFactor); + await sharp(imgBuffer).resize(newImageWidth, newImageHeight).toFile(path); - return PNG.sync.read(fs.readFileSync(cfg.path)); + return fs.readFileSync(path); }; export const alignImagesToSameSize = ( @@ -70,3 +94,20 @@ export const alignImagesToSameSize = ( fillSizeDifference(resizedSecond, secondImageWidth, secondImageHeight), ]; }; + +export const cleanupUnused = (rootPath: string) => { + glob + .sync("**/*.png", { + cwd: rootPath, + ignore: "node_modules/**/*", + }) + .forEach((pngPath) => { + const absolutePath = path.join(rootPath, pngPath); + if ( + !wasScreenshotUsed(pngPath) && + isImageGeneratedByPlugin(fs.readFileSync(absolutePath)) + ) { + fs.unlinkSync(absolutePath); + } + }); +}; diff --git a/src/plugins.ts b/src/plugins.ts index 5987482b..9fb356d9 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -27,6 +27,6 @@ export const initPlugin = ( initForceDeviceScaleFactor(on); } /* c8 ignore stop */ - on("task", initTaskHook()); + on("task", initTaskHook(config)); on("after:screenshot", initAfterScreenshotHook(config)); }; diff --git a/src/screenshotPath.utils.ts b/src/screenshotPath.utils.ts new file mode 100644 index 00000000..fef0cdd2 --- /dev/null +++ b/src/screenshotPath.utils.ts @@ -0,0 +1,46 @@ +import path from "path"; +import { FILE_SUFFIX, IMAGE_SNAPSHOT_PREFIX } from "./constants"; +import sanitize from "sanitize-filename"; + +const nameCacheCounter: Record = {}; + +export const generateScreenshotPath = ({ + titleFromOptions, + imagesDir, + specPath, +}: { + titleFromOptions: string; + imagesDir: string; + specPath: string; +}) => { + const screenshotPath = path.join( + path.dirname(specPath), + ...imagesDir.split("/"), + sanitize(titleFromOptions) + ); + + if (typeof nameCacheCounter[screenshotPath] === "undefined") { + nameCacheCounter[screenshotPath] = -1; + } + return path.join( + IMAGE_SNAPSHOT_PREFIX, + `${screenshotPath} #${++nameCacheCounter[screenshotPath]}${ + FILE_SUFFIX.actual + }.png` + ); +}; + +const screenshotPathRegex = new RegExp( + `^([\\s\\S]+?) #([0-9]+)(?:(?:\\${FILE_SUFFIX.diff})|(?:\\${FILE_SUFFIX.actual}))?\\.(?:png|PNG)$` +); +export const wasScreenshotUsed = (imagePath: string) => { + const matched = imagePath.match(screenshotPathRegex); + /* c8 ignore next */ if (!matched) return false; + const [, screenshotPath, numString] = matched; + const num = parseInt(numString); + /* c8 ignore next */ if (!screenshotPath || isNaN(num)) return false; + return ( + screenshotPath in nameCacheCounter && + num <= nameCacheCounter[screenshotPath] + ); +}; diff --git a/src/support.ts b/src/support.ts index a2e44a64..32319662 100644 --- a/src/support.ts +++ b/src/support.ts @@ -77,6 +77,8 @@ before(() => { after(() => { if (!top) return null; + cy.task(TASK.cleanupImages, { log: false }); + Cypress.$(top.document.body).on( "click", `a[href^="${LINK_PREFIX}"]`, diff --git a/src/task.hook.test.ts b/src/task.hook.test.ts index 39f7f349..df2a572a 100644 --- a/src/task.hook.test.ts +++ b/src/task.hook.test.ts @@ -1,37 +1,105 @@ import { it, expect, describe, beforeEach, afterEach } from "vitest"; import path from "path"; import { promises as fs, existsSync } from "fs"; +import { dir, file, setGracefulCleanup, withFile } from "tmp-promise"; import { approveImageTask, compareImagesTask, doesFileExistTask, - getScreenshotPathTask, + getScreenshotPathInfoTask, CompareImagesCfg, + cleanupImagesTask, } from "./task.hook"; -import { file, setGracefulCleanup, withFile } from "tmp-promise"; +import { generateScreenshotPath } from "./screenshotPath.utils"; +import { IMAGE_SNAPSHOT_PREFIX } from "./constants"; setGracefulCleanup(); const fixturesPath = path.resolve(__dirname, "..", "__tests__", "fixtures"); +const oldImgFixture = "screenshot.png"; +const newImgFixture = "screenshot.actual.png"; const newFileContent = "new file content"; -const writeTmpFixture = async (pathToWriteTo: string, fixtureName: string) => - fs.writeFile( +const generateConfig = async (cfg: Partial) => ({ + updateImages: false, + scaleFactor: 1, + title: "some title", + imgNew: await writeTmpFixture((await file()).path, newImgFixture), + imgOld: await writeTmpFixture((await file()).path, oldImgFixture), + maxDiffThreshold: 0.5, + diffConfig: {}, + ...cfg, +}); +const writeTmpFixture = async (pathToWriteTo: string, fixtureName: string) => { + await fs.mkdir(path.dirname(pathToWriteTo), { recursive: true }); + await fs.writeFile( pathToWriteTo, await fs.readFile(path.join(fixturesPath, fixtureName)) ); + return pathToWriteTo; +}; -describe("getScreenshotPathTask", () => { - it("returns sanitized path", () => { +describe("getScreenshotPathInfoTask", () => { + it("returns sanitized path and title", () => { expect( - getScreenshotPathTask({ - title: "some-title-withśpęćiał人物", + getScreenshotPathInfoTask({ + titleFromOptions: "some-title-withśpęćiał人物", imagesDir: "nested/images/dir", specPath: "some/nested/spec-path/spec.ts", }) - ).toBe( - "__cp-visual-regression-diff_snapshots__/some/nested/spec-path/nested/images/dir/some-title-withśpęćiał人物.actual.png" - ); + ).toEqual({ + screenshotPath: + "__cp-visual-regression-diff_snapshots__/some/nested/spec-path/nested/images/dir/some-title-withśpęćiał人物 #0.actual.png", + title: "some-title-withśpęćiał人物 #0.actual", + }); + }); +}); + +describe("cleanupImagesTask", () => { + describe("when env is set", () => { + const generateUsedScreenshotPath = async (projectRoot: string) => { + const screenshotPathWithPrefix = generateScreenshotPath({ + titleFromOptions: "some-file", + imagesDir: "images", + specPath: "some/spec/path", + }); + return path.join( + projectRoot, + screenshotPathWithPrefix.substring( + IMAGE_SNAPSHOT_PREFIX.length + path.sep.length + ) + ); + }; + + it("does not remove used screenshot", async () => { + const { path: projectRoot } = await dir(); + const screenshotPath = await writeTmpFixture( + await generateUsedScreenshotPath(projectRoot), + oldImgFixture + ); + + cleanupImagesTask({ + projectRoot, + env: { pluginVisualRegressionCleanupUnusedImages: true }, + } as unknown as Cypress.PluginConfigOptions); + + expect(existsSync(screenshotPath)).toBe(true); + }); + + it("removes unused screenshot", async () => { + const { path: projectRoot } = await dir(); + const screenshotPath = await writeTmpFixture( + path.join(projectRoot, "some-file-2 #0.png"), + oldImgFixture + ); + + cleanupImagesTask({ + projectRoot, + env: { pluginVisualRegressionCleanupUnusedImages: true }, + } as unknown as Cypress.PluginConfigOptions); + + expect(existsSync(screenshotPath)).toBe(false); + }); }); }); @@ -64,18 +132,6 @@ describe("approveImageTask", () => { }); describe("compareImagesTask", () => { - const title = "some title"; - const generateConfig = async (cfg: Partial) => ({ - updateImages: false, - scaleFactor: 1, - title, - imgNew: (await file()).path, - imgOld: (await file()).path, - maxDiffThreshold: 0.5, - diffConfig: {}, - ...cfg, - }); - describe("when images should be updated", () => { describe("when old screenshot exists", () => { it("resolves with a success message", async () => @@ -115,8 +171,6 @@ describe("compareImagesTask", () => { describe("when new image has different resolution", () => { it("resolves with an error message", async () => { const cfg = await generateConfig({ updateImages: false }); - await writeTmpFixture(cfg.imgOld, "screenshot.png"); - await writeTmpFixture(cfg.imgNew, "screenshot.actual.png"); await expect(compareImagesTask(cfg)).resolves.toMatchSnapshot(); }); @@ -125,8 +179,7 @@ describe("compareImagesTask", () => { describe("when new image is exactly the same as old one", () => { it("resolves with a success message", async () => { const cfg = await generateConfig({ updateImages: false }); - await writeTmpFixture(cfg.imgOld, "screenshot.png"); - await writeTmpFixture(cfg.imgNew, "screenshot.png"); + await writeTmpFixture(cfg.imgNew, oldImgFixture); await expect(compareImagesTask(cfg)).resolves.toMatchSnapshot(); }); @@ -139,7 +192,7 @@ describe("doesFileExistsTask", () => { it("checks whether file exists", () => { expect(doesFileExistTask({ path: "some/random/path" })).toBe(false); expect( - doesFileExistTask({ path: path.join(fixturesPath, "screenshot.png") }) + doesFileExistTask({ path: path.join(fixturesPath, oldImgFixture) }) ).toBe(true); }); }); diff --git a/src/task.hook.ts b/src/task.hook.ts index 440bbd3c..e4c1171d 100644 --- a/src/task.hook.ts +++ b/src/task.hook.ts @@ -1,11 +1,17 @@ import fs from "fs"; -import path from "path"; import { PNG } from "pngjs"; import pixelmatch, { PixelmatchOptions } from "pixelmatch"; import moveFile from "move-file"; -import sanitize from "sanitize-filename"; +import path from "path"; import { FILE_SUFFIX, IMAGE_SNAPSHOT_PREFIX, TASK } from "./constants"; -import { alignImagesToSameSize, importAndScaleImage } from "./image.utils"; +import { + cleanupUnused, + alignImagesToSameSize, + scaleImageAndWrite, + isImageCurrentVersion, + writePNG, +} from "./image.utils"; +import { generateScreenshotPath } from "./screenshotPath.utils"; import type { CompareImagesTaskReturn } from "./types"; export type CompareImagesCfg = { @@ -25,21 +31,23 @@ const unlinkSyncSafe = (path: string) => const moveSyncSafe = (pathFrom: string, pathTo: string) => fs.existsSync(pathFrom) && moveFile.sync(pathFrom, pathTo); -export const getScreenshotPathTask = ({ - title, - imagesDir, - specPath, -}: { - title: string; +export const getScreenshotPathInfoTask = (cfg: { + titleFromOptions: string; imagesDir: string; specPath: string; -}) => - path.join( - IMAGE_SNAPSHOT_PREFIX, - path.dirname(specPath), - ...imagesDir.split("/"), - `${sanitize(title)}${FILE_SUFFIX.actual}.png` - ); +}) => { + const screenshotPath = generateScreenshotPath(cfg); + + return { screenshotPath, title: path.basename(screenshotPath, ".png") }; +}; + +export const cleanupImagesTask = (config: Cypress.PluginConfigOptions) => { + if (config.env["pluginVisualRegressionCleanupUnusedImages"]) { + cleanupUnused(config.projectRoot); + } + + return null; +}; export const approveImageTask = ({ img }: { img: string }) => { const oldImg = img.replace(FILE_SUFFIX.actual, ""); @@ -62,11 +70,13 @@ export const compareImagesTask = async ( let error = false; if (fs.existsSync(cfg.imgOld) && !cfg.updateImages) { - const rawImgNew = await importAndScaleImage({ + const rawImgNewBuffer = await scaleImageAndWrite({ scaleFactor: cfg.scaleFactor, path: cfg.imgNew, }); - const rawImgOld = PNG.sync.read(fs.readFileSync(cfg.imgOld)); + const rawImgNew = PNG.sync.read(rawImgNewBuffer); + const rawImgOldBuffer = fs.readFileSync(cfg.imgOld); + const rawImgOld = PNG.sync.read(rawImgOldBuffer); const isImgSizeDifferent = rawImgNew.height !== rawImgOld.height || rawImgNew.width !== rawImgOld.width; @@ -110,7 +120,7 @@ export const compareImagesTask = async ( imgOldBase64 = PNG.sync.write(imgOld).toString("base64"); if (error) { - fs.writeFileSync( + writePNG( cfg.imgNew.replace(FILE_SUFFIX.actual, FILE_SUFFIX.diff), diffBuffer ); @@ -124,8 +134,13 @@ export const compareImagesTask = async ( maxDiffThreshold: cfg.maxDiffThreshold, }; } else { - // don't overwrite file if it's the same (imgDiff < cfg.maxDiffThreshold && !isImgSizeDifferent) - fs.unlinkSync(cfg.imgNew); + if (rawImgOld && !isImageCurrentVersion(rawImgOldBuffer)) { + writePNG(cfg.imgNew, rawImgNewBuffer); + moveFile.sync(cfg.imgNew, cfg.imgOld); + } else { + // don't overwrite file if it's the same (imgDiff < cfg.maxDiffThreshold && !isImgSizeDifferent) + fs.unlinkSync(cfg.imgNew); + } } } else { // there is no "old screenshot" or screenshots should be immediately updated @@ -133,6 +148,8 @@ export const compareImagesTask = async ( imgNewBase64 = ""; imgDiffBase64 = ""; imgOldBase64 = ""; + const imgBuffer = fs.readFileSync(cfg.imgNew); + writePNG(cfg.imgNew, imgBuffer); moveFile.sync(cfg.imgNew, cfg.imgOld); } @@ -162,8 +179,9 @@ export const doesFileExistTask = ({ path }: { path: string }) => fs.existsSync(path); /* c8 ignore start */ -export const initTaskHook = () => ({ - [TASK.getScreenshotPath]: getScreenshotPathTask, +export const initTaskHook = (config: Cypress.PluginConfigOptions) => ({ + [TASK.getScreenshotPathInfo]: getScreenshotPathInfoTask, + [TASK.cleanupImages]: cleanupImagesTask.bind(undefined, config), [TASK.doesFileExist]: doesFileExistTask, [TASK.approveImage]: approveImageTask, [TASK.compareImages]: compareImagesTask, diff --git a/tsconfig.json b/tsconfig.json index 3707d5af..6c8855c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "paths": { "@fixtures/*": ["__tests__/partials/*"], "@mocks/*": ["__tests__/mocks/*"] - } + }, + "resolveJsonModule": true }, "include": ["src/*.ts", "vitest.config.ts"] } diff --git a/yarn.lock b/yarn.lock index 041b4d24..987fc0f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1471,6 +1471,7 @@ __metadata: "@semantic-release/git": 10.0.1 "@semantic-release/npm": 9.0.1 "@semantic-release/release-notes-generator": 10.0.3 + "@types/glob": ^8.0.0 "@types/pixelmatch": 5.2.4 "@types/pngjs": 6.0.1 "@types/sharp": 0.31.0 @@ -1484,6 +1485,8 @@ __metadata: eslint-config-prettier: 8.5.0 eslint-plugin-cypress: 2.12.1 eslint-plugin-eslint-comments: 3.2.0 + glob: ^8.0.3 + meta-png: ^1.0.3 microbundle: 0.15.1 move-file: 2.1.0 pixelmatch: 5.3.0 @@ -2267,6 +2270,16 @@ __metadata: languageName: node linkType: hard +"@types/glob@npm:^8.0.0": + version: 8.0.0 + resolution: "@types/glob@npm:8.0.0" + dependencies: + "@types/minimatch": "*" + "@types/node": "*" + checksum: 1817b05f5a8aed851d102a65b5e926d5c777bef927ea62b36d635860eef5364f2046bb5a692d135b6f2b28f34e4a9d44ade9396122c0845bcc7636d35f624747 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.4 resolution: "@types/istanbul-lib-coverage@npm:2.0.4" @@ -2281,6 +2294,13 @@ __metadata: languageName: node linkType: hard +"@types/minimatch@npm:*": + version: 5.1.2 + resolution: "@types/minimatch@npm:5.1.2" + checksum: 0391a282860c7cb6fe262c12b99564732401bdaa5e395bee9ca323c312c1a0f45efbf34dce974682036e857db59a5c9b1da522f3d6055aeead7097264c8705a8 + languageName: node + linkType: hard + "@types/minimist@npm:^1.2.0, @types/minimist@npm:^1.2.2": version: 1.2.2 resolution: "@types/minimist@npm:1.2.2" @@ -5502,7 +5522,7 @@ fsevents@~2.3.2: languageName: node linkType: hard -"glob@npm:^8.0.1": +"glob@npm:^8.0.1, glob@npm:^8.0.3": version: 8.0.3 resolution: "glob@npm:8.0.3" dependencies: @@ -7168,6 +7188,13 @@ fsevents@~2.3.2: languageName: node linkType: hard +"meta-png@npm:^1.0.3": + version: 1.0.3 + resolution: "meta-png@npm:1.0.3" + checksum: cc7e1e0950b149273eb127622d8079725855ca14fb5e0175a4f1a7946d7f4a1c92e78de9f44eb1b9fa339c60f43b099c5135dc06b218cf77879fbd0a7f6ecddb + languageName: node + linkType: hard + "microbundle@npm:0.15.1": version: 0.15.1 resolution: "microbundle@npm:0.15.1"