From 0cadcac8df4091a2c6c9eaeecefb863bd010824b Mon Sep 17 00:00:00 2001 From: cnotv Date: Thu, 9 Jan 2025 17:51:53 +0100 Subject: [PATCH] Copy library files and customize logic --- cypress.visual.config.ts | 2 +- visual/command.ts | 180 +++++++++++++++++++++++++++++++++ visual/constants.ts | 3 + visual/plugin.ts | 211 +++++++++++++++++++++++++++++++++++++++ visual/support.ts | 6 +- visual/types.ts | 107 ++++++++++++++++++++ 6 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 visual/command.ts create mode 100644 visual/constants.ts create mode 100644 visual/plugin.ts create mode 100644 visual/types.ts diff --git a/cypress.visual.config.ts b/cypress.visual.config.ts index ffd3253f9a9..2dc8b199614 100644 --- a/cypress.visual.config.ts +++ b/cypress.visual.config.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ import { defineConfig } from 'cypress'; -import { addMatchImageSnapshotPlugin } from '@emerson-eps/cypress-image-snapshot/plugin' +import { addMatchImageSnapshotPlugin } from './visual/plugin' // Required for env vars to be available in cypress require('dotenv').config(); diff --git a/visual/command.ts b/visual/command.ts new file mode 100644 index 00000000000..3ca4e34cf9d --- /dev/null +++ b/visual/command.ts @@ -0,0 +1,180 @@ +import path from 'path' +import extend from 'just-extend' +import {CHECKSNAP, MATCH, RECORD} from './constants' +import type { + CypressImageSnapshotOptions, + DiffSnapshotResult, + SnapshotOptions, + Subject, +} from './types' + +const COMMAND_NAME = 'cypress-image-snapshot' +const screenshotsFolder = Cypress.config('screenshotsFolder') || 'cypress/screenshots' +const isUpdateSnapshots: boolean = Cypress.env('updateSnapshots') || false +const isSnapshotDebug: boolean = Cypress.env('debugSnapshots') || false +const newImageMessage = 'A new reference Image was created for' + +let currentTest: string +let errorMessages: { [key: string]: string } = {} + +export const defaultOptions: SnapshotOptions = { + screenshotsFolder, + isUpdateSnapshots, + isSnapshotDebug, + specFileName: Cypress.spec.name, + specRelativeFolder: Cypress.spec.relative, + extraFolders: '', + currentTestTitle: '', + failureThreshold: 0, + failureThresholdType: 'pixel', + timeout: 5000, + delayBetweenTries: 1000, +} + +const matchImageSnapshot = (defaultOptionsOverrides: CypressImageSnapshotOptions) =>( + subject: Subject, + nameOrCommandOptions: CypressImageSnapshotOptions | string, + commandOptions?: CypressImageSnapshotOptions, +) => { + const {filename, options} = getNameAndOptions( + nameOrCommandOptions, + defaultOptionsOverrides, + commandOptions, + ) + if (!currentTest) { + currentTest = Cypress.currentTest.title + } else if ( + currentTest !== Cypress.currentTest.title + ) { + // we need to ensure the errors messages about new references being created are kept + if (currentTest === Cypress.currentTest.title) { + errorMessages = Object.fromEntries( + Object.entries(errorMessages).filter(([, value]) => value.includes(newImageMessage)), + ) + } else { + errorMessages = {} + } + currentTest = Cypress.currentTest.title + } + + recursiveSnapshot(subject, options, filename) + cy.wrap(errorMessages).as('matchImageSnapshot') +} + +/** + * Add this function to your `supportFile` for e2e/component + * Accepts options that are used for all instances of `toMatchSnapshot` + */ +export const addMatchImageSnapshotCommand = (defaultOptionsOverrides: CypressImageSnapshotOptions = {}) => { + Cypress.Commands.add( + 'matchImageSnapshot', + { prevSubject: ['optional', 'element', 'document', 'window'] }, + matchImageSnapshot(defaultOptionsOverrides), + ) +} + +const recursiveSnapshot = (subject: Subject, options: SnapshotOptions, filename: string): void => { + const elementToScreenshot = cy.wrap(subject) + const screenshotName = getScreenshotFilename(filename) + + cy.task(MATCH, { + ...options, + currentTestTitle: Cypress.currentTest.title, + }) + + cy.task(CHECKSNAP, {screenshotName, options}).then((exist) => { + if (!exist) { + //base image does not exist yes + //so make sure we have a valid image by waiting the maximum timeout + cy.wait(options.timeout) + } + elementToScreenshot.screenshot(screenshotName, options) + cy.task(RECORD).then(({ + added, + pass, + updated, + imageDimensions, + diffPixelCount, + diffRatio, + diffSize, + diffOutputPath, + }) => { + if (pass) return; + + if (added) { + const message = `New snapshot: '${screenshotName}' was added` + Cypress.log({name: COMMAND_NAME, message}) + // An after each hook should check if @matchImageSnapshot is defined, if yes it should fail the tests + errorMessages[screenshotName] = `${newImageMessage} ${screenshotName}` + + return; + } + + if (!pass && !added && !updated) { + const message = diffSize + ? `Image size (${imageDimensions.baselineWidth}x${imageDimensions.baselineHeight}) different than saved snapshot size (${imageDimensions.receivedWidth}x${imageDimensions.receivedHeight}).\nSee diff for details: ${diffOutputPath}` + : `Image was ${diffRatio * 100}% different from saved snapshot with ${diffPixelCount} different pixels.\nSee diff for details: ${diffOutputPath}` + + // An after each hook should check if @matchImageSnapshot is defined, if yes it should fail the tests + errorMessages[screenshotName] = message + + Cypress.log({ name: COMMAND_NAME, message }) + } + }) + }) +} + +const getNameAndOptions = ( + nameOrCommandOptions: CypressImageSnapshotOptions | string, + defaultOptionsOverrides: CypressImageSnapshotOptions, + commandOptions?: CypressImageSnapshotOptions, +) => { + let filename: string | undefined + let options = extend( + true, + {}, + defaultOptions, + defaultOptionsOverrides, + ) as SnapshotOptions + if (typeof nameOrCommandOptions === 'string' && commandOptions) { + filename = nameOrCommandOptions + options = extend( + true, + {}, + defaultOptions, + defaultOptionsOverrides, + commandOptions, + ) as SnapshotOptions + } + if (typeof nameOrCommandOptions === 'string') { + filename = nameOrCommandOptions + } + if (typeof nameOrCommandOptions === 'object') { + options = extend( + true, + {}, + defaultOptions, + defaultOptionsOverrides, + nameOrCommandOptions, + ) as SnapshotOptions + } + return { + filename, + options, + } +} + +const getScreenshotFilename = (filename: string | undefined) => { + if (filename) { + return filename + } + return Cypress.currentTest.titlePath.join(' -- ') +} + +/** + * replaces forward slashes (/) and backslashes (\) in a given input string with the appropriate path separator based on the operating system + * @param input string to replace + */ +export const replaceSlashes = (input: string): string => { + return input.replace(/\\/g, path.sep).replace(/\//g, path.sep) +} diff --git a/visual/constants.ts b/visual/constants.ts new file mode 100644 index 00000000000..44434cb78a5 --- /dev/null +++ b/visual/constants.ts @@ -0,0 +1,3 @@ +export const MATCH = 'Matching image snapshot' +export const RECORD = 'Recording snapshot result' +export const CHECKSNAP = 'Check if base Image already exits' diff --git a/visual/plugin.ts b/visual/plugin.ts new file mode 100644 index 00000000000..99c22b50a40 --- /dev/null +++ b/visual/plugin.ts @@ -0,0 +1,211 @@ +import fs from 'node:fs/promises' +import * as fs1 from 'fs' +import path from 'node:path' +import chalk from 'chalk' +import {diffImageToSnapshot} from 'jest-image-snapshot/src/diff-snapshot' +import {MATCH, RECORD, CHECKSNAP} from './constants' +import type {DiffSnapshotResult, SnapshotOptions} from './types' + +/** + * Add this function in `setupNodeEvents` inside cypress.config.ts + * + * Required + * @type {Cypress.PluginConfig} + */ +export const addMatchImageSnapshotPlugin = (on: Cypress.PluginEvents) => { + on('after:screenshot', runImageDiffAfterScreenshot) + on('task', { + [MATCH]: setOptions, + [RECORD]: getSnapshotResult, + [CHECKSNAP]: checkSnapshotExistence, + }) +} + +// prevent the plugin running for general screenshots that aren't +// triggered by `matchImageSnapshot` +let isSnapshotActive = false + +let options = {} as SnapshotOptions +const setOptions = (commandOptions: SnapshotOptions) => { + isSnapshotActive = true + options = commandOptions + return null +} + +const PNG_EXT = '.png' +const SNAP_EXT = `.snap` +const SNAP_PNG_EXT = `${SNAP_EXT}${PNG_EXT}` +const DIFF_EXT = `.diff${PNG_EXT}` +const DEFAULT_DIFF_DIR = '__diff_output__' + +let snapshotResult = {} as DiffSnapshotResult +const getSnapshotResult = () => { + isSnapshotActive = false + return snapshotResult +} + +const checkSnapshotExistence = ({ + screenshotName, + options, +}: { + screenshotName: string + options: SnapshotOptions +}) => { + const { + specFileName, + specRelativeFolder, + extraFolders, + screenshotsFolder, + isUpdateSnapshots, + customSnapshotsDir, + } = options + const snapshotName = screenshotName.replace(/ \(attempt [0-9]+\)/, '') + + const snapshotsDir = customSnapshotsDir + ? path.join(process.cwd(), customSnapshotsDir, specFileName) + : path.join( + screenshotsFolder, + '..', + 'snapshots', + specRelativeFolder, + '..', + extraFolders, + specFileName, + ) + + const snapshotDotPath = path.join( + snapshotsDir, + `${snapshotName}${SNAP_PNG_EXT}`, + ) + return isUpdateSnapshots || fs1.existsSync(snapshotDotPath) +} + +const runImageDiffAfterScreenshot = async ( + screenshotConfig: Cypress.ScreenshotDetails, +) => { + const {path: screenshotPath} = screenshotConfig + //if screenshot command timeout, + //cypress will throw an error and isSnapshotActive will never be set to false + //so as additional contion we can check that screenshotConfig.name is set + if (!isSnapshotActive || screenshotConfig.name === undefined) { + return {path: screenshotPath} + } + + // name of the screenshot without the Cypress suffixes for test failures + const snapshotName = screenshotConfig.name.replace(/ \(attempt [0-9]+\)/, '') + + const receivedImageBuffer = await fs.readFile(screenshotPath) + await fs.rm(screenshotPath) + + const { + specFileName, + specRelativeFolder, + extraFolders, + currentTestTitle, + screenshotsFolder, + isUpdateSnapshots, + customSnapshotsDir, + customDiffDir, + } = options + + const snapshotsDir = customSnapshotsDir + ? path.join(process.cwd(), customSnapshotsDir, specFileName) + : path.join( + screenshotsFolder, + '..', + 'snapshots', + specRelativeFolder, + '..', + extraFolders, + specFileName, + ) + + const snapshotDotPath = path.join( + snapshotsDir, + `${snapshotName}${SNAP_PNG_EXT}`, + ) + + const diffDir = customDiffDir + ? path.join(process.cwd(), customDiffDir, specFileName) + : path.join(snapshotsDir, DEFAULT_DIFF_DIR) + + const diffDotPath = path.join(diffDir, `${snapshotName}${DIFF_EXT}`) + + //since the snpashot method is called multiple time until it passes + //we need to delete previous diff otherwise the git repo will wrongly be in a modified state + if (fs1.existsSync(diffDotPath)) { + await fs.rm(diffDotPath) + } + + logTestName(currentTestTitle) + log('options', options) + log('paths', { + screenshotPath, + snapshotsDir, + diffDir, + diffDotPath, + specFileName, + snapshotName, + snapshotDotPath, + }) + + snapshotResult = diffImageToSnapshot({ + ...options, + snapshotsDir, + diffDir, + receivedImageBuffer, + snapshotIdentifier: snapshotName + SNAP_EXT, + updateSnapshot: isUpdateSnapshots, + }) + log( + 'result from diffImageToSnapshot', + (() => { + const {imgSrcString, ...rest} = snapshotResult + return rest + })(), + ) + + const {pass, added, updated, diffOutputPath} = snapshotResult + + if (!pass && !added && !updated) { + log('image did not match') + + await fs.copyFile(diffOutputPath, diffDotPath) + await fs.rm(diffOutputPath) + + snapshotResult.diffOutputPath = diffDotPath + + log(`screenshot write to ${diffDotPath}...`) + return { + path: diffDotPath, + } + } + + if (pass) { + log('snapshot matches') + } + if (added) { + log('new snapshot generated') + } + if (updated) { + log('snapshot updated with new version') + } + + snapshotResult.diffOutputPath = snapshotDotPath + + log('screenshot write to snapshotDotPath') + return { + path: snapshotDotPath, + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const log = (...message: any) => { + if (options.isSnapshotDebug) { + console.log(chalk.blueBright.bold('matchImageSnapshot: '), ...message) + } +} + +const logTestName = (name: string) => { + log(chalk.yellow.bold(name)) +} diff --git a/visual/support.ts b/visual/support.ts index 65380145e9d..9b55fd832a6 100644 --- a/visual/support.ts +++ b/visual/support.ts @@ -1,8 +1,10 @@ -import { addMatchImageSnapshotCommand } from '@emerson-eps/cypress-image-snapshot/command' -import { CypressImageSnapshotOptions } from '@emerson-eps/cypress-image-snapshot/types'; +import { addMatchImageSnapshotCommand } from './command' +import { CypressImageSnapshotOptions } from './types'; const options = { + capture: 'viewport', failureThreshold: 0, + failOnSnapshotDiff: true, padding: 10, customSnapshotsDir: 'visual/snapshots/', delayBetweenTries: 0, diff --git a/visual/types.ts b/visual/types.ts new file mode 100644 index 00000000000..6cd3792cf08 --- /dev/null +++ b/visual/types.ts @@ -0,0 +1,107 @@ +import type {MatchImageSnapshotOptions} from 'jest-image-snapshot' + +declare global { + namespace Cypress { + interface Chainable { + matchImageSnapshot( + nameOrCommandOptions?: CypressImageSnapshotOptions | string, + ): Chainable + matchImageSnapshot( + name: string, + commandOptions: CypressImageSnapshotOptions, + ): Chainable + } + } +} + +type CypressScreenshotOptions = Partial + +// The options that are passed around internally from command to plugin +export type SnapshotOptions = { + screenshotsFolder: string + isUpdateSnapshots: boolean + isSnapshotDebug: boolean + specFileName: string + currentTestTitle: string + specRelativeFolder: string +} & CypressScreenshotOptions & + MatchImageSnapshotOptions & + ExtraSnapshotOptions + +// The options that are exposed to the user via `matchImageSnapshot` +// Prevents the private properties above from showing up in autocomplete +// Merges both Cypress and jest-image-snapshot options together. Not ideal +// if one day they choose a clashing key, but this way it keeps the public +// API non breaking +export type CypressImageSnapshotOptions = Partial< + CypressScreenshotOptions & MatchImageSnapshotOptions & ExtraSnapshotOptions +> + +export type Subject = + | void + | Document + | Window + | Cypress.JQueryWithSelector + +export type DiffSnapshotResult = { + added?: boolean + receivedSnapshotPath?: string + updated?: boolean + imgSrcString: string + imageDimensions: { + baselineHeight: number + baselineWidth: number + receivedWidth: number + receivedHeight: number + } + pass: boolean + diffSize: boolean + diffOutputPath: string + diffRatio: number + diffPixelCount: number +} + +export type DiffSnapshotOptions = { + receivedImageBuffer: Buffer + snapshotIdentifier: string + snapshotsDir: string + storeReceivedOnFailure?: boolean + receivedDir?: string + diffDir?: string + updateSnapshot?: boolean + updatePassedSnapshot?: boolean + customDiffConfig?: Record +} & Pick< + MatchImageSnapshotOptions, + | 'comparisonMethod' + | 'blur' + | 'allowSizeMismatch' + | 'diffDirection' + | 'onlyDiff' + | 'failureThreshold' + | 'failureThresholdType' +> + +type ExtraSnapshotOptions = { + /** + * Waits an arbitrary amount of time (in milliseconds) if the baseline image doesn't exist + * @default 2500 + */ + //TODO: Implement waiting if reference image doesn't exist :) + //delay: number + /** + * Time it takes for the snapshot command to time out if the snapshot is not correct + * @default 5000 + */ + timeout: number + /** + * Sets a delay between recursive snapshots + * @default 1000 + */ + delayBetweenTries: number + /** + * Adds additional folders to the snapshot save path + * @default '' + */ + extraFolders: string +}