import * as path from 'path'; import * as fs from 'fs'; import { ensureFolderExists, getNameWithSuffix, ScreenshotLocations, } from './file-utils'; import Diagnostics from './diagnostics'; import { tryGetValidScreenshot } from './try-get-valid-screenshot'; import { buildMask } from './build-text-mask'; import type { IComparerOptions, SelectorType } from './options'; const screenshotComparerDefault: IComparerOptions = { debug: false, path: './testing', screenshotsRelativePath: '/screenshots', destinationRelativePath: '/artifacts/compared-screenshots', generatePatch: false, disableScreenshots: false, highlightColor: { r: 0xff, g: 0, b: 0xff }, maskRadius: 5, attempts: 2, attemptTimeout: 500, looksSameComparisonOptions: { strict: false, tolerance: 5, ignoreAntialiasing: true, antialiasingTolerance: 5, ignoreCaret: true, }, // TODO text comparison options should be more strict textComparisonOptions: { strict: false, ignoreAntialiasing: true, ignoreCaret: true, }, enableTextMask: true, textMaskRadius: 1, textDiffTreshold: 0.05, }; function getComparerOptions( t: TestController, comparisonOptions?: Partial, ): IComparerOptions { const configOptions = (t as unknown as { testRun }).testRun.opts['screenshots-comparer'] as IComparerOptions; const { disableScreenshots } = (t as unknown as { testRun }).testRun.opts; return { ...screenshotComparerDefault, ...configOptions, ...comparisonOptions, disableScreenshots, }; } export async function compareScreenshot( t: TestController, screenshotName: string, element: SelectorType = null, comparisonOptions?: Partial, ): Promise { const options = getComparerOptions(t, comparisonOptions); const locations = new ScreenshotLocations(t, screenshotName, options); const diag = new Diagnostics(!!options.debug); diag.log('Options', options); if (options.disableScreenshots) { return true; } ensureFolderExists(locations.destination.directory); ensureFolderExists(locations.temp.directory); try { const { equal, screenshot, patchedByMaskScreenshot, textMask, } = await tryGetValidScreenshot(t, element, options, locations, diag); if (!equal) { const { mask, diff } = await buildMask( locations.source.read('file'), screenshot, patchedByMaskScreenshot, options.looksSameComparisonOptions, options.highlightColor, options.maskRadius, ); mask.and(locations.source.tryRead('mask')); diff.write(locations.temp.diffPath); mask.write(locations.temp.maskPath); locations.temp.copyTo(locations.destination); fs.copyFileSync(locations.source.filePath, getNameWithSuffix(locations.destination.filePath, '_etalon')); if (options.debug) { if (textMask) { const source = locations.source.read('file'); const actualMask = locations.source.tryRead('mask')?.as((x) => x.and(textMask)) || textMask; const { diff: diffExceptText, mask: maskExceptText } = await buildMask( source, screenshot, screenshot.as((sh) => sh.replace(source, actualMask)), options.looksSameComparisonOptions, options.highlightColor, options.maskRadius, ); diag.log('diff except text:', diffExceptText); diag.log('mask except text:', maskExceptText); } diag.save(locations.destination.directory, screenshotName); } if (options.generatePatch) { const relativeEtalonFileName = path.relative(path.resolve(options.path), locations.source.filePath); const filePathForCurrentScreenshot = path.join(options.destinationRelativePath, '/screenshots', relativeEtalonFileName); ensureFolderExists(path.dirname(filePathForCurrentScreenshot)); fs.copyFileSync(locations.temp.filePath, filePathForCurrentScreenshot); } return false; } if (options.debug === 'always') { diag.save(locations.destination.directory, screenshotName); } return true; } catch (e) { locations.temp.copyTo(locations.destination); throw e; } } export interface ScreenshotsComparer { takeScreenshot: (screenshotName: string, element?: SelectorType, comparisonOptions?: Partial | undefined) => Promise; compareResults: { isValid: () => boolean; errorMessages: () => string; }; } export function createScreenshotsComparer(t: TestController): ScreenshotsComparer { const errorMessages: string[] = []; const takeScreenshot = async ( screenshotName: string, element: SelectorType = null, comparisonOptions?: Partial, ): Promise => { try { const isValid = await compareScreenshot(t, screenshotName, element, comparisonOptions); if (!isValid) { errorMessages.push(`Screenshot:'${screenshotName}' invalid`); } } catch (e) { errorMessages.push(`Screenshot:'${screenshotName}' invalid, internalError: ${e.message}`); } return true; }; return { takeScreenshot, compareResults: { isValid: (): boolean => errorMessages.length === 0, errorMessages: (): string => errorMessages.join('\r\n'), }, }; }