@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
245 lines (207 loc) • 9.51 kB
text/typescript
import { Check, CheckDocument } from '@models';
import { buildIdentObject, ident, errMsg, ApiError, prettyCheckParams } from '@utils';
import log from "@logger";
import { LogOpts, RequestUser } from '@types';
import { domSnapshotService } from './dom-snapshot.service';
import { TestDocument } from '@models/Test.model';
import { AppDocument } from '@models/App.model';
import { SuiteDocument } from '@models/Suite.model';
import { SnapshotDocument } from '@models/Snapshot.model';
import { HttpStatus } from '@utils';
import { prepareActualSnapshot, isNeedFiles } from './snapshot-file.service';
import { startSession, endSession, updateTestAfterCheck } from './test-run.service';
import * as BaselineService from './baseline.service';
import * as CheckService from './check.service';
import { compareCheck } from './comparison.service';
import { CreateCheckParams, CreateCheckParamsExtended } from '../../types/Check';
const createCheckParams = (checkParam: CreateCheckParams, suite: SuiteDocument, app: AppDocument, test: TestDocument, currentUser: RequestUser): CreateCheckParamsExtended => ({
test: test.id,
name: checkParam.name,
status: 'pending',
viewport: checkParam.viewport,
browserName: checkParam.browserName,
browserVersion: checkParam.browserVersion,
browserFullVersion: checkParam.browserFullVersion,
os: checkParam.os,
updatedDate: Date.now(),
suite: suite.id,
app: app.id,
branch: checkParam.branch,
domDump: checkParam.domDump,
run: test.run.toString(),
creatorId: currentUser._id.toString(),
creatorUsername: currentUser.username,
hashCode: checkParam.hashCode,
failReasons: [],
toleranceThreshold: checkParam.toleranceThreshold,
});
import mongoose from 'mongoose';
import * as SnapshotService from './snapshot.service';
import fs, { promises as fsp } from 'fs';
import { config } from '@config';
import path from 'path';
/**
* Check if the MongoDB deployment supports transactions (requires replica set or sharded cluster)
*/
async function supportsTransactions(): Promise<boolean> {
try {
if (!mongoose.connection.db) {
log.warn('MongoDB connection not established. Transactions will be disabled.');
return false;
}
const adminDb = mongoose.connection.db.admin();
const serverStatus = await adminDb.serverStatus();
// Check if running as part of a replica set
return serverStatus.repl !== undefined;
} catch (e) {
log.warn(`Failed to detect MongoDB replica set: ${errMsg(e)}. Transactions will be disabled.`);
return false;
}
}
const cleanupOrphanFiles = async (
actualSnapshot: SnapshotDocument | null,
diffSnapshot: SnapshotDocument | null,
logOpts: LogOpts
) => {
if (actualSnapshot && actualSnapshot.filename === `${actualSnapshot.id}.png`) {
const imagePath = path.join(config.defaultImagesPath, actualSnapshot.filename);
try {
if (fs.existsSync(imagePath)) {
await fsp.unlink(imagePath);
log.debug(`deleted orphan file: ${imagePath}`, logOpts);
}
} catch (err) {
log.error(`failed to delete orphan file: ${imagePath}, error: ${errMsg(err)}`, logOpts);
}
}
if (diffSnapshot && diffSnapshot.filename) {
const imagePath = path.join(config.defaultImagesPath, diffSnapshot.filename);
try {
if (fs.existsSync(imagePath)) {
await fsp.unlink(imagePath);
log.debug(`deleted orphan diff file: ${imagePath}`, logOpts);
}
} catch (err) {
log.error(`failed to delete orphan diff file: ${imagePath}, error: ${errMsg(err)}`, logOpts);
}
}
};
const createCheck = async (checkParam: CreateCheckParams, test: TestDocument, suite: SuiteDocument, app: AppDocument, currentUser: RequestUser, skipSaveOnCompareError = false) => {
const logOpts: LogOpts = {
scope: 'createCheck',
user: currentUser.username,
itemType: 'check',
};
let actualSnapshot: SnapshotDocument | null = null;
let currentBaselineSnapshot: SnapshotDocument;
let diffSnapshot: SnapshotDocument | null = null;
const newCheckParams = createCheckParams(checkParam, suite, app, test, currentUser);
const checkIdent = buildIdentObject(newCheckParams);
let check: CheckDocument | null = null;
const totalCheckHandleTime = 0;
// Check if transactions are supported (requires replica set)
const useTransactions = await supportsTransactions();
let session: mongoose.ClientSession | undefined;
if (useTransactions) {
session = await mongoose.startSession();
session.startTransaction();
log.debug('Using MongoDB transactions for createCheck', logOpts);
} else {
log.debug('MongoDB transactions not available, executing without session', logOpts);
}
try {
const { needFilesStatus, snapshotFoundedByHashcode } = await isNeedFiles(checkParam, logOpts);
if (needFilesStatus) {
if (session) {
await session.abortTransaction();
session.endSession();
}
return { status: 'needFiles' };
}
// update test with suite and creator
// moved from controller to be part of transaction
test.suite = suite.id;
test.creatorId = currentUser._id;
test.creatorUsername = currentUser.username;
await test.save({ session });
actualSnapshot = await prepareActualSnapshot(checkParam, snapshotFoundedByHashcode, logOpts, session);
newCheckParams.actualSnapshotId = actualSnapshot.id;
log.info(`find a baseline for the check with identifier: '${JSON.stringify(checkIdent)}'`, logOpts);
const storedBaseline = await BaselineService.getAcceptedBaseline(checkIdent);
const inspectBaselineResult = await BaselineService.inspectBaseline(newCheckParams, storedBaseline, checkIdent, actualSnapshot, logOpts);
Object.assign(newCheckParams, inspectBaselineResult.inspectBaselineParams);
currentBaselineSnapshot = inspectBaselineResult.currentBaselineSnapshot;
const compareResult = await compareCheck(currentBaselineSnapshot, actualSnapshot, newCheckParams, skipSaveOnCompareError, currentUser, session);
Object.assign(newCheckParams, compareResult);
if (compareResult.diffSnapshot) {
diffSnapshot = compareResult.diffSnapshot;
}
log.debug(`create the new check document with params: '${prettyCheckParams(newCheckParams)}'`, logOpts);
check = await CheckService.createCheckDocument(newCheckParams, session);
const savedCheck = check;
log.debug(`the check with id: '${check.id}', was created, will updated with data during creating process`, logOpts);
logOpts.ref = String(check.id);
await updateTestAfterCheck(test, check, logOpts, session);
// Save DOM snapshot if provided (for RCA feature)
if (checkParam.domDump) {
try {
await domSnapshotService.createDomSnapshot({
checkId: check.id,
type: 'actual',
content: checkParam.domDump,
});
log.debug(`DOM snapshot created for check: '${check.id}'`, logOpts);
} catch (domErr) {
// DOM snapshot is non-critical, log and continue
log.warn(`Failed to create DOM snapshot for check '${check.id}': ${errMsg(domErr)}`, logOpts);
}
}
const lastSuccessCheck = await BaselineService.getLastSuccessCheck(checkIdent);
if (session) {
await session.commitTransaction();
session.endSession();
}
const checkObject = savedCheck.toObject();
// Convert status from array to string for SDK compatibility
if (checkObject.status && Array.isArray(checkObject.status)) {
checkObject.status = checkObject.status[0] as any;
}
type CheckResult = (typeof checkObject) & {
currentSnapshot: SnapshotDocument,
expectedSnapshot: SnapshotDocument,
diffSnapshot: SnapshotDocument,
executeTime: number,
lastSuccess: string,
}
const result: CheckResult = {
...checkObject,
currentSnapshot: actualSnapshot,
expectedSnapshot: currentBaselineSnapshot,
diffSnapshot: compareResult.diffSnapshot,
executeTime: totalCheckHandleTime,
lastSuccess: lastSuccessCheck ? lastSuccessCheck.id : null,
};
return result;
} catch (e: unknown) {
if (session) {
await session.abortTransaction();
session.endSession();
}
log.error(`${session ? 'transaction aborted' : 'operation failed'}, cleaning up files... error: ${errMsg(e)}`, logOpts);
await cleanupOrphanFiles(actualSnapshot, diffSnapshot, logOpts);
// Throw error instead of creating failed check - maintain data consistency
throw new ApiError(
HttpStatus.INTERNAL_SERVER_ERROR,
`Failed to create check: ${errMsg(e)}`
);
}
};
const getIdent = () => ident;
const getBaselines = BaselineService.getBaselines;
export {
startSession,
endSession,
createCheck,
getIdent,
getBaselines,
};