Skip to content

feat: auto clean unused files #124

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

Merged
merged 5 commits into from
Oct 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
})
```

Expand Down
6 changes: 6 additions & 0 deletions __tests__/fixtures/prepare-screenshot-for-cleanup.spec.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
describe('Cleanup test', () => {
it('Create screenshot to be removed', () => {
cy.visit('/');
cy.get('[data-testid="description"]').matchImage();
});
});
Binary file modified __tests__/fixtures/screenshot.actual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified __tests__/fixtures/screenshot.diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified __tests__/fixtures/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions example/cypress/e2e/spec.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
})
})
})
11 changes: 10 additions & 1 deletion example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
58 changes: 43 additions & 15 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cypress['Buffer']> | undefined;
img: InstanceType<Cypress['Buffer']> | undefined;
imgDiff: InstanceType<Cypress['Buffer']> | undefined;
};

interface Chainable<Subject> {
Expand All @@ -21,13 +37,11 @@ declare global {
* @memberof Cypress.Chainable
* @example cy.get('.my-element').matchImage();
*/
matchImage(options?: Cypress.MatchImageOptions): Chainable<Subject>;
matchImage(options?: Cypress.MatchImageOptions): Chainable<MatchImageReturn>;
}
}
}

const nameCacheCounter: Record<string, number> = {};

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
Expand Down Expand Up @@ -69,17 +83,15 @@ export const getConfig = (options: Cypress.MatchImageOptions) => ({
| Partial<Cypress.ScreenshotDefaultsOptions>
| undefined) ||
{},
matchAgainstPath: options.matchAgainstPath || undefined,
});

Cypress.Commands.add(
"matchImage",
{ prevSubject: "optional" },
(subject, options = {}) => {
const $el = subject as JQuery<HTMLElement> | 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,
Expand All @@ -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, {
Expand All @@ -115,14 +130,14 @@ Cypress.Commands.add(
})
.then(() => imgPath);
})
.then((imgPath) =>
cy
.then((imgPath) => {
return cy
.task<CompareImagesTaskReturn>(
TASK.compareImages,
{
scaleFactor,
imgNew: imgPath,
imgOld: imgPath.replace(FILE_SUFFIX.actual, ""),
imgOld: matchAgainstPath || imgPath.replace(FILE_SUFFIX.actual, ""),
updateImages,
maxDiffThreshold,
diffConfig,
Expand All @@ -133,7 +148,7 @@ Cypress.Commands.add(
res,
imgPath,
}))
)
})
.then(({ res, imgPath }) => {
const log = Cypress.log({
name: "log",
Expand Down Expand Up @@ -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,
}
});
}
);
7 changes: 6 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -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`;
Expand All @@ -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 */
57 changes: 49 additions & 8 deletions src/image.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 = (
Expand All @@ -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);
}
});
};
2 changes: 1 addition & 1 deletion src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ export const initPlugin = (
initForceDeviceScaleFactor(on);
}
/* c8 ignore stop */
on("task", initTaskHook());
on("task", initTaskHook(config));
on("after:screenshot", initAfterScreenshotHook(config));
};
46 changes: 46 additions & 0 deletions src/screenshotPath.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import path from "path";
import { FILE_SUFFIX, IMAGE_SNAPSHOT_PREFIX } from "./constants";
import sanitize from "sanitize-filename";

const nameCacheCounter: Record<string, number> = {};

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]
);
};
2 changes: 2 additions & 0 deletions src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]`,
Expand Down
Loading