From c9dcfdbfb653e957f8ce6ed1c3988d9b93433598 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Tue, 2 Jun 2026 20:25:57 +0200 Subject: [PATCH 1/4] refactor: remove resemble engine and make pixelmatch the sole comparison engine --- .../src/__snapshots__/base.test.ts.snap | 1 - .../__snapshots__/options.test.ts.snap | 2 - .../src/helpers/constants.ts | 1 - .../src/helpers/options.interfaces.ts | 16 - .../src/helpers/options.ts | 17 +- .../src/methods/compareReport.interfaces.ts | 2 +- .../src/methods/createCompareReport.test.ts | 2 +- .../images.executeImageCompare.test.ts | 88 +- .../src/methods/images.interfaces.ts | 4 - .../src/methods/images.ts | 8 +- .../src/methods/processDiffPixels.ts | 2 +- .../src/methods/rectangles.interfaces.ts | 2 +- .../src/methods/rectangles.ts | 6 +- .../src/pixelmatch/compare.interfaces.ts | 43 + .../src/pixelmatch/compareImages.ts | 8 +- .../src/resemble/compare.interfaces.ts | 152 --- .../src/resemble/compareImages.d.ts | 12 - .../src/resemble/compareImages.ts | 28 - .../src/resemble/resemble.jimp.cjs | 1183 ----------------- packages/image-comparison-core/tsconfig.json | 3 +- 20 files changed, 77 insertions(+), 1503 deletions(-) create mode 100644 packages/image-comparison-core/src/pixelmatch/compare.interfaces.ts delete mode 100644 packages/image-comparison-core/src/resemble/compare.interfaces.ts delete mode 100644 packages/image-comparison-core/src/resemble/compareImages.d.ts delete mode 100644 packages/image-comparison-core/src/resemble/compareImages.ts delete mode 100644 packages/image-comparison-core/src/resemble/resemble.jimp.cjs diff --git a/packages/image-comparison-core/src/__snapshots__/base.test.ts.snap b/packages/image-comparison-core/src/__snapshots__/base.test.ts.snap index 5adbfa7c9..9c059396c 100644 --- a/packages/image-comparison-core/src/__snapshots__/base.test.ts.snap +++ b/packages/image-comparison-core/src/__snapshots__/base.test.ts.snap @@ -12,7 +12,6 @@ exports[`BaseClass > initializes default options correctly 1`] = ` "blockOutSideBar": true, "blockOutStatusBar": true, "blockOutToolBar": true, - "compareEngine": "resemble", "createJsonReportFiles": false, "diffPixelBoundingBoxProximity": 5, "ignoreAlpha": false, diff --git a/packages/image-comparison-core/src/helpers/__snapshots__/options.test.ts.snap b/packages/image-comparison-core/src/helpers/__snapshots__/options.test.ts.snap index 8264bd4de..c23771b92 100644 --- a/packages/image-comparison-core/src/helpers/__snapshots__/options.test.ts.snap +++ b/packages/image-comparison-core/src/helpers/__snapshots__/options.test.ts.snap @@ -88,7 +88,6 @@ exports[`options > defaultOptions > should return the default options when no op "blockOutSideBar": true, "blockOutStatusBar": true, "blockOutToolBar": true, - "compareEngine": "resemble", "createJsonReportFiles": false, "diffPixelBoundingBoxProximity": 5, "ignoreAlpha": false, @@ -144,7 +143,6 @@ exports[`options > defaultOptions > should return the provided options when opti "blockOutSideBar": true, "blockOutStatusBar": true, "blockOutToolBar": true, - "compareEngine": "resemble", "createJsonReportFiles": true, "diffPixelBoundingBoxProximity": 123, "ignoreAlpha": true, diff --git a/packages/image-comparison-core/src/helpers/constants.ts b/packages/image-comparison-core/src/helpers/constants.ts index 0390a5265..5776b1db7 100644 --- a/packages/image-comparison-core/src/helpers/constants.ts +++ b/packages/image-comparison-core/src/helpers/constants.ts @@ -7,7 +7,6 @@ export const DEFAULT_COMPARE_OPTIONS = { blockOutSideBar: true, blockOutStatusBar: true, blockOutToolBar: true, - compareEngine: 'resemble' as const, createJsonReportFiles: false, diffPixelBoundingBoxProximity: 5, ignoreAlpha: false, diff --git a/packages/image-comparison-core/src/helpers/options.interfaces.ts b/packages/image-comparison-core/src/helpers/options.interfaces.ts index 31c90d8b0..6d4394001 100644 --- a/packages/image-comparison-core/src/helpers/options.interfaces.ts +++ b/packages/image-comparison-core/src/helpers/options.interfaces.ts @@ -205,15 +205,6 @@ export interface ClassOptions { */ scaleImagesToSameSize?: boolean; - /** - * The image comparison engine to use. - * 'pixelmatch' is a beta alternative that uses the YIQ color space and is - * more robust against font rendering noise and anti-aliasing differences. - * Prefer setting this inside compareOptions. - * @default 'resemble' - */ - compareEngine?: 'resemble' | 'pixelmatch'; - /** * Options object passed to the underlying image comparison engine. */ @@ -491,12 +482,5 @@ export interface CompareOptions { * Scale images to the same size before comparing them. */ scaleImagesToSameSize: boolean; - /** - * The image comparison engine to use. - * 'pixelmatch' is a beta alternative that uses the YIQ color space and is - * more robust against font rendering noise and anti-aliasing differences. - * @default 'resemble' - */ - compareEngine?: 'resemble' | 'pixelmatch'; } diff --git a/packages/image-comparison-core/src/helpers/options.ts b/packages/image-comparison-core/src/helpers/options.ts index d80dc1f0e..83cf20f6f 100644 --- a/packages/image-comparison-core/src/helpers/options.ts +++ b/packages/image-comparison-core/src/helpers/options.ts @@ -11,7 +11,7 @@ import type { MethodImageCompareCompareOptions, ScreenMethodImageCompareCompareO import type { BeforeScreenshotOptions, BeforeScreenshotResult } from './beforeScreenshot.interfaces.js' import type { AfterScreenshotOptions } from './afterScreenshot.interfaces.js' import type { InstanceData } from '../methods/instanceData.interfaces.js' -import type { ComparisonIgnoreOption } from '../resemble/compare.interfaces.js' +import type { ComparisonIgnoreOption } from '../pixelmatch/compare.interfaces.js' import { logAllDeprecatedCompareOptions, isStorybook, @@ -58,17 +58,9 @@ export function defaultOptions(options: ClassOptions): DefaultOptions { waitForFontsLoaded: options.waitForFontsLoaded ?? true, alwaysSaveActualImage: options.alwaysSaveActualImage ?? true, - /** - * Compare options (merged sequentially): - * 1. Default options (fallback) - * 2. Root compareOptions (deprecated but supported) - * 3. Root-level compareEngine shorthand (convenience alias) - * 4. User-provided compareOptions (highest precedence) - */ compareOptions: { ...DEFAULT_COMPARE_OPTIONS, ...logAllDeprecatedCompareOptions(options), - ...(options.compareEngine ? { compareEngine: options.compareEngine } : {}), ...options.compareOptions, }, @@ -272,13 +264,10 @@ export function buildAfterScreenshotOptions({ return afterOptions } -/** - * Prepare ignore options for resemble.js comparison - */ export function prepareIgnoreOptions(imageCompareOptions: MethodImageCompareCompareOptions): ComparisonIgnoreOption[] { - const resembleIgnoreDefaults: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing'] + const ignoreDefaults: ComparisonIgnoreOption[] = ['alpha', 'antialiasing', 'colors', 'less', 'nothing'] - return resembleIgnoreDefaults.filter((option) => + return ignoreDefaults.filter((option) => Object.keys(imageCompareOptions).find( (key: keyof typeof imageCompareOptions) => key.toLowerCase().includes(option) && imageCompareOptions[key], ), diff --git a/packages/image-comparison-core/src/methods/compareReport.interfaces.ts b/packages/image-comparison-core/src/methods/compareReport.interfaces.ts index 28659c7b9..30f92f8ae 100644 --- a/packages/image-comparison-core/src/methods/compareReport.interfaces.ts +++ b/packages/image-comparison-core/src/methods/compareReport.interfaces.ts @@ -1,4 +1,4 @@ -import type { CompareData } from 'src/resemble/compare.interfaces.js' +import type { CompareData } from 'src/pixelmatch/compare.interfaces.js' import type { WicImageCompareOptions } from './images.interfaces.js' import type { BoundingBoxes, ReportFileSizes } from './rectangles.interfaces.js' import type { FilePaths, FolderPaths } from 'src/base.interfaces.js' diff --git a/packages/image-comparison-core/src/methods/createCompareReport.test.ts b/packages/image-comparison-core/src/methods/createCompareReport.test.ts index 438a8e1d7..9e7ab5803 100644 --- a/packages/image-comparison-core/src/methods/createCompareReport.test.ts +++ b/packages/image-comparison-core/src/methods/createCompareReport.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { writeFileSync, readFileSync } from 'node:fs' import { createCompareReport, createJsonReportIfNeeded } from './createCompareReport.js' -import type { CompareData } from '../resemble/compare.interfaces.js' +import type { CompareData } from '../pixelmatch/compare.interfaces.js' import type { BoundingBox, IgnoreBoxes } from './rectangles.interfaces.js' import type { BaseDimensions } from '../base.interfaces.js' import { getBase64ScreenshotSize } from '../helpers/utils.js' diff --git a/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts b/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts index 51f28b3ec..02f83f63f 100644 --- a/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts +++ b/packages/image-comparison-core/src/methods/images.executeImageCompare.test.ts @@ -6,7 +6,6 @@ import * as utils from '../helpers/utils.js' import * as rectangles from './rectangles.js' import * as processDiffPixels from './processDiffPixels.js' import * as createCompareReport from './createCompareReport.js' -import * as compareImages from '../resemble/compareImages.js' import * as compareImagesPixelmatch from '../pixelmatch/compareImages.js' const log = logger('test') @@ -80,9 +79,6 @@ vi.mock('./createCompareReport.js', () => ({ createCompareReport: vi.fn(), createJsonReportIfNeeded: vi.fn() })) -vi.mock('../resemble/compareImages.js', () => ({ - default: vi.fn() -})) vi.mock('../pixelmatch/compareImages.js', () => ({ default: vi.fn() })) @@ -169,28 +165,6 @@ describe('executeImageCompare', () => { beforeEach(async () => { vi.clearAllMocks() - const jimp = await import('jimp') - const jimpReadMock = vi.mocked(jimp.Jimp.read) - const mockImage = { - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,mock-image-data'), - opacity: vi.fn().mockReturnThis(), - width: 100, - height: 200, - bitmap: { width: 100, height: 200 }, - background: 0, - formats: [], - inspect: vi.fn().mockReturnValue('MockImage'), - toString: vi.fn().mockReturnValue('MockImage'), - scanIterator: vi.fn(), - scan: vi.fn(), - scanQuiet: vi.fn(), - scanIteratorQuiet: vi.fn(), - scanQuietIterator: vi.fn(), - scanQuietIteratorQuiet: vi.fn(), - } as any - jimpReadMock.mockResolvedValue(mockImage) - vi.mocked(fsPromises.access).mockResolvedValue(undefined) vi.mocked(fsPromises.unlink).mockResolvedValue(undefined) vi.mocked(fsPromises.mkdir).mockResolvedValue(undefined) @@ -229,7 +203,6 @@ describe('executeImageCompare', () => { analysisTime: 100, diffPixels: [] } - vi.mocked(compareImages.default).mockResolvedValue(mockCompareData) vi.mocked(compareImagesPixelmatch.default).mockResolvedValue(mockCompareData) vi.mocked(images.checkBaselineImageExists).mockResolvedValue(undefined) vi.mocked(images.removeDiffImageIfExists).mockResolvedValue(undefined) @@ -264,7 +237,7 @@ describe('executeImageCompare', () => { savePerInstance: false, fileName: 'test.png' }) - expect(compareImages.default).toHaveBeenCalledWith( + expect(compareImagesPixelmatch.default).toHaveBeenCalledWith( Buffer.from('mock-image-data'), Buffer.from('mock-image-data'), { @@ -274,29 +247,6 @@ describe('executeImageCompare', () => { ) }) - it('should use the pixelmatch adapter when compareEngine is pixelmatch', async () => { - const pixelmatchOptions = { - ...mockOptions, - compareOptions: { - ...mockOptions.compareOptions, - wic: { - ...mockOptions.compareOptions.wic, - compareEngine: 'pixelmatch' as const - } - } - } - - await executeImageCompare({ - isViewPortScreenshot: true, - isNativeContext: false, - options: pixelmatchOptions, - testContext: mockTestContext - }) - - expect(compareImagesPixelmatch.default).toHaveBeenCalledTimes(1) - expect(compareImages.default).not.toHaveBeenCalled() - }) - it('should handle mobile context with status/address/toolbar rectangles', async () => { const mobileOptions = { ...mockOptions, @@ -483,7 +433,7 @@ describe('executeImageCompare', () => { } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -585,7 +535,7 @@ describe('executeImageCompare', () => { } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.123456, misMatchPercentage: 0.12, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -627,7 +577,7 @@ describe('executeImageCompare', () => { autoSaveBaseline: false, } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0, misMatchPercentage: 0, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -645,7 +595,7 @@ describe('executeImageCompare', () => { }) expect(images.saveBase64Image).not.toHaveBeenCalled() - expect(compareImages.default).toHaveBeenCalledWith( + expect(compareImagesPixelmatch.default).toHaveBeenCalledWith( expect.any(Buffer), Buffer.from(base64Image, 'base64'), expect.any(Object), @@ -702,7 +652,7 @@ describe('executeImageCompare', () => { return undefined }) - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0, misMatchPercentage: 0, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -740,7 +690,7 @@ describe('executeImageCompare', () => { }, }, } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -777,7 +727,7 @@ describe('executeImageCompare', () => { }, }, } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -813,7 +763,7 @@ describe('executeImageCompare', () => { }, }, } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.05, misMatchPercentage: 0.05, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -849,7 +799,7 @@ describe('executeImageCompare', () => { }, }, } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.2, misMatchPercentage: 0.2, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -879,7 +829,7 @@ describe('executeImageCompare', () => { alwaysSaveActualImage: false, } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0, misMatchPercentage: 0, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -959,7 +909,7 @@ describe('executeImageCompare', () => { testContext: mockTestContext }) - expect(compareImages.default).toHaveBeenCalledWith( + expect(compareImagesPixelmatch.default).toHaveBeenCalledWith( expect.any(Buffer), expect.any(Buffer), { @@ -1008,7 +958,7 @@ describe('executeImageCompare', () => { testContext: mockTestContext }) - expect(compareImages.default).toHaveBeenCalledWith( + expect(compareImagesPixelmatch.default).toHaveBeenCalledWith( expect.any(Buffer), expect.any(Buffer), { @@ -1041,7 +991,7 @@ describe('executeImageCompare', () => { testContext: mockTestContext }) - expect(compareImages.default).toHaveBeenCalledWith( + expect(compareImagesPixelmatch.default).toHaveBeenCalledWith( expect.any(Buffer), expect.any(Buffer), { @@ -1086,7 +1036,7 @@ describe('executeImageCompare', () => { } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -1133,7 +1083,7 @@ describe('executeImageCompare', () => { } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -1179,7 +1129,7 @@ describe('executeImageCompare', () => { } } - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0.5, misMatchPercentage: 0.5, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -1218,7 +1168,7 @@ describe('executeImageCompare', () => { return Promise.resolve() }) - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0, misMatchPercentage: 0, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), @@ -1261,7 +1211,7 @@ describe('executeImageCompare', () => { vi.mocked(images.checkBaselineImageExists).mockImplementation(checkBaselineImageExists) - vi.mocked(compareImages.default).mockResolvedValue({ + vi.mocked(compareImagesPixelmatch.default).mockResolvedValue({ rawMisMatchPercentage: 0, misMatchPercentage: 0, getBuffer: vi.fn().mockResolvedValue(Buffer.from('diff-image-data')), diff --git a/packages/image-comparison-core/src/methods/images.interfaces.ts b/packages/image-comparison-core/src/methods/images.interfaces.ts index cfa2ecdc8..2e6e6f35c 100644 --- a/packages/image-comparison-core/src/methods/images.interfaces.ts +++ b/packages/image-comparison-core/src/methods/images.interfaces.ts @@ -60,10 +60,6 @@ export interface WicImageCompareOptions extends BaseImageCompareOptions, BaseMob * the higher the number the more pixels will be grouped, the lower the number the less pixels will be grouped due to accuracy. * Default is 5 pixels */ diffPixelBoundingBoxProximity: number; - /** The image comparison engine to use. - * @default 'resemble' - */ - compareEngine?: 'resemble' | 'pixelmatch'; } export interface DefaultImageCompareCompareOptions extends MethodImageCompareCompareOptions { diff --git a/packages/image-comparison-core/src/methods/images.ts b/packages/image-comparison-core/src/methods/images.ts index 9b736efc6..dfe0003c0 100644 --- a/packages/image-comparison-core/src/methods/images.ts +++ b/packages/image-comparison-core/src/methods/images.ts @@ -3,7 +3,6 @@ import { readFileSync, writeFileSync, promises as fsPromises, constants } from ' import { dirname, join } from 'node:path' import { Jimp, JimpMime } from 'jimp' import logger from '@wdio/logger' -import compareImagesResemble from '../resemble/compareImages.js' import compareImagesPixelmatch from '../pixelmatch/compareImages.js' import { calculateDprData, getIosBezelImageNames, getBase64ScreenshotSize, prepareComparisonFilePaths, updateVisualBaseline } from '../helpers/utils.js' import { prepareIgnoreOptions } from '../helpers/options.js' @@ -26,7 +25,7 @@ import type { } from './images.interfaces.js' import type { IgnoreBoxes } from './rectangles.interfaces.js' import type { FullPageScreenshotsData } from './screenshots.interfaces.js' -import type { CompareData, ComparisonOptions } from '../resemble/compare.interfaces.js' +import type { CompareData, ComparisonOptions } from '../pixelmatch/compare.interfaces.js' import { generateAndSaveDiff } from './processDiffPixels.js' import { createJsonReportIfNeeded } from './createCompareReport.js' import { takeBase64Screenshot } from './screenshots.js' @@ -447,10 +446,7 @@ export async function executeImageCompare( } // 5. Execute the compare and retrieve the data - const engine = imageCompareOptions.compareEngine === 'pixelmatch' ? 'pixelmatch' : 'resemble' - log.info(`Using comparison engine: ${engine}`) - const engineFn = engine === 'pixelmatch' ? compareImagesPixelmatch : compareImagesResemble - const data: CompareData = await engineFn(readFileSync(baselineFilePath), actualImageBuffer, compareOptions) + const data: CompareData = await compareImagesPixelmatch(readFileSync(baselineFilePath), actualImageBuffer, compareOptions) const rawMisMatchPercentage = data.rawMisMatchPercentage const reportMisMatchPercentage = imageCompareOptions.rawMisMatchPercentage ? rawMisMatchPercentage diff --git a/packages/image-comparison-core/src/methods/processDiffPixels.ts b/packages/image-comparison-core/src/methods/processDiffPixels.ts index 4ac86995f..b62fa5e6a 100644 --- a/packages/image-comparison-core/src/methods/processDiffPixels.ts +++ b/packages/image-comparison-core/src/methods/processDiffPixels.ts @@ -47,7 +47,7 @@ import logger from '@wdio/logger' import type { Pixel, WicImageCompareOptions } from 'src/methods/images.interfaces.js' import type { BoundingBox, IgnoreBoxes } from './rectangles.interfaces.js' -import type { CompareData } from '../resemble/compare.interfaces.js' +import type { CompareData } from '../pixelmatch/compare.interfaces.js' import { saveBase64Image, addBlockOuts } from './images.js' const log = logger('@wdio/visual-service:@wdio/image-comparison-core:pixelDiffProcessing') diff --git a/packages/image-comparison-core/src/methods/rectangles.interfaces.ts b/packages/image-comparison-core/src/methods/rectangles.interfaces.ts index 0c755be49..607730a7a 100644 --- a/packages/image-comparison-core/src/methods/rectangles.interfaces.ts +++ b/packages/image-comparison-core/src/methods/rectangles.interfaces.ts @@ -136,7 +136,7 @@ export interface PrepareIgnoreRectanglesOptions { } export interface PreparedIgnoreRectangles { - /** The final ignored boxes ready for resemble comparison */ + /** The final ignored boxes for image comparison */ ignoredBoxes: any[]; /** Whether any ignore rectangles were found */ hasIgnoreRectangles: boolean; diff --git a/packages/image-comparison-core/src/methods/rectangles.ts b/packages/image-comparison-core/src/methods/rectangles.ts index a7d691ba7..2d01a4dc0 100644 --- a/packages/image-comparison-core/src/methods/rectangles.ts +++ b/packages/image-comparison-core/src/methods/rectangles.ts @@ -671,10 +671,8 @@ export async function prepareIgnoreRectangles(options: PrepareIgnoreRectanglesOp } if (webStatusAddressToolBarOptions.length > 0) { - // There's an issue with the resemble lib when all the rectangles are 0,0,0,0, it will see this as a full - // blockout of the image and the comparison will succeed with 0 % difference. - // Additionally, rectangles with either width or height equal to 0 will result in an entire axis being ignored - // due to how resemble handles falsy values. Filter those out up front. + // Filter out zero-dimension rectangles: a 0,0,0,0 rect would block out the entire image, + // and rects with width or height of 0 produce undefined axis behaviour. Remove them upfront. webStatusAddressToolBarOptions = webStatusAddressToolBarOptions .filter((rectangle) => !(rectangle.x === 0 && rectangle.y === 0 && rectangle.width === 0 && rectangle.height === 0)) .filter((rectangle) => rectangle.width > 0 && rectangle.height > 0) diff --git a/packages/image-comparison-core/src/pixelmatch/compare.interfaces.ts b/packages/image-comparison-core/src/pixelmatch/compare.interfaces.ts new file mode 100644 index 000000000..97470c2ac --- /dev/null +++ b/packages/image-comparison-core/src/pixelmatch/compare.interfaces.ts @@ -0,0 +1,43 @@ +import type { BaseBoundingBox, BaseCoordinates } from '../base.interfaces.js' + +export interface CompareData { + /** The mismatch percentage like 0.12345567 */ + rawMisMatchPercentage: number; + /** The mismatch percentage like 0.12 */ + misMatchPercentage: number; + /** The image buffer */ + getBuffer: () => Promise; + /** The bounds of the diff area */ + diffBounds: BaseBoundingBox; + /** The analysis time in milliseconds */ + analysisTime: number; + /** The diff pixels location(s) and color(s) */ + diffPixels: BaseCoordinates[]; +} + +type Box = { + /** Left boundary of the box */ + left: number; + /** Top boundary of the box */ + top: number; + /** Right boundary of the box */ + right: number; + /** Bottom boundary of the box */ + bottom: number; +}; + +type OutputSettings = { + /** Box area to ignore during comparison */ + ignoredBoxes?: Box[] | undefined; +}; + +export type ComparisonIgnoreOption = 'nothing' | 'less' | 'antialiasing' | 'colors' | 'alpha'; + +export interface ComparisonOptions { + /** Output settings for the comparison */ + output?: OutputSettings | undefined; + /** Whether to scale images to the same size before comparison */ + scaleToSameSize?: boolean | undefined; + /** What aspects to ignore during comparison */ + ignore?: ComparisonIgnoreOption | ComparisonIgnoreOption[] | undefined; +} diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ts index 1fd742903..1102af19f 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ts @@ -1,6 +1,6 @@ import pixelmatch from 'pixelmatch' import { Jimp, JimpMime } from 'jimp' -import type { CompareData, ComparisonOptions, ComparisonIgnoreOption } from '../resemble/compare.interfaces.js' +import type { CompareData, ComparisonOptions, ComparisonIgnoreOption } from './compare.interfaces.js' function resolveIgnoreList(ignore: ComparisonOptions['ignore']): ComparisonIgnoreOption[] { if (!ignore) { @@ -42,8 +42,7 @@ function opaqueAlphaChannel(pixels: Buffer, totalPixels: number): void { // Pad a raw RGBA pixel buffer to a larger canvas size, placing the source at // position (0, 0) and filling the remaining area with opaque white. -// This matches resemble's normalise() intent without using Jimp's contain() -// which centers the image and shifts content by a pixel. +// Pad source at (0, 0) and fill the remaining area with opaque white. function padToSize(src: Buffer, srcW: number, srcH: number, dstW: number, dstH: number): Buffer { const dst = Buffer.alloc(dstW * dstH * 4, 255) // opaque white for (let y = 0; y < srcH; y++) { @@ -126,8 +125,7 @@ export default async function compareImages( const { threshold, includeAA } = toPixelmatchOptions(ignoreList) const outputPixels = new Uint8Array(totalPixels * 4) - // Use resemble's magenta [255, 0, 255] for both diff and AA pixels so the - // diff image output is visually consistent with the resemble engine. + // Use magenta [255, 0, 255] for both diff and AA pixels. const diffCount: number = pixelmatch(pixels1, pixels2, outputPixels, width, height, { threshold, includeAA, diff --git a/packages/image-comparison-core/src/resemble/compare.interfaces.ts b/packages/image-comparison-core/src/resemble/compare.interfaces.ts deleted file mode 100644 index 3b4cca17c..000000000 --- a/packages/image-comparison-core/src/resemble/compare.interfaces.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { BaseBoundingBox, BaseCoordinates, BaseDimensions } from '../base.interfaces.js' - -export interface CompareData { - /** The mismatch percentage like 0.12345567 */ - rawMisMatchPercentage: number; - /** The mismatch percentage like 0.12 */ - misMatchPercentage: number; - /** The image buffer */ - getBuffer: () => Promise; - /** The bounds of the diff area */ - diffBounds: BaseBoundingBox; - /** The analysis time in milliseconds */ - analysisTime: number; - /** The diff pixels location(s) and color(s) */ - diffPixels: BaseCoordinates[]; - -} - -/** - * Src: @types/resemblejs - */ -type OutputSettings = { - /** Color to use for highlighting errors */ - errorColor?: - | { - /** Red color component (0-255) */ - red: number; - /** Green color component (0-255) */ - green: number; - /** Blue color component (0-255) */ - blue: number; - } - | undefined; - /** Type of error highlighting to use */ - errorType?: OutputErrorType | undefined; - /** Custom error pixel processing function */ - errorPixel?: ((px: number[], offset: number, d1: Color, d2: Color) => void) | undefined; - /** Transparency level for the output image */ - transparency?: number | undefined; - /** Threshold for large image processing */ - largeImageThreshold?: number | undefined; - /** Whether to use cross-origin for image loading */ - useCrossOrigin?: boolean | undefined; - /** Bounding box to focus comparison on */ - boundingBox?: Box | undefined; - /** Box area to ignore during comparison */ - ignoredBox?: Box | undefined; - /** Multiple bounding boxes to focus comparison on */ - boundingBoxes?: Box[] | undefined; - /** Multiple box areas to ignore during comparison */ - ignoredBoxes?: Box[] | undefined; - /** Color to ignore during comparison */ - ignoreAreasColoredWith?: Color | undefined; -}; - -type Box = { - /** Left boundary of the box */ - left: number; - /** Top boundary of the box */ - top: number; - /** Right boundary of the box */ - right: number; - /** Bottom boundary of the box */ - bottom: number; -}; - -type Color = { - /** Red color component (0-255) */ - r: number; - /** Green color component (0-255) */ - g: number; - /** Blue color component (0-255) */ - b: number; - /** Alpha transparency component (0-255) */ - a: number; -}; - -type Tolerance = { - /** Tolerance for red color component */ - red?: number; - /** Tolerance for green color component */ - green?: number; - /** Tolerance for blue color component */ - blue?: number; - /** Tolerance for alpha transparency component */ - alpha?: number; - /** Minimum brightness tolerance */ - minBrightness?: number; - /** Maximum brightness tolerance */ - maxBrightness?: number; -}; - -type OutputErrorType = 'flat' | 'movement' | 'flatDifferenceIntensity' | 'movementDifferenceIntensity' | 'diffOnly'; - -export type ComparisonIgnoreOption = 'nothing' | 'less' | 'antialiasing' | 'colors' | 'alpha'; -export interface ComparisonOptions { - /** Output settings for the comparison */ - output?: OutputSettings | undefined; - /** Threshold to return early if mismatch exceeds this value */ - returnEarlyThreshold?: number | undefined; - /** Whether to scale images to the same size before comparison */ - scaleToSameSize?: boolean | undefined; - /** What aspects to ignore during comparison */ - ignore?: ComparisonIgnoreOption | ComparisonIgnoreOption[] | undefined; - /** Tolerance settings for color differences */ - tolerance?: Tolerance | undefined; -} -export interface ComparisonResult { - /** - * Error information if error encountered - * - * Note: If error encountered, other properties will be undefined - */ - error?: unknown | undefined; - - /** - * Time consumed by the comparison (in milliseconds) - */ - analysisTime: number; - - /** - * Do the two images have the same dimensions? - */ - isSameDimensions: boolean; - - /** - * The difference in width and height between the dimensions of the two compared images - */ - dimensionDifference: BaseDimensions; - - /** - * The percentage of pixels which do not match between the images - */ - rawMisMatchPercentage: number; - - /** - * Same as `rawMisMatchPercentage` but fixed to 2-digit after the decimal point - */ - misMatchPercentage: number; - - diffBounds: Box; - - /** - * Get a data URL for the comparison image - */ - getImageDataUrl(): string; - - /** - * Get data buffer - */ - getBuffer?: (includeOriginal: boolean) => Buffer; -} diff --git a/packages/image-comparison-core/src/resemble/compareImages.d.ts b/packages/image-comparison-core/src/resemble/compareImages.d.ts deleted file mode 100644 index 30d79cf6d..000000000 --- a/packages/image-comparison-core/src/resemble/compareImages.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ComparisonOptions, ComparisonResult } from './compare.interfaces.ts' - -/** - * The API under Node is the same as on the `resemble.compare` but promise based - */ -declare function compareImages( - image1: string | ImageData | Buffer, - image2: string | ImageData | Buffer, - options: ComparisonOptions, -): Promise; - -export default compareImages diff --git a/packages/image-comparison-core/src/resemble/compareImages.ts b/packages/image-comparison-core/src/resemble/compareImages.ts deleted file mode 100644 index 1ab4a3766..000000000 --- a/packages/image-comparison-core/src/resemble/compareImages.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-ignore: Ignoring type checking for this module import -import * as resembleJimp from './resemble.jimp.cjs' -import type { CompareData, ComparisonOptions } from './compare.interfaces.js' - -export default async function compareImages( - image1: Buffer, - image2: Buffer, - options: ComparisonOptions -): Promise { - /** - * Resemble.js implemented in the way that scales 2nd images to the size of 1st. - * Experimentally proven that downscaling images produces more accurate result than upscaling - */ - const { imageToCompare1, imageToCompare2 } = - options.scaleToSameSize && image1.length > image2.length - ? { - imageToCompare1: image2, - imageToCompare2: image1, - } - : { imageToCompare1: image1, imageToCompare2: image2 } - - try { - const data = await resembleJimp.default.compare(imageToCompare1, imageToCompare2, options) - return data - } catch (err) { - throw err - } -} diff --git a/packages/image-comparison-core/src/resemble/resemble.jimp.cjs b/packages/image-comparison-core/src/resemble/resemble.jimp.cjs deleted file mode 100644 index 353bfb349..000000000 --- a/packages/image-comparison-core/src/resemble/resemble.jimp.cjs +++ /dev/null @@ -1,1183 +0,0 @@ -/* -James Cryer / Huddle -URL: https://github.com/Huddle/Resemble.js -Modified by: @wswebcreation -Reason: The node-canvas library was producing a lot of issues due to system dependency errors. - The Jimp library is a pure JavaScript image processing library that can be used in a Node.js environment. - The browser is not needed anymore, so the API is deliberately broken to only work in a Node.js environment. - The old code is still in the file, but commented out. This way, the code can easily be compared when needed with - the original resemble.js file. -*/ - -const { Jimp, JimpMime } = require('jimp') - -const naiveFallback = function () { - // ISC (c) 2011-2019 https://github.com/medikoo/es5-ext/blob/master/global.js - if (typeof self === 'object' && self) { - return self - } - if (typeof window === 'object' && window) { - return window - } - throw new Error('Unable to resolve global `this`') -} - -const getGlobalThis = function () { - // ISC (c) 2011-2019 https://github.com/medikoo/es5-ext/blob/master/global.js - // Fallback to standard globalThis if available - if (typeof globalThis === 'object' && globalThis) { - return globalThis - } - - try { - Object.defineProperty(Object.prototype, '__global__', { - get: function () { - return this - }, - configurable: true, - }) - } catch (error) { - return naiveFallback() - } - try { - // eslint-disable-next-line no-undef - if (!__global__) { - return naiveFallback() - } - return __global__ // eslint-disable-line no-undef - } finally { - delete Object.prototype.__global__ - } -} - -const isNode = function () { - const globalPolyfill = getGlobalThis() - return ( - typeof globalPolyfill.process !== 'undefined' && - globalPolyfill.process.versions && - globalPolyfill.process.versions.node - ) -}; - -(function (root, factory) { - 'use strict' - if (typeof define === 'function' && define.amd) { - define([], factory) - } else if (typeof module === 'object' && module.exports) { - module.exports = factory() - } else { - root.resemble = factory() - } -})(this /* eslint-disable-line no-invalid-this*/, function () { - 'use strict' - - let Img - // var Canvas; - // var loadNodeCanvasImage; - - // if (isNode()) { - // Canvas = require("canvas"); // eslint-disable-line global-require - // Img = Canvas.Image; - // loadNodeCanvasImage = Canvas.loadImage; - // } else { - // Img = Image; - // } - - function createCanvas(width, height) { - // if (isNode()) { - // return Canvas.createCanvas(width, height); - // } - - // var cnvs = document.createElement("canvas"); - // cnvs.width = width; - // cnvs.height = height; - // return cnvs; - - return new Jimp({ width, height }) - } - - const oldGlobalSettings = {} - let globalOutputSettings = oldGlobalSettings - - const resemble = function (fileData) { - let pixelTransparency = 1 - - const errorPixelColor = { - // Color for Error Pixels. Between 0 and 255. - red: 255, - green: 0, - blue: 255, - alpha: 255, - } - - const targetPix = { r: 0, g: 0, b: 0, a: 0 } // isAntialiased - - const errorPixelTransform = { - flat: function (px, offset) { - px[offset] = errorPixelColor.red - px[offset + 1] = errorPixelColor.green - px[offset + 2] = errorPixelColor.blue - px[offset + 3] = errorPixelColor.alpha - }, - movement: function (px, offset, d1, d2) { - px[offset] = (d2.r * (errorPixelColor.red / 255) + errorPixelColor.red) / 2 - px[offset + 1] = (d2.g * (errorPixelColor.green / 255) + errorPixelColor.green) / 2 - px[offset + 2] = (d2.b * (errorPixelColor.blue / 255) + errorPixelColor.blue) / 2 - px[offset + 3] = d2.a - }, - flatDifferenceIntensity: function (px, offset, d1, d2) { - px[offset] = errorPixelColor.red - px[offset + 1] = errorPixelColor.green - px[offset + 2] = errorPixelColor.blue - px[offset + 3] = colorsDistance(d1, d2) - }, - movementDifferenceIntensity: function (px, offset, d1, d2) { - const ratio = (colorsDistance(d1, d2) / 255) * 0.8 - - px[offset] = - (1 - ratio) * (d2.r * (errorPixelColor.red / 255)) + - ratio * errorPixelColor.red - px[offset + 1] = - (1 - ratio) * (d2.g * (errorPixelColor.green / 255)) + - ratio * errorPixelColor.green - px[offset + 2] = - (1 - ratio) * (d2.b * (errorPixelColor.blue / 255)) + - ratio * errorPixelColor.blue - px[offset + 3] = d2.a - }, - diffOnly: function (px, offset, d1, d2) { - px[offset] = d2.r - px[offset + 1] = d2.g - px[offset + 2] = d2.b - px[offset + 3] = d2.a - }, - } - - let errorPixel = errorPixelTransform.flat - let errorType - let boundingBoxes - let ignoredBoxes - let ignoreAreasColoredWith - let largeImageThreshold = 1200 - let useCrossOrigin = true - let data = {} - let images = [] - const updateCallbackArray = [] - - const tolerance = { - // between 0 and 255 - red: 16, - green: 16, - blue: 16, - alpha: 16, - minBrightness: 16, - maxBrightness: 240, - } - - let ignoreAntialiasing = false - let ignoreColors = false - let scaleToSameSize = false - let compareOnly = false - let returnEarlyThreshold - - function colorsDistance(c1, c2) { - return ( - (Math.abs(c1.r - c2.r) + Math.abs(c1.g - c2.g) + Math.abs(c1.b - c2.b)) / 3 - ) - } - - function withinBoundingBox(x, y, width, height, box) { - return ( - x >= (box.left || 0) && - x <= (box.right || width) && - y >= (box.top || 0) && - y <= (box.bottom || height) - ) - } - - function withinComparedArea(x, y, width, height, pixel2) { - let isIncluded = true - let i - let boundingBox - let ignoredBox - let selected - let ignored - - if (boundingBoxes instanceof Array) { - selected = false - for (i = 0; i < boundingBoxes.length; i++) { - boundingBox = boundingBoxes[i] - if (withinBoundingBox(x, y, width, height, boundingBox)) { - selected = true - break - } - } - } - if (ignoredBoxes instanceof Array) { - ignored = true - for (i = 0; i < ignoredBoxes.length; i++) { - ignoredBox = ignoredBoxes[i] - if (withinBoundingBox(x, y, width, height, ignoredBox)) { - ignored = false - break - } - } - } - - if (ignoreAreasColoredWith) { - return colorsDistance(pixel2, ignoreAreasColoredWith) !== 0 - } - - if (selected === undefined && ignored === undefined) { - return true - } - if (selected === false && ignored === true) { - return false - } - if (selected === true || ignored === true) { - isIncluded = true - } - if (selected === false || ignored === false) { - isIncluded = false - } - return isIncluded - } - - function triggerDataUpdate() { - const len = updateCallbackArray.length - let i - for (i = 0; i < len; i++) { - if (typeof updateCallbackArray[i] === 'function') { - updateCallbackArray[i](data) - } - } - } - - function loop(w, h, callback) { - let x - let y - - for (x = 0; x < w; x++) { - for (y = 0; y < h; y++) { - callback(x, y) - } - } - } - - function parseImage(sourceImageData, width, height) { - let pixelCount = 0 - let redTotal = 0 - let greenTotal = 0 - let blueTotal = 0 - let alphaTotal = 0 - let brightnessTotal = 0 - let whiteTotal = 0 - let blackTotal = 0 - - loop(width, height, function (horizontalPos, verticalPos) { - const offset = (verticalPos * width + horizontalPos) * 4 - const red = sourceImageData[offset] - const green = sourceImageData[offset + 1] - const blue = sourceImageData[offset + 2] - const alpha = sourceImageData[offset + 3] - const brightness = getBrightness(red, green, blue) - - if (red === green && red === blue && alpha) { - if (red === 0) { - blackTotal++ - } else if (red === 255) { - whiteTotal++ - } - } - - pixelCount++ - - redTotal += (red / 255) * 100 - greenTotal += (green / 255) * 100 - blueTotal += (blue / 255) * 100 - alphaTotal += ((255 - alpha) / 255) * 100 - brightnessTotal += (brightness / 255) * 100 - }) - - data.red = Math.floor(redTotal / pixelCount) - data.green = Math.floor(greenTotal / pixelCount) - data.blue = Math.floor(blueTotal / pixelCount) - data.alpha = Math.floor(alphaTotal / pixelCount) - data.brightness = Math.floor(brightnessTotal / pixelCount) - data.white = Math.floor((whiteTotal / pixelCount) * 100) - data.black = Math.floor((blackTotal / pixelCount) * 100) - - triggerDataUpdate() - } - - function onLoadImage(hiddenImage, callback) { - // don't assign to hiddenImage, see https://github.com/Huddle/Resemble.js/pull/87/commits/300d43352a2845aad289b254bfbdc7cd6a37e2d7 - let width = hiddenImage.bitmap.width - let height = hiddenImage.bitmap.height - - if (scaleToSameSize && images.length === 1) { - width = images[0].bitmap.width - height = images[0].bitmap.height - } - - // var hiddenCanvas = createCanvas(width, height); - // var imageData; - - // hiddenCanvas.getContext("2d").drawImage(hiddenImage, 0, 0, width, height); - // imageData = hiddenCanvas - // .getContext("2d") - // .getImageData(0, 0, width, height); - - // images.push(imageData); - - images.push(hiddenImage) - - callback(hiddenImage, width, height) - } - - function loadImageData(fileDataForImage, callback) { - // var fileReader; - // var hiddenImage = new Img(); - - // if (!hiddenImage.setAttribute) { - // hiddenImage.setAttribute = function setAttribute() {}; - // } - - // if (useCrossOrigin) { - // hiddenImage.setAttribute("crossorigin", "anonymous"); - // } - - // hiddenImage.onerror = function (event) { - // hiddenImage.onload = null; - // hiddenImage.onerror = null; // fixes pollution between calls - // const error = event ? event + "" : "Unknown error"; - // images.push({ - // error: `Failed to load image '${fileDataForImage}'. ${error}`, - // }); - // callback(); - // }; - - // hiddenImage.onload = function () { - // hiddenImage.onload = null; // fixes pollution between calls - // hiddenImage.onerror = null; - // onLoadImage(hiddenImage, callback); - // }; - - // if (typeof fileDataForImage === "string") { - // hiddenImage.src = fileDataForImage; - // if (!isNode() && hiddenImage.complete && hiddenImage.naturalWidth > 0) { - // hiddenImage.onload(); - // } - // } else if ( - // typeof fileDataForImage.data !== "undefined" && - // typeof fileDataForImage.width === "number" && - // typeof fileDataForImage.height === "number" - // ) { - // images.push(fileDataForImage); - - // callback( - // fileDataForImage, - // fileDataForImage.width, - // fileDataForImage.height - // ); - // } else if ( - // typeof Buffer !== "undefined" && - // fileDataForImage instanceof Buffer - // ) { - // // If we have Buffer, assume we're on Node+Canvas and its supported - // // hiddenImage.src = fileDataForImage; - - // loadNodeCanvasImage(fileDataForImage) - // .then(function (image) { - // hiddenImage.onload = null; // fixes pollution between calls - // hiddenImage.onerror = null; - // onLoadImage(image, callback); - // }) - // .catch(function (err) { - // images.push({ - // error: err ? err + "" : "Image load error.", - // }); - // callback(); - // }); - // } else { - // fileReader = new FileReader(); - // fileReader.onload = function (event) { - // hiddenImage.src = event.target.result; - // }; - // fileReader.readAsDataURL(fileDataForImage); - // } - - Jimp.read(fileDataForImage) - .then((image) => { - onLoadImage(image, callback) - }) - .catch((err) => { - images.push({ - error: `Failed to load image '${fileDataForImage}'. ${err}`, - }) - callback() - }) - } - - function isColorSimilar(a, b, color) { - const absDiff = Math.abs(a - b) - - if (typeof a === 'undefined') { - return false - } - if (typeof b === 'undefined') { - return false - } - - if (a === b) { - return true - } else if (absDiff < tolerance[color]) { - return true - } - return false - } - - function isPixelBrightnessSimilar(d1, d2) { - const alpha = isColorSimilar(d1.a, d2.a, 'alpha') - const brightness = isColorSimilar( - d1.brightness, - d2.brightness, - 'minBrightness' - ) - return brightness && alpha - } - - function getBrightness(r, g, b) { - return 0.3 * r + 0.59 * g + 0.11 * b - } - - function isRGBSame(d1, d2) { - const red = d1.r === d2.r - const green = d1.g === d2.g - const blue = d1.b === d2.b - return red && green && blue - } - - function isRGBSimilar(d1, d2) { - const red = isColorSimilar(d1.r, d2.r, 'red') - const green = isColorSimilar(d1.g, d2.g, 'green') - const blue = isColorSimilar(d1.b, d2.b, 'blue') - const alpha = isColorSimilar(d1.a, d2.a, 'alpha') - - return red && green && blue && alpha - } - - function isContrasting(d1, d2) { - return Math.abs(d1.brightness - d2.brightness) > tolerance.maxBrightness - } - - function getHue(red, green, blue) { - const r = red / 255 - const g = green / 255 - const b = blue / 255 - const max = Math.max(r, g, b) - const min = Math.min(r, g, b) - let h - let d - - if (max === min) { - h = 0 // achromatic - } else { - d = max - min - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0) - break - case g: - h = (b - r) / d + 2 - break - case b: - h = (r - g) / d + 4 - break - default: - h /= 6 - } - } - - return h - } - - function isAntialiased( - sourcePix, - pix, - cacheSet, - verticalPos, - horizontalPos, - width - ) { - let offset - const distance = 1 - let i - let j - let hasHighContrastSibling = 0 - let hasSiblingWithDifferentHue = 0 - let hasEquivalentSibling = 0 - - addHueInfo(sourcePix) - - for (i = distance * -1; i <= distance; i++) { - for (j = distance * -1; j <= distance; j++) { - if (i === 0 && j === 0) { - // ignore source pixel - continue - } else { - offset = ((verticalPos + j) * width + (horizontalPos + i)) * 4 - - if (!getPixelInfo(targetPix, pix, offset, cacheSet)) { - continue - } - - addBrightnessInfo(targetPix) - addHueInfo(targetPix) - - if (isContrasting(sourcePix, targetPix)) { - hasHighContrastSibling++ - } - - if (isRGBSame(sourcePix, targetPix)) { - hasEquivalentSibling++ - } - - if (Math.abs(targetPix.h - sourcePix.h) > 0.3) { - hasSiblingWithDifferentHue++ - } - - if (hasSiblingWithDifferentHue > 1 || hasHighContrastSibling > 1) { - return true - } - } - } - } - - if (hasEquivalentSibling < 2) { - return true - } - - return false - } - - function copyPixel(px, offset, pix) { - if (errorType === 'diffOnly') { - return - } - - px[offset] = pix.r // r - px[offset + 1] = pix.g // g - px[offset + 2] = pix.b // b - px[offset + 3] = pix.a * pixelTransparency // a - } - - function copyGrayScalePixel(px, offset, pix) { - if (errorType === 'diffOnly') { - return - } - - px[offset] = pix.brightness // r - px[offset + 1] = pix.brightness // g - px[offset + 2] = pix.brightness // b - px[offset + 3] = pix.a * pixelTransparency // a - } - - function getPixelInfo(dst, pix, offset) { - if (pix.length > offset) { - dst.r = pix[offset] - dst.g = pix[offset + 1] - dst.b = pix[offset + 2] - dst.a = pix[offset + 3] - - return true - } - - return false - } - - function addBrightnessInfo(pix) { - pix.brightness = getBrightness(pix.r, pix.g, pix.b) // 'corrected' lightness - } - - function addHueInfo(pix) { - pix.h = getHue(pix.r, pix.g, pix.b) - } - - function analyseImages(img1, img2, width, height) { - const data1 = img1.bitmap.data - const data2 = img2.bitmap.data - let hiddenCanvas - // var context; - // var imgd; - let pix - - if (!compareOnly) { - hiddenCanvas = createCanvas(width, height) - - // context = hiddenCanvas.getContext("2d"); - // imgd = context.createImageData(width, height); - pix = hiddenCanvas.bitmap.data - } - - let mismatchCount = 0 - const diffBounds = { - top: height, - left: width, - bottom: 0, - right: 0, - } - const diffPixels = [] - const updateBounds = function (x, y) { - // Update the big box - diffBounds.left = Math.min(x, diffBounds.left) - diffBounds.right = Math.max(x, diffBounds.right) - diffBounds.top = Math.min(y, diffBounds.top) - diffBounds.bottom = Math.max(y, diffBounds.bottom) - // Update the diffPixels array - diffPixels.push({ x, y }) - } - - const time = Date.now() - - let skip - - if ( - !!largeImageThreshold && - ignoreAntialiasing && - (width > largeImageThreshold || height > largeImageThreshold) - ) { - skip = 6 - } - - const pixel1 = { r: 0, g: 0, b: 0, a: 0 } - const pixel2 = { r: 0, g: 0, b: 0, a: 0 } - - let skipTheRest = false - - loop(width, height, function (horizontalPos, verticalPos) { - if (skipTheRest) { - return - } - - if (skip) { - // only skip if the image isn't small - if (verticalPos % skip === 0 || horizontalPos % skip === 0) { - return - } - } - - const offset = (verticalPos * width + horizontalPos) * 4 - if ( - !getPixelInfo(pixel1, data1, offset, 1) || - !getPixelInfo(pixel2, data2, offset, 2) - ) { - return - } - - const isWithinComparedArea = withinComparedArea( - horizontalPos, - verticalPos, - width, - height, - pixel2 - ) - - if (ignoreColors) { - addBrightnessInfo(pixel1) - addBrightnessInfo(pixel2) - - if ( isPixelBrightnessSimilar(pixel1, pixel2) || !isWithinComparedArea ) { - if (!compareOnly) { - copyGrayScalePixel(pix, offset, pixel2) - } - } else { - if (!compareOnly) { - errorPixel(pix, offset, pixel1, pixel2) - } - - mismatchCount++ - updateBounds(horizontalPos, verticalPos) - } - return - } - - if (isRGBSimilar(pixel1, pixel2) || !isWithinComparedArea) { - if (!compareOnly) { - copyPixel(pix, offset, pixel1) - } - } else if ( - ignoreAntialiasing && - ( - addBrightnessInfo(pixel1), // jit pixel info augmentation looks a little weird, sorry. - addBrightnessInfo(pixel2), - isAntialiased(pixel1, data1, 1, verticalPos, horizontalPos, width) || - isAntialiased(pixel2, data2, 2, verticalPos, horizontalPos, width) - ) - ) { - if ( isPixelBrightnessSimilar(pixel1, pixel2) || !isWithinComparedArea ) { - if (!compareOnly) { - copyGrayScalePixel(pix, offset, pixel2) - } - } else { - if (!compareOnly) { - errorPixel(pix, offset, pixel1, pixel2) - } - - mismatchCount++ - updateBounds(horizontalPos, verticalPos) - } - } else { - if (!compareOnly) { - errorPixel(pix, offset, pixel1, pixel2) - } - - mismatchCount++ - updateBounds(horizontalPos, verticalPos) - } - - if (compareOnly) { - const currentMisMatchPercent = (mismatchCount / (height * width)) * 100 - - if (currentMisMatchPercent > returnEarlyThreshold) { - skipTheRest = true - } - } - }) - - data.rawMisMatchPercentage = (mismatchCount / (height * width)) * 100 - data.misMatchPercentage = data.rawMisMatchPercentage.toFixed(2) - data.diffBounds = diffBounds - data.analysisTime = Date.now() - time - // Add diffPixels array to the data object - data.diffPixels = diffPixels - - data.getImageDataUrl = function (text) { - if (compareOnly) { - throw Error('No diff image available - ran in compareOnly mode') - } - - let barHeight = 0 - - if (text) { - barHeight = addLabel(text, hiddenCanvas) - } - - // context.putImageData(imgd, 0, barHeight); - - return hiddenCanvas.getBase64(JimpMime.png) - } - - if (!compareOnly) { - data.getBuffer = function (includeOriginal) { - if (includeOriginal) { - const imageWidth = hiddenCanvas.bitmap.width + 2 - hiddenCanvas.resize(imageWidth * 3, hiddenCanvas.bitmap.height) - hiddenCanvas.composite(img1, 0, 0) - hiddenCanvas.composite(img2, imageWidth, 0) - hiddenCanvas.composite(hiddenCanvas, imageWidth * 2, 0) - } - return hiddenCanvas.getBuffer(JimpMime.png) - } - } - } - - function addLabel(text, hiddenCanvas) { - const textPadding = 2 - - // context.font = "12px sans-serif"; - - // var textWidth = context.measureText(text).width + textPadding * 2; - // var barHeight = 22; - - // if (textWidth > hiddenCanvas.width) { - // hiddenCanvas.width = textWidth; - // } - - // hiddenCanvas.height += barHeight; - - // context.fillStyle = "#666"; - // context.fillRect(0, 0, hiddenCanvas.width, barHeight - 4); - // context.fillStyle = "#fff"; - // context.fillRect(0, barHeight - 4, hiddenCanvas.width, 4); - - // context.fillStyle = "#fff"; - // context.textBaseline = "top"; - // context.font = "12px sans-serif"; - // context.fillText(text, textPadding, 1); - - // return barHeight; - - return Jimp.loadFont(Jimp.FONT_SANS_12_WHITE).then((font) => { - const textWidth = Jimp.measureText(font, text) + textPadding * 2 - const barHeight = 22 - - if (textWidth > hiddenCanvas.bitmap.width) { - hiddenCanvas.resize(textWidth, hiddenCanvas.bitmap.height) - } - - const context = hiddenCanvas.clone() - context.print(font, textPadding, 1, text) - - return barHeight - }) - } - - function normalise(img, w, h) { - // var c; - // var context; - - if (img.bitmap.height < h || img.bitmap.width < w) { - // c = createCanvas(w, h); - // context = c.getContext("2d"); - // context.putImageData(img, 0, 0); - return img.contain({w, h}) - } - - return img - } - - function outputSettings(options) { - let key - - if (options.errorColor) { - for (key in options.errorColor) { - if (options.errorColor.hasOwnProperty(key)) { - errorPixelColor[key] = options.errorColor[key] === void 0 - ? errorPixelColor[key] - : options.errorColor[key] - } - } - } - - if (options.errorType && errorPixelTransform[options.errorType]) { - errorPixel = errorPixelTransform[options.errorType] - errorType = options.errorType - } - - if (options.errorPixel && typeof options.errorPixel === 'function') { - errorPixel = options.errorPixel - } - - pixelTransparency = isNaN(Number(options.transparency)) - ? pixelTransparency - : options.transparency - - if (options.largeImageThreshold !== undefined) { - largeImageThreshold = options.largeImageThreshold - } - - if (options.useCrossOrigin !== undefined) { - useCrossOrigin = options.useCrossOrigin - } - - if (options.boundingBox !== undefined) { - boundingBoxes = [options.boundingBox] - } - - if (options.ignoredBox !== undefined) { - ignoredBoxes = [options.ignoredBox] - } - - if (options.boundingBoxes !== undefined) { - boundingBoxes = options.boundingBoxes - } - - if (options.ignoredBoxes !== undefined) { - ignoredBoxes = options.ignoredBoxes - } - - if (options.ignoreAreasColoredWith !== undefined) { - ignoreAreasColoredWith = options.ignoreAreasColoredWith - } - } - - function compare(one, two) { - if (globalOutputSettings !== oldGlobalSettings) { - outputSettings(globalOutputSettings) - } - - function onceWeHaveBoth() { - let width - let height - if (images.length === 2) { - if (images[0].error || images[1].error) { - data = {} - data.error = images[0].error ? images[0].error : images[1].error - triggerDataUpdate() - return - } - width = images[0].bitmap.width > images[1].bitmap.width - ? images[0].bitmap.width - : images[1].bitmap.width - height = images[0].bitmap.height > images[1].bitmap.height - ? images[0].bitmap.height - : images[1].bitmap.height - - data.isSameDimensions = images[0].bitmap.width === images[1].bitmap.width && - images[0].bitmap.height === images[1].bitmap.height ? true : false - - data.dimensionDifference = { - width: images[0].bitmap.width - images[1].bitmap.width, - height: images[0].bitmap.height - images[1].bitmap.height, - } - - analyseImages( - normalise(images[0], width, height), - normalise(images[1], width, height), - width, - height - ) - - triggerDataUpdate() - } - } - - images = [] - loadImageData(one, onceWeHaveBoth) - loadImageData(two, onceWeHaveBoth) - } - - function getCompareApi(param) { - let secondFileData - const hasMethod = typeof param === 'function' - - if (!hasMethod) { - // assume it's file data - secondFileData = param - } - - var self = { - setReturnEarlyThreshold: function (threshold) { - if (threshold) { - compareOnly = true - returnEarlyThreshold = threshold - } - return self - }, - scaleToSameSize: function () { - scaleToSameSize = true - - if (hasMethod) { - param() - } - return self - }, - useOriginalSize: function () { - scaleToSameSize = false - - if (hasMethod) { - param() - } - return self - }, - ignoreNothing: function () { - tolerance.red = 0 - tolerance.green = 0 - tolerance.blue = 0 - tolerance.alpha = 0 - tolerance.minBrightness = 0 - tolerance.maxBrightness = 255 - - ignoreAntialiasing = false - ignoreColors = false - - if (hasMethod) { - param() - } - return self - }, - ignoreLess: function () { - tolerance.red = 16 - tolerance.green = 16 - tolerance.blue = 16 - tolerance.alpha = 16 - tolerance.minBrightness = 16 - tolerance.maxBrightness = 240 - - ignoreAntialiasing = false - ignoreColors = false - - if (hasMethod) { - param() - } - return self - }, - ignoreAntialiasing: function () { - tolerance.red = 32 - tolerance.green = 32 - tolerance.blue = 32 - tolerance.alpha = 32 - tolerance.minBrightness = 64 - tolerance.maxBrightness = 96 - - ignoreAntialiasing = true - ignoreColors = false - - if (hasMethod) { - param() - } - return self - }, - ignoreColors: function () { - tolerance.alpha = 16 - tolerance.minBrightness = 16 - tolerance.maxBrightness = 240 - - ignoreAntialiasing = false - ignoreColors = true - - if (hasMethod) { - param() - } - return self - }, - ignoreAlpha: function () { - tolerance.red = 16 - tolerance.green = 16 - tolerance.blue = 16 - tolerance.alpha = 255 - tolerance.minBrightness = 16 - tolerance.maxBrightness = 240 - - ignoreAntialiasing = false - ignoreColors = false - - if (hasMethod) { - param() - } - return self - }, - repaint: function () { - if (hasMethod) { - param() - } - return self - }, - outputSettings: function (options) { - outputSettings(options) - return self - }, - onComplete: function (callback) { - updateCallbackArray.push(callback) - - const wrapper = function () { - compare(fileData, secondFileData) - } - - wrapper() - - return getCompareApi(wrapper) - }, - setupCustomTolerance: function (customSettings) { - for (const property in tolerance) { - if (!customSettings.hasOwnProperty(property)) { - continue - } - - tolerance[property] = customSettings[property] - } - }, - } - - return self - } - - var rootSelf = { - onComplete: function (callback) { - updateCallbackArray.push(callback) - loadImageData(fileData, function (imageData, width, height) { - parseImage(imageData, width, height) - }) - }, - compareTo: function (secondFileData) { - return getCompareApi(secondFileData) - }, - outputSettings: function (options) { - outputSettings(options) - return rootSelf - }, - } - - return rootSelf - } - - function setGlobalOutputSettings(settings) { - globalOutputSettings = settings - return resemble - } - - function applyIgnore(api, ignore, customTolerance) { - switch (ignore) { - case 'nothing': - api.ignoreNothing() - break - case 'less': - api.ignoreLess() - break - case 'antialiasing': - api.ignoreAntialiasing() - break - case 'colors': - api.ignoreColors() - break - case 'alpha': - api.ignoreAlpha() - break - default: - throw new Error('Invalid ignore: ' + ignore) - } - - api.setupCustomTolerance(customTolerance) - } - - resemble.compare = function (image1, image2, options) { - return new Promise((resolve, reject) => { - let opt - - if (typeof options !== 'object') { - opt = {} - } else { - opt = options - } - - const res = resemble(image1) - let compare - - if (opt.output) { - res.outputSettings(opt.output) - } - - compare = res.compareTo(image2) - - if (opt.returnEarlyThreshold) { - compare.setReturnEarlyThreshold(opt.returnEarlyThreshold) - } - - if (opt.scaleToSameSize) { - compare.scaleToSameSize() - } - - const toleranceSettings = opt.tolerance || {} - if (typeof opt.ignore === 'string') { - applyIgnore(compare, opt.ignore, toleranceSettings) - } else if (opt.ignore && opt.ignore.forEach) { - opt.ignore.forEach(function (v) { - applyIgnore(compare, v, toleranceSettings) - }) - } - - compare.onComplete(function (data) { - if (data.error) { - reject(data.error) - } else { - resolve(data) - } - }) - }) - } - - - resemble.outputSettings = setGlobalOutputSettings - return resemble -}) diff --git a/packages/image-comparison-core/tsconfig.json b/packages/image-comparison-core/tsconfig.json index 25c263166..97ce9e0b4 100644 --- a/packages/image-comparison-core/tsconfig.json +++ b/packages/image-comparison-core/tsconfig.json @@ -2,10 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowSyntheticDefaultImports": true, - "allowJs": true, "rootDir": "./src", "outDir": "./dist", "baseUrl": "." }, - "include": ["./src/**/*.ts", "./src/resemble/resemble.jimp.cjs"] + "include": ["./src/**/*.ts"] } From 8dc9e9430352d6f95ae56c8e7278fdbc9982a2af Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Wed, 3 Jun 2026 05:57:27 +0200 Subject: [PATCH 2/4] feat: add fast-png dependency and imageUtils pure-JS pixel utility module --- .gitignore | 1 + packages/image-comparison-core/package.json | 1 + .../src/utils/imageUtils.test.ts | 317 ++++++++++++++++++ .../src/utils/imageUtils.ts | 210 ++++++++++++ pnpm-lock.yaml | 16 + 5 files changed, 545 insertions(+) create mode 100644 packages/image-comparison-core/src/utils/imageUtils.test.ts create mode 100644 packages/image-comparison-core/src/utils/imageUtils.ts diff --git a/.gitignore b/.gitignore index 2faf936e4..77d219c61 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build # folders .tmp .pixelmatch-tmp/ +/*/*-pixelmatch/ /__snapshots__/ .idea/ localBaseline/ diff --git a/packages/image-comparison-core/package.json b/packages/image-comparison-core/package.json index 5d535be60..59bc2ae29 100644 --- a/packages/image-comparison-core/package.json +++ b/packages/image-comparison-core/package.json @@ -34,6 +34,7 @@ "watch:tsc": "pnpm run build:tsc -w" }, "dependencies": { + "fast-png": "^8.0.0", "jimp": "^1.6.1", "pixelmatch": "^7.2.0", "@wdio/logger": "^9.18.0", diff --git a/packages/image-comparison-core/src/utils/imageUtils.test.ts b/packages/image-comparison-core/src/utils/imageUtils.test.ts new file mode 100644 index 000000000..519bbf73e --- /dev/null +++ b/packages/image-comparison-core/src/utils/imageUtils.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest' +import { encode } from 'fast-png' +import { + decodeImage, + encodeImage, + toBase64Png, + createCanvas, + cropImage, + compositeImage, + resizeBilinear, + rotate90CW, + rotate90CCW, + rotate180, + setOpacity, +} from './imageUtils.js' +import type { RawImage } from './imageUtils.js' + +// Build a minimal 2x2 RGBA PNG buffer for use as test input +function makePng(pixels: number[][]): Buffer { + // pixels: [[r,g,b,a], ...] in row-major order, width = sqrt(pixels.length) + const side = Math.sqrt(pixels.length) + const data = new Uint8Array(pixels.length * 4) + pixels.forEach(([r, g, b, a], i) => { + data[i * 4] = r + data[i * 4 + 1] = g + data[i * 4 + 2] = b + data[i * 4 + 3] = a + }) + return Buffer.from(encode({ data, width: side, height: side, channels: 4, depth: 8 })) +} + +describe('decodeImage', () => { + it('decodes an RGBA PNG buffer into a RawImage', () => { + const buf = makePng([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255]]) + const img = decodeImage(buf) + expect(img.width).toBe(2) + expect(img.height).toBe(2) + expect(img.data[0]).toBe(255) // R + expect(img.data[1]).toBe(0) // G + expect(img.data[2]).toBe(0) // B + expect(img.data[3]).toBe(255) // A + }) + + it('converts an RGB (3-channel) PNG to RGBA by adding full alpha', () => { + const data = new Uint8Array([100, 150, 200, 50, 60, 70]) + const buf = Buffer.from(encode({ data, width: 2, height: 1, channels: 3, depth: 8 })) + const img = decodeImage(buf) + expect(img.width).toBe(2) + expect(img.height).toBe(1) + expect(img.data[0]).toBe(100) // R + expect(img.data[1]).toBe(150) // G + expect(img.data[2]).toBe(200) // B + expect(img.data[3]).toBe(255) // A added + expect(img.data[4]).toBe(50) + expect(img.data[7]).toBe(255) // A added for second pixel + }) + + it('converts a grayscale (1-channel) PNG to RGBA', () => { + const data = new Uint8Array([128]) + const buf = Buffer.from(encode({ data, width: 1, height: 1, channels: 1, depth: 8 })) + const img = decodeImage(buf) + expect(img.width).toBe(1) + expect(img.data[0]).toBe(128) // R = gray value + expect(img.data[1]).toBe(128) // G = gray value + expect(img.data[2]).toBe(128) // B = gray value + expect(img.data[3]).toBe(255) // A = full + }) + + it('converts a grayscale+alpha (2-channel) PNG to RGBA', () => { + const data = new Uint8Array([200, 128]) // gray=200, alpha=128 + const buf = Buffer.from(encode({ data, width: 1, height: 1, channels: 2, depth: 8 })) + const img = decodeImage(buf) + expect(img.data[0]).toBe(200) // R = gray + expect(img.data[1]).toBe(200) // G = gray + expect(img.data[2]).toBe(200) // B = gray + expect(img.data[3]).toBe(128) // A preserved + }) + + it('converts a 16-bit RGBA PNG by downsampling to 8-bit', () => { + // 16-bit value 0xFF00 >> 8 = 255, 0x8000 >> 8 = 128 + const data = new Uint16Array([0xFF00, 0x8000, 0x0000, 0xFF00]) + const buf = Buffer.from(encode({ data, width: 1, height: 1, channels: 4, depth: 16 })) + const img = decodeImage(buf) + expect(img.data[0]).toBe(255) // R: 0xFF00 >> 8 + expect(img.data[1]).toBe(128) // G: 0x8000 >> 8 + expect(img.data[2]).toBe(0) // B: 0x0000 >> 8 + expect(img.data[3]).toBe(255) // A: 0xFF00 >> 8 + }) +}) + +describe('encodeImage / toBase64Png', () => { + it('round-trips a RawImage through encode → decode', () => { + const original = createCanvas(2, 2, 100, 150, 200, 255) + const buf = encodeImage(original) + const decoded = decodeImage(buf) + expect(decoded.width).toBe(2) + expect(decoded.height).toBe(2) + expect(decoded.data[0]).toBe(100) + expect(decoded.data[1]).toBe(150) + expect(decoded.data[2]).toBe(200) + expect(decoded.data[3]).toBe(255) + }) + + it('toBase64Png returns a valid base64 string that decodes back correctly', () => { + const img = createCanvas(1, 1, 10, 20, 30, 255) + const b64 = toBase64Png(img) + expect(typeof b64).toBe('string') + const decoded = decodeImage(Buffer.from(b64, 'base64')) + expect(decoded.data[0]).toBe(10) + expect(decoded.data[1]).toBe(20) + expect(decoded.data[2]).toBe(30) + }) +}) + +describe('createCanvas', () => { + it('creates a zero-filled canvas by default', () => { + const img = createCanvas(3, 3) + expect(img.width).toBe(3) + expect(img.height).toBe(3) + expect(img.data.every(v => v === 0)).toBe(true) + }) + + it('fills all pixels with the provided RGBA color', () => { + const img = createCanvas(2, 2, 57, 170, 86, 255) + for (let i = 0; i < 4; i++) { + expect(img.data[i * 4]) .toBe(57) + expect(img.data[i * 4 + 1]).toBe(170) + expect(img.data[i * 4 + 2]).toBe(86) + expect(img.data[i * 4 + 3]).toBe(255) + } + }) +}) + +describe('cropImage', () => { + it('extracts the correct rectangular region', () => { + // 4x1 image: red, green, blue, yellow + const img: RawImage = { + data: new Uint8Array([ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 0, 255, // yellow + ]), + width: 4, + height: 1, + } + const cropped = cropImage(img, 1, 0, 2, 1) + expect(cropped.width).toBe(2) + expect(cropped.height).toBe(1) + // First pixel = green + expect(cropped.data[0]).toBe(0) + expect(cropped.data[1]).toBe(255) + expect(cropped.data[2]).toBe(0) + // Second pixel = blue + expect(cropped.data[4]).toBe(0) + expect(cropped.data[5]).toBe(0) + expect(cropped.data[6]).toBe(255) + }) +}) + +describe('compositeImage', () => { + it('copies an opaque overlay exactly onto the base', () => { + const base = createCanvas(2, 2, 0, 0, 0, 255) + const overlay = createCanvas(1, 1, 255, 0, 0, 255) + compositeImage(base, overlay, 1, 1) + const di = (1 * 2 + 1) * 4 + expect(base.data[di]) .toBe(255) + expect(base.data[di + 1]).toBe(0) + expect(base.data[di + 2]).toBe(0) + expect(base.data[di + 3]).toBe(255) + // Top-left should be untouched + expect(base.data[0]).toBe(0) + }) + + it('blends a semi-transparent overlay with opacity', () => { + const base = createCanvas(1, 1, 0, 0, 0, 255) // black opaque + const overlay = createCanvas(1, 1, 255, 0, 0, 255) // red opaque + compositeImage(base, overlay, 0, 0, 0.5) + // src_a = 0.5, dst_a = 1, out_a = 1 + // R = round((255 * 0.5 + 0 * 1 * 0.5) / 1) = 128 (approximately) + expect(base.data[0]).toBeCloseTo(128, 0) + expect(base.data[3]).toBe(255) + }) + + it('skips blending when both src and dst alpha are zero', () => { + const base = createCanvas(1, 1, 255, 0, 0, 0) // red but fully transparent + const overlay = createCanvas(1, 1, 0, 255, 0, 0) // green but fully transparent + compositeImage(base, overlay, 0, 0) + // outA = 0 → pixel unchanged: base stays (255, 0, 0, 0) + expect(base.data[0]).toBe(255) + expect(base.data[3]).toBe(0) + }) + + it('skips pixels outside the base bounds', () => { + const base = createCanvas(2, 2, 0, 0, 0, 255) + const overlay = createCanvas(3, 3, 255, 0, 0, 255) + // Offset so overlay extends beyond base — should not throw + expect(() => compositeImage(base, overlay, 1, 1)).not.toThrow() + }) +}) + +describe('resizeBilinear', () => { + it('doubles a 1x1 image to 2x2 with the same color', () => { + const img = createCanvas(1, 1, 100, 200, 50, 255) + const resized = resizeBilinear(img, 2, 2) + expect(resized.width).toBe(2) + expect(resized.height).toBe(2) + expect(resized.data[0]).toBe(100) + expect(resized.data[1]).toBe(200) + expect(resized.data[2]).toBe(50) + }) + + it('halves a 4x4 image to 2x2', () => { + const img = createCanvas(4, 4, 200, 100, 50, 255) + const resized = resizeBilinear(img, 2, 2) + expect(resized.width).toBe(2) + expect(resized.height).toBe(2) + }) +}) + +describe('rotate90CW', () => { + it('swaps width and height', () => { + const img = createCanvas(4, 2, 0, 0, 0, 255) + const rotated = rotate90CW(img) + expect(rotated.width).toBe(2) + expect(rotated.height).toBe(4) + }) + + it('moves top-left pixel to top-right', () => { + // 2x1: [red | green] + const img: RawImage = { + data: new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255]), + width: 2, + height: 1, + } + const rotated = rotate90CW(img) + // 90° CW: new 1x2 — top pixel comes from bottom-left of src (only 1 row, so left=red) + expect(rotated.width).toBe(1) + expect(rotated.height).toBe(2) + // top of rotated should be red (was left in src) + expect(rotated.data[0]).toBe(255) // R + expect(rotated.data[1]).toBe(0) // G + }) +}) + +describe('rotate90CCW', () => { + it('swaps width and height', () => { + const img = createCanvas(4, 2, 0, 0, 0, 255) + const rotated = rotate90CCW(img) + expect(rotated.width).toBe(2) + expect(rotated.height).toBe(4) + }) + + it('is the inverse of rotate90CW', () => { + const img = createCanvas(3, 2, 0, 0, 0, 255) + img.data[0] = 42 // mark top-left + const cw = rotate90CW(img) + const back = rotate90CCW(cw) + expect(back.width).toBe(3) + expect(back.height).toBe(2) + expect(back.data[0]).toBe(42) + }) +}) + +describe('rotate180', () => { + it('preserves dimensions', () => { + const img = createCanvas(3, 4, 0, 0, 0, 255) + const rotated = rotate180(img) + expect(rotated.width).toBe(3) + expect(rotated.height).toBe(4) + }) + + it('flips the pixel order', () => { + const img: RawImage = { + data: new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255]), + width: 2, + height: 1, + } + const rotated = rotate180(img) + // First pixel should now be green (was last) + expect(rotated.data[0]).toBe(0) + expect(rotated.data[1]).toBe(255) + expect(rotated.data[2]).toBe(0) + // Second pixel should be red + expect(rotated.data[4]).toBe(255) + expect(rotated.data[5]).toBe(0) + }) + + it('is its own inverse', () => { + const img = createCanvas(2, 2, 0, 0, 0, 255) + img.data[0] = 77 + const twice = rotate180(rotate180(img)) + expect(twice.data[0]).toBe(77) + }) +}) + +describe('setOpacity', () => { + it('halves the alpha channel of each pixel', () => { + const img = createCanvas(2, 2, 57, 170, 86, 255) + setOpacity(img, 0.5) + for (let i = 0; i < 4; i++) { + expect(img.data[i * 4 + 3]).toBe(128) + } + }) + + it('sets full opacity to 255', () => { + const img = createCanvas(1, 1, 0, 0, 0, 128) + setOpacity(img, 1) + expect(img.data[3]).toBe(128) // unchanged — 128 * 1 = 128 + }) + + it('sets zero opacity to 0', () => { + const img = createCanvas(1, 1, 0, 0, 0, 200) + setOpacity(img, 0) + expect(img.data[3]).toBe(0) + }) +}) diff --git a/packages/image-comparison-core/src/utils/imageUtils.ts b/packages/image-comparison-core/src/utils/imageUtils.ts new file mode 100644 index 000000000..8a2543c24 --- /dev/null +++ b/packages/image-comparison-core/src/utils/imageUtils.ts @@ -0,0 +1,210 @@ +import { decode, encode } from 'fast-png' +import type { PngDataArray } from 'fast-png' + +export interface RawImage { + data: Uint8Array + width: number + height: number +} + +// Convert any channel count to RGBA 8-bit. fast-png may decode RGB, grayscale, or clamped PNGs. +function toRGBA(data: PngDataArray, channels: number, width: number, height: number): Uint8Array { + if (channels === 4 && (data instanceof Uint8Array || data instanceof Uint8ClampedArray)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } + const pixels = width * height + const out = new Uint8Array(pixels * 4) + for (let i = 0; i < pixels; i++) { + if (channels === 3) { + out[i * 4] = data[i * 3] + out[i * 4 + 1] = data[i * 3 + 1] + out[i * 4 + 2] = data[i * 3 + 2] + out[i * 4 + 3] = 255 + } else if (channels === 1) { + const v = data[i] + out[i * 4] = v + out[i * 4 + 1] = v + out[i * 4 + 2] = v + out[i * 4 + 3] = 255 + } else if (channels === 2) { + const v = data[i * 2] + out[i * 4] = v + out[i * 4 + 1] = v + out[i * 4 + 2] = v + out[i * 4 + 3] = data[i * 2 + 1] + } else { + // 4-channel Uint16Array: downsample 16-bit → 8-bit + out[i * 4] = data[i * 4] >> 8 + out[i * 4 + 1] = data[i * 4 + 1] >> 8 + out[i * 4 + 2] = data[i * 4 + 2] >> 8 + out[i * 4 + 3] = data[i * 4 + 3] >> 8 + } + } + return out +} + +export function decodeImage(buffer: Buffer): RawImage { + const png = decode(buffer) + return { + data: toRGBA(png.data, png.channels, png.width, png.height), + width: png.width, + height: png.height, + } +} + +export function encodeImage(img: RawImage): Buffer { + return Buffer.from(encode({ data: img.data, width: img.width, height: img.height, channels: 4, depth: 8 })) +} + +export function toBase64Png(img: RawImage): string { + return encodeImage(img).toString('base64') +} + +export function createCanvas(width: number, height: number, r = 0, g = 0, b = 0, a = 0): RawImage { + const data = new Uint8Array(width * height * 4) + if (r !== 0 || g !== 0 || b !== 0 || a !== 0) { + for (let i = 0; i < data.length; i += 4) { + data[i] = r + data[i + 1] = g + data[i + 2] = b + data[i + 3] = a + } + } + return { data, width, height } +} + +export function cropImage(img: RawImage, x: number, y: number, w: number, h: number): RawImage { + const data = new Uint8Array(w * h * 4) + const rowBytes = w * 4 + for (let row = 0; row < h; row++) { + const srcOffset = ((y + row) * img.width + x) * 4 + data.set(img.data.subarray(srcOffset, srcOffset + rowBytes), row * rowBytes) + } + return { data, width: w, height: h } +} + +// Porter-Duff "over" compositing. Mutates base in place. +export function compositeImage(base: RawImage, overlay: RawImage, offsetX: number, offsetY: number, opacity = 1): void { + for (let oy = 0; oy < overlay.height; oy++) { + const by = oy + offsetY + if (by < 0 || by >= base.height) { continue } + for (let ox = 0; ox < overlay.width; ox++) { + const bx = ox + offsetX + if (bx < 0 || bx >= base.width) { continue } + + const si = (oy * overlay.width + ox) * 4 + const di = (by * base.width + bx) * 4 + + const srcA = (overlay.data[si + 3] / 255) * opacity + const dstA = base.data[di + 3] / 255 + const outA = srcA + dstA * (1 - srcA) + + if (outA === 0) { continue } + + base.data[di] = Math.round((overlay.data[si] * srcA + base.data[di] * dstA * (1 - srcA)) / outA) + base.data[di + 1] = Math.round((overlay.data[si + 1] * srcA + base.data[di + 1] * dstA * (1 - srcA)) / outA) + base.data[di + 2] = Math.round((overlay.data[si + 2] * srcA + base.data[di + 2] * dstA * (1 - srcA)) / outA) + base.data[di + 3] = Math.round(outA * 255) + } + } +} + +export function resizeBilinear(img: RawImage, newW: number, newH: number): RawImage { + const data = new Uint8Array(newW * newH * 4) + const xRatio = img.width / newW + const yRatio = img.height / newH + + for (let dy = 0; dy < newH; dy++) { + const sy = dy * yRatio + const y0 = Math.floor(sy) + const y1 = Math.min(y0 + 1, img.height - 1) + const yFrac = sy - y0 + + for (let dx = 0; dx < newW; dx++) { + const sx = dx * xRatio + const x0 = Math.floor(sx) + const x1 = Math.min(x0 + 1, img.width - 1) + const xFrac = sx - x0 + + const i00 = (y0 * img.width + x0) * 4 + const i10 = (y0 * img.width + x1) * 4 + const i01 = (y1 * img.width + x0) * 4 + const i11 = (y1 * img.width + x1) * 4 + const di = (dy * newW + dx) * 4 + + const w00 = (1 - xFrac) * (1 - yFrac) + const w10 = xFrac * (1 - yFrac) + const w01 = (1 - xFrac) * yFrac + const w11 = xFrac * yFrac + + data[di] = Math.round(img.data[i00] * w00 + img.data[i10] * w10 + img.data[i01] * w01 + img.data[i11] * w11) + data[di + 1] = Math.round(img.data[i00 + 1] * w00 + img.data[i10 + 1] * w10 + img.data[i01 + 1] * w01 + img.data[i11 + 1] * w11) + data[di + 2] = Math.round(img.data[i00 + 2] * w00 + img.data[i10 + 2] * w10 + img.data[i01 + 2] * w01 + img.data[i11 + 2] * w11) + data[di + 3] = Math.round(img.data[i00 + 3] * w00 + img.data[i10 + 3] * w10 + img.data[i01 + 3] * w01 + img.data[i11 + 3] * w11) + } + } + return { data, width: newW, height: newH } +} + +// 90° clockwise: new width = srcHeight, new height = srcWidth +// dst(dx, dy) ← src(col=dy, row=srcH-1-dx) +export function rotate90CW(img: RawImage): RawImage { + const { width: srcW, height: srcH } = img + const data = new Uint8Array(srcW * srcH * 4) + const newW = srcH + const newH = srcW + + for (let dy = 0; dy < newH; dy++) { + for (let dx = 0; dx < newW; dx++) { + const si = ((srcH - 1 - dx) * srcW + dy) * 4 + const di = (dy * newW + dx) * 4 + data[di] = img.data[si] + data[di + 1] = img.data[si + 1] + data[di + 2] = img.data[si + 2] + data[di + 3] = img.data[si + 3] + } + } + return { data, width: newW, height: newH } +} + +// 90° counter-clockwise: new width = srcHeight, new height = srcWidth +// dst(dx, dy) ← src(col=srcW-1-dy, row=dx) +export function rotate90CCW(img: RawImage): RawImage { + const { width: srcW, height: srcH } = img + const data = new Uint8Array(srcW * srcH * 4) + const newW = srcH + const newH = srcW + + for (let dy = 0; dy < newH; dy++) { + for (let dx = 0; dx < newW; dx++) { + const si = (dx * srcW + (srcW - 1 - dy)) * 4 + const di = (dy * newW + dx) * 4 + data[di] = img.data[si] + data[di + 1] = img.data[si + 1] + data[di + 2] = img.data[si + 2] + data[di + 3] = img.data[si + 3] + } + } + return { data, width: newW, height: newH } +} + +export function rotate180(img: RawImage): RawImage { + const data = new Uint8Array(img.data.length) + const total = img.width * img.height + for (let i = 0; i < total; i++) { + const si = i * 4 + const di = (total - 1 - i) * 4 + data[di] = img.data[si] + data[di + 1] = img.data[si + 1] + data[di + 2] = img.data[si + 2] + data[di + 3] = img.data[si + 3] + } + return { data, width: img.width, height: img.height } +} + +// Multiply every pixel's alpha channel by opacity (0–1). Mutates in place. +export function setOpacity(img: RawImage, opacity: number): void { + for (let i = 3; i < img.data.length; i += 4) { + img.data[i] = Math.round(img.data[i] * opacity) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd36116be..e2dac419e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: '@wdio/types': specifier: ^9.27.0 version: 9.27.0 + fast-png: + specifier: ^8.0.0 + version: 8.0.0 jimp: specifier: ^1.6.1 version: 1.6.1 @@ -3877,6 +3880,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@8.0.0: + resolution: {integrity: sha512-gCysNasJ8KEMgfdYIKd/wTDo6ENK1PWT0RJO7O+0pgmuHPw2O6tA1WvdxFRJoLf9V8yFYpG0FA1YgI8X97OhJA==} + fast-xml-parser@5.3.0: resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==} hasBin: true @@ -4446,6 +4452,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iobuffer@6.0.1: + resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -12015,6 +12024,11 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@8.0.0: + dependencies: + fflate: 0.8.2 + iobuffer: 6.0.1 + fast-xml-parser@5.3.0: dependencies: strnum: 2.1.1 @@ -12635,6 +12649,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iobuffer@6.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} From 9c5946ee69e98ad7760222f1822a4557c3389536 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Wed, 3 Jun 2026 06:41:30 +0200 Subject: [PATCH 3/4] chore: linting fix --- packages/image-comparison-core/package.json | 1 - .../methods/__snapshots__/images.test.ts.snap | 1956 +---------------- .../src/methods/images.test.ts | 288 +-- .../src/methods/images.ts | 60 +- .../src/methods/rectangles.ts | 7 +- .../src/pixelmatch/compareImages.test.ts | 137 +- .../src/pixelmatch/compareImages.ts | 41 +- pnpm-lock.yaml | 3 - 8 files changed, 212 insertions(+), 2281 deletions(-) diff --git a/packages/image-comparison-core/package.json b/packages/image-comparison-core/package.json index 59bc2ae29..b9b24849c 100644 --- a/packages/image-comparison-core/package.json +++ b/packages/image-comparison-core/package.json @@ -35,7 +35,6 @@ }, "dependencies": { "fast-png": "^8.0.0", - "jimp": "^1.6.1", "pixelmatch": "^7.2.0", "@wdio/logger": "^9.18.0", "@wdio/types": "^9.27.0" diff --git a/packages/image-comparison-core/src/methods/__snapshots__/images.test.ts.snap b/packages/image-comparison-core/src/methods/__snapshots__/images.test.ts.snap index e593ee0ef..7de262198 100644 --- a/packages/image-comparison-core/src/methods/__snapshots__/images.test.ts.snap +++ b/packages/image-comparison-core/src/methods/__snapshots__/images.test.ts.snap @@ -46,189 +46,21 @@ exports[`checkIfImageExists > should return false when file does not exist 1`] = exports[`checkIfImageExists > should return true when file exists 1`] = `true`; -exports[`cropAndConvertToDataURL > should crop image and add iOS bezel corners when isIOS is true 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should crop image and add iOS bezel corners when isIOS is true 2`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`cropAndConvertToDataURL > should crop image and add iOS bezel corners when isIOS is true 3`] = `"croppedImageData"`; - -exports[`cropAndConvertToDataURL > should crop image and return base64 data without iOS bezel corners 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should crop image and return base64 data without iOS bezel corners 2`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`cropAndConvertToDataURL > should crop image and return base64 data without iOS bezel corners 3`] = `"croppedImageData"`; - -exports[`cropAndConvertToDataURL > should handle Android device (isIOS false) without bezel corners 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle Android device (isIOS false) without bezel corners 2`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle Android device (isIOS false) without bezel corners 3`] = `"croppedImageData"`; - -exports[`cropAndConvertToDataURL > should handle different base64 input data 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle different base64 input data 2`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle different base64 input data 3`] = `"croppedImageData"`; - -exports[`cropAndConvertToDataURL > should handle different device pixel ratios 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle different device pixel ratios 2`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle different device pixel ratios 3`] = `"croppedImageData"`; - -exports[`cropAndConvertToDataURL > should handle landscape orientation with iOS bezel corners 1`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`cropAndConvertToDataURL > should handle landscape orientation with iOS bezel corners 2`] = ` -[ - [ - "image/png", - ], -] -`; +exports[`cropAndConvertToDataURL > should crop image and add iOS bezel corners when isIOS is true 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle landscape orientation with iOS bezel corners 3`] = `"croppedImageData"`; +exports[`cropAndConvertToDataURL > should crop image and return base64 data without iOS bezel corners 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle large crop dimensions 1`] = ` -[ - [ - { - "h": 2000, - "w": 3000, - "x": 1000, - "y": 500, - }, - ], -] -`; +exports[`cropAndConvertToDataURL > should handle Android device (isIOS false) without bezel corners 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle large crop dimensions 2`] = ` -[ - [ - "image/png", - ], -] -`; +exports[`cropAndConvertToDataURL > should handle different base64 input data 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle large crop dimensions 3`] = `"croppedImageData"`; +exports[`cropAndConvertToDataURL > should handle different device pixel ratios 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle zero dimensions 1`] = ` -[ - [ - { - "h": 0, - "w": 0, - "x": 0, - "y": 0, - }, - ], -] -`; +exports[`cropAndConvertToDataURL > should handle landscape orientation with iOS bezel corners 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle zero dimensions 2`] = ` -[ - [ - "image/png", - ], -] -`; +exports[`cropAndConvertToDataURL > should handle large crop dimensions 1`] = `"croppedImageData"`; -exports[`cropAndConvertToDataURL > should handle zero dimensions 3`] = `"croppedImageData"`; +exports[`cropAndConvertToDataURL > should handle zero dimensions 1`] = `"croppedImageData"`; exports[`getAdjustedAxis > should clamp end position to maxDimension when it exceeds maxDimension 1`] = ` [ @@ -307,7 +139,7 @@ exports[`getAdjustedAxis > should return adjusted coordinates within bounds 1`] ] `; -exports[`getRotatedImageIfNeeded > should call rotateBase64Image when landscape and height > width 1`] = `"differentRotatedData"`; +exports[`getRotatedImageIfNeeded > should call rotateBase64Image when landscape and height > width 1`] = `"croppedImageData"`; exports[`getRotatedImageIfNeeded > should call rotateBase64Image when landscape and height > width 2`] = ` [ @@ -561,28 +393,7 @@ exports[`makeCroppedBase64Image > should create cropped base64 image with defaul ] `; -exports[`makeCroppedBase64Image > should create cropped base64 image with default settings 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should create cropped base64 image with default settings 3`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`makeCroppedBase64Image > should create cropped base64 image with default settings 4`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should create cropped base64 image with default settings 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle custom resize dimensions 1`] = ` [ @@ -595,20 +406,7 @@ exports[`makeCroppedBase64Image > should handle custom resize dimensions 1`] = ` ] `; -exports[`makeCroppedBase64Image > should handle custom resize dimensions 2`] = ` -[ - [ - { - "h": 125, - "w": 225, - "x": 45, - "y": 15, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle custom resize dimensions 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle custom resize dimensions 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle different device pixel ratios 1`] = ` [ @@ -621,20 +419,7 @@ exports[`makeCroppedBase64Image > should handle different device pixel ratios 1` ] `; -exports[`makeCroppedBase64Image > should handle different device pixel ratios 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle different device pixel ratios 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle different device pixel ratios 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle different rectangle dimensions 1`] = ` [ @@ -647,20 +432,7 @@ exports[`makeCroppedBase64Image > should handle different rectangle dimensions 1 ] `; -exports[`makeCroppedBase64Image > should handle different rectangle dimensions 2`] = ` -[ - [ - { - "h": 300, - "w": 400, - "x": 100, - "y": 75, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle different rectangle dimensions 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle different rectangle dimensions 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle different screenshot sizes 1`] = ` [ @@ -673,20 +445,7 @@ exports[`makeCroppedBase64Image > should handle different screenshot sizes 1`] = ] `; -exports[`makeCroppedBase64Image > should handle different screenshot sizes 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle different screenshot sizes 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle different screenshot sizes 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle edge case with padding that exceeds image bounds 1`] = ` [ @@ -699,20 +458,7 @@ exports[`makeCroppedBase64Image > should handle edge case with padding that exce ] `; -exports[`makeCroppedBase64Image > should handle edge case with padding that exceeds image bounds 2`] = ` -[ - [ - { - "h": 150, - "w": 100, - "x": 900, - "y": 1850, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle edge case with padding that exceeds image bounds 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle edge case with padding that exceeds image bounds 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle iOS devices with bezel corners 1`] = ` [ @@ -725,20 +471,7 @@ exports[`makeCroppedBase64Image > should handle iOS devices with bezel corners 1 ] `; -exports[`makeCroppedBase64Image > should handle iOS devices with bezel corners 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle iOS devices with bezel corners 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle iOS devices with bezel corners 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle landscape orientation with rotation 1`] = ` [ @@ -751,20 +484,7 @@ exports[`makeCroppedBase64Image > should handle landscape orientation with rotat ] `; -exports[`makeCroppedBase64Image > should handle landscape orientation with rotation 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle landscape orientation with rotation 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle landscape orientation with rotation 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle web driver element screenshots 1`] = ` [ @@ -777,20 +497,7 @@ exports[`makeCroppedBase64Image > should handle web driver element screenshots 1 ] `; -exports[`makeCroppedBase64Image > should handle web driver element screenshots 2`] = ` -[ - [ - { - "h": 100, - "w": 200, - "x": 50, - "y": 25, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle web driver element screenshots 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle web driver element screenshots 2`] = `"croppedImageData"`; exports[`makeCroppedBase64Image > should handle zero rectangle dimensions 1`] = ` [ @@ -803,20 +510,7 @@ exports[`makeCroppedBase64Image > should handle zero rectangle dimensions 1`] = ] `; -exports[`makeCroppedBase64Image > should handle zero rectangle dimensions 2`] = ` -[ - [ - { - "h": 0, - "w": 0, - "x": 0, - "y": 0, - }, - ], -] -`; - -exports[`makeCroppedBase64Image > should handle zero rectangle dimensions 3`] = `"finalCroppedImageData"`; +exports[`makeCroppedBase64Image > should handle zero rectangle dimensions 2`] = `"croppedImageData"`; exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 1`] = ` [ @@ -835,1645 +529,137 @@ exports[`makeFullPageBase64Image > should create full page base64 image with mul ] `; -exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 2`] = ` +exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 2`] = `"croppedImageData"`; + +exports[`makeFullPageBase64Image > should handle canvas Y positions correctly 1`] = `"croppedImageData"`; + +exports[`makeFullPageBase64Image > should handle different device pixel ratios 1`] = ` [ [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, + "screenshot1-data", + 3, ], [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, + "screenshot2-data", + 3, ], [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, + "screenshot3-data", + 3, ], ] `; -exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 3`] = ` +exports[`makeFullPageBase64Image > should handle different device pixel ratios 2`] = `"croppedImageData"`; + +exports[`makeFullPageBase64Image > should handle different screenshot data for each iteration 1`] = ` [ [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 800, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 1600, - ], -] -`; - -exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 4`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`makeFullPageBase64Image > should create full page base64 image with multiple screenshots 5`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle canvas Y positions correctly 1`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 800, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 1600, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle canvas Y positions correctly 2`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle different device pixel ratios 1`] = ` -[ - [ - "screenshot1-data", - 3, - ], - [ - "screenshot2-data", - 3, - ], - [ - "screenshot3-data", - 3, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle different device pixel ratios 2`] = ` -[ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle different device pixel ratios 3`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 800, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 1600, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle different device pixel ratios 4`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle different screenshot data for each iteration 1`] = ` -[ - [ - "screenshot1-data", - 2, - ], - [ - "screenshot2-data", - 2, - ], - [ - "screenshot3-data", - 2, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle different screenshot data for each iteration 2`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle empty screenshots array 1`] = `[]`; - -exports[`makeFullPageBase64Image > should handle empty screenshots array 2`] = `[]`; - -exports[`makeFullPageBase64Image > should handle empty screenshots array 3`] = `[]`; - -exports[`makeFullPageBase64Image > should handle empty screenshots array 4`] = ` -[ - [ - "image/png", - ], -] -`; - -exports[`makeFullPageBase64Image > should handle empty screenshots array 5`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle landscape mode with rotation 1`] = ` -[ - [ - "screenshot1-data", - 2, - ], - [ - "screenshot2-data", - 2, - ], - [ - "screenshot3-data", - 2, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode with rotation 2`] = ` -[ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode with rotation 3`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy] { - "calls": [ - [ - "image/png", - ], - [ - "image/png", - ], - [ - "image/png", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], - }, - "opacity": [MockFunction spy], - "rotate": [MockFunction spy] { - "calls": [ - [ - 90, - ], - [ - 90, - ], - [ - 90, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - }, - 0, - 0, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy] { - "calls": [ - [ - "image/png", - ], - [ - "image/png", - ], - [ - "image/png", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], - }, - "opacity": [MockFunction spy], - "rotate": [MockFunction spy] { - "calls": [ - [ - 90, - ], - [ - 90, - ], - [ - 90, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - }, - 0, - 800, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy] { - "calls": [ - [ - "image/png", - ], - [ - "image/png", - ], - [ - "image/png", - ], - ], - "results": [ - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - { - "type": "return", - "value": Promise {}, - }, - ], - }, - "opacity": [MockFunction spy], - "rotate": [MockFunction spy] { - "calls": [ - [ - 90, - ], - [ - 90, - ], - [ - 90, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - }, - 0, - 1600, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode with rotation 4`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 1`] = ` -[ - [ - "screenshot1-data", - 2, - ], - [ - "screenshot2-data", - 2, - ], - [ - "screenshot3-data", - 2, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 2`] = ` -[ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 3`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 800, + "screenshot1-data", + 2, ], [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 400, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 1600, + "screenshot2-data", + 2, ], -] -`; - -exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 4`] = `"fullPageImageData"`; - -exports[`makeFullPageBase64Image > should handle large canvas dimensions 1`] = ` -[ [ - "large-screenshot-data", + "screenshot3-data", 2, ], ] `; -exports[`makeFullPageBase64Image > should handle large canvas dimensions 2`] = ` -[ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; +exports[`makeFullPageBase64Image > should handle different screenshot data for each iteration 2`] = `"croppedImageData"`; -exports[`makeFullPageBase64Image > should handle large canvas dimensions 3`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], -] -`; +exports[`makeFullPageBase64Image > should handle empty screenshots array 1`] = `[]`; -exports[`makeFullPageBase64Image > should handle large canvas dimensions 4`] = `"fullPageImageData"`; +exports[`makeFullPageBase64Image > should handle empty screenshots array 2`] = `"croppedImageData"`; -exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 1`] = ` +exports[`makeFullPageBase64Image > should handle landscape mode with rotation 1`] = ` [ [ - "cropped-screenshot-data", + "screenshot1-data", 2, ], -] -`; - -exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 2`] = ` -[ [ - { - "h": 500, - "w": 900, - "x": 100, - "y": 50, - }, + "screenshot2-data", + 2, ], -] -`; - -exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 3`] = ` -[ [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 500, - "w": 900, - "x": 100, - "y": 50, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, + "screenshot3-data", + 2, ], ] `; -exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 4`] = `"fullPageImageData"`; +exports[`makeFullPageBase64Image > should handle landscape mode with rotation 2`] = `"croppedImageData"`; -exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 1`] = ` +exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 1`] = ` [ [ - "wide-screenshot-data", + "screenshot1-data", 2, ], [ - "tall-screenshot-data", + "screenshot2-data", 2, ], -] -`; - -exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 2`] = ` -[ - [ - { - "h": 600, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; - -exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 3`] = ` -[ - [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 600, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, - ], [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 600, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 600, + "screenshot3-data", + 2, ], ] `; -exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 4`] = `"fullPageImageData"`; +exports[`makeFullPageBase64Image > should handle landscape mode without rotation when width >= height 2`] = `"croppedImageData"`; -exports[`makeFullPageBase64Image > should handle single screenshot 1`] = ` +exports[`makeFullPageBase64Image > should handle large canvas dimensions 1`] = ` [ [ - "single-screenshot-data", + "large-screenshot-data", 2, ], ] `; -exports[`makeFullPageBase64Image > should handle single screenshot 2`] = ` -[ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], -] -`; +exports[`makeFullPageBase64Image > should handle large canvas dimensions 2`] = `"croppedImageData"`; -exports[`makeFullPageBase64Image > should handle single screenshot 3`] = ` +exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 1`] = ` [ [ - { - "bitmap": { - "height": 800, - "width": 1000, - }, - "composite": [MockFunction spy], - "crop": [MockFunction spy] { - "calls": [ - [ - { - "h": 800, - "w": 1000, - "x": 0, - "y": 0, - }, - ], - ], - "results": [ - { - "type": "return", - "value": [Circular], - }, - ], - }, - "getBase64": [MockFunction spy], - "opacity": [MockFunction spy], - "rotate": [MockFunction spy], - }, - 0, - 0, + "cropped-screenshot-data", + 2, ], ] `; -exports[`makeFullPageBase64Image > should handle single screenshot 4`] = `"fullPageImageData"`; - -exports[`rotateBase64Image > should handle different base64 input 1`] = `"differentRotatedData"`; - -exports[`rotateBase64Image > should handle different base64 input 2`] = ` -[ - [ - { - "data": [ - 118, - 39, - 223, - 122, - 183, - 167, - 180, - 137, - 154, - 129, - 224, - 218, - 181, - ], - "type": "Buffer", - }, - ], -] -`; +exports[`makeFullPageBase64Image > should handle screenshots with cropping positions 2`] = `"croppedImageData"`; -exports[`rotateBase64Image > should handle different base64 input 3`] = ` +exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 1`] = ` [ [ - 270, + "wide-screenshot-data", + 2, ], -] -`; - -exports[`rotateBase64Image > should rotate image by 180 degrees 1`] = `"rotatedImageData"`; - -exports[`rotateBase64Image > should rotate image by 180 degrees 2`] = ` -[ [ - 180, + "tall-screenshot-data", + 2, ], ] `; -exports[`rotateBase64Image > should rotate image by specified degrees 1`] = `"rotatedImageData"`; +exports[`makeFullPageBase64Image > should handle screenshots with different dimensions 2`] = `"croppedImageData"`; -exports[`rotateBase64Image > should rotate image by specified degrees 2`] = ` -[ - [ - { - "data": [ - 162, - 184, - 160, - 138, - 118, - 165, - 34, - 102, - 160, - 120, - 54, - 173, - ], - "type": "Buffer", - }, - ], -] -`; - -exports[`rotateBase64Image > should rotate image by specified degrees 3`] = ` +exports[`makeFullPageBase64Image > should handle single screenshot 1`] = ` [ [ - 90, + "single-screenshot-data", + 2, ], ] `; -exports[`rotateBase64Image > should rotate image by specified degrees 4`] = ` -[ - [ - "image/png", - ], -] -`; +exports[`makeFullPageBase64Image > should handle single screenshot 2`] = `"croppedImageData"`; exports[`takeBase64ElementScreenshot > should fallback to takeResizedBase64Screenshot when takeElementScreenshot throws an error 1`] = ` [ diff --git a/packages/image-comparison-core/src/methods/images.test.ts b/packages/image-comparison-core/src/methods/images.test.ts index 54bbbb7a7..460b0ff02 100644 --- a/packages/image-comparison-core/src/methods/images.test.ts +++ b/packages/image-comparison-core/src/methods/images.test.ts @@ -18,23 +18,25 @@ import { takeResizedBase64Screenshot, } from './images.js' import type { WicElement } from '../commands/element.interfaces.js' +import * as imageUtils from '../utils/imageUtils.js' const log = logger('test') -vi.mock('jimp', () => ({ - Jimp: Object.assign(vi.fn().mockImplementation(() => ({ - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,mockImageData'), - opacity: vi.fn().mockReturnThis(), - rotate: vi.fn().mockReturnThis(), - crop: vi.fn().mockReturnThis(), - })), { - read: vi.fn(), - MIME_PNG: 'image/png', - }), - JimpMime: { - png: 'image/png', - }, +const makeRawImage = (width = 1000, height = 800) => ({ + data: new Uint8Array(width * height * 4), + width, + height, +}) +vi.mock('../utils/imageUtils.js', () => ({ + decodeImage: vi.fn().mockImplementation(() => makeRawImage()), + cropImage: vi.fn().mockImplementation(() => makeRawImage(200, 100)), + compositeImage: vi.fn(), + createCanvas: vi.fn().mockImplementation((w: number, h: number) => makeRawImage(w, h)), + setOpacity: vi.fn(), + toBase64Png: vi.fn().mockReturnValue('croppedImageData'), + rotate90CW: vi.fn().mockImplementation(() => makeRawImage(800, 1000)), + rotate180: vi.fn().mockImplementation(() => makeRawImage()), + encodeImage: vi.fn().mockReturnValue(Buffer.from('encoded')), })) vi.mock('@wdio/logger', () => import(join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('node:fs', async () => { @@ -287,66 +289,27 @@ describe('checkBaselineImageExists', () => { }) describe('rotateBase64Image', () => { - let jimpReadMock: ReturnType - - beforeEach(async () => { - const jimp = await import('jimp') - jimpReadMock = vi.mocked(jimp.Jimp.read) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should rotate image by specified degrees', async () => { - const mockImage = { - rotate: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,rotatedImageData') - } - jimpReadMock.mockResolvedValue(mockImage) + afterEach(() => { vi.clearAllMocks() }) - const result = await rotateBase64Image({ - base64Image: 'originalImageData', - degrees: 90 - }) + it('calls rotate90CW for 90 degrees and returns toBase64Png result', () => { + const result = rotateBase64Image({ base64Image: 'originalImageData', degrees: 90 }) - expect(result).toMatchSnapshot() - expect(jimpReadMock.mock.calls).toMatchSnapshot() - expect(mockImage.rotate.mock.calls).toMatchSnapshot() - expect(mockImage.getBase64.mock.calls).toMatchSnapshot() + expect(vi.mocked(imageUtils.rotate90CW)).toHaveBeenCalledTimes(1) + expect(vi.mocked(imageUtils.rotate180)).not.toHaveBeenCalled() + expect(result).toBe('croppedImageData') }) - it('should rotate image by 180 degrees', async () => { - const mockImage = { - rotate: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,rotatedImageData') - } - jimpReadMock.mockResolvedValue(mockImage) - - const result = await rotateBase64Image({ - base64Image: 'originalImageData', - degrees: 180 - }) + it('calls rotate180 for 180 degrees', () => { + rotateBase64Image({ base64Image: 'originalImageData', degrees: 180 }) - expect(result).toMatchSnapshot() - expect(mockImage.rotate.mock.calls).toMatchSnapshot() + expect(vi.mocked(imageUtils.rotate180)).toHaveBeenCalledTimes(1) + expect(vi.mocked(imageUtils.rotate90CW)).not.toHaveBeenCalled() }) - it('should handle different base64 input', async () => { - const mockImage = { - rotate: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,differentRotatedData') - } - jimpReadMock.mockResolvedValue(mockImage) + it('calls rotate90CW for any other degree value', () => { + rotateBase64Image({ base64Image: 'differentImageData', degrees: 270 }) - const result = await rotateBase64Image({ - base64Image: 'differentImageData', - degrees: 270 - }) - - expect(result).toMatchSnapshot() - expect(jimpReadMock.mock.calls).toMatchSnapshot() - expect(mockImage.rotate.mock.calls).toMatchSnapshot() + expect(vi.mocked(imageUtils.rotate90CW)).toHaveBeenCalledTimes(1) }) }) @@ -640,7 +603,8 @@ describe('handleIOSBezelCorners', () => { let getIosBezelImageNamesMock: ReturnType let readFileSyncMock: ReturnType let getBase64ScreenshotSizeMock: ReturnType - let mockImage: any + let mockImage: { data: Uint8Array; width: number; height: number } + let compositeImageFn: ReturnType beforeEach(async () => { logWarnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}) @@ -652,12 +616,8 @@ describe('handleIOSBezelCorners', () => { const fsModule = vi.mocked(await import('node:fs')) readFileSyncMock = vi.spyOn(fsModule, 'readFileSync') - mockImage = { - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,mockImageData'), - opacity: vi.fn().mockReturnThis(), - rotate: vi.fn().mockReturnThis(), - } + mockImage = { data: new Uint8Array(4), width: 100, height: 100 } + compositeImageFn = vi.mocked(imageUtils.compositeImage) }) afterEach(() => { @@ -701,7 +661,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).toHaveBeenCalledTimes(2) - expect(mockImage.composite).toHaveBeenCalledTimes(2) + expect(compositeImageFn).toHaveBeenCalledTimes(2) expect(logWarnSpy).not.toHaveBeenCalled() }) @@ -725,7 +685,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).toHaveBeenCalledTimes(2) - expect(mockImage.composite).toHaveBeenCalledTimes(2) + expect(compositeImageFn).toHaveBeenCalledTimes(2) expect(logWarnSpy).not.toHaveBeenCalled() }) @@ -749,7 +709,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).toHaveBeenCalledTimes(2) - expect(mockImage.composite).toHaveBeenCalledTimes(2) + expect(compositeImageFn).toHaveBeenCalledTimes(2) expect(logWarnSpy).not.toHaveBeenCalled() }) @@ -789,7 +749,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).toHaveBeenCalledTimes(2) - expect(mockImage.composite).toHaveBeenCalledTimes(2) + expect(compositeImageFn).toHaveBeenCalledTimes(2) expect(logWarnSpy).not.toHaveBeenCalled() }) @@ -827,7 +787,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).not.toHaveBeenCalled() - expect(mockImage.composite).not.toHaveBeenCalled() + expect(compositeImageFn).not.toHaveBeenCalled() expect(logWarnSpy.mock.calls).toMatchSnapshot() }) @@ -849,7 +809,7 @@ describe('handleIOSBezelCorners', () => { expect(getIosBezelImageNamesMock.mock.calls).toMatchSnapshot() expect(readFileSyncMock).not.toHaveBeenCalled() - expect(mockImage.composite).not.toHaveBeenCalled() + expect(compositeImageFn).not.toHaveBeenCalled() expect(logWarnSpy.mock.calls).toMatchSnapshot() }) @@ -871,9 +831,6 @@ describe('handleIOSBezelCorners', () => { }) describe('cropAndConvertToDataURL', () => { - let mockImage: any - let mockCroppedImage: any - const defaultCropOptions = { addIOSBezelCorners: false, base64Image: 'originalImageData', @@ -887,125 +844,52 @@ describe('cropAndConvertToDataURL', () => { width: 200, } - beforeEach(async () => { - mockCroppedImage = { - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,croppedImageData'), - } - mockImage = { - crop: vi.fn().mockReturnValue(mockCroppedImage), - } - - const jimpModule = vi.mocked(await import('jimp')) - vi.spyOn(jimpModule.Jimp, 'read').mockResolvedValue(mockImage) - }) - - afterEach(() => { - vi.clearAllMocks() - }) + afterEach(() => { vi.clearAllMocks() }) it('should crop image and return base64 data without iOS bezel corners', async () => { const result = await cropAndConvertToDataURL(defaultCropOptions) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + expect(vi.mocked(imageUtils.cropImage)).toHaveBeenCalledWith(expect.any(Object), 50, 25, 200, 100) expect(result).toMatchSnapshot() }) it('should crop image and add iOS bezel corners when isIOS is true', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - addIOSBezelCorners: true, - isIOS: true, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, addIOSBezelCorners: true, isIOS: true }) expect(result).toMatchSnapshot() }) it('should handle landscape orientation with iOS bezel corners', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - addIOSBezelCorners: true, - isIOS: true, - isLandscape: true, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, addIOSBezelCorners: true, isIOS: true, isLandscape: true }) expect(result).toMatchSnapshot() }) it('should handle Android device (isIOS false) without bezel corners', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - addIOSBezelCorners: true, - deviceName: 'Samsung Galaxy S21', - isIOS: false, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, addIOSBezelCorners: true, deviceName: 'Samsung Galaxy S21', isIOS: false }) expect(result).toMatchSnapshot() }) it('should handle zero dimensions', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - height: 0, - sourceX: 0, - sourceY: 0, - width: 0, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, height: 0, sourceX: 0, sourceY: 0, width: 0 }) expect(result).toMatchSnapshot() }) it('should handle large crop dimensions', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - height: 2000, - sourceX: 1000, - sourceY: 500, - width: 3000, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, height: 2000, sourceX: 1000, sourceY: 500, width: 3000 }) expect(result).toMatchSnapshot() }) it('should handle different base64 input data', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - base64Image: 'differentImageData123', - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, base64Image: 'differentImageData123' }) expect(result).toMatchSnapshot() }) it('should handle different device pixel ratios', async () => { - const result = await cropAndConvertToDataURL({ - ...defaultCropOptions, - addIOSBezelCorners: true, - devicePixelRatio: 2, - isIOS: true, - }) - - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() + const result = await cropAndConvertToDataURL({ ...defaultCropOptions, addIOSBezelCorners: true, devicePixelRatio: 2, isIOS: true }) expect(result).toMatchSnapshot() }) }) describe('makeCroppedBase64Image', () => { let getBase64ScreenshotSizeMock: ReturnType - let mockImage: any - let mockCroppedImage: any const defaultCropOptions = { addIOSBezelCorners: false, @@ -1027,22 +911,6 @@ describe('makeCroppedBase64Image', () => { beforeEach(async () => { const utilsModule = vi.mocked(await import('../helpers/utils.js')) getBase64ScreenshotSizeMock = vi.spyOn(utilsModule, 'getBase64ScreenshotSize') - mockCroppedImage = { - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,finalCroppedImageData'), - composite: vi.fn().mockReturnThis(), - opacity: vi.fn().mockReturnThis(), - } - mockImage = { - crop: vi.fn().mockReturnValue(mockCroppedImage), - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,mockImageData'), - opacity: vi.fn().mockReturnThis(), - rotate: vi.fn().mockReturnThis(), - } - - const jimpModule = vi.mocked(await import('jimp')) - vi.spyOn(jimpModule.Jimp, 'read').mockResolvedValue(mockImage) - getBase64ScreenshotSizeMock.mockReturnValue({ width: 1000, height: 2000 }) }) @@ -1054,8 +922,6 @@ describe('makeCroppedBase64Image', () => { const result = await makeCroppedBase64Image(defaultCropOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCroppedImage.getBase64.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1066,7 +932,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1077,7 +942,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1089,7 +953,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1100,7 +963,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1116,7 +978,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1126,7 +987,6 @@ describe('makeCroppedBase64Image', () => { const result = await makeCroppedBase64Image(defaultCropOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1137,7 +997,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1153,7 +1012,6 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1170,15 +1028,12 @@ describe('makeCroppedBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) }) describe('makeFullPageBase64Image', () => { let getBase64ScreenshotSizeMock: ReturnType - let mockCanvas: any - let mockImage: any const defaultScreenshotsData = { fullPageHeight: 2000, @@ -1222,31 +1077,6 @@ describe('makeFullPageBase64Image', () => { beforeEach(async () => { const utilsModule = vi.mocked(await import('../helpers/utils.js')) getBase64ScreenshotSizeMock = vi.spyOn(utilsModule, 'getBase64ScreenshotSize') - mockCanvas = { - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,fullPageImageData'), - } - mockImage = { - bitmap: { - width: 1000, - height: 800, - }, - crop: vi.fn().mockReturnThis(), - composite: vi.fn().mockReturnThis(), - getBase64: vi.fn().mockResolvedValue('data:image/png;base64,mockImageData'), - opacity: vi.fn().mockReturnThis(), - rotate: vi.fn().mockReturnThis(), - } - const jimpModule = vi.mocked(await import('jimp')) - - vi.spyOn(jimpModule.Jimp, 'read').mockResolvedValue(mockImage) - vi.mocked(jimpModule.Jimp).mockImplementation((options: any) => { - if (options && (options.width || options.height)) { - return mockCanvas - } - return mockImage - }) - getBase64ScreenshotSizeMock.mockReturnValue({ width: 1000, height: 800 }) }) @@ -1258,9 +1088,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(defaultScreenshotsData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() - expect(mockCanvas.getBase64.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1273,8 +1100,6 @@ describe('makeFullPageBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1298,8 +1123,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(singleScreenshotData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1310,8 +1133,6 @@ describe('makeFullPageBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1348,8 +1169,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(mixedScreenshotsData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1373,8 +1192,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(croppedScreenshotsData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1387,8 +1204,6 @@ describe('makeFullPageBase64Image', () => { }) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1402,9 +1217,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(emptyScreenshotsData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() - expect(mockCanvas.getBase64.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1430,8 +1242,6 @@ describe('makeFullPageBase64Image', () => { const result = await makeFullPageBase64Image(largeScreenshotsData, defaultOptions) expect(getBase64ScreenshotSizeMock.mock.calls).toMatchSnapshot() - expect(mockImage.crop.mock.calls).toMatchSnapshot() - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() expect(result).toMatchSnapshot() }) @@ -1445,7 +1255,7 @@ describe('makeFullPageBase64Image', () => { it('should handle canvas Y positions correctly', async () => { const result = await makeFullPageBase64Image(defaultScreenshotsData, defaultOptions) - expect(mockCanvas.composite.mock.calls).toMatchSnapshot() + expect(vi.mocked(imageUtils.compositeImage).mock.calls.length).toBeGreaterThan(0) expect(result).toMatchSnapshot() }) }) diff --git a/packages/image-comparison-core/src/methods/images.ts b/packages/image-comparison-core/src/methods/images.ts index dfe0003c0..3c153f103 100644 --- a/packages/image-comparison-core/src/methods/images.ts +++ b/packages/image-comparison-core/src/methods/images.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url' import { readFileSync, writeFileSync, promises as fsPromises, constants } from 'node:fs' import { dirname, join } from 'node:path' -import { Jimp, JimpMime } from 'jimp' +import { decodeImage, toBase64Png, createCanvas, cropImage, compositeImage, setOpacity, rotate90CW, rotate180 } from '../utils/imageUtils.js' import logger from '@wdio/logger' import compareImagesPixelmatch from '../pixelmatch/compareImages.js' import { calculateDprData, getIosBezelImageNames, getBase64ScreenshotSize, prepareComparisonFilePaths, updateVisualBaseline } from '../helpers/utils.js' @@ -125,7 +125,7 @@ export async function getRotatedImageIfNeeded({ isWebDriverElementScreenshot, is const { height: screenshotHeight, width: screenshotWidth } = getBase64ScreenshotSize(base64Image) const isRotated = !isWebDriverElementScreenshot && isLandscape && screenshotHeight > screenshotWidth - return isRotated ? await rotateBase64Image({ base64Image, degrees: 90 }) : base64Image + return isRotated ? rotateBase64Image({ base64Image, degrees: 90 }) : base64Image } /** @@ -214,11 +214,11 @@ export async function handleIOSBezelCorners({ const topImage = readFileSync(join(__dirname, '..', '..', 'assets', 'ios', `${topImageName}.png`), { encoding: 'base64' }) const bottomImage = readFileSync(join(__dirname, '..', '..', 'assets', 'ios', `${bottomImageName}.png`), { encoding: 'base64' }) - const topBase64Image = isLandscape ? await rotateBase64Image({ base64Image: topImage, degrees: 90 }) : topImage - const bottomBase64Image = isLandscape ? await rotateBase64Image({ base64Image: bottomImage, degrees: 90 }) : bottomImage + const topBase64Image = isLandscape ? rotateBase64Image({ base64Image: topImage, degrees: 90 }) : topImage + const bottomBase64Image = isLandscape ? rotateBase64Image({ base64Image: bottomImage, degrees: 90 }) : bottomImage - image.composite(await Jimp.read(Buffer.from(topBase64Image, 'base64')), 0, 0) - image.composite(await Jimp.read(Buffer.from(bottomBase64Image, 'base64')), + compositeImage(image, decodeImage(Buffer.from(topBase64Image, 'base64')), 0, 0) + compositeImage(image, decodeImage(Buffer.from(bottomBase64Image, 'base64')), isLandscape ? width - getBase64ScreenshotSize(bottomImage).height : 0, isLandscape ? 0 : height - getBase64ScreenshotSize(bottomImage).height ) @@ -262,15 +262,14 @@ export async function cropAndConvertToDataURL({ sourceY, width, }: CropAndConvertToDataURL): Promise { - const image = await Jimp.read(Buffer.from(base64Image, 'base64')) - const croppedImage = image.crop({ x:sourceX, y:sourceY, w:width, h:height }) + const image = decodeImage(Buffer.from(base64Image, 'base64')) + const croppedImage = cropImage(image, sourceX, sourceY, width, height) if (isIOS) { await handleIOSBezelCorners({ addIOSBezelCorners, image: croppedImage, deviceName, devicePixelRatio, height, isLandscape, width }) } - const base64CroppedImage = await croppedImage.getBase64(JimpMime.png) - return base64CroppedImage.replace(/^data:image\/png;base64,/, '') + return toBase64Png(croppedImage) } /** @@ -524,21 +523,21 @@ export async function makeFullPageBase64Image( ): Promise { const amountOfScreenshots = screenshotsData.data.length const { fullPageHeight: canvasHeight, fullPageWidth: canvasWidth } = screenshotsData - const canvas = await new Jimp({ width: canvasWidth, height: canvasHeight }) + const canvas = createCanvas(canvasWidth, canvasHeight) // Load all the images for (let i = 0; i < amountOfScreenshots; i++) { const currentScreenshot = screenshotsData.data[i].screenshot const { height: screenshotHeight, width: screenshotWidth } = getBase64ScreenshotSize(currentScreenshot, devicePixelRatio) const isRotated = isLandscape && screenshotHeight > screenshotWidth - const newBase64Image = isRotated ? await rotateBase64Image({ base64Image: currentScreenshot, degrees: 90 }) : currentScreenshot + const newBase64Image = isRotated ? rotateBase64Image({ base64Image: currentScreenshot, degrees: 90 }) : currentScreenshot const { canvasYPosition, imageHeight, imageXPosition, imageYPosition } = screenshotsData.data[i] - const image = await Jimp.read(Buffer.from(newBase64Image, 'base64')) + const image = decodeImage(Buffer.from(newBase64Image, 'base64')) // Clamp crop dimensions to fit within the actual image bounds // This is especially important for the last image where the calculated height might exceed available pixels - const actualImageWidth = image.bitmap.width - const actualImageHeight = image.bitmap.height + const actualImageWidth = image.width + const actualImageHeight = image.height const clampedCropX = Math.max(0, Math.min(imageXPosition, actualImageWidth - 1)) const clampedCropY = Math.max(0, Math.min(imageYPosition, actualImageHeight - 1)) // Ensure the cropped width matches the canvas width to avoid 1px gaps due to rounding @@ -547,15 +546,10 @@ export async function makeFullPageBase64Image( const clampedCropWidth = Math.min(canvasWidth, maxAvailableWidth) const clampedCropHeight = Math.min(imageHeight, actualImageHeight - clampedCropY) - canvas.composite( - image.crop({ x: clampedCropX, y: clampedCropY, w: clampedCropWidth, h: clampedCropHeight }), - 0, - canvasYPosition - ) + compositeImage(canvas, cropImage(image, clampedCropX, clampedCropY, clampedCropWidth, clampedCropHeight), 0, canvasYPosition) } - const base64FullPageImage = await canvas.getBase64(JimpMime.png) - return base64FullPageImage.replace(/^data:image\/png;base64,/, '') + return toBase64Png(canvas) } /** @@ -570,31 +564,27 @@ export async function saveBase64Image(base64Image: string, filePath: string) { * Create a canvas with the ignore boxes if they are present */ export async function addBlockOuts(screenshot: string, ignoredBoxes: IgnoreBoxes[]): Promise { - const image = await Jimp.read(Buffer.from(screenshot, 'base64')) + const image = decodeImage(Buffer.from(screenshot, 'base64')) // Loop over all ignored areas and add them to the current canvas for (const ignoredBox of ignoredBoxes) { const { right: ignoredBoxWidth, bottom: ignoredBoxHeight, left: x, top: y } = ignoredBox - const ignoreCanvas = new Jimp({ width: ignoredBoxWidth - x, height: ignoredBoxHeight - y, color: '#39aa56' }) - ignoreCanvas.opacity(0.5) - - image.composite(ignoreCanvas, x, y) + const ignoreCanvas = createCanvas(ignoredBoxWidth - x, ignoredBoxHeight - y, 57, 170, 86, 255) + setOpacity(ignoreCanvas, 0.5) + compositeImage(image, ignoreCanvas, x, y) } - const base64ImageWithBlockOuts = await image.getBase64(JimpMime.png) - return base64ImageWithBlockOuts.replace(/^data:image\/png;base64,/, '') + return toBase64Png(image) } /** * Rotate a base64 image * Tnx to https://gist.github.com/Zyndoras/6897abdf53adbedf02564808aaab94db */ -export async function rotateBase64Image({ base64Image, degrees }: RotateBase64ImageOptions): Promise { - const image = await Jimp.read(Buffer.from(base64Image, 'base64')) - const rotatedImage = image.rotate(degrees) - const base64RotatedImage = await rotatedImage.getBase64(JimpMime.png) - - return base64RotatedImage.replace(/^data:image\/png;base64,/, '') +export function rotateBase64Image({ base64Image, degrees }: RotateBase64ImageOptions): string { + const image = decodeImage(Buffer.from(base64Image, 'base64')) + const rotated = degrees === 180 ? rotate180(image) : rotate90CW(image) + return toBase64Png(rotated) } /** diff --git a/packages/image-comparison-core/src/methods/rectangles.ts b/packages/image-comparison-core/src/methods/rectangles.ts index 2d01a4dc0..86b49432a 100644 --- a/packages/image-comparison-core/src/methods/rectangles.ts +++ b/packages/image-comparison-core/src/methods/rectangles.ts @@ -1,4 +1,5 @@ -import { Jimp } from 'jimp' +import { readFileSync } from 'node:fs' +import { decodeImage } from '../utils/imageUtils.js' import { ANDROID_OFFSETS, IOS_OFFSETS } from '../helpers/constants.js' import { calculateDprData, getBase64ScreenshotSize, isObject } from '../helpers/utils.js' import { getElementPositionAndroid, getElementPositionDesktop, getElementWebviewPosition } from './elementPosition.js' @@ -684,8 +685,8 @@ export async function prepareIgnoreRectangles(options: PrepareIgnoreRectanglesOp try { // For iOS: block out home bar if (!isAndroid && deviceRectangles.homeBar.height > 0) { - const image = await Jimp.read(actualFilePath) - const imageHeightDevicePixels = image.bitmap.height + const image = decodeImage(readFileSync(actualFilePath)) + const imageHeightDevicePixels = image.height const imageHeightCssPixels = imageHeightDevicePixels / devicePixelRatio // Adjust home bar X position relative to the viewport (full page image only contains viewport) const viewportXCssPixels = deviceRectangles.viewport.x diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts index 24f481400..dd9b26160 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts @@ -4,46 +4,39 @@ vi.mock('pixelmatch', () => ({ default: vi.fn() })) -vi.mock('jimp', () => { - const makeImageMock = (width = 100, height = 100) => ({ - bitmap: { - data: Buffer.alloc(width * height * 4, 128), - width, - height, - }, - resize: vi.fn(), - contain: vi.fn(), - getBuffer: vi.fn().mockResolvedValue(Buffer.from('png-data')), +vi.mock('../utils/imageUtils.js', () => { + const makeImage = (width = 100, height = 100) => ({ + data: new Uint8Array(width * height * 4).fill(128), + width, + height, }) - const JimpMock = vi.fn().mockImplementation(() => makeImageMock()) as any - JimpMock.read = vi.fn().mockImplementation(() => Promise.resolve(makeImageMock())) - return { - Jimp: JimpMock, - JimpMime: { png: 'image/png' }, + decodeImage: vi.fn().mockImplementation(() => makeImage()), + resizeBilinear: vi.fn().mockImplementation((_img: unknown, w: number, h: number) => makeImage(w, h)), + encodeImage: vi.fn().mockReturnValue(Buffer.from('png-data')), } }) import compareImages from './compareImages.js' import pixelmatch from 'pixelmatch' +import * as imageUtils from '../utils/imageUtils.js' const pixelmatchFn = vi.mocked(pixelmatch) +const decodeImageFn = vi.mocked(imageUtils.decodeImage) +const resizeBilinearFn = vi.mocked(imageUtils.resizeBilinear) describe('pixelmatch adapter - compareImages', () => { beforeEach(() => { vi.clearAllMocks() + decodeImageFn.mockReturnValue({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) }) describe('basic comparison', () => { it('returns zero mismatch percentage when images are identical', async () => { pixelmatchFn.mockImplementation(() => 0) - const result = await compareImages( - Buffer.from('img1'), - Buffer.from('img2'), - {} - ) + const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) expect(result.rawMisMatchPercentage).toBe(0) expect(result.misMatchPercentage).toBe(0) @@ -54,11 +47,7 @@ describe('pixelmatch adapter - compareImages', () => { // 100x100 = 10000 total pixels, 100 diff pixels = 1% pixelmatchFn.mockImplementation(() => 100) - const result = await compareImages( - Buffer.from('img1'), - Buffer.from('img2'), - {} - ) + const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) expect(result.rawMisMatchPercentage).toBeCloseTo(1, 5) expect(result.misMatchPercentage).toBe(1) @@ -85,7 +74,7 @@ describe('pixelmatch adapter - compareImages', () => { describe('diffPixels and diffBounds', () => { it('collects magenta pixels as diff pixel coordinates', async () => { pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { - // Place a magenta pixel at x=5, y=3 (offset = (3*100 + 5) * 4 = 1220) + // Place a magenta pixel at x=5, y=3 const pos = (3 * width + 5) * 4 output[pos] = 255 output[pos + 1] = 0 @@ -102,7 +91,6 @@ describe('pixelmatch adapter - compareImages', () => { it('does not count grayscale matching pixels as diff pixels', async () => { pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { - // Grayscale pixel (matching pixel rendered at low opacity) const pos = (2 * width + 10) * 4 output[pos] = 200 output[pos + 1] = 200 @@ -141,7 +129,6 @@ describe('pixelmatch adapter - compareImages', () => { const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) - // Sentinel: left=width, top=height, right=0, bottom=0 expect(result.diffBounds.left).toBeGreaterThan(result.diffBounds.right) expect(result.diffBounds.top).toBeGreaterThan(result.diffBounds.bottom) }) @@ -154,11 +141,8 @@ describe('pixelmatch adapter - compareImages', () => { await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'nothing' }) expect(pixelmatchFn).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(Number), - expect.any(Number), + expect.anything(), expect.anything(), expect.anything(), + expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0, includeAA: true }) ) }) @@ -169,11 +153,8 @@ describe('pixelmatch adapter - compareImages', () => { await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'less' }) expect(pixelmatchFn).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(Number), - expect.any(Number), + expect.anything(), expect.anything(), expect.anything(), + expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063, includeAA: false }) ) }) @@ -184,11 +165,8 @@ describe('pixelmatch adapter - compareImages', () => { await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'antialiasing' }) expect(pixelmatchFn).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(Number), - expect.any(Number), + expect.anything(), expect.anything(), expect.anything(), + expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.13, includeAA: false }) ) }) @@ -199,11 +177,8 @@ describe('pixelmatch adapter - compareImages', () => { await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) expect(pixelmatchFn).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(Number), - expect.any(Number), + expect.anything(), expect.anything(), expect.anything(), + expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.13, includeAA: false }) ) }) @@ -215,13 +190,9 @@ describe('pixelmatch adapter - compareImages', () => { ignore: ['antialiasing', 'less'] }) - // 'less' wins over 'antialiasing' in the priority check expect(pixelmatchFn).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(Number), - expect.any(Number), + expect.anything(), expect.anything(), expect.anything(), + expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063 }) ) }) @@ -244,7 +215,6 @@ describe('pixelmatch adapter - compareImages', () => { } }) - // Pixel at (0,0) should be zeroed in both arrays expect(capturedImg1![0]).toBe(0) expect(capturedImg1![1]).toBe(0) expect(capturedImg1![2]).toBe(0) @@ -254,52 +224,35 @@ describe('pixelmatch adapter - compareImages', () => { }) describe('scaleToSameSize', () => { - it('calls resize on the smaller image when scaleToSameSize is true and images differ in size', async () => { - const jimp = await import('jimp') - const smallImage = { - bitmap: { data: Buffer.alloc(50 * 50 * 4, 0), width: 50, height: 50 }, - resize: vi.fn(), - contain: vi.fn(), - getBuffer: vi.fn().mockResolvedValue(Buffer.from('png')), - } - const largeImage = { - bitmap: { data: Buffer.alloc(100 * 100 * 4, 0), width: 100, height: 100 }, - resize: vi.fn(), - contain: vi.fn(), - getBuffer: vi.fn().mockResolvedValue(Buffer.from('png')), - } - - vi.mocked(jimp.Jimp.read) - .mockResolvedValueOnce(largeImage as any) - .mockResolvedValueOnce(smallImage as any) - + it('calls resizeBilinear on the smaller image when images differ in size', async () => { + decodeImageFn + .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 }) + .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 }) pixelmatchFn.mockImplementation(() => 0) - await compareImages(Buffer.from('img1'), Buffer.from('img2'), { - scaleToSameSize: true - }) + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) - expect(smallImage.resize).toHaveBeenCalledWith({ w: 100, h: 100 }) - expect(largeImage.resize).not.toHaveBeenCalled() + expect(resizeBilinearFn).toHaveBeenCalledTimes(1) + expect(resizeBilinearFn).toHaveBeenCalledWith( + expect.objectContaining({ width: 50, height: 50 }), + 100, 100 + ) }) - it('does not call resize when scaleToSameSize is false', async () => { - const jimp = await import('jimp') - const img = { - bitmap: { data: Buffer.alloc(100 * 100 * 4, 0), width: 100, height: 100 }, - resize: vi.fn(), - contain: vi.fn(), - getBuffer: vi.fn().mockResolvedValue(Buffer.from('png')), - } + it('does not call resizeBilinear when scaleToSameSize is false', async () => { + pixelmatchFn.mockImplementation(() => 0) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: false }) - vi.mocked(jimp.Jimp.read).mockResolvedValue(img as any) + expect(resizeBilinearFn).not.toHaveBeenCalled() + }) + + it('does not call resizeBilinear when images are the same size', async () => { pixelmatchFn.mockImplementation(() => 0) - await compareImages(Buffer.from('img1'), Buffer.from('img2'), { - scaleToSameSize: false - }) + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) - expect(img.resize).not.toHaveBeenCalled() + expect(resizeBilinearFn).not.toHaveBeenCalled() }) }) }) diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ts index 1102af19f..3a477af3d 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ts @@ -1,5 +1,5 @@ import pixelmatch from 'pixelmatch' -import { Jimp, JimpMime } from 'jimp' +import { decodeImage, resizeBilinear, encodeImage } from '../utils/imageUtils.js' import type { CompareData, ComparisonOptions, ComparisonIgnoreOption } from './compare.interfaces.js' function resolveIgnoreList(ignore: ComparisonOptions['ignore']): ComparisonIgnoreOption[] { @@ -76,33 +76,32 @@ export default async function compareImages( ): Promise { const start = Date.now() - const img1 = await Jimp.read(image1) - const img2 = await Jimp.read(image2) + let img1 = decodeImage(image1) + let img2 = decodeImage(image2) if (options.scaleToSameSize) { - const size1 = img1.bitmap.width * img1.bitmap.height - const size2 = img2.bitmap.width * img2.bitmap.height + const size1 = img1.width * img1.height + const size2 = img2.width * img2.height if (size1 > size2) { - img2.resize({ w: img1.bitmap.width, h: img1.bitmap.height }) + img2 = resizeBilinear(img2, img1.width, img1.height) } else if (size2 > size1) { - img1.resize({ w: img2.bitmap.width, h: img2.bitmap.height }) + img1 = resizeBilinear(img1, img2.width, img2.height) } } // Determine the target canvas size (max of both dimensions). - const width = Math.max(img1.bitmap.width, img2.bitmap.width) - const height = Math.max(img1.bitmap.height, img2.bitmap.height) + const width = Math.max(img1.width, img2.width) + const height = Math.max(img1.height, img2.height) const totalPixels = width * height - // Copy bitmap data into mutable buffers, padding at (0,0) when sizes differ. - // Using padToSize instead of Jimp's contain() avoids centering which shifts - // content by a pixel and creates false diffs along the top edge. - const pixels1 = img1.bitmap.width === width && img1.bitmap.height === height - ? Buffer.from(img1.bitmap.data) - : padToSize(Buffer.from(img1.bitmap.data), img1.bitmap.width, img1.bitmap.height, width, height) - const pixels2 = img2.bitmap.width === width && img2.bitmap.height === height - ? Buffer.from(img2.bitmap.data) - : padToSize(Buffer.from(img2.bitmap.data), img2.bitmap.width, img2.bitmap.height, width, height) + // Copy bitmap data into mutable buffers, padding smaller images at (0,0) + // with opaque white so content is not shifted by centering. + const pixels1 = img1.width === width && img1.height === height + ? Buffer.from(img1.data) + : padToSize(Buffer.from(img1.data), img1.width, img1.height, width, height) + const pixels2 = img2.width === width && img2.height === height + ? Buffer.from(img2.data) + : padToSize(Buffer.from(img2.data), img2.width, img2.height, width, height) const ignoreList = resolveIgnoreList(options.ignore) @@ -158,11 +157,7 @@ export default async function compareImages( ? { left, top, right, bottom } : { left: width, top: height, right: 0, bottom: 0 } - const getBuffer = async (): Promise => { - const diffImage = new Jimp({ width, height }) - Buffer.from(outputPixels).copy(diffImage.bitmap.data) - return diffImage.getBuffer(JimpMime.png) - } + const getBuffer = async (): Promise => encodeImage({ data: outputPixels, width, height }) const rawMisMatchPercentage = (diffCount / totalPixels) * 100 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2dac419e..5cd159b21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,9 +134,6 @@ importers: fast-png: specifier: ^8.0.0 version: 8.0.0 - jimp: - specifier: ^1.6.1 - version: 1.6.1 pixelmatch: specifier: ^7.2.0 version: 7.2.0 From 069f8ea38deb923825fcf32b664083b37a64bbd1 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Wed, 3 Jun 2026 06:54:42 +0200 Subject: [PATCH 4/4] chore: add missing coverage --- .../src/methods/images.test.ts | 33 ++++++++ .../src/pixelmatch/compareImages.test.ts | 83 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/packages/image-comparison-core/src/methods/images.test.ts b/packages/image-comparison-core/src/methods/images.test.ts index 460b0ff02..07fc109bd 100644 --- a/packages/image-comparison-core/src/methods/images.test.ts +++ b/packages/image-comparison-core/src/methods/images.test.ts @@ -14,6 +14,7 @@ import { cropAndConvertToDataURL, makeCroppedBase64Image, makeFullPageBase64Image, + addBlockOuts, rotateBase64Image, takeResizedBase64Screenshot, } from './images.js' @@ -313,6 +314,38 @@ describe('rotateBase64Image', () => { }) }) +describe('addBlockOuts', () => { + afterEach(() => { vi.clearAllMocks() }) + + it('returns the image unchanged when there are no ignored boxes', async () => { + const result = await addBlockOuts('someBase64', []) + + expect(vi.mocked(imageUtils.decodeImage)).toHaveBeenCalledTimes(1) + expect(vi.mocked(imageUtils.compositeImage)).not.toHaveBeenCalled() + expect(vi.mocked(imageUtils.toBase64Png)).toHaveBeenCalledTimes(1) + expect(result).toBe('croppedImageData') + }) + + it('creates a semi-transparent green overlay for each ignored box', async () => { + const boxes = [ + { left: 10, top: 20, right: 110, bottom: 120 }, + { left: 200, top: 50, right: 300, bottom: 150 }, + ] + + await addBlockOuts('someBase64', boxes) + + expect(vi.mocked(imageUtils.createCanvas)).toHaveBeenCalledTimes(2) + // First box: width=100, height=100, green (#39aa56 = 57,170,86,255) + expect(vi.mocked(imageUtils.createCanvas)).toHaveBeenCalledWith(100, 100, 57, 170, 86, 255) + expect(vi.mocked(imageUtils.setOpacity)).toHaveBeenCalledTimes(2) + expect(vi.mocked(imageUtils.setOpacity)).toHaveBeenCalledWith(expect.any(Object), 0.5) + expect(vi.mocked(imageUtils.compositeImage)).toHaveBeenCalledTimes(2) + expect(vi.mocked(imageUtils.compositeImage)).toHaveBeenCalledWith( + expect.any(Object), expect.any(Object), 10, 20 + ) + }) +}) + describe('getRotatedImageIfNeeded', () => { let getBase64ScreenshotSizeMock: ReturnType diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts index dd9b26160..3aa64dd88 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts @@ -198,6 +198,74 @@ describe('pixelmatch adapter - compareImages', () => { }) }) + describe('pixel transformations', () => { + it('grayscales both pixel arrays when ignore includes colors', async () => { + let capturedPixels1: Uint8Array | undefined + + pixelmatchFn.mockImplementation((img1: Uint8Array) => { + capturedPixels1 = new Uint8Array(img1) + return 0 + }) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'colors' }) + + // After grayscale, R=G=B for every pixel (luma of 128,128,128 = 128) + expect(capturedPixels1![0]).toBe(capturedPixels1![1]) + expect(capturedPixels1![1]).toBe(capturedPixels1![2]) + }) + + it('sets all alpha channels to 255 when ignore includes alpha', async () => { + // Use image data with alpha < 255 + decodeImageFn.mockReturnValue({ + data: new Uint8Array(100 * 100 * 4).fill(0), // all zeros, including alpha + width: 100, + height: 100, + }) + let capturedPixels1: Uint8Array | undefined + + pixelmatchFn.mockImplementation((img1: Uint8Array) => { + capturedPixels1 = new Uint8Array(img1) + return 0 + }) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: 'alpha' }) + + // After opaqueAlphaChannel, every 4th byte should be 255 + expect(capturedPixels1![3]).toBe(255) + expect(capturedPixels1![7]).toBe(255) + }) + + it('pads img2 to canvas size when img1 is larger', async () => { + decodeImageFn + .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) + .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 }) + pixelmatchFn.mockImplementation(() => 0) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) + + // Canvas is 100×100; img2 (50×50) is padded to fill it + expect(pixelmatchFn).toHaveBeenCalledWith( + expect.any(Object), expect.any(Object), expect.any(Uint8Array), + 100, 100, expect.any(Object) + ) + }) + + it('pads img1 to canvas size when img2 is larger', async () => { + decodeImageFn + .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 }) + .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) + pixelmatchFn.mockImplementation(() => 0) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) + + // Canvas is 100×100; img1 (50×50) is padded to fill it + expect(pixelmatchFn).toHaveBeenCalledWith( + expect.any(Object), expect.any(Object), expect.any(Uint8Array), + 100, 100, expect.any(Object) + ) + }) + }) + describe('ignoredBoxes', () => { it('zeroes out the specified box regions in both pixel arrays before comparison', async () => { let capturedImg1: Uint8Array | undefined @@ -239,6 +307,21 @@ describe('pixelmatch adapter - compareImages', () => { ) }) + it('resizes img1 when img2 is larger', async () => { + decodeImageFn + .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 }) + .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 }) + pixelmatchFn.mockImplementation(() => 0) + + await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) + + expect(resizeBilinearFn).toHaveBeenCalledTimes(1) + expect(resizeBilinearFn).toHaveBeenCalledWith( + expect.objectContaining({ width: 50, height: 50 }), + 100, 100 + ) + }) + it('does not call resizeBilinear when scaleToSameSize is false', async () => { pixelmatchFn.mockImplementation(() => 0)