UNPKG

@syngrisi/syngrisi

Version:
275 lines (241 loc) 13.1 kB
import path from 'path'; import { promises as fsp } from 'fs'; import { Baseline } from '@models'; import { SnapshotDocument } from '@models/Snapshot.model'; import { config } from '@config'; import { getDiff } from '@lib/comparison'; import log from '@logger'; import { SnapshotDiff } from '@schemas/SnapshotDiff.schema'; import { LogOpts, RequestUser } from '@types'; import { UserDocument } from '@models/User.model'; import { CreateCheckParamsExtended } from '../../types/Check'; import { createSnapshot } from './snapshot-file.service'; import { errMsg, ApiError } from '@utils'; import { HttpStatus } from '@utils'; import { executeBeforeCompareHook, executeAfterCompareHook } from '../plugins'; export interface CompareSnapshotsOptions { vShifting?: boolean; ignore?: string; ignoredBoxes?: any[]; } export const isWithinToleranceThreshold = (rawMismatch: number, threshold: number) => { if (!Number.isFinite(rawMismatch) || !Number.isFinite(threshold)) return false; const normalizedThreshold = Math.max(0, Math.min(100, threshold)); return rawMismatch > 0 && rawMismatch <= normalizedThreshold; }; export const compareSnapshots = async (baselineSnapshot: SnapshotDocument, actual: SnapshotDocument, opts: CompareSnapshotsOptions = {}) => { const logOpts = { scope: 'compareSnapshots', ref: baselineSnapshot.id, itemType: 'snapshot', msgType: 'COMPARE', }; try { log.debug(`compare baseline and actual snapshots with ids: [${baselineSnapshot.id}, ${actual.id}]`, logOpts); log.debug(`current baseline snapshot: ${JSON.stringify(baselineSnapshot)}`, logOpts); let diff: SnapshotDiff; if (baselineSnapshot.imghash === actual.imghash) { log.debug(`baseline and actual snapshot have the identical image hashes: '${baselineSnapshot.imghash}'`, logOpts); diff = { isSameDimensions: true, dimensionDifference: { width: 0, height: 0 }, rawMisMatchPercentage: 0, misMatchPercentage: '0.00', analysisTime: 0, executionTotalTime: '0', getBuffer: null }; } else { if (!baselineSnapshot.filename || !actual.filename) { throw new Error('Snapshot filename is missing'); } const baselinePath = path.join(config.defaultImagesPath, baselineSnapshot.filename); const actualPath = path.join(config.defaultImagesPath, actual.filename); const baselineData = await fsp.readFile(baselinePath); const actualData = await fsp.readFile(actualPath); log.debug(`baseline path: ${baselinePath}`, logOpts); log.debug(`actual path: ${actualPath}`, logOpts); diff = await getDiff(baselineData, actualData, opts); } log.silly(`the diff is: '${JSON.stringify(diff, null, 2)}'`); if (diff.rawMisMatchPercentage.toString() !== '0') { log.debug(`images are different, ids: [${baselineSnapshot.id}, ${actual.id}], rawMisMatchPercentage: '${diff.rawMisMatchPercentage}'`); } if (diff.stabMethod && diff.vOffset) { if (diff.stabMethod === 'downup') { actual.vOffset = -diff.vOffset; await actual.save(); } if (diff.stabMethod === 'updown') { baselineSnapshot.vOffset = -diff.vOffset; await baselineSnapshot.save(); } } return diff; } catch (e: unknown) { const errMsg = `cannot compare snapshots: ${e}\n ${e instanceof Error ? e.stack : e}`; log.error(errMsg, logOpts); throw new Error(String(e)); } }; type DimensionType = { height: number, width: number }; export const ignoreDifferentResolutions = ({ height, width }: DimensionType) => { if ((width === 0) && (height === -1)) return true; if ((width === 0) && (height === 1)) return true; return false; }; export interface CompareResult { failReasons: string[]; diffId: string; diffSnapshot: SnapshotDocument; status: string; result: string; isSameDimensions: boolean; dimensionDifference: DimensionType; } import { ClientSession } from 'mongoose'; export const compareCheck = async ( expectedSnapshot: SnapshotDocument, actualSnapshot: SnapshotDocument, newCheckParams: CreateCheckParamsExtended, skipSaveOnCompareError: boolean, currentUser: RequestUser, session?: ClientSession ): Promise<CompareResult> => { const logOpts: LogOpts = { scope: 'createCheck.compare', user: currentUser.username, itemType: 'check', msgType: 'COMPARE', }; const executionTimer = process.hrtime(); const compareResult: Partial<CompareResult> = {}; compareResult.failReasons = [...newCheckParams.failReasons]; let checkCompareResult: SnapshotDiff; let diffSnapshot: SnapshotDocument | null = null; const areSnapshotsDifferent = (result: SnapshotDiff) => result.rawMisMatchPercentage.toString() !== '0'; const areSnapshotsWrongDimensions = (result: Partial<CompareResult>) => !result.isSameDimensions && !ignoreDifferentResolutions(result.dimensionDifference!); if ((newCheckParams.status !== 'new') && (!compareResult.failReasons.includes('not_accepted'))) { try { log.debug(`'the check with name: '${newCheckParams.name}' isn't new, make comparing'`, logOpts); const baseline = await Baseline.findOne({ snapshootId: expectedSnapshot._id }).exec(); const compareOptions: CompareSnapshotsOptions = { vShifting: newCheckParams.vShifting }; if (baseline) { if (baseline.ignoreRegions) { log.debug(`ignore regions: '${baseline.ignoreRegions}', type: '${typeof baseline.ignoreRegions}'`); compareOptions.ignoredBoxes = JSON.parse(baseline.ignoreRegions); } compareOptions.ignore = baseline.matchType || 'nothing'; } // API-supplied threshold takes priority over baseline setting const apiThreshold = typeof newCheckParams.toleranceThreshold === 'number' ? newCheckParams.toleranceThreshold : null; const baselineThreshold = Number(baseline?.toleranceThreshold || 0); const toleranceThreshold = Math.max(0, Math.min(100, apiThreshold ?? baselineThreshold)); const toleranceSource: 'api' | 'baseline' = apiThreshold !== null ? 'api' : 'baseline'; // Plugin hook: beforeCompare - allow plugins to skip or modify comparison const checkContext = { expectedSnapshot, actualSnapshot, checkParams: newCheckParams, baseline: baseline || undefined, compareOptions, }; const beforeHookResult = await executeBeforeCompareHook(checkContext); if ('skip' in beforeHookResult && beforeHookResult.skip) { log.info(`Comparison skipped by plugin, using override result`, logOpts); const overrideResult = beforeHookResult.result; return { failReasons: overrideResult.failReasons || [], status: overrideResult.status, result: overrideResult.result || JSON.stringify({ pluginOverride: true }), } as CompareResult; } checkCompareResult = await compareSnapshots(expectedSnapshot, actualSnapshot, compareOptions); log.silly(`ignoreDifferentResolutions: '${ignoreDifferentResolutions(checkCompareResult.dimensionDifference)}'`); log.silly(`dimensionDifference: '${JSON.stringify(checkCompareResult.dimensionDifference)}`); // Auto-ignore 1px height difference if configured to ignore resolutions if (areSnapshotsDifferent(checkCompareResult) && ignoreDifferentResolutions(checkCompareResult.dimensionDifference!)) { const baselineDims = checkCompareResult.baselineDimensions; const actualDims = checkCompareResult.actualDimensions; if (baselineDims && actualDims) { const heightDiff = actualDims.height - baselineDims.height; let ignoredBox; if (heightDiff === 1) { // Actual is taller. Ignore bottom 1px. ignoredBox = { left: 0, top: actualDims.height - 1, right: actualDims.width, bottom: actualDims.height }; } else if (heightDiff === -1) { // Baseline is taller. Ignore bottom 1px. ignoredBox = { left: 0, top: baselineDims.height - 1, right: baselineDims.width, bottom: baselineDims.height }; } if (ignoredBox) { log.debug(`Retrying comparison with ignored box for 1px diff: ${JSON.stringify(ignoredBox)}`, logOpts); const retryOptions = { ...compareOptions }; // resemble expects objects with left, top, right, bottom for ignoreRectangles in compareImagesNode? // compareImagesNode maps them: return [it.left, it.top, it.right - it.left, it.bottom - it.top]; // So we should pass objects. retryOptions.ignoredBoxes = retryOptions.ignoredBoxes ? [...retryOptions.ignoredBoxes, ignoredBox] : [ignoredBox]; const retryResult = await compareSnapshots(expectedSnapshot, actualSnapshot, retryOptions); if (!areSnapshotsDifferent(retryResult)) { log.debug(`Retry passed with ignored box`, logOpts); checkCompareResult = retryResult; } } } } const rawMismatch = Number(checkCompareResult.rawMisMatchPercentage || 0); const mismatchWithinTolerance = isWithinToleranceThreshold(rawMismatch, toleranceThreshold); if (mismatchWithinTolerance) { log.debug(`mismatch '${rawMismatch}' is within tolerance '${toleranceThreshold}', mark check as passed`, logOpts); } if ((areSnapshotsDifferent(checkCompareResult) && !mismatchWithinTolerance) || areSnapshotsWrongDimensions(checkCompareResult)) { let logMsg; if (areSnapshotsWrongDimensions(checkCompareResult)) { logMsg = 'snapshots have different dimensions'; compareResult.failReasons.push('wrong_dimensions'); } if (areSnapshotsDifferent(checkCompareResult) && !mismatchWithinTolerance) { logMsg = 'snapshots have differences'; compareResult.failReasons.push('different_images'); } if (logMsg) log.debug(logMsg, logOpts); log.debug(`saving diff snapshot for check with name: '${newCheckParams.name}'`, logOpts); if (!skipSaveOnCompareError) { diffSnapshot = await createSnapshot({ name: newCheckParams.name, fileData: checkCompareResult.getBuffer!(), }, session); compareResult.diffId = diffSnapshot.id; compareResult.diffSnapshot = diffSnapshot; } compareResult.status = 'failed'; } else { compareResult.status = 'passed'; } (checkCompareResult as unknown as Record<string, unknown>).appliedToleranceThreshold = toleranceThreshold; (checkCompareResult as unknown as Record<string, unknown>).passedByTolerance = mismatchWithinTolerance; (checkCompareResult as unknown as Record<string, unknown>).toleranceSource = toleranceSource; checkCompareResult.totalCheckHandleTime = process.hrtime(executionTimer).toString(); compareResult.result = JSON.stringify(checkCompareResult, null, '\t'); } catch (e: unknown) { // compareResult.updatedDate = Date.now(); compareResult.status = 'failed'; compareResult.result = JSON.stringify({ server_error: `error during comparing - ${errMsg(e)}` }); compareResult.failReasons.push('internal_server_error'); throw new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, `error during comparing: ${errMsg(e)}`); } } if (compareResult.failReasons.length > 0) { compareResult.status = 'failed'; } // Plugin hook: afterCompare - allow plugins to override the final result const checkContextForAfterHook = { expectedSnapshot, actualSnapshot, checkParams: newCheckParams, }; const finalResult = await executeAfterCompareHook( checkContextForAfterHook, compareResult as CompareResult ); return finalResult; };