UNPKG

@syngrisi/syngrisi

Version:
601 lines (521 loc) 22.6 kB
import { Check, Test, Suite, Baseline, CheckDocument, Snapshot, } from '@models'; import { Types, Schema } from 'mongoose'; import { calculateAcceptedStatus, buildIdentObject } from '@utils'; import * as snapshotService from './snapshot.service'; import { domSnapshotService } from './dom-snapshot.service'; import * as orm from '@lib/dbItems'; import log from '@lib/logger'; import { BaselineDocument } from '@models/Baseline.model'; import { LogOpts, RequestUser } from '@root/src/types'; import { webhookService } from './webhook.service'; import { compareCheck } from './comparison.service'; import { CreateCheckParamsExtended } from '../../types/Check'; async function calculateTestStatus(testId: string): Promise<string> { const checksInTest = await Check.find({ test: testId }); const statuses = checksInTest.map((x: CheckDocument) => x.status[0]); let testCalculatedStatus = 'Failed'; if (statuses.every((x: string) => (x === 'new') || (x === 'passed'))) { testCalculatedStatus = 'Passed'; } if (statuses.every((x: string) => (x === 'new'))) { testCalculatedStatus = 'New'; } return testCalculatedStatus; } export interface baselineParamsType extends Document { snapshootId?: string; name: string; app: string; branch: string; browserName: string; browserVersion?: string; browserFullVersion?: string; viewport: string; os: string; markedAs?: 'bug' | 'accepted'; lastMarkedDate?: Date; createdDate?: Date; updatedDate?: Date; markedById?: Schema.Types.ObjectId; markedByUsername?: string; ignoreRegions?: string; boundRegions?: string; matchType?: 'antialiasing' | 'nothing' | 'colors'; toleranceThreshold?: number; meta?: object; actualSnapshotId: Schema.Types.ObjectId; markedDate: Date; } const validateBaselineParam = (params: baselineParamsType): void => { const mandatoryParams = ['markedAs', 'markedById', 'markedByUsername', 'markedDate']; for (const param of mandatoryParams) { if (!params[param as keyof baselineParamsType]) { const errMsg = `invalid baseline parameters, '${param}' is empty, params: ${JSON.stringify(params)}`; log.error(errMsg); throw new Error(errMsg); } } }; async function createNewBaseline(params: baselineParamsType): Promise<BaselineDocument> { const logOpts = { scope: 'createNewBaseline', msgType: 'CREATE', }; validateBaselineParam(params); const identFields = buildIdentObject(params); const lastBaseline = await Baseline.findOne(identFields).sort({ createdDate: -1 }).exec(); const filter = { ...identFields, snapshootId: params.actualSnapshotId }; const baselineParams: Record<string, unknown> = { ...identFields }; if (lastBaseline?.ignoreRegions) { baselineParams.ignoreRegions = lastBaseline.ignoreRegions; } if (typeof lastBaseline?.toleranceThreshold === 'number') { baselineParams.toleranceThreshold = lastBaseline.toleranceThreshold; } const update = { $setOnInsert: { ...baselineParams, snapshootId: params.actualSnapshotId, createdDate: new Date(), }, $set: { markedAs: params.markedAs, markedById: params.markedById, markedByUsername: params.markedByUsername, lastMarkedDate: params.markedDate, }, }; try { const baseline = await Baseline.findOneAndUpdate( filter, update, { new: true, upsert: true }, ).exec(); log.debug(`baseline upserted for snapshot id: ${params.actualSnapshotId}`, logOpts); log.silly({ baseline }); return baseline as BaselineDocument; } catch (err: any) { if (err?.code === 11000) { log.warn(`baseline duplicate key detected for filter ${JSON.stringify(filter)}, retrying fetch`, logOpts); const existing = await Baseline.findOne(filter).exec(); if (existing) { existing.markedAs = params.markedAs; existing.markedById = params.markedById; existing.markedByUsername = params.markedByUsername; existing.lastMarkedDate = params.markedDate; existing.createdDate = new Date(); existing.snapshootId = params.actualSnapshotId; return existing.save(); } } log.error(`cannot upsert baseline: ${err instanceof Error ? err.message : String(err)}`, logOpts); throw err; } } const extractSnapshotId = (snapshot: unknown): string | undefined => { if (!snapshot) return undefined; if (typeof snapshot === 'string') return snapshot; if (typeof snapshot === 'object') { const snapshotObj = snapshot as { _id?: unknown, id?: unknown, toString?: () => string }; if (snapshotObj._id) return String(snapshotObj._id); if (snapshotObj.id) return String(snapshotObj.id); if (typeof snapshotObj.toString === 'function') return snapshotObj.toString(); } return undefined; }; const unwrapIdentValue = ( value: unknown, visited: WeakSet<object> = new WeakSet(), ): unknown => { if (!value) return undefined; if (typeof value !== 'object') return value; if (value instanceof Types.ObjectId || (value as { _bsontype?: string })?._bsontype === 'ObjectID') { return value; } const obj = value as { _id?: unknown, id?: unknown }; if (visited.has(obj)) return undefined; visited.add(obj); if (obj._id && obj._id !== value) { return unwrapIdentValue(obj._id, visited); } if (obj.id && obj.id !== value) { return unwrapIdentValue(obj.id, visited); } return value; }; const extractIdentValueAsString = (value: unknown): string => { const unwrapped = unwrapIdentValue(value); if (!unwrapped) return ''; if (typeof unwrapped === 'string') return unwrapped; if (unwrapped instanceof Types.ObjectId || (unwrapped as { _bsontype?: string })?._bsontype === 'ObjectID') { return unwrapped.toString(); } return String(unwrapped); }; const normalizeIdentValueForQuery = (field: string, value: unknown): unknown => { if (value === undefined || value === null) return undefined; const unwrapped = unwrapIdentValue(value); if (field === 'app') { if (unwrapped instanceof Types.ObjectId || (unwrapped as { _bsontype?: string })?._bsontype === 'ObjectID') { return unwrapped; } const strValue = extractIdentValueAsString(unwrapped); if (!strValue) return undefined; return Types.ObjectId.isValid(strValue) ? new Types.ObjectId(strValue) : strValue; } return extractIdentValueAsString(unwrapped); }; const enrichChecksWithCurrentAcceptance = async ( checks: Array<CheckDocument | Record<string, unknown>>, ): Promise<Record<string, unknown>[]> => { if (!checks || checks.length === 0) return []; const plainChecks = checks.map((check) => ( (check && typeof (check as CheckDocument).toJSON === 'function') ? (check as CheckDocument).toJSON() : { ...(check as Record<string, unknown>) } )); // Get unique combinations of ident fields to query baselines const identFields = ['name', 'viewport', 'browserName', 'os', 'app', 'branch']; const baselineQueries: Record<string, unknown>[] = []; const checksByIdentKey = new Map<string, Record<string, unknown>[]>(); plainChecks.forEach((check) => { const identKey = identFields.map((field) => extractIdentValueAsString(check?.[field])).join('|'); if (!checksByIdentKey.has(identKey)) { checksByIdentKey.set(identKey, []); // Build query for this ident combination const query: Record<string, unknown> = {}; identFields.forEach((field) => { const normalized = normalizeIdentValueForQuery(field, check?.[field]); if (normalized !== undefined) query[field] = normalized; }); // Only add query if we have all required fields const hasAllFields = identFields.every((field) => query[field] !== undefined); if (hasAllFields) { baselineQueries.push(query); } else { log.warn(`Check ${check._id} missing required ident fields. Has: ${Object.keys(query).join(', ')}`, { scope: 'enrichChecksWithCurrentAcceptance', }); } } checksByIdentKey.get(identKey)?.push(check); }); // Fetch the latest baseline for each unique ident combination const baselinesMap = new Map<string, Record<string, unknown>>(); if (baselineQueries.length > 0) { try { const baselines = await Baseline.aggregate([ { $match: { $or: baselineQueries }, }, { $sort: { createdDate: -1 }, }, { $group: { _id: { name: '$name', viewport: '$viewport', browserName: '$browserName', os: '$os', app: '$app', branch: '$branch', }, doc: { $first: '$$ROOT' }, }, }, { $replaceRoot: { newRoot: '$doc' }, }, ]).exec(); baselines.forEach((baseline) => { const baselineObj = baseline as unknown as Record<string, unknown>; const identKey = identFields.map((field) => extractIdentValueAsString(baselineObj?.[field])).join('|'); baselinesMap.set(identKey, baselineObj); log.debug(`[enrichChecks] Found baseline for identKey=${identKey}, snapshootId=${baselineObj.snapshootId}`, { scope: 'enrichChecksWithCurrentAcceptance', }); }); } catch (err) { log.error(`[enrichChecks] Error fetching baselines: ${err}`, { scope: 'enrichChecksWithCurrentAcceptance' }); throw err; } } // Enrich checks with acceptance flags return plainChecks.map((check) => { const identKey = identFields.map((field) => extractIdentValueAsString(check?.[field])).join('|'); const baseline = baselinesMap.get(identKey); const actualSnapshotId = extractSnapshotId(check?.actualSnapshotId); const baselineSnapshotId = baseline ? extractSnapshotId(baseline.snapshootId) : undefined; const checkBaselineSnapshotId = extractSnapshotId(check?.baselineId); const matchesOwnBaseline = Boolean( actualSnapshotId && checkBaselineSnapshotId && actualSnapshotId === checkBaselineSnapshotId, ); const matchesLatestBaseline = Boolean( actualSnapshotId && baselineSnapshotId && actualSnapshotId === baselineSnapshotId, ); const isCurrentlyAccepted = Boolean( check?.markedAs === 'accepted' && (matchesOwnBaseline || matchesLatestBaseline), ); const hasKnownBaseline = Boolean(checkBaselineSnapshotId || baselineSnapshotId); const wasAcceptedEarlier = Boolean( check?.markedAs === 'accepted' && hasKnownBaseline && !isCurrentlyAccepted, ); // Debug logging if (check?.markedAs === 'accepted') { log.debug(`[enrichChecks] Check ${check._id}: actualSnapshot=${actualSnapshotId}, baselineSnapshot=${baselineSnapshotId}, checkBaselineSnapshot=${checkBaselineSnapshotId}, isCurrentlyAccepted=${isCurrentlyAccepted}, wasAcceptedEarlier=${wasAcceptedEarlier}, hasBaseline=${Boolean(baseline)}`, { scope: 'enrichChecksWithCurrentAcceptance', }); } return { ...check, isCurrentlyAccepted, wasAcceptedEarlier, }; }); }; const accept = async ( id: string, baselineId: string, user: RequestUser, ): Promise<Record<string, unknown>> => { const logOpts = { msgType: 'ACCEPT', itemType: 'check', ref: id, user: user?.username, scope: 'accept', }; log.debug(`accept check: ${id}`, logOpts); const check = await Check.findById(id).exec(); if (!check) throw new Error(`cannot find check with id: ${id}`); const test = await Test.findById(check.test).exec(); if (!test) throw new Error(`cannot find test with id: ${check.test}`); check.markedById = user._id; check.markedByUsername = user.username; check.markedDate = new Date(); check.markedAs = 'accepted'; check.status = (check.status[0] === 'new') ? ['new'] : ['passed']; // check.status = ['passed']; check.updatedDate = new Date(); if (baselineId) { check.baselineId = new Types.ObjectId(baselineId); } log.debug(`update check with options: '${JSON.stringify(check.toObject())}'`, logOpts); const baseline = await createNewBaseline(check.toObject()); // Link DOM snapshot to the baseline for RCA feature try { await domSnapshotService.linkDomSnapshotToBaseline(id, baseline._id.toString()); log.debug(`DOM snapshot linked to baseline: '${baseline._id}'`, logOpts); } catch (domErr) { // DOM snapshot linking is non-critical log.warn(`Failed to link DOM snapshot to baseline: ${domErr}`, logOpts); } await check.save(); const testCalculatedStatus = await calculateTestStatus(String(check.test)); const testCalculatedAcceptedStatus = await calculateAcceptedStatus(check.test); test.status = testCalculatedStatus; test.markedAs = testCalculatedAcceptedStatus; test.updatedDate = new Date(); await Suite.findByIdAndUpdate(check.suite, { updatedDate: Date.now() }); log.debug(`update test with status: '${testCalculatedStatus}', marked: '${testCalculatedAcceptedStatus}'`, logOpts, { msgType: 'UPDATE', itemType: 'test', ref: test._id, }); await test.save(); await check.save(); log.debug(`check with id: '${id}' was updated`, logOpts); const [enrichedCheck] = await enrichChecksWithCurrentAcceptance([check]); webhookService.triggerWebhooks('check.updated', enrichedCheck).catch((e) => log.error(`Webhook error: ${e}`)); return enrichedCheck; }; async function removeCheck(id: string, user: RequestUser): Promise<CheckDocument> { const logMeta = { scope: 'removeCheck', itemType: 'check', ref: id, msgType: 'REMOVE', user: user?.username, }; try { const check = (await Check.findByIdAndDelete(id).exec()) as unknown as CheckDocument; if (!check) throw new Error(`cannot find check with id: ${id}`); log.debug(`check with id: '${id}' was removed, update test: ${check.test}`, logMeta); const test = await Test.findById(check.test).exec(); if (!test) throw new Error(`cannot find test with id: ${check.test}`); const testCalculatedStatus = await calculateTestStatus(String(check.test)); const testCalculatedAcceptedStatus = await calculateAcceptedStatus(check.test); test.status = testCalculatedStatus; test.markedAs = testCalculatedAcceptedStatus; test.updatedDate = new Date(); await orm.updateItemDate('VRSSuite', check.suite); await test.save(); if (check.baselineId && String(check.baselineId) !== 'undefined') { log.debug(`try to remove the snapshot, baseline: ${check.baselineId}`, logMeta); await snapshotService.remove(check.baselineId.toString()); } if (check.actualSnapshotId && String(check.baselineId) !== 'undefined') { log.debug(`try to remove the snapshot, actual: ${check.actualSnapshotId}`, logMeta); await snapshotService.remove(check.actualSnapshotId.toString()); } if (check.diffId && String(check.baselineId) !== 'undefined') { log.debug(`try to remove snapshot, diff: ${check.diffId}`, logMeta); await snapshotService.remove(check.diffId.toString()); } // Remove DOM snapshots associated with the check try { await domSnapshotService.removeDomSnapshotsByCheckId(id); log.debug(`DOM snapshots removed for check: ${id}`, logMeta); } catch (domErr) { log.warn(`Failed to remove DOM snapshots for check ${id}: ${domErr}`, logMeta); } return check; } catch (e: unknown) { const errMsg = `cannot remove a check with id: '${id}', error: '${e instanceof Error ? e.stack : String(e)}'`; log.error(errMsg, logMeta); throw new Error(errMsg); } } const remove = async (id: string, user: RequestUser): Promise<CheckDocument> => { const logOpts = { scope: 'removeCheck', itemType: 'check', ref: id, user: user?.username, msgType: 'REMOVE', }; log.info(`remove check with, id: '${id}', user: '${user.username}'`, logOpts); return removeCheck(id, user); }; const update = async (id: string, opts: Partial<CheckDocument>, user: string): Promise<CheckDocument> => { const logMeta: LogOpts = { msgType: 'UPDATE', itemType: 'check', ref: id, user, scope: 'updateCheck', }; log.debug(`update check with id '${id}' with params '${JSON.stringify(opts, null, 2)}'`, logMeta); const check = await Check.findOneAndUpdate({ _id: id }, opts, { new: true }).exec(); if (!check) throw new Error(`cannot find check with id: ${id}`); const test = await Test.findOne({ _id: check.test }).exec(); if (!test) throw new Error(`cannot find test with id: ${check.test}`); test.status = await calculateTestStatus(String(check.test)); await orm.updateItemDate('VRSCheck', check); await orm.updateItemDate('VRSTest', test); await test.save(); await check.save(); webhookService.triggerWebhooks('check.updated', check).catch((e) => log.error(`Webhook error: ${e}`)); return check; }; const recompare = async (id: string, user: RequestUser): Promise<Record<string, unknown>> => { const logOpts: LogOpts = { scope: 'recompareCheck', itemType: 'check', ref: id, user: user?.username, msgType: 'COMPARE', }; const check = await Check.findById(id).exec(); if (!check) throw new Error(`cannot find check with id: ${id}`); if (!check.baselineId) { throw new Error(`cannot recompare check '${id}': baselineId is empty`); } if (!check.actualSnapshotId) { throw new Error(`cannot recompare check '${id}': actualSnapshotId is empty`); } const baselineSnapshot = await Snapshot.findById(check.baselineId).exec(); if (!baselineSnapshot) { throw new Error(`cannot recompare check '${id}': baseline snapshot '${check.baselineId}' not found`); } const actualSnapshot = await Snapshot.findById(check.actualSnapshotId).exec(); if (!actualSnapshot) { throw new Error(`cannot recompare check '${id}': actual snapshot '${check.actualSnapshotId}' not found`); } const oldDiffId = check.diffId ? check.diffId.toString() : null; const checkParamsForCompare: CreateCheckParamsExtended = { test: check.test.toString(), name: check.name, status: 'pending', viewport: check.viewport || '', browserName: check.browserName || '', browserVersion: check.browserVersion || '', browserFullVersion: check.browserFullVersion || '', os: check.os || '', updatedDate: Date.now(), suite: check.suite.toString(), app: check.app.toString(), branch: check.branch || '', run: check.run ? check.run.toString() : '', creatorId: check.creatorId ? check.creatorId.toString() : user._id.toString(), creatorUsername: check.creatorUsername || user.username, failReasons: [], actualSnapshotId: check.actualSnapshotId.toString(), hashCode: '', toleranceThreshold: (check as any).toleranceThreshold, }; const compareResult = await compareCheck( baselineSnapshot, actualSnapshot, checkParamsForCompare, false, user, ); check.status = [compareResult.status as ('new' | 'pending' | 'approved' | 'running' | 'passed' | 'failed' | 'aborted' | 'blinking')]; check.result = compareResult.result; check.failReasons = compareResult.failReasons; check.updatedDate = new Date(); if (compareResult.diffId) { check.diffId = new Types.ObjectId(compareResult.diffId); } else { check.diffId = undefined; } await check.save(); const test = await Test.findById(check.test).exec(); if (test) { const testCalculatedStatus = await calculateTestStatus(String(check.test)); const testCalculatedAcceptedStatus = await calculateAcceptedStatus(check.test); test.status = testCalculatedStatus; test.markedAs = testCalculatedAcceptedStatus; test.updatedDate = new Date(); await test.save(); await Suite.findByIdAndUpdate(check.suite, { updatedDate: Date.now() }); } const newDiffId = check.diffId ? check.diffId.toString() : null; if (oldDiffId && oldDiffId !== newDiffId) { snapshotService.remove(oldDiffId).catch((e) => { log.warn(`failed to remove old diff snapshot '${oldDiffId}': ${String(e)}`, logOpts); }); } const [enrichedCheck] = await enrichChecksWithCurrentAcceptance([check]); webhookService.triggerWebhooks('check.updated', enrichedCheck).catch((e) => log.error(`Webhook error: ${e}`)); return enrichedCheck; }; const createCheckDocument = async (checkParams: any, session?: any): Promise<CheckDocument> => { const [check] = await Check.create([checkParams], { session }); webhookService.triggerWebhooks('check.created', check).catch((e) => log.error(`Webhook error: ${e}`)); return check; }; export { accept, remove, update, recompare, enrichChecksWithCurrentAcceptance, createCheckDocument, };