From bbe24283434c107c97b481cfb2c55e7e8ebfae7f Mon Sep 17 00:00:00 2001 From: shadowusr Date: Thu, 21 Dec 2023 02:25:23 +0300 Subject: [PATCH] refactor: replace image-handler with images-info-saver --- hermione.ts | 27 +- lib/cache.ts | 33 ++ lib/common-utils.ts | 26 +- lib/constants/errors.js | 9 - lib/constants/errors.ts | 3 + lib/constants/index.ts | 1 + lib/errors/index.ts | 12 +- lib/gui/tool-runner/index.ts | 76 +-- lib/gui/tool-runner/report-subscriber.ts | 10 +- lib/gui/tool-runner/utils.ts | 4 +- lib/image-handler.ts | 366 --------------- lib/image-store.ts | 10 +- lib/images-info-saver.ts | 231 +++++++++ ...ges-saver.ts => local-image-file-saver.ts} | 4 +- lib/plugin-api.ts | 12 +- lib/report-builder/gui.ts | 23 +- lib/report-builder/static.ts | 66 ++- lib/reporter-helpers.ts | 70 ++- lib/server-utils.ts | 47 +- .../section/body/page-screenshot.tsx | 4 +- lib/test-adapter/playwright.ts | 122 ++--- lib/test-adapter/reporter.ts | 14 +- lib/test-adapter/sqlite.ts | 8 +- lib/test-adapter/utils/index.ts | 16 +- lib/tests-tree-builder/gui.ts | 14 +- lib/types.ts | 82 ++-- lib/workers/worker.ts | 6 +- playwright.ts | 19 +- test/unit/hermione.js | 7 +- test/unit/lib/image-handler.ts | 441 ------------------ test/unit/lib/images-info-saver.ts | 265 +++++++++++ test/unit/lib/report-builder/gui.js | 6 +- test/unit/lib/report-builder/static.js | 7 +- test/unit/workers/worker.js | 8 +- 34 files changed, 944 insertions(+), 1105 deletions(-) create mode 100644 lib/cache.ts delete mode 100644 lib/constants/errors.js create mode 100644 lib/constants/errors.ts delete mode 100644 lib/image-handler.ts create mode 100644 lib/images-info-saver.ts rename lib/{local-images-saver.ts => local-image-file-saver.ts} (67%) delete mode 100644 test/unit/lib/image-handler.ts create mode 100644 test/unit/lib/images-info-saver.ts diff --git a/hermione.ts b/hermione.ts index f3788405e..be061abe8 100644 --- a/hermione.ts +++ b/hermione.ts @@ -11,10 +11,13 @@ import {parseConfig} from './lib/config'; import {SKIPPED, SUCCESS, TestStatus, ToolName, UNKNOWN_ATTEMPT} from './lib/constants'; import {HtmlReporter} from './lib/plugin-api'; import {StaticReportBuilder} from './lib/report-builder/static'; -import {formatTestResult, logPathToHtmlReport, logError} from './lib/server-utils'; +import {formatTestResult, logPathToHtmlReport, logError, getExpectedCacheKey} from './lib/server-utils'; import {SqliteClient} from './lib/sqlite-client'; -import {HtmlReporterApi, ImageInfoFull, ReporterOptions} from './lib/types'; +import {HtmlReporterApi, ImageInfoFull, ReporterOptions, TestSpecByPath} from './lib/types'; import {createWorkers, CreateWorkersRunner} from './lib/workers/create-workers'; +import {SqliteImageStore} from './lib/image-store'; +import {Cache} from './lib/cache'; +import {ImagesInfoSaver} from './lib/images-info-saver'; export = (hermione: Hermione, opts: Partial): void => { if (hermione.isWorker() || !opts.enabled) { @@ -56,7 +59,17 @@ export = (hermione: Hermione, opts: Partial): void => { hermione.on(hermione.events.INIT, withMiddleware(async () => { const dbClient = await SqliteClient.create({htmlReporter, reportPath: config.path}); - staticReportBuilder = StaticReportBuilder.create(htmlReporter, config, {dbClient}); + const imageStore = new SqliteImageStore(dbClient); + const expectedPathsCache = new Cache<[TestSpecByPath, string | undefined], string>(getExpectedCacheKey); + + const imagesInfoSaver = new ImagesInfoSaver({ + imageFileSaver: htmlReporter.imagesSaver, + expectedPathsCache, + imageStore, + reportPath: htmlReporter.config.path + }); + + staticReportBuilder = StaticReportBuilder.create(htmlReporter, config, {dbClient, imagesInfoSaver}); handlingTestResults = Promise.all([ staticReportBuilder.saveStaticFiles(), @@ -92,7 +105,7 @@ async function handleTestResults(hermione: Hermione, reportBuilder: StaticReport hermione.on(hermione.events.TEST_PASS, testResult => { promises.push(queue.add(async () => { - const formattedResult = formatTestResult(testResult, SUCCESS, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResult = formatTestResult(testResult, SUCCESS, UNKNOWN_ATTEMPT); await reportBuilder.addTestResult(formattedResult); }).catch(reject)); @@ -102,7 +115,7 @@ async function handleTestResults(hermione: Hermione, reportBuilder: StaticReport promises.push(queue.add(async () => { const status = hasFailedImages(testResult.assertViewResults as ImageInfoFull[]) ? TestStatus.FAIL : TestStatus.ERROR; - const formattedResult = formatTestResult(testResult, status, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResult = formatTestResult(testResult, status, UNKNOWN_ATTEMPT); await reportBuilder.addTestResult(formattedResult); }).catch((e) => { @@ -114,7 +127,7 @@ async function handleTestResults(hermione: Hermione, reportBuilder: StaticReport promises.push(queue.add(async () => { const status = hasFailedImages(testResult.assertViewResults as ImageInfoFull[]) ? TestStatus.FAIL : TestStatus.ERROR; - const formattedResult = formatTestResult(testResult, status, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResult = formatTestResult(testResult, status, UNKNOWN_ATTEMPT); await reportBuilder.addTestResult(formattedResult); }).catch((e) => { @@ -124,7 +137,7 @@ async function handleTestResults(hermione: Hermione, reportBuilder: StaticReport hermione.on(hermione.events.TEST_PENDING, testResult => { promises.push(queue.add(async () => { - const formattedResult = formatTestResult(testResult as HermioneTestResult, SKIPPED, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResult = formatTestResult(testResult as HermioneTestResult, SKIPPED, UNKNOWN_ATTEMPT); await reportBuilder.addTestResult(formattedResult); }).catch(reject)); diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 000000000..5a8fc4227 --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,33 @@ +export class Cache { + private _getKeyHash: (key: Key) => string; + private _cache: Map; + + constructor(hashFn: (key: Key) => string) { + this._getKeyHash = hashFn; + this._cache = new Map(); + } + + has(key: Key): boolean { + const keyHash = this._getKeyHash(key); + + return this._cache.has(keyHash); + } + + get(key: Key): Value | undefined { + const keyHash = this._getKeyHash(key); + + return this._cache.get(keyHash); + } + + set(key: Key, value: Value): this { + const keyHash = this._getKeyHash(key); + + if (value !== undefined) { + this._cache.set(keyHash, value); + } else { + this._cache.delete(keyHash); + } + + return this; + } +} diff --git a/lib/common-utils.ts b/lib/common-utils.ts index 1988b43e4..25bab40e4 100644 --- a/lib/common-utils.ts +++ b/lib/common-utils.ts @@ -17,7 +17,15 @@ import { } from './constants'; import {CHECKED, INDETERMINATE, UNCHECKED} from './constants/checked-statuses'; -import {ImageBase64, ImageData, ImageInfoFail, ImageInfoFull, TestError} from './types'; +import { + ImageBase64, + ImageBuffer, + ImageFile, + ImageInfoDiff, + ImageInfoFull, + ImageInfoWithState, + TestError +} from './types'; import {ErrorName, ImageDiffError, NoRefImageError} from './errors'; import {ReporterTestResult} from './test-adapter'; @@ -119,7 +127,7 @@ export const hasNoRefImageErrors = ({assertViewResults = []}: {assertViewResults export const hasFailedImages = (imagesInfo: ImageInfoFull[] = []): boolean => { return imagesInfo.some((imageInfo: ImageInfoFull) => { - return (imageInfo as ImageInfoFail).stateName && + return (imageInfo as ImageInfoDiff).stateName && (isErrorStatus(imageInfo.status) || isFailStatus(imageInfo.status) || isNoRefImageError(imageInfo) || isImageDiffError(imageInfo)); }); }; @@ -164,7 +172,7 @@ export const determineStatus = (testResult: Pick { +export const isBase64Image = (image: ImageFile | ImageBuffer | ImageBase64 | null | undefined): image is ImageBase64 => { return Boolean((image as ImageBase64 | undefined)?.base64); }; @@ -244,3 +252,15 @@ export const getTitleDelimiter = (toolName: ToolName): string => { export function getDetailsFileName(testId: string, browserId: string, attempt: number): string { return `${testId}-${browserId}_${Number(attempt) + 1}_${Date.now()}.json`; } + +export const getTestHash = (testResult: ReporterTestResult): string => { + return testResult.testPath.concat(testResult.browserId, testResult.attempt.toString()).join(' '); +}; + +export const isImageBufferData = (imageData: ImageBuffer | ImageFile | ImageBase64 | undefined): imageData is ImageBuffer => { + return Boolean((imageData as ImageBuffer).buffer); +}; + +export const isImageInfoWithState = (imageInfo: ImageInfoFull): imageInfo is ImageInfoWithState => { + return Boolean((imageInfo as ImageInfoWithState).stateName); +}; diff --git a/lib/constants/errors.js b/lib/constants/errors.js deleted file mode 100644 index 1d1c07c6e..000000000 --- a/lib/constants/errors.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -module.exports = { - getCommonErrors: () => ({ - NO_REF_IMAGE_ERROR: 'NoRefImageError', - ASSERT_VIEW_ERROR: 'AssertViewError' - }), - ERROR_TITLE_TEXT_LENGTH: 200 -}; diff --git a/lib/constants/errors.ts b/lib/constants/errors.ts new file mode 100644 index 000000000..f90376362 --- /dev/null +++ b/lib/constants/errors.ts @@ -0,0 +1,3 @@ +export const ERROR_TITLE_TEXT_LENGTH = 200; + +export const NEW_ISSUE_LINK = 'https://github.com/gemini-testing/html-reporter/issues/new'; diff --git a/lib/constants/index.ts b/lib/constants/index.ts index ea288b471..da621fc00 100644 --- a/lib/constants/index.ts +++ b/lib/constants/index.ts @@ -2,6 +2,7 @@ export * from './browser'; export * from './database'; export * from './defaults'; export * from './diff-modes'; +export * from './errors'; export * from './group-tests'; export * from './paths'; export * from './tests'; diff --git a/lib/errors/index.ts b/lib/errors/index.ts index 0e6b1d449..f68dcab3c 100644 --- a/lib/errors/index.ts +++ b/lib/errors/index.ts @@ -1,5 +1,5 @@ import {CoordBounds} from 'looks-same'; -import {DiffOptions, ImageData} from '../types'; +import {DiffOptions, ImageFile} from '../types'; import {ValueOf} from 'type-fest'; export const ErrorName = { @@ -17,11 +17,11 @@ export interface ImageDiffError { stack: string; stateName: string; diffOpts: DiffOptions; - currImg: ImageData; - refImg: ImageData; + currImg: ImageFile; + refImg: ImageFile; diffClusters: CoordBounds[]; diffBuffer?: ArrayBuffer; - diffImg?: ImageData; + diffImg?: ImageFile; } export interface NoRefImageError { @@ -29,6 +29,6 @@ export interface NoRefImageError { stateName: string; message: string; stack?: string; - currImg: ImageData; - refImg?: ImageData; + currImg: ImageFile; + refImg?: ImageFile; } diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 5eed1cd92..c8f3e1a6e 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -26,14 +26,14 @@ import { } from '../../constants'; import {formatId, mkFullTitle, mergeDatabasesForReuse, filterByEqualDiffSizes} from './utils'; import {getTestsTreeFromDatabase} from '../../db-utils/server'; -import {formatTestResult} from '../../server-utils'; +import {formatTestResult, getExpectedCacheKey} from '../../server-utils'; import { AssertViewResult, HermioneTestResult, HtmlReporterApi, - ImageData, - ImageInfoFail, - ReporterConfig + ImageFile, + ImageInfoDiff, ImageInfoUpdated, + ReporterConfig, TestSpecByPath } from '../../types'; import {GuiCliOptions, GuiConfigs} from '../index'; import {Tree, TreeImage} from '../../tests-tree-builder/base'; @@ -41,19 +41,21 @@ import {TestSpec} from './runner/runner'; import {Response} from 'express'; import {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../../tests-tree-builder/gui'; import {ReporterTestResult} from '../../test-adapter'; -import {ImagesInfoFormatter} from '../../image-handler'; import {SqliteClient} from '../../sqlite-client'; import PQueue from 'p-queue'; import os from 'os'; +import {Cache} from '../../cache'; +import {ImagesInfoSaver} from '../../images-info-saver'; +import {SqliteImageStore} from '../../image-store'; type ToolRunnerArgs = [paths: string[], hermione: Hermione & HtmlReporterApi, configs: GuiConfigs]; export type ToolRunnerTree = GuiReportBuilderResult & Pick; interface HermioneTestExtended extends HermioneTest { - assertViewResults: {stateName: string, refImg: ImageData, currImg: ImageData}; + assertViewResults: {stateName: string, refImg: ImageFile, currImg: ImageFile}; attempt: number; - imagesInfo: Pick[]; + imagesInfo: Pick[]; } type HermioneTestPlain = Pick; @@ -67,10 +69,9 @@ export interface UndoAcceptImagesResult { const formatTestResultUnsafe = ( test: HermioneTest | HermioneTestExtended | HermioneTestPlain, status: TestStatus, - attempt: number, - {imageHandler}: {imageHandler: ImagesInfoFormatter} + attempt: number ): ReporterTestResult => { - return formatTestResult(test as HermioneTestResult, status, attempt, {imageHandler}); + return formatTestResult(test as HermioneTestResult, status, attempt); }; export class ToolRunner { @@ -85,6 +86,7 @@ export class ToolRunner { private _eventSource: EventSource; protected _reportBuilder: GuiReportBuilder | null; private _tests: Record; + private _expectedImagesCache: Cache<[TestSpecByPath, string | undefined], string>; static create(this: new (...args: ToolRunnerArgs) => T, ...args: ToolRunnerArgs): T { return new this(...args); @@ -105,6 +107,8 @@ export class ToolRunner { this._reportBuilder = null; this._tests = {}; + + this._expectedImagesCache = new Cache(getExpectedCacheKey); } get config(): HermioneConfig { @@ -119,8 +123,16 @@ export class ToolRunner { await mergeDatabasesForReuse(this._reportPath); const dbClient = await SqliteClient.create({htmlReporter: this._hermione.htmlReporter, reportPath: this._reportPath, reuse: true}); + const imageStore = new SqliteImageStore(dbClient); + + const imagesInfoSaver = new ImagesInfoSaver({ + imageFileSaver: this._hermione.htmlReporter.imagesSaver, + expectedPathsCache: this._expectedImagesCache, + imageStore, + reportPath: this._hermione.htmlReporter.config.path + }); - this._reportBuilder = GuiReportBuilder.create(this._hermione.htmlReporter, this._pluginConfig, {dbClient}); + this._reportBuilder = GuiReportBuilder.create(this._hermione.htmlReporter, this._pluginConfig, {dbClient, imagesInfoSaver}); this._subscribeOnEvents(); this._collection = await this._readTests(); @@ -174,7 +186,7 @@ export class ToolRunner { const [selectedImage, ...comparedImages] = this._ensureReportBuilder().getImageDataToFindEqualDiffs(imageIds); const imagesWithEqualBrowserName = comparedImages.filter((image) => image.browserName === selectedImage.browserName); - const imagesWithEqualDiffSizes = filterByEqualDiffSizes(imagesWithEqualBrowserName, (selectedImage as ImageInfoFail).diffClusters); + const imagesWithEqualDiffSizes = filterByEqualDiffSizes(imagesWithEqualBrowserName, (selectedImage as ImageInfoDiff).diffClusters); return _.isEmpty(imagesWithEqualDiffSizes) ? [] : [selectedImage].concat(imagesWithEqualDiffSizes); } @@ -185,22 +197,14 @@ export class ToolRunner { return Promise.all(tests.map(async (test): Promise => { const updateResult = this._prepareTestResult(test); - const formattedResultWithoutAttempt = formatTestResultUnsafe(updateResult, UPDATED, UNKNOWN_ATTEMPT, reportBuilder); - - const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); - - updateResult.attempt = formattedResult.attempt; + const formattedResultWithoutAttempt = formatTestResultUnsafe(updateResult, UPDATED, UNKNOWN_ATTEMPT); + const formattedResult = reportBuilder.provideAttempt(formattedResultWithoutAttempt); - await Promise.all(updateResult.imagesInfo.map(async (imageInfo) => { - const {stateName} = imageInfo; + const formattedResultUpdated = await reporterHelper.updateReferenceImages(formattedResult, this._reportPath, this._handleReferenceUpdate.bind(this)); - await reporterHelper.updateReferenceImage(formattedResult, this._reportPath, stateName); + await reportBuilder.addTestResult(formattedResultUpdated); - const result = _.extend(updateResult, {refImg: imageInfo.expectedImg}); - this._emitUpdateReference(result, stateName); - })); - - return reportBuilder.getTestBranch(formattedResult.id); + return reportBuilder.getTestBranch(formattedResultUpdated.id); })); } @@ -210,7 +214,7 @@ export class ToolRunner { await Promise.all(tests.map(async (test) => { const updateResult = this._prepareTestResult(test); - const formattedResultWithoutAttempt = formatTestResultUnsafe(updateResult, UPDATED, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResultUnsafe(updateResult, UPDATED, UNKNOWN_ATTEMPT); await Promise.all(updateResult.imagesInfo.map(async (imageInfo) => { const {stateName} = imageInfo; @@ -241,10 +245,10 @@ export class ToolRunner { } if (previousExpectedPath && (updateResult as HermioneTest).fullTitle) { - reportBuilder.imageHandler.updateCacheExpectedPath({ - fullName: (updateResult as HermioneTest).fullTitle(), + this._expectedImagesCache.set([{ + testPath: [(updateResult as HermioneTest).fullTitle()], browserId: (updateResult as HermioneTest).browserId - }, stateName, previousExpectedPath); + }, stateName], previousExpectedPath); } })); })); @@ -253,7 +257,7 @@ export class ToolRunner { } async findEqualDiffs(images: TestEqualDiffsData[]): Promise { - const [selectedImage, ...comparedImages] = images as (ImageInfoFail & {diffClusters: CoordBounds[]})[]; + const [selectedImage, ...comparedImages] = images as (ImageInfoDiff & {diffClusters: CoordBounds[]})[]; const {tolerance, antialiasingTolerance} = this.config; const compareOpts = {tolerance, antialiasingTolerance, stopOnFirstFail: true, shouldCluster: false}; @@ -316,9 +320,9 @@ export class ToolRunner { this._tests[testId] = _.extend(test, {browserId}); if (test.pending) { - queue.add(async () => reportBuilder.addTestResult(formatTestResultUnsafe(test, SKIPPED, UNKNOWN_ATTEMPT, reportBuilder))); + queue.add(async () => reportBuilder.addTestResult(formatTestResultUnsafe(test, SKIPPED, UNKNOWN_ATTEMPT))); } else { - queue.add(async () => reportBuilder.addTestResult(formatTestResultUnsafe(test, IDLE, UNKNOWN_ATTEMPT, reportBuilder))); + queue.add(async () => reportBuilder.addTestResult(formatTestResultUnsafe(test, IDLE, UNKNOWN_ATTEMPT))); } }); @@ -345,7 +349,7 @@ export class ToolRunner { const imagesInfo = test.imagesInfo .filter(({stateName, actualImg}) => Boolean(stateName) && Boolean(actualImg)) .map((imageInfo) => { - const {stateName, actualImg} = imageInfo as {stateName: string, actualImg: ImageData}; + const {stateName, actualImg} = imageInfo as {stateName: string, actualImg: ImageFile}; const path = this._hermione.config.browsers[browserId].getScreenshotPath(rawTest, stateName); const refImg = {path, size: actualImg.size}; @@ -370,10 +374,12 @@ export class ToolRunner { : res; } - protected _emitUpdateReference({refImg}: {refImg: ImageData}, state: string): void { + protected _handleReferenceUpdate(testResult: ReporterTestResult, imageInfo: ImageInfoUpdated, state: string): void { + this._expectedImagesCache.set([testResult, imageInfo.stateName], imageInfo.expectedImg.path); + this._hermione.emit( this._hermione.events.UPDATE_REFERENCE, - {refImg, state} + {refImg: imageInfo.refImg, state} ); } diff --git a/lib/gui/tool-runner/report-subscriber.ts b/lib/gui/tool-runner/report-subscriber.ts index 5edd00145..aa3791694 100644 --- a/lib/gui/tool-runner/report-subscriber.ts +++ b/lib/gui/tool-runner/report-subscriber.ts @@ -31,7 +31,7 @@ export const subscribeOnToolEvents = (hermione: Hermione, reportBuilder: GuiRepo hermione.on(hermione.events.TEST_BEGIN, (data) => { queue.add(async () => { - const formattedResultWithoutAttempt = formatTestResult(data as HermioneTestResult, RUNNING, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResult(data as HermioneTestResult, RUNNING, UNKNOWN_ATTEMPT); const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); const testBranch = reportBuilder.getTestBranch(formattedResult.id); @@ -42,7 +42,7 @@ export const subscribeOnToolEvents = (hermione: Hermione, reportBuilder: GuiRepo hermione.on(hermione.events.TEST_PASS, (testResult) => { queue.add(async () => { - const formattedResultWithoutAttempt = formatTestResult(testResult, SUCCESS, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResult(testResult, SUCCESS, UNKNOWN_ATTEMPT); const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); @@ -55,7 +55,7 @@ export const subscribeOnToolEvents = (hermione: Hermione, reportBuilder: GuiRepo queue.add(async () => { const status = hasFailedImages(testResult.assertViewResults as ImageInfoFull[]) ? TestStatus.FAIL : TestStatus.ERROR; - const formattedResultWithoutAttempt = formatTestResult(testResult, status, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResult(testResult, status, UNKNOWN_ATTEMPT); const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); @@ -68,7 +68,7 @@ export const subscribeOnToolEvents = (hermione: Hermione, reportBuilder: GuiRepo queue.add(async () => { const status = hasFailedImages(testResult.assertViewResults as ImageInfoFull[]) ? TestStatus.FAIL : TestStatus.ERROR; - const formattedResultWithoutAttempt = formatTestResult(testResult, status, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResult(testResult, status, UNKNOWN_ATTEMPT); const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); @@ -79,7 +79,7 @@ export const subscribeOnToolEvents = (hermione: Hermione, reportBuilder: GuiRepo hermione.on(hermione.events.TEST_PENDING, async (testResult) => { queue.add(async () => { - const formattedResultWithoutAttempt = formatTestResult(testResult as HermioneTestResult, SKIPPED, UNKNOWN_ATTEMPT, reportBuilder); + const formattedResultWithoutAttempt = formatTestResult(testResult as HermioneTestResult, SKIPPED, UNKNOWN_ATTEMPT); const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); diff --git a/lib/gui/tool-runner/utils.ts b/lib/gui/tool-runner/utils.ts index c0732e838..f69af9d3d 100644 --- a/lib/gui/tool-runner/utils.ts +++ b/lib/gui/tool-runner/utils.ts @@ -9,7 +9,7 @@ import {logger} from '../../common-utils'; import {DATABASE_URLS_JSON_NAME, LOCAL_DATABASE_NAME} from '../../constants'; import {mergeTables} from '../../db-utils/server'; import {TestEqualDiffsData, TestRefUpdateData} from '../../tests-tree-builder/gui'; -import {ImageInfoFail, ImageSize} from '../../types'; +import {ImageInfoDiff, ImageSize} from '../../types'; export const formatId = (hash: string, browserId: string): string => `${hash}/${browserId}`; @@ -58,7 +58,7 @@ export const filterByEqualDiffSizes = (imagesInfo: TestEqualDiffsData[], refDiff const refDiffSizes = refDiffClusters.map(getDiffClusterSizes); return _.filter(imagesInfo, (imageInfo) => { - const imageInfoFail = imageInfo as ImageInfoFail; + const imageInfoFail = imageInfo as ImageInfoDiff; const imageDiffSizes = imageInfoFail.diffClusters?.map(getDiffClusterSizes) ?? []; const equal = compareDiffSizes(imageDiffSizes, refDiffSizes); diff --git a/lib/image-handler.ts b/lib/image-handler.ts deleted file mode 100644 index c53badaf9..000000000 --- a/lib/image-handler.ts +++ /dev/null @@ -1,366 +0,0 @@ -import path from 'path'; -import EventEmitter2 from 'eventemitter2'; -import fs from 'fs-extra'; -import _ from 'lodash'; -import tmp from 'tmp'; - -import type {ImageStore} from './image-store'; -import {RegisterWorkers} from './workers/create-workers'; -import * as utils from './server-utils'; -import { - AssertViewResult, - ImageBase64, - ImageData, - ImageInfo, ImageInfoError, - ImageInfoFail, - ImageInfoFull, - ImagesSaver, - ImageInfoPageSuccess -} from './types'; -import {ERROR, FAIL, PluginEvents, SUCCESS, TestStatus, UNKNOWN_ATTEMPT, UPDATED} from './constants'; -import { - getError, - getShortMD5, - isBase64Image, - isImageDiffError, - isNoRefImageError, - logger, - mkTestId -} from './common-utils'; -import {ImageDiffError} from './errors'; -import {cacheExpectedPaths, cacheAllImages, cacheDiffImages} from './image-cache'; -import {ReporterTestResult} from './test-adapter'; - -// A type to prevent accidental infinite recursion on a type level -export type ReporterTestResultPlain = Omit; - -export interface ImagesInfoFormatter { - getImagesInfo(testResult: ReporterTestResultPlain): ImageInfoFull[]; -} - -export interface ImageHandlerOptions { - reportPath: string; -} - -interface TestSpec { - fullName: string; - browserId: string; -} - -export class ImageHandler extends EventEmitter2 implements ImagesInfoFormatter { - private _imageStore: ImageStore; - private _imagesSaver: ImagesSaver; - private _options: ImageHandlerOptions; - - constructor(imageStore: ImageStore, imagesSaver: ImagesSaver, options: ImageHandlerOptions) { - super(); - this._imageStore = imageStore; - this._imagesSaver = imagesSaver; - this._options = options; - } - - static getCurrImg(assertViewResults: AssertViewResult[], stateName?: string): ImageData | undefined { - return _.get(_.find(assertViewResults, {stateName}), 'currImg'); - } - - static getDiffImg(assertViewResults: AssertViewResult[], stateName?: string): ImageData | undefined { - return _.get(_.find(assertViewResults, {stateName}), 'diffImg'); - } - - static getRefImg(assertViewResults: AssertViewResult[], stateName?: string): ImageData | undefined { - return _.get(_.find(assertViewResults, {stateName}), 'refImg'); - } - - static getScreenshot(testResult: ReporterTestResultPlain): ImageBase64 | ImageData | null | undefined { - return testResult.screenshot; - } - - getImagesFor(testResult: ReporterTestResultPlain, assertViewStatus: TestStatus, stateName?: string): ImageInfo | undefined { - const refImg = ImageHandler.getRefImg(testResult.assertViewResults, stateName); - const currImg = ImageHandler.getCurrImg(testResult.assertViewResults, stateName); - - const pageImg = ImageHandler.getScreenshot(testResult); - - const {path: refPath} = this._getExpectedPath(testResult, stateName); - const currPath = utils.getCurrentPath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir, stateName}); - const diffPath = utils.getDiffPath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir, stateName}); - - // Handling whole page common screenshots - if (!stateName && pageImg) { - return { - actualImg: { - path: this._getImgFromStorage(currPath), - size: pageImg.size - } - }; - } - - if ((assertViewStatus === SUCCESS || assertViewStatus === UPDATED) && refImg) { - const result: ImageInfo = { - expectedImg: {path: this._getImgFromStorage(refPath), size: refImg.size} - }; - if (currImg) { - result.actualImg = {path: this._getImgFromStorage(currPath), size: currImg.size}; - } - - return result; - } - - if (assertViewStatus === FAIL && refImg && currImg) { - return { - expectedImg: { - path: this._getImgFromStorage(refPath), - size: refImg.size - }, - actualImg: { - path: this._getImgFromStorage(currPath), - size: currImg.size - }, - diffImg: { - path: this._getImgFromStorage(diffPath), - size: { - width: _.max([_.get(refImg, 'size.width'), _.get(currImg, 'size.width')]) as number, - height: _.max([_.get(refImg, 'size.height'), _.get(currImg, 'size.height')]) as number - } - } - }; - } - - if (assertViewStatus === ERROR && currImg) { - return { - actualImg: { - path: this._getImgFromStorage(currPath), - size: currImg.size - } - }; - } - - return; - } - - getImagesInfo(testResult: ReporterTestResultPlain): ImageInfoFull[] { - const imagesInfo: ImageInfoFull[] = testResult.assertViewResults?.map((assertResult): ImageInfoFull => { - let status: TestStatus, error: {message: string; stack?: string;} | undefined; - - if (assertResult.isUpdated === true) { - status = UPDATED; - } else if (isImageDiffError(assertResult)) { - status = FAIL; - } else if (isNoRefImageError(assertResult)) { - status = ERROR; - error = _.pick(assertResult, ['message', 'name', 'stack']); - } else { - status = SUCCESS; - } - - const {stateName, refImg} = assertResult; - const diffClusters = (assertResult as ImageDiffError).diffClusters; - - return _.extend( - {stateName, refImg, status: status, error, diffClusters}, - this.getImagesFor(testResult, status, stateName) - ) as ImageInfoFull; - }) ?? []; - - // Common page screenshot - if (ImageHandler.getScreenshot(testResult)) { - const error = getError(testResult.error); - - if (!_.isEmpty(error)) { - imagesInfo.push(_.extend( - {status: ERROR, error}, - this.getImagesFor(testResult, ERROR) - ) as ImageInfoError); - } else { - imagesInfo.push(_.extend( - {status: SUCCESS}, - this.getImagesFor(testResult, SUCCESS) - ) as ImageInfoPageSuccess); - } - } - - return imagesInfo; - } - - async saveTestImages(testResult: ReporterTestResultPlain, worker: RegisterWorkers<['saveDiffTo']>): Promise { - const {assertViewResults = []} = testResult; - - const result = await Promise.all(assertViewResults.map(async (assertResult) => { - const {stateName} = assertResult; - const {path: destRefPath, reused: reusedReference} = this._getExpectedPath(testResult, stateName); - const srcRefPath = ImageHandler.getRefImg(testResult.assertViewResults, stateName)?.path; - - const destCurrPath = utils.getCurrentPath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir, stateName}); - const srcCurrPath = ImageHandler.getCurrImg(testResult.assertViewResults, stateName)?.path; - - const destDiffPath = utils.getDiffPath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir, stateName}); - const srcDiffPath = ImageHandler.getDiffImg(assertViewResults, stateName)?.path ?? path.resolve(tmp.tmpdir, destDiffPath); - const actions: unknown[] = []; - - if (!(assertResult instanceof Error)) { - actions.push(this._saveImg(srcRefPath, destRefPath)); - } - - if (isImageDiffError(assertResult)) { - if (!assertResult.diffImg) { - await this._saveDiffInWorker(assertResult, srcDiffPath, worker); - } - - actions.push( - this._saveImg(srcCurrPath, destCurrPath), - this._saveImg(srcDiffPath, destDiffPath) - ); - - if (!reusedReference) { - actions.push(this._saveImg(srcRefPath, destRefPath)); - } - } - - if (isNoRefImageError(assertResult)) { - actions.push(this._saveImg(srcCurrPath, destCurrPath)); - } - - return Promise.all(actions); - })); - - if (ImageHandler.getScreenshot(testResult)) { - await this._savePageScreenshot(testResult); - } - - await this.emitAsync(PluginEvents.TEST_SCREENSHOTS_SAVED, { - testId: mkTestId(testResult.fullName, testResult.browserId), - attempt: testResult.attempt, - imagesInfo: this.getImagesInfo(testResult) - }); - - return result; - } - - setImagesSaver(newImagesSaver: ImagesSaver): void { - this._imagesSaver = newImagesSaver; - } - - updateCacheExpectedPath(testResult: TestSpec, stateName: string, expectedPath: string): void { - const key = this._getExpectedKey(testResult, stateName); - - if (expectedPath) { - cacheExpectedPaths.set(key, expectedPath); - } else { - cacheExpectedPaths.delete(key); - } - } - - private _getExpectedKey(testResult: TestSpec, stateName?: string): string { - const shortTestId = getShortMD5(mkTestId(testResult.fullName, testResult.browserId)); - - return shortTestId + '#' + stateName; - } - - private _getExpectedPath(testResult: ReporterTestResultPlain, stateName?: string): {path: string, reused: boolean} { - const key = this._getExpectedKey(testResult, stateName); - let result: {path: string; reused: boolean}; - - if (testResult.status === UPDATED) { - const expectedPath = utils.getReferencePath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir, stateName}); - - result = {path: expectedPath, reused: false}; - } else if (cacheExpectedPaths.has(key)) { - result = {path: cacheExpectedPaths.get(key) as string, reused: true}; - } else { - const imageInfo = this._imageStore.getLastImageInfoFromDb(testResult, stateName); - - if (imageInfo && (imageInfo as ImageInfoFail).expectedImg) { - const expectedPath = (imageInfo as ImageInfoFail).expectedImg.path; - - result = {path: expectedPath, reused: true}; - } else { - const expectedPath = utils.getReferencePath({ - attempt: testResult.attempt, - browserId: testResult.browserId, - imageDir: testResult.imageDir, - stateName - }); - - result = {path: expectedPath, reused: false}; - } - } - - if (testResult.attempt !== UNKNOWN_ATTEMPT) { - cacheExpectedPaths.set(key, result.path); - } - - return result; - } - - private _getImgFromStorage(imgPath: string): string { - // fallback for updating image in gui mode - return cacheAllImages.get(imgPath) || imgPath; - } - - private async _saveDiffInWorker(imageDiffError: ImageDiffError, destPath: string, worker: RegisterWorkers<['saveDiffTo']>): Promise { - await utils.makeDirFor(destPath); - - // new versions of hermione provide `diffBuffer` - if (imageDiffError.diffBuffer) { - const pngBuffer = Buffer.from(imageDiffError.diffBuffer); - - await fs.writeFile(destPath, pngBuffer); - - return; - } - - const currPath = imageDiffError.currImg.path; - const refPath = imageDiffError.refImg.path; - - const [currBuffer, refBuffer] = await Promise.all([ - fs.readFile(currPath), - fs.readFile(refPath) - ]); - - const hash = utils.createHash(currBuffer) + utils.createHash(refBuffer); - - if (cacheDiffImages.has(hash)) { - const cachedDiffPath = cacheDiffImages.get(hash) as string; - - await fs.copy(cachedDiffPath, destPath); - return; - } - - await worker.saveDiffTo(imageDiffError, destPath); - - cacheDiffImages.set(hash, destPath); - } - - private async _savePageScreenshot(testResult: ReporterTestResultPlain): Promise { - const screenshot = ImageHandler.getScreenshot(testResult); - if (!(screenshot as ImageBase64)?.base64 && !(screenshot as ImageData)?.path) { - logger.warn('Cannot save screenshot on reject'); - - return Promise.resolve(); - } - - const currPath = utils.getCurrentPath({attempt: testResult.attempt, browserId: testResult.browserId, imageDir: testResult.imageDir}); - let localPath: string; - - if (isBase64Image(screenshot)) { - localPath = path.resolve(tmp.tmpdir, currPath); - await utils.makeDirFor(localPath); - await fs.writeFile(localPath, new Buffer(screenshot.base64, 'base64'), 'base64'); - } else { - localPath = screenshot?.path as string; - } - - await this._saveImg(localPath, currPath); - } - - private async _saveImg(localPath: string | undefined, destPath: string): Promise { - if (!localPath) { - return Promise.resolve(undefined); - } - - const res = await this._imagesSaver.saveImg(localPath, {destPath, reportDir: this._options.reportPath}); - - cacheAllImages.set(destPath, res || destPath); - return res; - } -} diff --git a/lib/image-store.ts b/lib/image-store.ts index 5970f0ea3..44805afa8 100644 --- a/lib/image-store.ts +++ b/lib/image-store.ts @@ -1,10 +1,9 @@ import {DB_COLUMNS} from './constants'; import {SqliteClient} from './sqlite-client'; -import {ImageInfo, ImageInfoFull, LabeledSuitesRow} from './types'; -import {ReporterTestResultPlain} from './image-handler'; +import {ImageInfo, ImageInfoFull, LabeledSuitesRow, TestSpecByPath} from './types'; export interface ImageStore { - getLastImageInfoFromDb(testResult: ReporterTestResultPlain, stateName?: string): ImageInfo | undefined ; + getLastImageInfoFromDb(testResult: TestSpecByPath, stateName?: string): ImageInfo | undefined ; } export class SqliteImageStore implements ImageStore { @@ -14,7 +13,7 @@ export class SqliteImageStore implements ImageStore { this._dbClient = dbClient; } - getLastImageInfoFromDb(testResult: ReporterTestResultPlain, stateName?: string): ImageInfo | undefined { + getLastImageInfoFromDb(testResult: TestSpecByPath, stateName?: string): ImageInfo | undefined { const browserName = testResult.browserId; const suitePath = testResult.testPath; const suitePathString = JSON.stringify(suitePath); @@ -23,7 +22,8 @@ export class SqliteImageStore implements ImageStore { select: DB_COLUMNS.IMAGES_INFO, where: `${DB_COLUMNS.SUITE_PATH} = ? AND ${DB_COLUMNS.NAME} = ?`, orderBy: DB_COLUMNS.TIMESTAMP, - orderDescending: true + orderDescending: true, + noCache: true }, suitePathString, browserName); const imagesInfo: ImageInfoFull[] = imagesInfoResult && JSON.parse(imagesInfoResult[DB_COLUMNS.IMAGES_INFO as keyof Pick]) || []; diff --git a/lib/images-info-saver.ts b/lib/images-info-saver.ts new file mode 100644 index 000000000..f8c5c4581 --- /dev/null +++ b/lib/images-info-saver.ts @@ -0,0 +1,231 @@ +import util from 'util'; +import makeDebug from 'debug'; +import EventEmitter2 from 'eventemitter2'; +import fs from 'fs-extra'; +import sizeOf from 'image-size'; +import _ from 'lodash'; +import PQueue from 'p-queue'; + +import {RegisterWorkers} from './workers/create-workers'; +import {ReporterTestResult} from './test-adapter'; +import { + DiffOptions, ImageBase64, ImageBuffer, + ImageFile, + ImageFileSaver, + ImageInfoDiff, + ImageInfoFull, + ImageSize, TestSpecByPath +} from './types'; +import {copyAndUpdate, removeBufferFromImagesInfo} from './test-adapter/utils'; +import {cacheDiffImages} from './image-cache'; +import {NEW_ISSUE_LINK, PluginEvents, TestStatus, UPDATED} from './constants'; +import {createHash, getCurrentPath, getDiffPath, getReferencePath, getTempPath, makeDirFor} from './server-utils'; +import {isBase64Image, mkTestId, isImageBufferData} from './common-utils'; +import {ImageStore} from './image-store'; +import {Cache} from './cache'; + +const debug = makeDebug('html-reporter:images-info-saver'); + +interface ImagesInfoSaverOptions { + imageFileSaver: ImageFileSaver; + reportPath: string; + imageStore: ImageStore; + expectedPathsCache: Cache<[TestSpecByPath, string | undefined], string>; +} + +export class ImagesInfoSaver extends EventEmitter2 { + private _imageFileSaver: ImageFileSaver; + private _reportPath: string; + private _imageStore: ImageStore; + private _expectedPathsCache: Cache<[TestSpecByPath, string | undefined], string>; + + constructor(options: ImagesInfoSaverOptions) { + super(); + + this._imageFileSaver = options.imageFileSaver; + this._reportPath = options.reportPath; + this._imageStore = options.imageStore; + this._expectedPathsCache = options.expectedPathsCache; + } + + async save(testResult: ReporterTestResult, workers?: RegisterWorkers<['saveDiffTo']>): Promise { + const testDebug = debug.extend(testResult.imageDir); + testDebug(`Saving images of ${testResult.id}`); + + const newImagesInfos: ImageInfoFull[] = []; + + await Promise.all(testResult.imagesInfo.map(async (imagesInfo, index) => { + const imageDebug = testDebug.extend(index.toString()); + imageDebug.enabled && imageDebug('Handling %j', removeBufferFromImagesInfo(imagesInfo)); + + const newImagesInfo = _.clone(imagesInfo); + const {stateName} = imagesInfo as ImageInfoDiff; + const actions = new PQueue(); + + actions.add(async () => { + (newImagesInfo as {actualImg?: ImageFile}).actualImg + = await this._saveActualImageIfNeeded(testResult, imagesInfo, stateName, {logger: imageDebug}); + }); + + actions.add(async () => { + (newImagesInfo as {diffImg?: ImageFile}).diffImg = + await this._saveDiffImageIfNeeded(testResult, imagesInfo, stateName, {workers, logger: imageDebug}); + }); + + actions.add(async () => { + (newImagesInfo as {expectedImg?: ImageFile}).expectedImg = + await this._saveExpectedImageIfNeeded(testResult, imagesInfo, stateName, {logger: imageDebug}); + }); + + await actions.onIdle(); + + newImagesInfos.push(_.omitBy(newImagesInfo, _.isNil) as ImageInfoFull); + })); + + await this.emitAsync(PluginEvents.TEST_SCREENSHOTS_SAVED, { + testId: mkTestId(testResult.fullName, testResult.browserId), + attempt: testResult.attempt, + imagesInfo: newImagesInfos + }); + + return copyAndUpdate(testResult, {imagesInfo: newImagesInfos}); + } + + setImageFileSaver(imageFileSaver: ImageFileSaver): void { + this._imageFileSaver = imageFileSaver; + } + + private async _createDiffInFile(imagesInfo: ImageInfoDiff, filePath: string, workers: RegisterWorkers<['saveDiffTo']>): Promise { + await makeDirFor(filePath); + + const actualPath = imagesInfo.actualImg.path; + const expectedPath = imagesInfo.expectedImg.path; + + const [currBuffer, refBuffer] = await Promise.all([ + fs.readFile(actualPath), + fs.readFile(expectedPath) + ]); + + const hash = createHash(currBuffer) + createHash(refBuffer); + + if (cacheDiffImages.has(hash)) { + const cachedDiffPath = cacheDiffImages.get(hash) as string; + + await fs.copy(cachedDiffPath, filePath); + } else { + await workers.saveDiffTo({ + ...imagesInfo.diffOptions, + reference: expectedPath, + current: actualPath + } satisfies DiffOptions, filePath); + + cacheDiffImages.set(hash, filePath); + } + + return {path: filePath, size: _.pick(sizeOf(filePath), ['height', 'width']) as ImageSize}; + } + + private _getReusedExpectedPath(testResult: TestSpecByPath, imagesInfo: ImageInfoFull): string | null { + if (imagesInfo.status === UPDATED) { + return null; + } + + const {stateName} = imagesInfo as ImageInfoDiff; + + if (this._expectedPathsCache.has([testResult, stateName])) { + return this._expectedPathsCache.get([testResult, stateName]) as string; + } + + const lastImageInfo = this._imageStore.getLastImageInfoFromDb(testResult, stateName) as ImageInfoDiff; + + if (lastImageInfo && lastImageInfo.expectedImg) { + this._expectedPathsCache.set([testResult, stateName], (lastImageInfo.expectedImg as ImageFile).path); + return (lastImageInfo.expectedImg as ImageFile).path; + } + + return null; + } + + private async _saveImage(imageData: ImageFile | ImageBuffer | ImageBase64, destPath: string): Promise { + const sourceFilePath = isImageBufferData(imageData) || isBase64Image(imageData) ? getTempPath(destPath) : imageData.path; + if (isImageBufferData(imageData)) { + await fs.writeFile(sourceFilePath, Buffer.from(imageData.buffer)); + } else if (isBase64Image(imageData)) { + await makeDirFor(sourceFilePath); + await fs.writeFile(sourceFilePath, Buffer.from(imageData.base64, 'base64'), 'base64'); + } + + const savedFilePath = await this._imageFileSaver.saveImg(sourceFilePath, { + destPath, + reportDir: this._reportPath + }); + + return savedFilePath || destPath; + } + + private async _saveActualImageIfNeeded(testResult: ReporterTestResult, imagesInfo: ImageInfoFull, stateName: string | undefined, {logger}: {logger: debug.Debugger}): Promise { + const actualImg = imagesInfo.actualImg; + if (!actualImg) { + return actualImg; + } + + const reportActualPath = getCurrentPath(testResult, stateName); + + const newActualPath = await this._saveImage(actualImg, reportActualPath); + logger(`Saved actual image from ${(actualImg as ImageFile).path ?? ''} to ${newActualPath}`); + + return {path: newActualPath, size: actualImg.size}; + } + + private async _saveDiffImageIfNeeded( + testResult: ReporterTestResult, + imagesInfo: ImageInfoFull, + stateName: string | undefined, + {workers, logger}: {workers?: RegisterWorkers<['saveDiffTo']>, logger: debug.Debugger} + ): Promise { + const shouldSaveDiff = imagesInfo.status === TestStatus.FAIL && + (imagesInfo.diffImg || (imagesInfo.actualImg && imagesInfo.expectedImg)); + if (!shouldSaveDiff) { + return; + } + let {diffImg} = imagesInfo; + const reportDiffPath = getDiffPath(testResult, stateName); + + if (!diffImg) { + if (!workers) { + throw new Error('Couldn\'t generate diff image, because workers were not passed.\n' + + util.format('Test result: %o\n', testResult) + + `Please report this error to html-reporter team: ${NEW_ISSUE_LINK}.`); + } + diffImg = await this._createDiffInFile(imagesInfo, reportDiffPath, workers); + logger(`Created new diff in file ${reportDiffPath}`); + } + + const newDiffPath = await this._saveImage(diffImg, reportDiffPath); + logger(`Saved diff image from ${(diffImg as ImageFile).path ?? ''} to ${newDiffPath}`); + + const size = _.pick(sizeOf(isImageBufferData(diffImg) ? Buffer.from(diffImg.buffer) : diffImg.path), ['height', 'width']) as ImageSize; + + return {path: newDiffPath, size}; + } + + private async _saveExpectedImageIfNeeded(testResult: ReporterTestResult, imagesInfo: ImageInfoFull, stateName: string | undefined, {logger}: {logger: debug.Debugger}): Promise { + if (!(imagesInfo as ImageInfoDiff).expectedImg) { + return; + } + const {expectedImg} = imagesInfo as ImageInfoDiff; + const reusedExpectedPath = this._getReusedExpectedPath(testResult, imagesInfo); + const reportDiffPath = reusedExpectedPath ?? getReferencePath(testResult, stateName); + + let newExpectedPath = reportDiffPath; + + if (!reusedExpectedPath) { + newExpectedPath = await this._saveImage(expectedImg, reportDiffPath); + logger(`Saved expected image from ${(expectedImg as ImageFile).path ?? ''} to ${newExpectedPath}`); + } else { + logger(`Reused expected image from ${reusedExpectedPath}`); + } + + return {path: newExpectedPath, size: expectedImg.size}; + } +} diff --git a/lib/local-images-saver.ts b/lib/local-image-file-saver.ts similarity index 67% rename from lib/local-images-saver.ts rename to lib/local-image-file-saver.ts index dea4dc890..205ede6f3 100644 --- a/lib/local-images-saver.ts +++ b/lib/local-image-file-saver.ts @@ -1,7 +1,7 @@ import {copyFileAsync} from './server-utils'; -import type {ImagesSaver} from './types'; +import type {ImageFileSaver} from './types'; -export const LocalImagesSaver: ImagesSaver = { +export const LocalImageFileSaver: ImageFileSaver = { saveImg: async (srcCurrPath, {destPath, reportDir}) => { await copyFileAsync(srcCurrPath, destPath, {reportDir}); diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index e23f56544..5176d8d7d 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -1,15 +1,15 @@ import EventsEmitter2 from 'eventemitter2'; import {PluginEvents, ToolName} from './constants'; import {downloadDatabases, getTestsTreeFromDatabase, mergeDatabases} from './db-utils/server'; -import {LocalImagesSaver} from './local-images-saver'; +import {LocalImageFileSaver} from './local-image-file-saver'; import {version} from '../package.json'; -import {ImagesSaver, ReporterConfig, ReportsSaver} from './types'; +import {ImageFileSaver, ReporterConfig, ReportsSaver} from './types'; export interface HtmlReporterValues { toolName: ToolName; extraItems: Record; metaInfoExtenders: Record; - imagesSaver: ImagesSaver; + imagesSaver: ImageFileSaver; reportsSaver: ReportsSaver | null; } @@ -41,7 +41,7 @@ export class HtmlReporter extends EventsEmitter2 { toolName: toolName ?? ToolName.Hermione, extraItems: {}, metaInfoExtenders: {}, - imagesSaver: LocalImagesSaver, + imagesSaver: LocalImageFileSaver, reportsSaver: null }; this._version = version; @@ -75,12 +75,12 @@ export class HtmlReporter extends EventsEmitter2 { return this._values.metaInfoExtenders; } - set imagesSaver(imagesSaver: ImagesSaver) { + set imagesSaver(imagesSaver: ImageFileSaver) { this.emit(PluginEvents.IMAGES_SAVER_UPDATED, imagesSaver); this._values.imagesSaver = imagesSaver; } - get imagesSaver(): ImagesSaver { + get imagesSaver(): ImageFileSaver { return this._values.imagesSaver; } diff --git a/lib/report-builder/gui.ts b/lib/report-builder/gui.ts index a491be61f..386c7936e 100644 --- a/lib/report-builder/gui.ts +++ b/lib/report-builder/gui.ts @@ -147,30 +147,31 @@ export class GuiReportBuilder extends StaticReportBuilder { this._skips.push({suite, browser, comment}); } - const formattedResultWithImagePaths = this._extendTestWithImagePaths(formattedResult); + const formattedResultWithImages = this._loadImagesFromPreviousAttempt(formattedResult); - this._testsTree.addTestResult(formattedResultWithImagePaths); + this._testsTree.addTestResult(formattedResultWithImages); - return formattedResultWithImagePaths; + return formattedResultWithImages; } - private _extendTestWithImagePaths(formattedResult: ReporterTestResult): ReporterTestResult { + private _loadImagesFromPreviousAttempt(formattedResult: ReporterTestResult): ReporterTestResult { if (formattedResult.status !== UPDATED) { return formattedResult; } - const failResultId = copyAndUpdate(formattedResult, {attempt: formattedResult.attempt - 1}).id; - const failImagesInfo = _.clone(this._testsTree.getImagesInfo(failResultId)) as ImageInfoFull[]; + const previousResultId = copyAndUpdate(formattedResult, {attempt: formattedResult.attempt - 1}).id; + const newImagesInfo = _.clone(this._testsTree.getImagesInfo(previousResultId)) as ImageInfoFull[]; - if (failImagesInfo.length) { + if (newImagesInfo.length) { formattedResult.imagesInfo?.forEach((imageInfo) => { const {stateName} = imageInfo as ImageInfoWithState; - let index = _.findIndex(failImagesInfo, {stateName}); - index = index >= 0 ? index : _.findLastIndex(failImagesInfo); - failImagesInfo[index] = imageInfo; + let index = _.findIndex(newImagesInfo, {stateName}); + index = index >= 0 ? index : _.findLastIndex(newImagesInfo); + + newImagesInfo[index] = imageInfo; }); } - return copyAndUpdate(formattedResult, {imagesInfo: failImagesInfo}); + return copyAndUpdate(formattedResult, {imagesInfo: newImagesInfo}); } } diff --git a/lib/report-builder/static.ts b/lib/report-builder/static.ts index 38843eceb..ea8d6b1e0 100644 --- a/lib/report-builder/static.ts +++ b/lib/report-builder/static.ts @@ -2,6 +2,7 @@ import path from 'path'; import {GeneralEventEmitter} from 'eventemitter2'; import _ from 'lodash'; import fs from 'fs-extra'; +import PQueue from 'p-queue'; import { IDLE, @@ -15,26 +16,26 @@ import {ReporterTestResult} from '../test-adapter'; import {saveErrorDetails, saveStaticFilesToReportDir, writeDatabaseUrlsFile} from '../server-utils'; import {ReporterConfig} from '../types'; import {HtmlReporter} from '../plugin-api'; -import {ImageHandler} from '../image-handler'; -import {SqliteImageStore} from '../image-store'; import {getTestFromDb} from '../db-utils/server'; import {TestAttemptManager} from '../test-attempt-manager'; import {copyAndUpdate} from '../test-adapter/utils'; import {RegisterWorkers} from '../workers/create-workers'; +import {ImagesInfoSaver} from '../images-info-saver'; const ignoredStatuses = [RUNNING, IDLE]; export interface StaticReportBuilderOptions { dbClient: SqliteClient; + imagesInfoSaver: ImagesInfoSaver; } export class StaticReportBuilder { protected _htmlReporter: HtmlReporter; protected _pluginConfig: ReporterConfig; protected _dbClient: SqliteClient; - protected _imageHandler: ImageHandler; + protected _imagesInfoSaver: ImagesInfoSaver; protected _testAttemptManager: TestAttemptManager; - private _workers: RegisterWorkers<['saveDiffTo']> | null; + private _workers?: RegisterWorkers<['saveDiffTo']>; static create( this: new (htmlReporter: HtmlReporter, pluginConfig: ReporterConfig, options: StaticReportBuilderOptions) => T, @@ -45,7 +46,7 @@ export class StaticReportBuilder { return new this(htmlReporter, pluginConfig, options); } - constructor(htmlReporter: HtmlReporter, pluginConfig: ReporterConfig, {dbClient}: StaticReportBuilderOptions) { + constructor(htmlReporter: HtmlReporter, pluginConfig: ReporterConfig, {dbClient, imagesInfoSaver}: StaticReportBuilderOptions) { this._htmlReporter = htmlReporter; this._pluginConfig = pluginConfig; @@ -53,20 +54,13 @@ export class StaticReportBuilder { this._testAttemptManager = new TestAttemptManager(); - const imageStore = new SqliteImageStore(this._dbClient); - this._imageHandler = new ImageHandler(imageStore, htmlReporter.imagesSaver, {reportPath: pluginConfig.path}); - - this._workers = null; + this._imagesInfoSaver = imagesInfoSaver; this._htmlReporter.on(PluginEvents.IMAGES_SAVER_UPDATED, (newImagesSaver) => { - this._imageHandler.setImagesSaver(newImagesSaver); + this._imagesInfoSaver.setImageFileSaver(newImagesSaver); }); - this._htmlReporter.listenTo(this._imageHandler as unknown as GeneralEventEmitter, [PluginEvents.TEST_SCREENSHOTS_SAVED]); - } - - get imageHandler(): ImageHandler { - return this._imageHandler; + this._htmlReporter.listenTo(this._imagesInfoSaver as unknown as GeneralEventEmitter, [PluginEvents.TEST_SCREENSHOTS_SAVED]); } async saveStaticFiles(): Promise { @@ -82,17 +76,8 @@ export class StaticReportBuilder { this._workers = workers; } - private _ensureWorkers(): RegisterWorkers<['saveDiffTo']> { - if (!this._workers) { - throw new Error('You must register workers before using report builder.' + - 'Make sure registerWorkers() was called before adding any test results.'); - } - - return this._workers; - } - /** If passed test result doesn't have attempt, this method registers new attempt and sets attempt number */ - private _provideAttempt(testResultOriginal: ReporterTestResult): ReporterTestResult { + provideAttempt(testResultOriginal: ReporterTestResult): ReporterTestResult { let formattedResult = testResultOriginal; if (testResultOriginal.attempt === UNKNOWN_ATTEMPT) { @@ -103,38 +88,41 @@ export class StaticReportBuilder { return formattedResult; } - private async _saveTestResultData(testResult: ReporterTestResult): Promise { + private async _saveTestResultData(testResult: ReporterTestResult): Promise { if ([IDLE, RUNNING, UPDATED].includes(testResult.status)) { - return; + return testResult; } - const actions: Promise[] = []; + const actions = new PQueue(); + let testResultWithImagePaths: ReporterTestResult = testResult; - if (!_.isEmpty(testResult.assertViewResults)) { - actions.push(this._imageHandler.saveTestImages(testResult, this._ensureWorkers())); - } + actions.add(async () => { + testResultWithImagePaths = await this._imagesInfoSaver.save(testResult, this._workers); + }); if (this._pluginConfig.saveErrorDetails && testResult.errorDetails) { - actions.push(saveErrorDetails(testResult, this._pluginConfig.path)); + actions.add(async () => saveErrorDetails(testResult, this._pluginConfig.path)); } - await Promise.all(actions); + await actions.onIdle(); + + return testResultWithImagePaths; } async addTestResult(formattedResultOriginal: ReporterTestResult): Promise { - const formattedResult = this._provideAttempt(formattedResultOriginal); + const formattedResult = this.provideAttempt(formattedResultOriginal); // Test result data has to be saved before writing to db, because user may save data to custom location - await this._saveTestResultData(formattedResult); + const testResultWithImagePaths = await this._saveTestResultData(formattedResult); // To prevent skips duplication on reporter startup - const isPreviouslySkippedTest = formattedResult.status === SKIPPED && getTestFromDb(this._dbClient, formattedResult); + const isPreviouslySkippedTest = testResultWithImagePaths.status === SKIPPED && getTestFromDb(this._dbClient, formattedResult); - if (!ignoredStatuses.includes(formattedResult.status) && !isPreviouslySkippedTest) { - this._dbClient.write(formattedResult); + if (!ignoredStatuses.includes(testResultWithImagePaths.status) && !isPreviouslySkippedTest) { + this._dbClient.write(testResultWithImagePaths); } - return formattedResult; + return testResultWithImagePaths; } protected _deleteTestResultFromDb(...args: Parameters): void { diff --git a/lib/reporter-helpers.ts b/lib/reporter-helpers.ts index f5d4ec2a2..4499e9491 100644 --- a/lib/reporter-helpers.ts +++ b/lib/reporter-helpers.ts @@ -1,39 +1,65 @@ -import * as path from 'path'; -import * as tmp from 'tmp'; -import {getShortMD5} from './common-utils'; +import path from 'path'; +import tmp from 'tmp'; +import _ from 'lodash'; +import {getShortMD5, isImageInfoWithState} from './common-utils'; import * as utils from './server-utils'; -import {ImageHandler} from './image-handler'; import {ReporterTestResult} from './test-adapter'; +import {getImagesInfoByStateName} from './server-utils'; +import {copyAndUpdate} from './test-adapter/utils'; +import {ImageInfoFull, ImageInfoUpdated} from './types'; +import {UPDATED} from './constants'; const mkReferenceHash = (testId: string, stateName: string): string => getShortMD5(`${testId}#${stateName}`); -export const updateReferenceImage = async (testResult: ReporterTestResult, reportPath: string, stateName: string): Promise => { - const currImg = ImageHandler.getCurrImg(testResult.assertViewResults, stateName); +type OnReferenceUpdateCb = (testResult: ReporterTestResult, images: ImageInfoUpdated, state: string) => void; - const src = currImg?.path - ? path.resolve(reportPath, currImg.path) - : utils.getCurrentAbsolutePath(testResult, reportPath, stateName); +export const updateReferenceImages = async (testResult: ReporterTestResult, reportPath: string, onReferenceUpdateCb: OnReferenceUpdateCb): Promise => { + const newImagesInfo: ImageInfoFull[] = await Promise.all(testResult.imagesInfo.map(async (imageInfo) => { + const newImageInfo = _.clone(imageInfo); - // TODO: get rid of type assertion - const referencePath = ImageHandler.getRefImg(testResult.assertViewResults, stateName)?.path as string; + if (!isImageInfoWithState(newImageInfo) || newImageInfo.status !== UPDATED) { + return newImageInfo; + } - if (utils.fileExists(referencePath)) { - const referenceId = mkReferenceHash(testResult.id, stateName); - const oldReferencePath = path.resolve(tmp.tmpdir, referenceId); - await utils.copyFileAsync(referencePath, oldReferencePath); - } + const {stateName} = newImageInfo; + + const {actualImg} = newImageInfo; + const src = actualImg?.path + ? path.resolve(reportPath, actualImg.path) + : utils.getCurrentAbsolutePath(testResult, reportPath, stateName); + + // TODO: get rid of type assertion + const referencePath = newImageInfo?.refImg?.path as string; + + if (utils.fileExists(referencePath)) { + const referenceId = mkReferenceHash(testResult.id, stateName); + const oldReferencePath = path.resolve(tmp.tmpdir, referenceId); + await utils.copyFileAsync(referencePath, oldReferencePath); + } + + const reportReferencePath = utils.getReferencePath(testResult, stateName); + + await Promise.all([ + utils.copyFileAsync(src, referencePath), + utils.copyFileAsync(src, path.resolve(reportPath, reportReferencePath)) + ]); + + const {expectedImg} = newImageInfo; + expectedImg.path = reportReferencePath; + + onReferenceUpdateCb(testResult, newImageInfo, stateName); + + return newImageInfo; + })); - return Promise.all([ - utils.copyFileAsync(src, referencePath), - utils.copyFileAsync(src, utils.getReferenceAbsolutePath(testResult, reportPath, stateName)) - ]); + return copyAndUpdate(testResult, {imagesInfo: newImagesInfo}); }; export const revertReferenceImage = async (removedResult: ReporterTestResult, newResult: ReporterTestResult, stateName: string): Promise => { const referenceId = removedResult.id; const referenceHash = mkReferenceHash(referenceId, stateName); const oldReferencePath = path.resolve(tmp.tmpdir, referenceHash); - const referencePath = ImageHandler.getRefImg(newResult.assertViewResults, stateName)?.path; + const referencePath = getImagesInfoByStateName(newResult.imagesInfo, stateName)?.refImg?.path; if (!referencePath) { return; @@ -43,7 +69,7 @@ export const revertReferenceImage = async (removedResult: ReporterTestResult, ne }; export const removeReferenceImage = async (testResult: ReporterTestResult, stateName: string): Promise => { - const imagePath = ImageHandler.getRefImg(testResult.assertViewResults, stateName)?.path; + const imagePath = getImagesInfoByStateName(testResult.imagesInfo, stateName)?.refImg?.path; if (!imagePath) { return; diff --git a/lib/server-utils.ts b/lib/server-utils.ts index 714e670ae..d2930090f 100644 --- a/lib/server-utils.ts +++ b/lib/server-utils.ts @@ -3,49 +3,58 @@ import url from 'url'; import chalk from 'chalk'; import _ from 'lodash'; import fs from 'fs-extra'; -import {logger} from './common-utils'; +import {getShortMD5, logger, mkTestId} from './common-utils'; import {UPDATED, RUNNING, IDLE, SKIPPED, IMAGES_PATH, TestStatus} from './constants'; import type {HtmlReporter} from './plugin-api'; import type {ReporterTestResult} from './test-adapter'; -import {CustomGuiItem, HermioneTestResult, ReporterConfig} from './types'; +import { + CustomGuiItem, + HermioneTestResult, + ImageInfoWithState, + ReporterConfig, + TestSpecByPath +} from './types'; import type Hermione from 'hermione'; import crypto from 'crypto'; -import {ImagesInfoFormatter} from './image-handler'; import {HermioneTestAdapter} from './test-adapter'; import {Router} from 'express'; +import tmp from 'tmp'; const DATA_FILE_NAME = 'data.js'; interface GetPathOptions { - stateName?: string; imageDir: string; attempt: number; browserId: string; } -export const getReferencePath = (options: GetPathOptions): string => createPath({kind: 'ref', ...options}); -export const getCurrentPath = (options: GetPathOptions): string => createPath({kind: 'current', ...options}); -export const getDiffPath = (options: GetPathOptions): string => createPath({kind: 'diff', ...options}); +export const getReferencePath = (options: GetPathOptions, stateName?: string): string => + createPath({kind: 'ref', stateName, ..._.pick(options, ['attempt', 'browserId', 'imageDir'])}); +export const getCurrentPath = (options: GetPathOptions, stateName?: string): string => + createPath({kind: 'current', stateName, ..._.pick(options, ['attempt', 'browserId', 'imageDir'])}); +export const getDiffPath = (options: GetPathOptions, stateName?: string): string => + createPath({kind: 'diff', stateName, ..._.pick(options, ['attempt', 'browserId', 'imageDir'])}); export const getReferenceAbsolutePath = (testResult: ReporterTestResult, reportDir: string, stateName: string): string => { - const referenceImagePath = getReferencePath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId, stateName}); + const referenceImagePath = getReferencePath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId}, stateName); return path.resolve(reportDir, referenceImagePath); }; export const getCurrentAbsolutePath = (testResult: ReporterTestResult, reportDir: string, stateName: string): string => { - const currentImagePath = getCurrentPath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId, stateName}); + const currentImagePath = getCurrentPath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId}, stateName); return path.resolve(reportDir, currentImagePath); }; export const getDiffAbsolutePath = (testResult: ReporterTestResult, reportDir: string, stateName: string): string => { - const diffImagePath = getDiffPath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId, stateName}); + const diffImagePath = getDiffPath({attempt: testResult.attempt, imageDir: testResult.imageDir, browserId: testResult.browserId}, stateName); return path.resolve(reportDir, diffImagePath); }; interface CreatePathOptions extends GetPathOptions { + stateName?: string; kind: string; } @@ -57,6 +66,8 @@ export function createPath({attempt: attemptInput, imageDir: imageDirInput, brow return path.join(...components); } +export const getTempPath = (destPath: string): string => path.resolve(tmp.tmpdir, destPath); + export function createHash(buffer: Buffer): string { return crypto .createHash('sha1') @@ -304,10 +315,9 @@ export function mapPlugins(plugins: ReporterConfig['plugins'], callback: (nam export const formatTestResult = ( rawResult: HermioneTestResult, status: TestStatus, - attempt: number, - {imageHandler}: {imageHandler: ImagesInfoFormatter} + attempt: number ): ReporterTestResult => { - return new HermioneTestAdapter(rawResult, {attempt, status, imagesInfoFormatter: imageHandler}); + return new HermioneTestAdapter(rawResult, {attempt, status}); }; export const saveErrorDetails = async (testResult: ReporterTestResult, reportPath: string): Promise => { @@ -323,3 +333,14 @@ export const saveErrorDetails = async (testResult: ReporterTestResult, reportPat await makeDirFor(detailsFilePath); await fs.writeFile(detailsFilePath, detailsData); }; + +export const getExpectedCacheKey = ([testResult, stateName]: [TestSpecByPath, string | undefined]): string => { + const shortTestId = getShortMD5(mkTestId(testResult.testPath.join(' '), testResult.browserId)); + + return shortTestId + '#' + stateName; +}; + +export const getImagesInfoByStateName = (imagesInfo: ReporterTestResult['imagesInfo'], stateName: string): ImageInfoWithState | undefined => { + return imagesInfo.find( + imagesInfo => (imagesInfo as ImageInfoWithState).stateName === stateName) as ImageInfoWithState | undefined; +}; diff --git a/lib/static/components/section/body/page-screenshot.tsx b/lib/static/components/section/body/page-screenshot.tsx index f2e87424b..fafc15537 100644 --- a/lib/static/components/section/body/page-screenshot.tsx +++ b/lib/static/components/section/body/page-screenshot.tsx @@ -1,10 +1,10 @@ import React, {Component} from 'react'; import Details from '../../details'; import ResizedScreenshot from '../../state/screenshot/resized'; -import {ImageData} from '../../../../types'; +import {ImageFile} from '../../../../types'; interface PageScreenshotProps { - image: ImageData; + image: ImageFile; } export class PageScreenshot extends Component { diff --git a/lib/test-adapter/playwright.ts b/lib/test-adapter/playwright.ts index be38db2f6..7074e8cd6 100644 --- a/lib/test-adapter/playwright.ts +++ b/lib/test-adapter/playwright.ts @@ -6,11 +6,18 @@ import stripAnsi from 'strip-ansi'; import {ReporterTestResult} from './index'; import {testsAttempts} from './cache/playwright'; -import {getShortMD5, isImageDiffError, isNoRefImageError, mkTestId} from '../common-utils'; -import {FAIL, PWT_TITLE_DELIMITER, TestStatus} from '../constants'; +import {getError, getShortMD5, isImageDiffError, isNoRefImageError, mkTestId} from '../common-utils'; +import {ERROR, FAIL, PWT_TITLE_DELIMITER, SUCCESS, TestStatus} from '../constants'; import {ErrorName} from '../errors'; -import {ImagesInfoFormatter} from '../image-handler'; -import {AssertViewResult, ErrorDetails, ImageData, ImageInfoFull, ImageSize, TestError} from '../types'; +import { + DiffOptions, + ErrorDetails, + ImageFile, + ImageInfoDiff, + ImageInfoFull, ImageInfoNoRef, ImageInfoPageError, ImageInfoPageSuccess, ImageInfoSuccess, + ImageSize, + TestError +} from '../types'; import * as utils from '../server-utils'; import type {CoordBounds} from 'looks-same'; @@ -39,6 +46,10 @@ export enum ImageTitleEnding { const ANY_IMAGE_ENDING_REGEXP = new RegExp(Object.values(ImageTitleEnding).map(ending => `${ending}$`).join('|')); +const DEFAULT_DIFF_OPTIONS = { + diffColor: '#ff00ff' +} satisfies Partial; + export const getStatus = (result: PlaywrightTestResult): TestStatus => { if (result.status === PwtTestStatus.PASSED) { return TestStatus.SUCCESS; @@ -108,7 +119,7 @@ const extractImageError = (result: PlaywrightTestResult, {state, expectedAttachm } : null; }; -const getImageData = (attachment: PlaywrightAttachment | undefined): ImageData | null => { +const getImageData = (attachment: PlaywrightAttachment | undefined): ImageFile | null => { if (!attachment) { return null; } @@ -119,20 +130,14 @@ const getImageData = (attachment: PlaywrightAttachment | undefined): ImageData | }; }; -export interface PlaywrightTestAdapterOptions { - imagesInfoFormatter: ImagesInfoFormatter; -} - export class PlaywrightTestAdapter implements ReporterTestResult { private readonly _testCase: PlaywrightTestCase; private readonly _testResult: PlaywrightTestResult; private _attempt: number; - private _imagesInfoFormatter: ImagesInfoFormatter; - constructor(testCase: PlaywrightTestCase, testResult: PlaywrightTestResult, {imagesInfoFormatter}: PlaywrightTestAdapterOptions) { + constructor(testCase: PlaywrightTestCase, testResult: PlaywrightTestResult) { this._testCase = testCase; this._testResult = testResult; - this._imagesInfoFormatter = imagesInfoFormatter; const testId = mkTestId(this.fullName, this.browserId); if (utils.shouldUpdateAttempt(this.status)) { @@ -142,44 +147,6 @@ export class PlaywrightTestAdapter implements ReporterTestResult { this._attempt = testsAttempts.get(testId) || 0; } - get assertViewResults(): AssertViewResult[] { - return Object.entries(this._attachmentsByState).map(([state, attachments]): AssertViewResult | null => { - const expectedAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Expected)); - const diffAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Diff)); - const actualAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Actual)); - - const [refImg, diffImg, currImg] = [expectedAttachment, diffAttachment, actualAttachment].map(getImageData); - - const error = extractImageError(this._testResult, {state, expectedAttachment, diffAttachment, actualAttachment}) || this.error; - - if (error?.name === ErrorName.IMAGE_DIFF && refImg && diffImg && currImg) { - return { - name: ErrorName.IMAGE_DIFF, - stateName: state, - refImg, - diffImg, - currImg, - diffClusters: _.get(error, 'diffClusters', []) - }; - } else if (error?.name === ErrorName.NO_REF_IMAGE && currImg) { - return { - name: ErrorName.NO_REF_IMAGE, - message: error.message, - stack: error.stack, - stateName: state, - currImg - }; - } else if (!error && refImg) { - return { - stateName: state, - refImg - }; - } - - return null; - }).filter(Boolean) as AssertViewResult[]; - } - get attempt(): number { return this._attempt; } @@ -237,8 +204,57 @@ export class PlaywrightTestAdapter implements ReporterTestResult { return getShortMD5(this.fullName); } - get imagesInfo(): ImageInfoFull[] | undefined { - return this._imagesInfoFormatter.getImagesInfo(this); + get imagesInfo(): ImageInfoFull[] { + const imagesInfo = Object.entries(this._attachmentsByState).map(([state, attachments]): ImageInfoFull | null => { + const expectedAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Expected)); + const diffAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Diff)); + const actualAttachment = attachments.find(a => a.name?.endsWith(ImageTitleEnding.Actual)); + + const [refImg, diffImg, actualImg] = [expectedAttachment, diffAttachment, actualAttachment].map(getImageData); + + const error = extractImageError(this._testResult, {state, expectedAttachment, diffAttachment, actualAttachment}) || this.error; + + if (error?.name === ErrorName.IMAGE_DIFF && refImg && diffImg && actualImg) { + return { + status: FAIL, + stateName: state, + refImg, + diffImg, + actualImg, + expectedImg: refImg, + diffClusters: _.get(error, 'diffClusters', []), + // TODO: extract diffOptions from config + diffOptions: {current: actualImg.path, reference: refImg.path, ...DEFAULT_DIFF_OPTIONS} + } satisfies ImageInfoDiff; + } else if (error?.name === ErrorName.NO_REF_IMAGE && refImg && actualImg) { + return { + status: ERROR, + stateName: state, + error: _.pick(error, ['message', 'name', 'stack']), + refImg, + actualImg + } satisfies ImageInfoNoRef; + } else if (!error && refImg) { + return { + status: SUCCESS, + stateName: state, + refImg, + expectedImg: refImg, + ...(actualImg ? {actualImg} : {}) + } satisfies ImageInfoSuccess; + } + + return null; + }).filter((value): value is ImageInfoFull => value !== null); + + if (this.screenshot) { + imagesInfo.push({ + status: _.isEmpty(getError(this.error)) ? SUCCESS : ERROR, + actualImg: this.screenshot + } satisfies ImageInfoPageSuccess | ImageInfoPageError as ImageInfoPageSuccess | ImageInfoPageError); + } + + return imagesInfo; } get meta(): Record { @@ -249,7 +265,7 @@ export class PlaywrightTestAdapter implements ReporterTestResult { return true; } - get screenshot(): ImageData | null { + get screenshot(): ImageFile | null { const pageScreenshot = this._testResult.attachments.find(a => a.contentType === 'image/png' && a.name === 'screenshot'); return getImageData(pageScreenshot); diff --git a/lib/test-adapter/reporter.ts b/lib/test-adapter/reporter.ts index 22a36bb22..bcd0ac593 100644 --- a/lib/test-adapter/reporter.ts +++ b/lib/test-adapter/reporter.ts @@ -1,9 +1,9 @@ import {TestStatus} from '../constants'; -import {AssertViewResult, TestError, ErrorDetails, ImageInfoFull, ImageBase64, ImageData} from '../types'; +import {TestError, ErrorDetails, ImageInfoFull, ImageBase64, ImageFile} from '../types'; import {ReporterTestResult} from './index'; import _ from 'lodash'; -import {extractErrorDetails, getTestHash} from './utils'; -import {getShortMD5} from '../common-utils'; +import {extractErrorDetails} from './utils'; +import {getShortMD5, getTestHash} from '../common-utils'; // This class is primarily useful when cloning ReporterTestResult. // It allows to override some properties while keeping computable @@ -18,10 +18,6 @@ export class ReporterTestAdapter implements ReporterTestResult { this._errorDetails = null; } - get assertViewResults(): AssertViewResult[] { - return this._testResult.assertViewResults; - } - get attempt(): number { return this._testResult.attempt; } @@ -68,7 +64,7 @@ export class ReporterTestAdapter implements ReporterTestResult { return getShortMD5(this.fullName); } - get imagesInfo(): ImageInfoFull[] | undefined { + get imagesInfo(): ImageInfoFull[] { return this._testResult.imagesInfo; } @@ -80,7 +76,7 @@ export class ReporterTestAdapter implements ReporterTestResult { return this._testResult.multipleTabs; } - get screenshot(): ImageBase64 | ImageData | null | undefined { + get screenshot(): ImageBase64 | ImageFile | null | undefined { return this.error?.screenshot; } diff --git a/lib/test-adapter/sqlite.ts b/lib/test-adapter/sqlite.ts index 5093d5ccd..9c156d642 100644 --- a/lib/test-adapter/sqlite.ts +++ b/lib/test-adapter/sqlite.ts @@ -6,12 +6,12 @@ import { ErrorDetails, ImageInfoFull, ImageBase64, - ImageData, + ImageFile, RawSuitesRow } from '../types'; import {ReporterTestResult} from './index'; import {Writable} from 'type-fest'; -import {getTestHash} from './utils'; +import {getTestHash} from '../common-utils'; const tryParseJson = (json: string): unknown | undefined => { try { @@ -100,7 +100,7 @@ export class SqliteTestAdapter implements ReporterTestResult { return getTestHash(this); } - get imagesInfo(): ImageInfoFull[] | undefined { + get imagesInfo(): ImageInfoFull[] { if (!_.has(this._parsedTestResult, 'imagesInfo')) { this._parsedTestResult.imagesInfo = tryParseJson(this._testResult[DB_COLUMN_INDEXES.imagesInfo]) as ImageInfoFull[]; } @@ -120,7 +120,7 @@ export class SqliteTestAdapter implements ReporterTestResult { return Boolean(this._testResult[DB_COLUMN_INDEXES.multipleTabs]); } - get screenshot(): ImageBase64 | ImageData | null | undefined { + get screenshot(): ImageBase64 | ImageFile | null | undefined { return this.error?.screenshot; } diff --git a/lib/test-adapter/utils/index.ts b/lib/test-adapter/utils/index.ts index 09a1e1d79..2ccd1476d 100644 --- a/lib/test-adapter/utils/index.ts +++ b/lib/test-adapter/utils/index.ts @@ -1,17 +1,16 @@ import _ from 'lodash'; import {ReporterTestResult} from '../index'; import {TupleToUnion} from 'type-fest'; -import {ErrorDetails} from '../../types'; +import {ErrorDetails, ImageInfoDiff, ImageInfoFull} from '../../types'; import {ERROR_DETAILS_PATH} from '../../constants'; import {ReporterTestAdapter} from '../reporter'; -import {getDetailsFileName} from '../../common-utils'; +import {getDetailsFileName, isImageBufferData} from '../../common-utils'; export const copyAndUpdate = ( original: ReporterTestResult, updates: Partial ): ReporterTestResult => { const keys = [ - 'assertViewResults', 'attempt', 'browserId', 'description', @@ -64,6 +63,13 @@ export const extractErrorDetails = (testResult: ReporterTestResult): ErrorDetail return null; }; -export const getTestHash = (testResult: ReporterTestResult): string => { - return testResult.testPath.concat(testResult.browserId, testResult.attempt.toString()).join(' '); +export const removeBufferFromImagesInfo = (imagesInfo: ImageInfoFull): ImageInfoFull => { + const {diffImg} = imagesInfo as ImageInfoDiff; + const newImagesInfo = _.clone(imagesInfo); + + if (isImageBufferData(diffImg)) { + (newImagesInfo as ImageInfoDiff).diffImg = {...diffImg, buffer: Buffer.from('')}; + } + + return newImagesInfo; }; diff --git a/lib/tests-tree-builder/gui.ts b/lib/tests-tree-builder/gui.ts index 3336a8fbd..b94f7f300 100644 --- a/lib/tests-tree-builder/gui.ts +++ b/lib/tests-tree-builder/gui.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import {BaseTestsTreeBuilder, Tree, TreeImage, TreeTestResult, TreeSuite} from './base'; import {TestStatus, UPDATED} from '../constants'; import {isUpdatedStatus} from '../common-utils'; -import {ImageInfoFail, ImageInfoWithState} from '../types'; +import {ImageFile, ImageInfoWithState} from '../types'; interface SuiteBranch { id: string; @@ -22,8 +22,8 @@ export interface TestRefUpdateData { state: {name: string}; metaInfo: TreeTestResult['metaInfo']; imagesInfo: { - stateName: ImageInfoWithState['stateName']; - actualImg: ImageInfoWithState['actualImg']; + stateName: string; + actualImg: ImageFile; status: TestStatus; }[]; attempt: number; @@ -81,10 +81,10 @@ export class GuiTestsTreeBuilder extends BaseTestsTreeBuilder { const suite = this._tree.suites.byId[browser.parentId]; const imagesInfo = imagesByResultId[resultId] - .filter(treeImage => (treeImage as ImageInfoFail).stateName) - .map((treeImage) => ({ - stateName: (treeImage as ImageInfoWithState).stateName, - actualImg: treeImage.actualImg, + .filter(treeImage => (treeImage as ImageInfoWithState).stateName) + .map((treeImage) => ({ + stateName: (treeImage as ImageInfoWithState).stateName as string, + actualImg: treeImage.actualImg as ImageFile, status: UPDATED })); diff --git a/lib/types.ts b/lib/types.ts index d129f0ec9..5097baca0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -15,7 +15,7 @@ export interface HermioneTestResult extends HermioneTestResultOriginal { timestamp?: number; } -export interface ImagesSaver { +export interface ImageFileSaver { saveImg: (localFilePath: string, options: {destPath: string; reportDir: string}) => string | Promise; } @@ -34,11 +34,15 @@ export interface ImageSize { height: number; } -export interface ImageData { +export interface ImageFile { path: string; size: ImageSize; } +export interface ImageBuffer { + buffer: Buffer; +} + export interface ImageBase64 { base64: string; size: ImageSize @@ -50,63 +54,81 @@ export interface DiffOptions extends LooksSameOptions { diffColor: string; } -export interface ImageInfoFail { +export interface TestError { + name: string; + message: string; + stack?: string; + stateName?: string; + details?: ErrorDetails + screenshot?: ImageBase64 | ImageFile +} + +export interface ImageInfoDiff { status: TestStatus.FAIL; stateName: string; - refImg?: ImageData; + refImg: ImageFile; diffClusters?: CoordBounds[]; - expectedImg: ImageData; - actualImg: ImageData; - diffImg: ImageData; + expectedImg: ImageFile; + actualImg: ImageFile; + diffImg?: ImageFile | ImageBuffer; + diffOptions: DiffOptions; } interface AssertViewSuccess { stateName: string; - refImg: ImageData; + refImg: ImageFile; } export interface ImageInfoSuccess { - status: TestStatus.SUCCESS | TestStatus.UPDATED; + status: TestStatus.SUCCESS; stateName: string; - refImg?: ImageData; + refImg?: ImageFile; diffClusters?: CoordBounds[]; - expectedImg: ImageData; - actualImg?: ImageData; + expectedImg: ImageFile; + actualImg?: ImageFile; } export interface ImageInfoPageSuccess { status: TestStatus.SUCCESS; - actualImg: ImageData; + actualImg: ImageFile | ImageBase64; } -export interface ImageInfoError { +export interface ImageInfoPageError { status: TestStatus.ERROR; - error?: {message: string; stack: string;} - stateName?: string; - refImg?: ImageData; - diffClusters?: CoordBounds[]; - actualImg: ImageData; + actualImg: ImageFile | ImageBase64; } -export type ImageInfoWithState = ImageInfoFail | ImageInfoSuccess | ImageInfoError; +export interface ImageInfoNoRef { + status: TestStatus.ERROR; + error?: TestError; + stateName: string; + refImg: ImageFile; + actualImg: ImageFile; +} + +export interface ImageInfoUpdated { + status: TestStatus.UPDATED; + stateName: string; + refImg: ImageFile; + actualImg: ImageFile; + expectedImg: ImageFile; +} + +export type ImageInfoWithState = ImageInfoDiff | ImageInfoSuccess | ImageInfoNoRef | ImageInfoUpdated; -export type ImageInfoFull = ImageInfoFail | ImageInfoSuccess | ImageInfoError | ImageInfoPageSuccess; +export type ImageInfoFull = ImageInfoWithState | ImageInfoPageSuccess | ImageInfoPageError; export type ImageInfo = - | Omit + | Omit | Omit - | Omit + | Omit | Omit; export type AssertViewResult = (AssertViewSuccess | ImageDiffError | NoRefImageError) & {isUpdated?: boolean}; -export interface TestError { - name: string; - message: string; - stack?: string; - stateName?: string; - details?: ErrorDetails - screenshot?: ImageBase64 | ImageData +export interface TestSpecByPath { + testPath: string[]; + browserId: string; } export interface HtmlReporterApi { diff --git a/lib/workers/worker.ts b/lib/workers/worker.ts index 5476c4bb4..3a27c1ff2 100644 --- a/lib/workers/worker.ts +++ b/lib/workers/worker.ts @@ -1,8 +1,8 @@ import looksSame from 'looks-same'; -import type {ImageDiffError} from '../errors'; +import {DiffOptions} from '../types'; -export function saveDiffTo(imageDiffError: ImageDiffError, diffPath: string): Promise { - const {diffColor: highlightColor, ...otherOpts} = imageDiffError.diffOpts; +export function saveDiffTo(diffOpts: DiffOptions, diffPath: string): Promise { + const {diffColor: highlightColor, ...otherOpts} = diffOpts; return looksSame.createDiff({diff: diffPath, highlightColor, ...otherOpts}); } diff --git a/playwright.ts b/playwright.ts index c0056a248..93454caa7 100644 --- a/playwright.ts +++ b/playwright.ts @@ -8,12 +8,16 @@ import type {Reporter, TestCase, TestResult as PwtTestResult} from '@playwright/ import {StaticReportBuilder} from './lib/report-builder/static'; import {HtmlReporter} from './lib/plugin-api'; -import {ReporterConfig} from './lib/types'; +import {ReporterConfig, TestSpecByPath} from './lib/types'; import {parseConfig} from './lib/config'; import {PluginEvents, ToolName} from './lib/constants'; import {RegisterWorkers} from './lib/workers/create-workers'; import {PlaywrightTestAdapter} from './lib/test-adapter/playwright'; import {SqliteClient} from './lib/sqlite-client'; +import {SqliteImageStore} from './lib/image-store'; +import {ImagesInfoSaver} from './lib/images-info-saver'; +import {Cache} from './lib/cache'; +import {getExpectedCacheKey} from './lib/server-utils'; export {ReporterConfig} from './lib/types'; @@ -39,8 +43,17 @@ class MyReporter implements Reporter { this._initPromise = (async (htmlReporter: HtmlReporter, config: ReporterConfig): Promise => { const dbClient = await SqliteClient.create({htmlReporter, reportPath: config.path}); + const imageStore = new SqliteImageStore(dbClient); + const expectedPathsCache = new Cache<[TestSpecByPath, string | undefined], string>(getExpectedCacheKey); - this._staticReportBuilder = StaticReportBuilder.create(htmlReporter, config, {dbClient}); + const imagesInfoSaver = new ImagesInfoSaver({ + imageFileSaver: htmlReporter.imagesSaver, + expectedPathsCache, + imageStore, + reportPath: htmlReporter.config.path + }); + + this._staticReportBuilder = StaticReportBuilder.create(htmlReporter, config, {dbClient, imagesInfoSaver}); this._staticReportBuilder.registerWorkers(workers); await this._staticReportBuilder.saveStaticFiles(); @@ -55,7 +68,7 @@ class MyReporter implements Reporter { const staticReportBuilder = this._staticReportBuilder as StaticReportBuilder; - const formattedResult = new PlaywrightTestAdapter(test, result, {imagesInfoFormatter: staticReportBuilder.imageHandler}); + const formattedResult = new PlaywrightTestAdapter(test, result); await staticReportBuilder.addTestResult(formattedResult); }); diff --git a/test/unit/hermione.js b/test/unit/hermione.js index 6dd21fdbf..f418baf25 100644 --- a/test/unit/hermione.js +++ b/test/unit/hermione.js @@ -33,9 +33,8 @@ describe('lib/hermione', () => { 'better-sqlite3': sinon.stub().returns(mkSqliteDb()) }); - const {ImageHandler} = proxyquire('lib/image-handler', { + const {ImagesInfoSaver} = proxyquire('lib/images-info-saver', { 'fs-extra': fs, - './image-cache': {cacheExpectedPaths, cacheAllImages, cacheDiffImages}, './server-utils': utils }); @@ -48,11 +47,11 @@ describe('lib/hermione', () => { 'fs-extra': fs, '../server-utils': utils, '../test-adapter': {TestAdapter}, - '../image-handler': {ImageHandler} + '../images-info-saver': {ImagesInfoSaver} }); const HtmlReporter = proxyquire('lib/plugin-api', { - './local-images-saver': proxyquire('lib/local-images-saver', { + './local-image-file-saver': proxyquire('lib/local-image-file-saver', { './server-utils': utils }) }).HtmlReporter; diff --git a/test/unit/lib/image-handler.ts b/test/unit/lib/image-handler.ts deleted file mode 100644 index 20f45687b..000000000 --- a/test/unit/lib/image-handler.ts +++ /dev/null @@ -1,441 +0,0 @@ -import * as fsOriginal from 'fs-extra'; -import _ from 'lodash'; -import proxyquire from 'proxyquire'; -import sinon, {SinonStubbedInstance} from 'sinon'; -import type tmpOriginal from 'tmp'; - -import type * as originalUtils from 'lib/server-utils'; -import {logger} from 'lib/common-utils'; -import {ImageHandler as ImageHandlerOriginal} from 'lib/image-handler'; -import {RegisterWorkers} from 'lib/workers/create-workers'; -import {AssertViewResult, ImageInfoFail, ImageInfoFull, ImageInfoSuccess, ImagesSaver} from 'lib/types'; -import {ErrorName, ImageDiffError} from 'lib/errors'; -import {ImageStore} from 'lib/image-store'; -import {FAIL, PluginEvents, SUCCESS, UPDATED} from 'lib/constants'; -import {ReporterTestResult} from 'lib/test-adapter'; - -describe('image-handler', function() { - const sandbox = sinon.sandbox.create(); - let fs: sinon.SinonStubbedInstance; - let utils: sinon.SinonStubbedInstance; - let tmp: typeof tmpOriginal; - let err: AssertViewResult; - let ImageHandler: typeof ImageHandlerOriginal; - const cacheExpectedPaths = new Map(), - cacheAllImages = new Map(), - cacheDiffImages = new Map(); - - class ImageDiffErrorStub extends Error { - name = ErrorName.IMAGE_DIFF; - } - class NoRefImageErrorStub extends Error { - name = ErrorName.NO_REF_IMAGE; - } - - const mkImageStore = (): SinonStubbedInstance => ({getLastImageInfoFromDb: sinon.stub()} as SinonStubbedInstance); - - const mkImagesSaver = (): SinonStubbedInstance => ({saveImg: sinon.stub()} as SinonStubbedInstance); - - const mkTestResult = (result: Partial): ReporterTestResult => _.defaults(result, { - id: 'some-id', - attempt: 0, - fullName: 'default-title' - }) as ReporterTestResult; - - const mkErrStub = (ErrType: typeof ImageDiffErrorStub | typeof NoRefImageErrorStub = ImageDiffErrorStub, {stateName, currImg, refImg, diffBuffer}: Partial = {}): AssertViewResult => { - const err: AssertViewResult = new ErrType() as any; - - err.stateName = stateName || 'plain'; - (err as ImageDiffError).currImg = currImg || {path: 'curr/path'} as any; - err.refImg = refImg || {path: 'ref/path'} as any; - (err as ImageDiffError).diffBuffer = diffBuffer; - - return err; - }; - - const mkWorker = (): sinon.SinonStubbedInstance> => { - return {saveDiffTo: sandbox.stub()} as any; - }; - - beforeEach(() => { - fs = sinon.stub(_.clone(fsOriginal)); - err = mkErrStub(); - tmp = {tmpdir: 'default/dir'} as any; - - const originalUtils = proxyquire('lib/server-utils', { - 'fs-extra': fs - }); - utils = _.clone(originalUtils); - - ImageHandler = proxyquire('lib/image-handler', { - tmp, - 'fs-extra': fs, - './server-utils': utils, - './image-cache': {cacheExpectedPaths, cacheAllImages, cacheDiffImages} - }).ImageHandler; - - sandbox.stub(utils, 'getCurrentPath').returns(''); - sandbox.stub(utils, 'getDiffPath').returns(''); - sandbox.stub(utils, 'getReferencePath').returns(''); - - fs.readFile.resolves(Buffer.from('')); - fs.writeFile.resolves(); - fs.copy.resolves(); - }); - - afterEach(() => { - sandbox.restore(); - - cacheExpectedPaths.clear(); - cacheAllImages.clear(); - cacheDiffImages.clear(); - }); - - describe('saveTestImages', () => { - it('should build diff to tmp dir', async () => { - (tmp as any).tmpdir = 'tmp/dir'; - const testResult = mkTestResult({ - assertViewResults: [err] - }); - utils.getDiffPath.returns('diff/report/path'); - - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: 'some-dir'}); - const worker = mkWorker(); - await imageHandler.saveTestImages(testResult, worker); - - assert.calledOnceWith(worker.saveDiffTo, err, sinon.match('tmp/dir/diff/report/path')); - }); - - it('should save diff in report from tmp dir using external storage', async () => { - (tmp as any).tmpdir = 'tmp/dir'; - const testResult = mkTestResult({ - assertViewResults: [err] - }); - utils.getDiffPath.returns('diff/report/path'); - const imagesSaver = mkImagesSaver(); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'html-report/path'}); - const worker = mkWorker(); - await imageHandler.saveTestImages(testResult, worker); - - assert.calledWith( - imagesSaver.saveImg, - sinon.match('tmp/dir/diff/report/path'), - {destPath: 'diff/report/path', reportDir: 'html-report/path'} - ); - }); - - it('should emit TEST_SCREENSHOTS_SAVED event', async () => { - (tmp as any).tmpdir = 'tmp/dir'; - const testResult = mkTestResult({ - browserId: 'chrome', - assertViewResults: [err] - }); - utils.getDiffPath.returns('diff/report/path'); - - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - sinon.stub(imageHandler, 'getImagesInfo').returns([{test: 123}]); - const worker = mkWorker(); - - const screenshotsSavedHandler = sinon.stub(); - imageHandler.on(PluginEvents.TEST_SCREENSHOTS_SAVED, screenshotsSavedHandler); - - await imageHandler.saveTestImages(testResult, worker); - - assert.calledOnceWith(screenshotsSavedHandler, { - attempt: 0, - testId: 'default-title.chrome', - imagesInfo: [{test: 123}] - }); - }); - - describe('saving error screenshot', () => { - beforeEach(() => { - sandbox.stub(logger, 'warn'); - sandbox.stub(utils, 'makeDirFor').resolves(); - sandbox.stub(utils, 'copyFileAsync'); - }); - - describe('if screenshot on reject does not exist', () => { - it('should not save screenshot', () => { - const testResult = mkTestResult({ - error: {screenshot: {base64: null}} as any, - assertViewResults: [] - }); - const hermioneTestAdapter = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - - return hermioneTestAdapter.saveTestImages(testResult, mkWorker()) - .then(() => assert.notCalled(fs.writeFile)); - }); - - it('should warn about it', () => { - const testResult = mkTestResult({ - screenshot: {base64: null} as any, - assertViewResults: [] - }); - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - - return imageHandler.saveTestImages(testResult, mkWorker()) - .then(() => assert.calledWith(logger.warn as sinon.SinonStub, 'Cannot save screenshot on reject')); - }); - }); - - it('should create directory for screenshot', () => { - const testResult = mkTestResult({ - screenshot: {base64: 'base64-data'} as any, - assertViewResults: [] - }); - utils.getCurrentPath.returns('dest/path'); - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - - return imageHandler.saveTestImages(testResult, mkWorker()) - .then(() => assert.calledOnceWith(utils.makeDirFor, sinon.match('dest/path'))); - }); - - it('should save screenshot from base64 format', async () => { - const testResult = mkTestResult({ - screenshot: {base64: 'base64-data'} as any, - assertViewResults: [] - }); - utils.getCurrentPath.returns('dest/path'); - const bufData = new Buffer('base64-data', 'base64'); - const imagesSaver = mkImagesSaver(); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'report/path'}); - - await imageHandler.saveTestImages(testResult, mkWorker()); - - assert.calledOnceWith(fs.writeFile, sinon.match('dest/path'), bufData, 'base64'); - assert.calledWith(imagesSaver.saveImg, sinon.match('dest/path'), {destPath: 'dest/path', reportDir: 'report/path'}); - }); - }); - - describe('saving reference image', () => { - it('should save reference, if it is not reused', async () => { - (tmp as any).tmpdir = 'tmp/dir'; - const testResult = mkTestResult({assertViewResults: [err]}); - utils.getReferencePath.returns('ref/report/path'); - const imagesSaver = mkImagesSaver(); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'html-report/path'}); - - await imageHandler.saveTestImages(testResult, mkWorker()); - - assert.calledWith( - imagesSaver.saveImg, 'ref/path', - {destPath: 'ref/report/path', reportDir: 'html-report/path'} - ); - }); - - it('should not save reference, if it is reused', async () => { - (tmp as any).tmpdir = 'tmp/dir'; - const error = mkErrStub(ImageDiffErrorStub, {stateName: 'plain'}); - const testResult = mkTestResult({assertViewResults: [error], browserId: 'browser-id'}); - utils.getReferencePath.returns('ref/report/path'); - const imagesSaver = mkImagesSaver(); - cacheExpectedPaths.set('da89771#plain', 'ref/report/path'); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'html-report/path'}); - - await imageHandler.saveTestImages(testResult, mkWorker()); - - assert.neverCalledWith( - imagesSaver.saveImg, 'ref/path', - {destPath: 'ref/report/path', reportDir: 'html-report/path'} - ); - }); - - it('should save png buffer, if it is passed', async () => { - const error = mkErrStub(ImageDiffErrorStub, {stateName: 'plain', diffBuffer: 'foo' as any}); - const testResult = mkTestResult({assertViewResults: [error]}); - utils.getDiffPath.returns('diff/report/path'); - - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - const workers = {saveDiffTo: sandbox.stub()}; - await imageHandler.saveTestImages(testResult, mkWorker()); - - assert.calledOnceWith(fs.writeFile, sinon.match('diff/report/path'), Buffer.from('foo')); - assert.notCalled(workers.saveDiffTo); - }); - }); - }); - - ([ - {field: 'refImg', method: 'getRefImg'}, - {field: 'currImg', method: 'getCurrImg'} - ] as const).forEach(({field, method}) => { - describe(`${method}`, () => { - it(`should return ${field} from test result`, () => { - const testResult = mkTestResult({assertViewResults: [ - {[field]: 'some-value', stateName: 'plain'} as any]}); - - assert.equal((ImageHandler[method])(testResult.assertViewResults, 'plain'), 'some-value' as any); - }); - }); - }); - - describe('getScreenshot', () => { - it('should return error screenshot from test result', () => { - const testResult = mkTestResult({screenshot: 'some-value'} as any); - - assert.equal(ImageHandler.getScreenshot(testResult), 'some-value' as any); - }); - }); - - describe('getImagesInfo', () => { - beforeEach(() => { - sandbox.stub(utils, 'copyFileAsync'); - utils.getReferencePath.returns('some/ref.png'); - }); - - it('should return diffClusters', () => { - const testResult = mkTestResult({ - assertViewResults: [{diffClusters: [{left: 0, top: 0, right: 1, bottom: 1}]}] as any - }); - const imageHandler = new ImageHandler(mkImageStore(), mkImagesSaver(), {reportPath: ''}); - - const [{diffClusters}] = imageHandler.getImagesInfo(testResult) as ImageInfoFail[]; - - assert.deepEqual(diffClusters, [{left: 0, top: 0, right: 1, bottom: 1}]); - }); - - it('should return saved images', async () => { - const testResult = mkTestResult({ - assertViewResults: [mkErrStub()], - status: SUCCESS - }); - - const imagesSaver = mkImagesSaver(); - imagesSaver.saveImg.withArgs( - 'ref/path', - {destPath: 'some/ref.png', reportDir: 'some/rep'} - ).returns('saved/ref.png'); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'some/rep'}); - const workers = mkWorker(); - - await imageHandler.saveTestImages(testResult, workers); - - const {expectedImg} = imageHandler.getImagesFor(testResult, SUCCESS, 'plain') as ImageInfoSuccess; - assert.equal(expectedImg.path, 'saved/ref.png'); - }); - - it('should return dest image path by default', async () => { - const testResult = mkTestResult({ - assertViewResults: [mkErrStub()], - status: SUCCESS - }); - - const imagesSaver = mkImagesSaver(); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'some/rep'}); - const workers = mkWorker(); - - await imageHandler.saveTestImages(testResult, workers); - - const {expectedImg} = imageHandler.getImagesFor(testResult, SUCCESS, 'plain') as ImageInfoSuccess; - assert.equal(expectedImg.path, 'some/ref.png'); - }); - - it('should return ref image path after update image for NoRefImageError', async () => { - const testResult = mkTestResult({ - assertViewResults: [mkErrStub(NoRefImageErrorStub)], - status: UPDATED - }); - - const imagesSaver = mkImagesSaver(); - const imageHandler = new ImageHandler(mkImageStore(), imagesSaver, {reportPath: 'some/rep'}); - const workers = mkWorker(); - - await imageHandler.saveTestImages(testResult, workers); - - const {expectedImg} = imageHandler.getImagesFor(testResult, UPDATED, 'plain') as ImageInfoSuccess; - assert.equal(expectedImg.path, 'some/ref.png'); - }); - - describe('expected path', () => { - const mkLastImageInfo_ = (opts = {}): ImageInfoFull => { - const {stateName, expectedImgPath} = _.defaults(opts, { - stateName: 'plain', - expectedImgPath: 'default/expected/img/path.png' - }); - - return { - stateName, - expectedImg: { - path: expectedImgPath - } - } as any; - }; - - it('should be pulled from the store if exists', async () => { - const testResult = mkTestResult({ - fullName: 'some-title', - assertViewResults: [mkErrStub()] - }); - const imageStore = mkImageStore(); - imageStore.getLastImageInfoFromDb.withArgs(testResult, 'plain').returns(mkLastImageInfo_()); - - const imageHandler = new ImageHandler(imageStore, mkImagesSaver(), {reportPath: ''}); - - imageHandler.getImagesFor(testResult, FAIL, 'plain'); - - assert.notCalled(utils.getReferencePath); - }); - - it('should be generated if does not exist in store', async () => { - const testResult = mkTestResult({ - fullName: 'some-title', - assertViewResults: [mkErrStub()] - }); - const imageStore = mkImageStore(); - imageStore.getLastImageInfoFromDb.withArgs(testResult, 'plain').returns(undefined); - - const imageHandler = new ImageHandler(imageStore, mkImagesSaver(), {reportPath: ''}); - - imageHandler.getImagesFor(testResult, FAIL, 'plain'); - - assert.calledOnce(utils.getReferencePath); - }); - - it('should be generated on update', async () => { - const testResult = mkTestResult({ - assertViewResults: [mkErrStub()], - fullName: 'some-title', - status: UPDATED - }); - const imageStore = mkImageStore(); - imageStore.getLastImageInfoFromDb.withArgs(testResult, 'plain').returns(mkLastImageInfo_()); - const imageHandler = new ImageHandler(imageStore, mkImagesSaver(), {reportPath: ''}); - - imageHandler.getImagesFor(testResult, UPDATED, 'plain'); - - assert.calledOnce(utils.getReferencePath); - }); - - it('should be queried from the database for each browser', async () => { - const chromeTestResult = mkTestResult({browserId: 'chrome'}); - const firefoxTestResult = mkTestResult({browserId: 'firefox'}); - - const imageStore = mkImageStore(); - const imageHandler = new ImageHandler(imageStore, mkImagesSaver(), {reportPath: ''}); - - imageHandler.getImagesFor(chromeTestResult, FAIL, 'plain'); - imageHandler.getImagesFor(firefoxTestResult, FAIL, 'plain'); - - assert.calledTwice(imageStore.getLastImageInfoFromDb); - assert.calledWith(imageStore.getLastImageInfoFromDb.firstCall, chromeTestResult, 'plain'); - assert.calledWith(imageStore.getLastImageInfoFromDb.secondCall, firefoxTestResult, 'plain'); - }); - - it('should be queried from the database once per state', async () => { - const testResult = mkTestResult({ - fullName: 'some-title', - assertViewResults: [mkErrStub()] - }); - const imageStore = mkImageStore(); - imageStore.getLastImageInfoFromDb.returns(mkLastImageInfo_()); - const imageHandler = new ImageHandler(imageStore, mkImagesSaver(), {reportPath: ''}); - - imageHandler.getImagesFor(testResult, FAIL, 'plain'); - imageHandler.getImagesFor(testResult, FAIL, 'plain'); - - assert.calledOnce(imageStore.getLastImageInfoFromDb); - }); - }); - }); -}); diff --git a/test/unit/lib/images-info-saver.ts b/test/unit/lib/images-info-saver.ts new file mode 100644 index 000000000..5d4bb3001 --- /dev/null +++ b/test/unit/lib/images-info-saver.ts @@ -0,0 +1,265 @@ +import * as fsOriginal from 'fs-extra'; +import {ImagesInfoSaver as ImagesInfoSaverOriginal} from 'lib/images-info-saver'; +import {Writable} from 'type-fest'; +import {ReporterTestResult} from 'lib/test-adapter'; +import { + ImageBase64, + ImageBuffer, + ImageFile, + ImageInfoDiff, + ImageInfoNoRef, + ImageInfoSuccess, + TestSpecByPath +} from 'lib/types'; +import sinon from 'sinon'; +import {LocalImageFileSaver} from 'lib/local-image-file-saver'; +import {SqliteImageStore} from 'lib/image-store'; +import {Cache} from 'lib/cache'; +import {PluginEvents, TestStatus} from 'lib/constants'; +import proxyquire from 'proxyquire'; +import _ from 'lodash'; +import {RegisterWorkers} from 'lib/workers/create-workers'; + +describe('images-info-saver', () => { + const sandbox = sinon.sandbox.create(); + + describe('save', () => { + const fs = _.clone(fsOriginal); + + const originalUtils = proxyquire('lib/server-utils', { + 'fs-extra': fs + }); + const utils = _.clone(originalUtils); + + const {ImagesInfoSaver} = proxyquire('lib/images-info-saver', { + 'fs-extra': fs, + './server-utils': utils, + 'image-size': sinon.stub() + }); + + let imagesInfoSaver: ImagesInfoSaverOriginal; + let imageFileSaver: sinon.SinonStubbedInstance; + let imageStore: sinon.SinonStubbedInstance; + let expectedPathsCache: sinon.SinonStubbedInstance>; + let reportPath: string; + + beforeEach(() => { + sandbox.stub(fs, 'readFile'); + sandbox.stub(fs, 'copy'); + sandbox.stub(fs, 'writeFile'); + + sandbox.stub(utils, 'makeDirFor').resolves(); + sandbox.stub(utils, 'getCurrentPath').returns('report-current-path'); + sandbox.stub(utils, 'getDiffPath').returns('report-diff-path'); + sandbox.stub(utils, 'getReferencePath').returns('report-expected-path'); + sandbox.stub(utils, 'getTempPath').returns('temp-path'); + + reportPath = 'test-report-path'; + imageFileSaver = { + saveImg: sinon.stub() + }; + imageStore = sinon.createStubInstance(SqliteImageStore); + expectedPathsCache = sinon.createStubInstance(Cache); + + imagesInfoSaver = new ImagesInfoSaver({ + imageFileSaver, + reportPath, + imageStore, + expectedPathsCache: expectedPathsCache as any + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('actual images', () => { + it('should save and update path', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const sourcePath = 'path/to/actual-image.png'; + const destPath = 'path/to/saved-actual-image.png'; + + const actualImg: ImageFile = {path: sourcePath, size: {width: 100, height: 100}}; + const imagesInfo = [{actualImg} as ImageInfoNoRef]; + + imageFileSaver.saveImg.resolves(destPath); + testResult.imagesInfo = imagesInfo; + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + const savedActualImg = updatedTestResult.imagesInfo[0].actualImg as ImageFile; + assert.calledWith(imageFileSaver.saveImg, sourcePath, sinon.match({reportDir: reportPath, destPath: 'report-current-path'})); + assert.equal(savedActualImg.path, destPath); + }); + + it('should not fail if it\'s not available', async () => { + const testResult: Writable = {} as ReporterTestResult; + + testResult.imagesInfo = [{} as ImageInfoNoRef]; + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + assert.notCalled(imageFileSaver.saveImg); + assert.isUndefined(updatedTestResult.imagesInfo[0].actualImg); + }); + + it('should save base64 page screenshots', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const actualImg: ImageBase64 = {base64: 'base64string', size: {width: 100, height: 100}}; + testResult.imagesInfo = [{status: TestStatus.SUCCESS, actualImg}]; + + imageFileSaver.saveImg.resolves('path/to/saved-base64-image.png'); + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + const savedActualImg = updatedTestResult.imagesInfo[0].actualImg as ImageFile; + assert.calledWith(imageFileSaver.saveImg, 'temp-path'); + assert.equal(savedActualImg.path, 'path/to/saved-base64-image.png'); + }); + }); + + describe('diff images', () => { + it('should generate diff in worker if needed', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const actualImg = {path: 'actual-path'} as ImageFile; + const expectedImg = {path: 'expected-path'} as ImageFile; + const imagesInfo = {status: TestStatus.FAIL, actualImg, expectedImg} as ImageInfoDiff; + testResult.imagesInfo = [imagesInfo]; + + const saveDiffToStub = sinon.stub().resolves(); + const workers = {saveDiffTo: saveDiffToStub} as unknown as RegisterWorkers<['saveDiffTo']>; + + sandbox.stub(utils, 'createHash').returns('123'); + + const updatedTestResult = await imagesInfoSaver.save(testResult, workers); + + const savedDiffImg = (updatedTestResult.imagesInfo[0] as ImageInfoDiff).diffImg as ImageFile; + assert.calledWith(saveDiffToStub, sinon.match({ + reference: expectedImg.path, + current: actualImg.path + }), 'report-diff-path'); + assert.calledWith(imageFileSaver.saveImg, 'report-diff-path', {reportDir: reportPath, destPath: 'report-diff-path'}); + assert.equal(savedDiffImg.path, 'report-diff-path'); + }); + + it('should do nothing unless needed', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const imagesInfo = {status: TestStatus.SUCCESS} as ImageInfoSuccess; + testResult.imagesInfo = [imagesInfo]; + + const saveDiffToStub = sinon.stub().resolves(); + const workers = {saveDiffTo: saveDiffToStub} as unknown as RegisterWorkers<['saveDiffTo']>; + + await imagesInfoSaver.save(testResult, workers); + + assert.notCalled(saveDiffToStub); + assert.notCalled(imageFileSaver.saveImg); + }); + + it('should save and update path when diff image path is provided', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const diffImg = {path: 'diff-path'} as ImageFile; + const imagesInfo = {status: TestStatus.FAIL, diffImg} as ImageInfoDiff; + testResult.imagesInfo = [imagesInfo]; + + const saveDiffToStub = sinon.stub().resolves(); + const workers = {saveDiffTo: saveDiffToStub} as unknown as RegisterWorkers<['saveDiffTo']>; + + sandbox.stub(utils, 'createHash').returns('123'); + + const updatedTestResult = await imagesInfoSaver.save(testResult, workers); + + const savedDiffImg = (updatedTestResult.imagesInfo[0] as ImageInfoDiff).diffImg as ImageFile; + assert.notCalled(saveDiffToStub); + assert.calledWith(imageFileSaver.saveImg, 'diff-path', {reportDir: reportPath, destPath: 'report-diff-path'}); + assert.equal(savedDiffImg.path, 'report-diff-path'); + }); + + it('should work fine with buffer', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const diffImg = {buffer: Buffer.from('')} as ImageBuffer; + const imagesInfo = {status: TestStatus.FAIL, diffImg} as ImageInfoDiff; + testResult.imagesInfo = [imagesInfo]; + + const saveDiffToStub = sinon.stub().resolves(); + const workers = {saveDiffTo: saveDiffToStub} as unknown as RegisterWorkers<['saveDiffTo']>; + + sandbox.stub(utils, 'createHash').returns('123'); + + const updatedTestResult = await imagesInfoSaver.save(testResult, workers); + + const savedDiffImg = (updatedTestResult.imagesInfo[0] as ImageInfoDiff).diffImg as ImageFile; + assert.notCalled(saveDiffToStub); + assert.calledWith(imageFileSaver.saveImg, 'temp-path', {reportDir: reportPath, destPath: 'report-diff-path'}); + assert.equal(savedDiffImg.path, 'report-diff-path'); + }); + }); + + describe('expected images', () => { + it('should save and update path', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const expectedImg = {path: 'expected-path'} as ImageFile; + const imagesInfo = {status: TestStatus.FAIL, expectedImg} as ImageInfoDiff; + testResult.imagesInfo = [imagesInfo]; + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + const savedExpectedImg = (updatedTestResult.imagesInfo[0] as ImageInfoDiff).expectedImg as ImageFile; + assert.calledWith(imageFileSaver.saveImg, 'expected-path', {reportDir: reportPath, destPath: 'report-expected-path'}); + assert.equal(savedExpectedImg.path, 'report-expected-path'); + }); + it('should reuse previous images from cache', async () => { + const testResult: Writable = {} as ReporterTestResult; + + const expectedImg = {path: 'expected-path'} as ImageFile; + const imagesInfo = {status: TestStatus.FAIL, expectedImg} as ImageInfoDiff; + testResult.imagesInfo = [imagesInfo]; + + expectedPathsCache.has.returns(true); + expectedPathsCache.get.returns('cached-expected-path'); + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + const savedExpectedImg = (updatedTestResult.imagesInfo[0] as ImageInfoDiff).expectedImg as ImageFile; + assert.notCalled(imageFileSaver.saveImg); + assert.equal(savedExpectedImg.path, 'cached-expected-path'); + }); + }); + + it('should emit TEST_SCREENSHOTS_SAVED event', async () => { + const testResult: Writable = { + fullName: 'some-name', + browserId: 'some-browser', + attempt: 0 + } as ReporterTestResult; + + const sourcePath = 'path/to/actual-image.png'; + const destPath = 'path/to/saved-actual-image.png'; + + const actualImg: ImageFile = {path: sourcePath, size: {width: 100, height: 100}}; + const imagesInfo = [{actualImg} as ImageInfoNoRef]; + + imageFileSaver.saveImg.resolves(destPath); + testResult.imagesInfo = imagesInfo; + + const eventHandler = sinon.stub(); + imagesInfoSaver.on(PluginEvents.TEST_SCREENSHOTS_SAVED, eventHandler); + + const updatedTestResult = await imagesInfoSaver.save(testResult); + + assert.calledWith(imageFileSaver.saveImg, sourcePath, sinon.match({reportDir: reportPath, destPath: 'report-current-path'})); + assert.calledWith(eventHandler, sinon.match({ + testId: 'some-name.some-browser', + attempt: 0, + imagesInfo: updatedTestResult.imagesInfo + })); + }); + }); +}); diff --git a/test/unit/lib/report-builder/gui.js b/test/unit/lib/report-builder/gui.js index a269c6c71..0ac3487a2 100644 --- a/test/unit/lib/report-builder/gui.js +++ b/test/unit/lib/report-builder/gui.js @@ -12,7 +12,7 @@ const {SUCCESS, FAIL, ERROR, SKIPPED, IDLE, RUNNING, UPDATED} = require('lib/con const {LOCAL_DATABASE_NAME} = require('lib/constants/database'); const {TestAttemptManager} = require('lib/test-attempt-manager'); const {ImageDiffError} = require('../../utils'); -const {ImageHandler} = require('lib/image-handler'); +const {ImagesInfoSaver} = require('lib/images-info-saver'); const TEST_REPORT_PATH = 'test'; const TEST_DB_PATH = `${TEST_REPORT_PATH}/${LOCAL_DATABASE_NAME}`; @@ -85,7 +85,7 @@ describe('GuiReportBuilder', () => { copyAndUpdate = sandbox.stub().callsFake(_.identity); - const imageHandler = sandbox.createStubInstance(ImageHandler); + const imagesInfoSaver = sandbox.createStubInstance(ImagesInfoSaver); hasImage = sandbox.stub().returns(true); deleteFile = sandbox.stub().resolves(); @@ -94,7 +94,7 @@ describe('GuiReportBuilder', () => { StaticReportBuilder: proxyquire('lib/report-builder/static', { '../sqlite-client': {SqliteClient}, '../image-handler': {ImageHandler: function() { - return imageHandler; + return imagesInfoSaver; }} }).StaticReportBuilder }, diff --git a/test/unit/lib/report-builder/static.js b/test/unit/lib/report-builder/static.js index 081dd5c10..49960e3ee 100644 --- a/test/unit/lib/report-builder/static.js +++ b/test/unit/lib/report-builder/static.js @@ -27,13 +27,12 @@ describe('StaticReportBuilder', () => { }); const utils = _.clone(originalUtils); - const {ImageHandler} = proxyquire('lib/image-handler', { + const {ImagesInfoSaver} = proxyquire('lib/images-info-saver', { 'fs-extra': fs, - './image-cache': {cacheExpectedPaths, cacheAllImages, cacheDiffImages}, './server-utils': utils }); - const {LocalImagesSaver} = proxyquire('lib/local-images-saver', { + const {LocalImagesSaver} = proxyquire('lib/local-image-file-saver.ts', { './server-utils': utils }); @@ -73,7 +72,7 @@ describe('StaticReportBuilder', () => { StaticReportBuilder = proxyquire('lib/report-builder/static', { 'fs-extra': fs, '../server-utils': utils, - '../image-handler': {ImageHandler} + '../images-info-saver': {ImagesInfoSaver} }).StaticReportBuilder; }); diff --git a/test/unit/workers/worker.js b/test/unit/workers/worker.js index d47407821..7dc27dbae 100644 --- a/test/unit/workers/worker.js +++ b/test/unit/workers/worker.js @@ -20,17 +20,13 @@ describe('worker', () => { }); it('should pass diffColor option as a highlightColor option to looks-same', async () => { - await worker.saveDiffTo({ - diffOpts: {diffColor: '#foobar'} - }); + await worker.saveDiffTo({diffColor: '#foobar'}); assert.calledWith(looksSame.createDiff, sinon.match({highlightColor: '#foobar'})); }); it('should pass through other diff options to looks-same', async () => { - await worker.saveDiffTo({ - diffOpts: {foo: 'bar', baz: 'qux'} - }); + await worker.saveDiffTo({foo: 'bar', baz: 'qux'}); assert.calledWith(looksSame.createDiff, sinon.match({foo: 'bar', baz: 'qux'})); });