diff --git a/README.md b/README.md index f057958e..a60da892 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,13 @@ cy.matchImage({ // default: false updateImages: true, // directory path in which screenshot images will be stored - // image visualiser will normalise path separators depending on OS it's being run within, so always use / for nested paths - // default: '__image_snapshots__' - imagesDir: 'this-might-be-your-custom/maybe-nested-directory', + // relative path are resolved against project root + // absolute paths (both on unix and windows OS) supported + // path separators will be normalised by the plugin depending on OS, you should always use / as path separator, e.g.: C:/my-directory/nested for windows-like drive notation + // There are one special variable available to be used in the path: + // - {spec_path} - relative path leading from project root to the current spec file directory (e.g. `/src/components/my-tested-component`) + // default: '{spec_path}/__image_snapshots__' + imagesPath: 'this-might-be-your-custom/maybe-nested-directory', // maximum threshold above which the test should fail // default: 0.01 maxDiffThreshold: 0.1, diff --git a/example/package.json b/example/package.json index dd3ab53c..cd65cd81 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,7 @@ "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", - "test:open": "vue-cli-service test:e2e --env \"pluginVisualRegressionImagesDir=__image_snapshots_local__\"", + "test:open": "vue-cli-service test:e2e --env \"pluginVisualRegressionImagesPath={spec_path}/__image_snapshots_local__\"", "test:run": "vue-cli-service test:e2e", "test:ct": "yarn test:open --component", "test:ct:ci": "yarn test:run --component --headless", diff --git a/src/afterScreenshot.hook.test.ts b/src/afterScreenshot.hook.test.ts index c1b3bf93..929416c4 100644 --- a/src/afterScreenshot.hook.test.ts +++ b/src/afterScreenshot.hook.test.ts @@ -1,9 +1,9 @@ import { it, expect, describe } from "vitest"; import path from "path"; import { promises as fs, existsSync } from "fs"; -import { initAfterScreenshotHook } from "./afterScreenshot.hook"; +import { initAfterScreenshotHook, parseAbsolutePath } from "./afterScreenshot.hook"; import { dir, file, setGracefulCleanup } from "tmp-promise"; -import { IMAGE_SNAPSHOT_PREFIX } from "./constants"; +import { IMAGE_SNAPSHOT_PREFIX, PATH_VARIABLES } from "./constants"; setGracefulCleanup(); @@ -31,3 +31,23 @@ describe("initAfterScreenshotHook", () => { await fs.unlink(expectedNewPath); }); }); + +describe('parseAbsolutePath', () => { + const projectRoot = '/its/project/root'; + + it('resolves relative paths against project root', () => { + expect(parseAbsolutePath({ screenshotPath: 'some/path.png', projectRoot })) + .toBe('/its/project/root/some/path.png'); + }); + + it('builds proper win paths when found', () => { + expect(parseAbsolutePath({ screenshotPath: `${PATH_VARIABLES.winSystemRootPath}/D/some/path.png`, projectRoot })) + // that's expected output accorind to https://stackoverflow.com/a/64135721/8805801 + .toBe('D:\\/some/path.png'); + }); + + it('resolves relative paths against project root', () => { + expect(parseAbsolutePath({ screenshotPath: `${PATH_VARIABLES.unixSystemRootPath}/some/path.png`, projectRoot })) + .toBe('/some/path.png'); + }); +}); diff --git a/src/afterScreenshot.hook.ts b/src/afterScreenshot.hook.ts index d3def41f..98d0e801 100644 --- a/src/afterScreenshot.hook.ts +++ b/src/afterScreenshot.hook.ts @@ -1,10 +1,17 @@ import path from "path"; -import fs from "fs"; +import { promises as fs } from "fs"; import moveFile from "move-file"; -import { IMAGE_SNAPSHOT_PREFIX } from "./constants"; +import { IMAGE_SNAPSHOT_PREFIX, PATH_VARIABLES } from "./constants"; type NotFalsy = T extends false | null | undefined ? never : T; +const MIMIC_ROOT_WIN_REGEX = new RegExp( + `^${PATH_VARIABLES.winSystemRootPath}\\${path.sep}([A-Z])\\${path.sep}` +); +const MIMIC_ROOT_UNIX_REGEX = new RegExp( + `^${PATH_VARIABLES.unixSystemRootPath}\\${path.sep}` +); + const getConfigVariableOrThrow = ( config: Cypress.PluginConfigOptions, name: K @@ -14,25 +21,33 @@ const getConfigVariableOrThrow = ( } /* c8 ignore start */ - throw `[Image snapshot] CypressConfig.${name} cannot be missing or \`false\`!`; + throw `[@frsource/cypress-plugin-visual-regression-diff] CypressConfig.${name} cannot be missing or \`false\`!`; }; /* c8 ignore stop */ -const removeScreenshotsDirectory = ( - screenshotsFolder: string, - onSuccess: () => void, - onError: (e: Error) => void -) => { - fs.rm( - path.join(screenshotsFolder, IMAGE_SNAPSHOT_PREFIX), - { recursive: true, force: true }, - (err) => { - /* c8 ignore start */ - if (err) return onError(err); - /* c8 ignore stop */ - onSuccess(); - } - ); +export const parseAbsolutePath = ({ + screenshotPath, + projectRoot, +}: { + screenshotPath: string; + projectRoot: string; +}) => { + let newAbsolutePath: string; + const matchedMimicingWinRoot = screenshotPath.match(MIMIC_ROOT_WIN_REGEX); + const matchedMimicingUnixRoot = screenshotPath.match(MIMIC_ROOT_UNIX_REGEX); + if (matchedMimicingWinRoot && matchedMimicingWinRoot[1]) { + const driveLetter = matchedMimicingWinRoot[1]; + newAbsolutePath = path.join( + `${driveLetter}:\\`, + screenshotPath.substring(matchedMimicingWinRoot[0].length) + ); + } else if (matchedMimicingUnixRoot) { + newAbsolutePath = + path.sep + screenshotPath.substring(matchedMimicingUnixRoot[0].length); + } else { + newAbsolutePath = path.join(projectRoot, screenshotPath); + } + return path.normalize(newAbsolutePath); }; export const initAfterScreenshotHook = @@ -47,27 +62,25 @@ export const initAfterScreenshotHook = /* c8 ignore start */ if (details.name?.indexOf(IMAGE_SNAPSHOT_PREFIX) !== 0) return; /* c8 ignore stop */ - return new Promise((resolve, reject) => { - const screenshotsFolder = getConfigVariableOrThrow( - config, - "screenshotsFolder" - ); + const screenshotsFolder = getConfigVariableOrThrow( + config, + "screenshotsFolder" + ); + const screenshotPath = details.name.substring( + IMAGE_SNAPSHOT_PREFIX.length + path.sep.length + ); + const newAbsolutePath = parseAbsolutePath({ + screenshotPath, + projectRoot: config.projectRoot, + }); - const newRelativePath = details.name.substring( - IMAGE_SNAPSHOT_PREFIX.length + path.sep.length - ); - const newAbsolutePath = path.normalize( - path.join(config.projectRoot, newRelativePath) - ); + return (async () => { + await moveFile(details.path, newAbsolutePath); + await fs.rm(path.join(screenshotsFolder, IMAGE_SNAPSHOT_PREFIX), { + recursive: true, + force: true, + }); - void moveFile(details.path, newAbsolutePath) - .then(() => - removeScreenshotsDirectory( - screenshotsFolder, - () => resolve({ path: newAbsolutePath }), - reject - ) - ) - .catch(reject); - }); + return { path: newAbsolutePath }; + })(); }; diff --git a/src/commands.ts b/src/commands.ts index 96e16326..3c12a8ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,25 +10,13 @@ declare global { screenshotConfig?: Partial; diffConfig?: Parameters[5]; updateImages?: boolean; + /** + * @deprecated since version 3.0, use imagesPath instead + */ imagesDir?: string; + imagesPath?: 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 { @@ -37,7 +25,7 @@ declare global { * @memberof Cypress.Chainable * @example cy.get('.my-element').matchImage(); */ - matchImage(options?: Cypress.MatchImageOptions): Chainable; + matchImage(options?: Cypress.MatchImageOptions): Chainable; } } } @@ -50,41 +38,60 @@ const constructCypressError = (log: Cypress.Log, err: Error) => { return err; }; -export const getConfig = (options: Cypress.MatchImageOptions) => ({ - scaleFactor: - Cypress.env("pluginVisualRegressionForceDeviceScaleFactor") === false - ? 1 - : 1 / window.devicePixelRatio, - updateImages: - options.updateImages || - (Cypress.env("pluginVisualRegressionUpdateImages") as - | boolean - | undefined) || - false, - imagesDir: +const getImagesDir = (options: Cypress.MatchImageOptions) => { + const imagesDir = options.imagesDir || - (Cypress.env("pluginVisualRegressionImagesDir") as string | undefined) || - "__image_snapshots__", - maxDiffThreshold: - options.maxDiffThreshold || - (Cypress.env("pluginVisualRegressionMaxDiffThreshold") as - | number - | undefined) || - 0.01, - diffConfig: - options.diffConfig || - (Cypress.env("pluginVisualRegressionDiffConfig") as - | Parameters[5] - | undefined) || - {}, - screenshotConfig: - options.screenshotConfig || - (Cypress.env("pluginVisualRegressionScreenshotConfig") as - | Partial - | undefined) || - {}, - matchAgainstPath: options.matchAgainstPath || undefined, -}); + (Cypress.env("pluginVisualRegressionImagesDir") as string | undefined); + + // TODO: remove in 4.0.0 + if (imagesDir) { + console.warn( + "@frsource/cypress-plugin-visual-regression-diff] `imagesDir` option is deprecated, use `imagesPath` instead (https://github.com/FRSOURCE/cypress-plugin-visual-regression-diff#configuration)" + ); + } + + return imagesDir; +}; + +export const getConfig = (options: Cypress.MatchImageOptions) => { + const imagesDir = getImagesDir(options); + + return { + scaleFactor: + Cypress.env("pluginVisualRegressionForceDeviceScaleFactor") === false + ? 1 + : 1 / window.devicePixelRatio, + updateImages: + options.updateImages || + (Cypress.env("pluginVisualRegressionUpdateImages") as + | boolean + | undefined) || + false, + imagesPath: + (imagesDir && `{spec_path}/${imagesDir}`) || + options.imagesPath || + (Cypress.env("pluginVisualRegressionImagesPath") as string | undefined) || + "{spec_path}/__image_snapshots__", + maxDiffThreshold: + options.maxDiffThreshold || + (Cypress.env("pluginVisualRegressionMaxDiffThreshold") as + | number + | undefined) || + 0.01, + diffConfig: + options.diffConfig || + (Cypress.env("pluginVisualRegressionDiffConfig") as + | Parameters[5] + | undefined) || + {}, + screenshotConfig: + options.screenshotConfig || + (Cypress.env("pluginVisualRegressionScreenshotConfig") as + | Partial + | undefined) || + {}, + }; +}; Cypress.Commands.add( "matchImage", @@ -96,11 +103,10 @@ Cypress.Commands.add( const { scaleFactor, updateImages, - imagesDir, + imagesPath, maxDiffThreshold, diffConfig, screenshotConfig, - matchAgainstPath, } = getConfig(options); return cy @@ -110,7 +116,7 @@ Cypress.Commands.add( { titleFromOptions: options.title || Cypress.currentTest.titlePath.join(" "), - imagesDir, + imagesPath, specPath: Cypress.spec.relative, }, { log: false } @@ -120,7 +126,7 @@ Cypress.Commands.add( title = titleFromTask; let imgPath: string; return ($el ? cy.wrap($el) : cy) - .screenshot(screenshotPath as string, { + .screenshot(screenshotPath, { ...screenshotConfig, onAfterScreenshot(el, props) { imgPath = props.path; @@ -130,14 +136,14 @@ Cypress.Commands.add( }) .then(() => imgPath); }) - .then((imgPath) => { - return cy + .then((imgPath) => + cy .task( TASK.compareImages, { scaleFactor, imgNew: imgPath, - imgOld: matchAgainstPath || imgPath.replace(FILE_SUFFIX.actual, ""), + imgOld: imgPath.replace(FILE_SUFFIX.actual, ""), updateImages, maxDiffThreshold, diffConfig, @@ -148,7 +154,7 @@ Cypress.Commands.add( res, imgPath, })) - }) + ) .then(({ res, imgPath }) => { const log = Cypress.log({ name: "log", @@ -185,19 +191,6 @@ 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 39b47244..8949341a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,3 @@ -/* c8 ignore start */ const PLUGIN_NAME = "cp-visual-regression-diff"; export const LINK_PREFIX = `#${PLUGIN_NAME}-`; export const OVERLAY_CLASS = `${PLUGIN_NAME}-overlay`; @@ -18,5 +17,12 @@ export const TASK = { /* c8 ignore next */ }; +export const PATH_VARIABLES = { + specPath: "{spec_path}", + unixSystemRootPath: "{unix_system_root_path}", + winSystemRootPath: "{win_system_root_path}", +} as const; + +export const WINDOWS_LIKE_DRIVE_REGEX = /^[A-Z]:$/; + export const METADATA_KEY = "FRSOURCE_CPVRD_V"; -/* c8 ignore stop */ diff --git a/src/screenshotPath.utils.ts b/src/screenshotPath.utils.ts index fef0cdd2..871899df 100644 --- a/src/screenshotPath.utils.ts +++ b/src/screenshotPath.utils.ts @@ -1,21 +1,34 @@ import path from "path"; -import { FILE_SUFFIX, IMAGE_SNAPSHOT_PREFIX } from "./constants"; +import { FILE_SUFFIX, IMAGE_SNAPSHOT_PREFIX, PATH_VARIABLES, WINDOWS_LIKE_DRIVE_REGEX } from "./constants"; import sanitize from "sanitize-filename"; const nameCacheCounter: Record = {}; export const generateScreenshotPath = ({ titleFromOptions, - imagesDir, + imagesPath, specPath, }: { titleFromOptions: string; - imagesDir: string; + imagesPath: string; specPath: string; }) => { + const parsePathPartVariables = (pathPart: string, i: number) => { + if (pathPart === PATH_VARIABLES.specPath) { + return path.dirname(specPath); + } else if (i === 0 && !pathPart) { + // when unix-like absolute path + return PATH_VARIABLES.unixSystemRootPath; + } else if (i === 0 && WINDOWS_LIKE_DRIVE_REGEX.test(pathPart)) { + // when win-like absolute path + return path.join(PATH_VARIABLES.winSystemRootPath, pathPart[0]); + } + + return pathPart; + }; + const screenshotPath = path.join( - path.dirname(specPath), - ...imagesDir.split("/"), + ...imagesPath.split("/").map(parsePathPartVariables), sanitize(titleFromOptions) ); diff --git a/src/task.hook.test.ts b/src/task.hook.test.ts index df2a572a..16d38ff8 100644 --- a/src/task.hook.test.ts +++ b/src/task.hook.test.ts @@ -40,19 +40,61 @@ const writeTmpFixture = async (pathToWriteTo: string, fixtureName: string) => { }; describe("getScreenshotPathInfoTask", () => { + const specPath = "some/nested/spec-path/spec.ts"; + it("returns sanitized path and title", () => { expect( getScreenshotPathInfoTask({ titleFromOptions: "some-title-withśpęćiał人物", - imagesDir: "nested/images/dir", - specPath: "some/nested/spec-path/spec.ts", + imagesPath: "nested/images/dir", + specPath, }) ).toEqual({ screenshotPath: - "__cp-visual-regression-diff_snapshots__/some/nested/spec-path/nested/images/dir/some-title-withśpęćiał人物 #0.actual.png", + "__cp-visual-regression-diff_snapshots__/nested/images/dir/some-title-withśpęćiał人物 #0.actual.png", title: "some-title-withśpęćiał人物 #0.actual", }); }); + + it("supports {spec_path} variable", () => { + expect( + getScreenshotPathInfoTask({ + titleFromOptions: "some-title", + imagesPath: "{spec_path}/images/dir", + specPath, + }) + ).toEqual({ + screenshotPath: + "__cp-visual-regression-diff_snapshots__/some/nested/spec-path/images/dir/some-title #0.actual.png", + title: 'some-title #0.actual', + }); + }); + + it("supports OS-specific absolute paths", () => { + expect( + getScreenshotPathInfoTask({ + titleFromOptions: "some-title", + imagesPath: "/images/dir", + specPath, + }) + ).toEqual({ + screenshotPath: + "__cp-visual-regression-diff_snapshots__/{unix_system_root_path}/images/dir/some-title #0.actual.png", + title: 'some-title #0.actual', + }); + + expect( + getScreenshotPathInfoTask({ + titleFromOptions: "some-title", + imagesPath: "C:/images/dir", + specPath, + }) + ).toEqual({ + screenshotPath: + "__cp-visual-regression-diff_snapshots__/{win_system_root_path}/C/images/dir/some-title #0.actual.png", + title: 'some-title #0.actual', + }); + }); }); describe("cleanupImagesTask", () => { @@ -60,7 +102,7 @@ describe("cleanupImagesTask", () => { const generateUsedScreenshotPath = async (projectRoot: string) => { const screenshotPathWithPrefix = generateScreenshotPath({ titleFromOptions: "some-file", - imagesDir: "images", + imagesPath: "images", specPath: "some/spec/path", }); return path.join( diff --git a/src/task.hook.ts b/src/task.hook.ts index e4c1171d..763814ba 100644 --- a/src/task.hook.ts +++ b/src/task.hook.ts @@ -33,7 +33,7 @@ const moveSyncSafe = (pathFrom: string, pathTo: string) => export const getScreenshotPathInfoTask = (cfg: { titleFromOptions: string; - imagesDir: string; + imagesPath: string; specPath: string; }) => { const screenshotPath = generateScreenshotPath(cfg); @@ -65,15 +65,15 @@ export const compareImagesTask = async ( cfg: CompareImagesCfg ): Promise => { const messages = [] as string[]; + const rawImgNewBuffer = await scaleImageAndWrite({ + scaleFactor: cfg.scaleFactor, + path: cfg.imgNew, + }); let imgDiff: number | undefined; let imgNewBase64: string, imgOldBase64: string, imgDiffBase64: string; let error = false; if (fs.existsSync(cfg.imgOld) && !cfg.updateImages) { - const rawImgNewBuffer = await scaleImageAndWrite({ - scaleFactor: cfg.scaleFactor, - path: cfg.imgNew, - }); const rawImgNew = PNG.sync.read(rawImgNewBuffer); const rawImgOldBuffer = fs.readFileSync(cfg.imgOld); const rawImgOld = PNG.sync.read(rawImgOldBuffer); @@ -148,8 +148,7 @@ export const compareImagesTask = async ( imgNewBase64 = ""; imgDiffBase64 = ""; imgOldBase64 = ""; - const imgBuffer = fs.readFileSync(cfg.imgNew); - writePNG(cfg.imgNew, imgBuffer); + writePNG(cfg.imgNew, rawImgNewBuffer); moveFile.sync(cfg.imgNew, cfg.imgOld); }