Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 70 additions & 27 deletions src/image-utils/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
// SPDX-License-Identifier: Apache-2.0
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { packPng, cropImage } from './utils';
import { ElementRect, ElementSize, ScreenshotWithOffset } from '../page-objects/types';
import { packPng, cropImage, parsePng } from './utils';
import { ElementRect, ElementSize, RawScreenshot, ScreenshotWithOffset } from '../page-objects/types';

interface Size {
width: number;
height: number;
}

type Screenshot = ScreenshotWithOffset | RawScreenshot;

export function compareImages(firstImage: PNG, secondImage: PNG, { width, height }: ElementSize) {
// This prevents an error thrown from pixelmatch when comparing 0-sized images.
Expand All @@ -19,14 +26,18 @@ export function compareImages(firstImage: PNG, secondImage: PNG, { width, height
return { diffPixels, diffImage };
}

function normalizeSize(firstScreenshot: ScreenshotWithOffset, secondScreenshot: ScreenshotWithOffset) {
function normalizeSize(firstScreenshot: Screenshot, secondScreenshot: Screenshot): Size {
return {
height: Math.round(Math.max(firstScreenshot.height, secondScreenshot.height)),
width: Math.round(Math.max(firstScreenshot.width, secondScreenshot.width)),
};
}

function scaleSize(size: ElementSize, pixelRatio: number) {
function isScreenshotWithOffset(s: Screenshot): s is ScreenshotWithOffset {
return 'offset' in s && s.offset !== undefined;
}

function scaleSize(size: ElementSize, pixelRatio: number): Size {
return {
width: Math.ceil(size.width * pixelRatio),
height: Math.ceil(size.height * pixelRatio),
Expand All @@ -41,36 +52,55 @@ export interface CropAndCompareResult {
diffPixels: number;
}

async function getDecodedImage(screenshot: Screenshot): Promise<PNG> {
if (isScreenshotWithOffset(screenshot)) {
return screenshot.image;
}
return parsePng(screenshot.rawBase64);
}

async function cropIfNeeded(screenshot: Screenshot, size: Size) {
const image = await getDecodedImage(screenshot);
if (isScreenshotWithOffset(screenshot)) {
return cropImage(image, buildCropRect(screenshot as ScreenshotWithOffset, size), screenshot.pixelRatio);
} else {
return image;
}
}

export async function cropAndCompare(
firstScreenshot: ScreenshotWithOffset,
secondScreenshot: ScreenshotWithOffset
firstScreenshot: Screenshot,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cropAndCompare now supports both approaches: comparing screenshots with raw PNG and with decoded image + cropping data.

secondScreenshot: Screenshot
): Promise<CropAndCompareResult> {
// Fast path: if rawBase64 is present on both, identical, and no cropping needed, skip all decoding entirely.
if (
!isScreenshotWithOffset(firstScreenshot) &&
!isScreenshotWithOffset(secondScreenshot) &&
firstScreenshot.rawBase64 === secondScreenshot.rawBase64
) {
const buffer = Buffer.from(firstScreenshot.rawBase64, 'base64');
return { firstImage: buffer, secondImage: buffer, diffImage: null, isEqual: true, diffPixels: 0 };
}

const pixelRatio = firstScreenshot.pixelRatio || 1;

const size = normalizeSize(firstScreenshot, secondScreenshot);
const firstImageCropRect: ElementRect = {
height: size.height,
width: size.width,
bottom: firstScreenshot.offset.top + size.height,
right: firstScreenshot.offset.left + size.width,
top: firstScreenshot.offset.top,
left: firstScreenshot.offset.left,
};
const secondImageCropRect: ElementRect = {
height: size.height,
width: size.width,
bottom: secondScreenshot.offset.top + size.height,
right: secondScreenshot.offset.left + size.width,
top: secondScreenshot.offset.top,
left: secondScreenshot.offset.left,
};
const firstImage = cropImage(firstScreenshot.image, firstImageCropRect, pixelRatio);
const secondImage = cropImage(secondScreenshot.image, secondImageCropRect, pixelRatio);
const { diffImage, diffPixels } = compareImages(firstImage, secondImage, scaleSize(size, pixelRatio));
const scaledSize = scaleSize(size, pixelRatio);

const firstImage = await cropIfNeeded(firstScreenshot, size);
const secondImage = await cropIfNeeded(secondScreenshot, size);

const { diffImage, diffPixels } = compareImages(firstImage, secondImage, scaledSize);

// Skip packPng when no cropping was needed and rawBase64 is available
const [firstPacked, secondPacked, diffPacked] = await Promise.all([
packPng(firstImage),
packPng(secondImage),
!isScreenshotWithOffset(firstScreenshot) ? Buffer.from(firstScreenshot.rawBase64, 'base64') : packPng(firstImage),
!isScreenshotWithOffset(secondScreenshot)
? Buffer.from(secondScreenshot.rawBase64, 'base64')
: packPng(secondImage),
diffImage && packPng(diffImage),
]);

return {
firstImage: firstPacked,
secondImage: secondPacked,
Expand All @@ -79,3 +109,16 @@ export async function cropAndCompare(
diffPixels,
};
}

function buildCropRect(screenshot: ScreenshotWithOffset, size: ElementSize): ElementRect {
const top = screenshot.offset?.top ?? 0;
const left = screenshot.offset?.left ?? 0;
return {
height: size.height,
width: size.width,
bottom: top + size.height,
right: left + size.width,
top,
left,
};
}
4 changes: 3 additions & 1 deletion src/page-objects/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
export { default as BasePageObject } from './base';
export { default as ScreenshotBasePageObject } from './screenshot-base';
export { default as ScreenshotPageObject, PermutationScreenshot } from './screenshot';
export { default as RawScreenshotPageObject, RawPermutationScreenshot } from './raw-screenshot';
export { default as EventsSpy } from './events-spy';
export { ScreenshotWithOffset, ElementSize, ElementRect, ElementOffset } from './types';
export { ScreenshotWithOffset, RawScreenshot, ElementSize, ElementRect, ElementOffset } from './types';
68 changes: 68 additions & 0 deletions src/page-objects/raw-screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import ScreenshotBasePageObject from './screenshot-base';
import { RawScreenshot } from './types';

/**
* Raw permutation screenshot captured via takeElementScreenshot.
* No decoded image, no offset — just the raw base64 PNG per element.
*/
export interface RawPermutationScreenshot extends RawScreenshot {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially I attempted to let the existing resources such as ScreenshotPageObject provide both options (raw screenshots vs decode), but it became way too complicated to do it in a backwards compatible way respecting the types that each consumer expects. So I added a sibling class RawScreenshotPageObject instead, which the visual regression action on Github will use.

id: string;
}

/**
* A page object that captures screenshots using takeElementScreenshot.
* Returns raw base64 PNGs without decoding, cropping, or re-encoding —
* significantly faster when pixel-level comparison can be done on raw bytes.
*/
export default class RawScreenshotPageObject extends ScreenshotBasePageObject {
async captureBySelector(selector: string): Promise<RawScreenshot> {
await this.waitForVisible(selector);
const { pixelRatio } = await this.getViewportSize();
const box = await this.getBoundingBox(selector);

const originalWindowSize = await this.fitWindowHeightToContent();
const element = this.browser.$(selector);
const rawBase64 = await this.browser.takeElementScreenshot(await element.elementId);
await this.safeSetWindowSize(originalWindowSize.width, originalWindowSize.height);

return { rawBase64, pixelRatio, height: box.height, width: box.width };
}

async captureViewport(): Promise<RawScreenshot> {
const { height, width } = await this.getViewportSize();
const rawBase64 = await this.browser.takeScreenshot();
return { rawBase64, height, width };
}

async capturePermutations(): Promise<RawPermutationScreenshot[]> {
await this.windowScrollTo({ top: 0, left: 0 });

// Adapt viewport height to fit all elements before taking screenshots
const originalWindowSize = await this.fitWindowHeightToContent();

const elements = await this.browser.$$('[data-permutation]').map(el => el);
if (elements.length === 0) {
await this.safeSetWindowSize(originalWindowSize.width, originalWindowSize.height);
throw new Error('No permutations found on current page.');
}

const { pixelRatio } = await this.getViewportSize();
const results: RawPermutationScreenshot[] = [];
for (const element of elements) {
const id = (await element.getAttribute('data-permutation')) || '';
const rawBase64 = await this.browser.takeElementScreenshot(await element.elementId);
const size = await element.getSize();
results.push({
id,
rawBase64,
width: size.width * pixelRatio,
height: size.height * pixelRatio,
});
}

await this.safeSetWindowSize(originalWindowSize.width, originalWindowSize.height);
return results;
}
}
61 changes: 61 additions & 0 deletions src/page-objects/screenshot-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { ScrollAction, scrollAction, getPageDimensions } from '../browser-scripts';
import BasePageObject from './base';
import fullPageScreenshot from './full-page-screenshot';

/**
* Base class for screenshot page objects. Provides scroll helpers,
* full-page screenshot support, and window size management.
*/
export default class ScreenshotBasePageObject extends BasePageObject {

@jperals jperals Jun 26, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base class which the old ScreenshotPageObject and the new RawScreenshotPageObject extend. Its methods are taken from ScreenshotPageObject.

constructor(browser: WebdriverIO.Browser, public readonly forceScrollAndMerge: boolean = false) {
super(browser);
}

async focusNextElement() {
return this.keys('Tab');
}

async scrollToBottom(selector: string) {
const action: ScrollAction = 'scrollToBottom';
await this.browser.execute(scrollAction, { action, selector });
}

async scrollToRight(selector: string) {
const action: ScrollAction = 'scrollToRight';
await this.browser.execute(scrollAction, { action, selector });
}

async fullPageScreenshot() {
// preserve scroll position in order to avoid side effects after screenshot taking
const scrollPosition = await this.getWindowScroll();
// Wait for the page to settle before taking a screenshot
await this.waitForJsTimers();
const screenshot = await fullPageScreenshot(this.browser, this.forceScrollAndMerge);
// restore scroll position
await this.windowScrollTo(scrollPosition);
return screenshot;
}

protected async fitWindowHeightToContent(): Promise<{ width: number; height: number }> {
const originalWindowSize = await this.browser.getWindowSize();
const { viewportHeight, pageHeight } = await this.browser.execute(getPageDimensions);
const windowUIHeight = originalWindowSize.height - viewportHeight;
await this.safeSetWindowSize(originalWindowSize.width, pageHeight + windowUIHeight);
return originalWindowSize;
}

/* istanbul ignore next -- setWindowSize is unsupported on some mobile browsers, not testable in CI */
protected async safeSetWindowSize(width: number, height: number): Promise<void> {
try {
await this.browser.setWindowSize(width, height);
} catch (error) {
if (error instanceof Error && error.message.includes('Method has not yet been implemented')) {
console.log('setWindowSize is not supported on this device');
} else {
throw error;
}
}
}
}
63 changes: 3 additions & 60 deletions src/page-objects/screenshot.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
ScrollAction,
scrollAction,
getPermutationSizes,
getPageDimensions,
PermutationInfo,
} from '../browser-scripts';
import { getPermutationSizes, PermutationInfo } from '../browser-scripts';
import { parsePng } from '../image-utils';
import BasePageObject from './base';
import { ElementOffset, ScreenshotCapturingOptions, ScreenshotWithOffset } from './types';
import fullPageScreenshot from './full-page-screenshot';
import ScreenshotBasePageObject from './screenshot-base';

export interface PermutationScreenshot extends ScreenshotWithOffset {
id: string;
}

export default class ScreenshotPageObject extends BasePageObject {
constructor(browser: WebdriverIO.Browser, public readonly forceScrollAndMerge: boolean = false) {
super(browser);
}

async focusNextElement() {
return this.keys('Tab');
}

async scrollToBottom(selector: string) {
const action: ScrollAction = 'scrollToBottom';
await this.browser.execute(scrollAction, { action, selector });
}

async scrollToRight(selector: string) {
const action: ScrollAction = 'scrollToRight';
await this.browser.execute(scrollAction, { action, selector });
}

async fullPageScreenshot() {
// preserve scroll position in order to avoid side effects after screenshot taking
const scrollPosition = await this.getWindowScroll();
// Wait for the page to settle before taking a screenshot
await this.waitForJsTimers();
const screenshot = await fullPageScreenshot(this.browser, this.forceScrollAndMerge);
// restore scroll position
await this.windowScrollTo(scrollPosition);
return screenshot;
}

export default class ScreenshotPageObject extends ScreenshotBasePageObject {
async captureBySelector(selector: string, options: ScreenshotCapturingOptions = {}): Promise<ScreenshotWithOffset> {
await this.waitForVisible(selector);
const { pixelRatio, top, left } = await this.getViewportSize();
Expand Down Expand Up @@ -95,25 +59,4 @@ export default class ScreenshotPageObject extends BasePageObject {

return permutations.map((permutation: PermutationInfo) => ({ ...permutation, image }));
}

private async fitWindowHeightToContent(): Promise<{ width: number; height: number }> {
const originalWindowSize = await this.browser.getWindowSize();
const { viewportHeight, pageHeight } = await this.browser.execute(getPageDimensions);
const windowUIHeight = originalWindowSize.height - viewportHeight;
await this.safeSetWindowSize(originalWindowSize.width, pageHeight + windowUIHeight);
return originalWindowSize;
}

/* istanbul ignore next -- setWindowSize is unsupported on some mobile browsers, not testable in CI */
private async safeSetWindowSize(width: number, height: number): Promise<void> {
try {
await this.browser.setWindowSize(width, height);
} catch (error) {
if (error instanceof Error && error.message.includes('Method has not yet been implemented')) {
console.log('setWindowSize is not supported on this device');
} else {
throw error;
}
}
}
}
11 changes: 11 additions & 0 deletions src/page-objects/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,15 @@ export interface ScreenshotWithOffset extends ElementSize {
image: PNG;
offset: ElementOffset;
pixelRatio?: number;
/** Optional raw base64 PNG for fast byte-equality comparison. */
rawBase64?: string;
}

/**
* A raw screenshot with base64 data and dimensions. No decoded image, no offset.
* Returned by RawScreenshotPageObject which uses takeElementScreenshot.
*/
export interface RawScreenshot extends ElementSize {
rawBase64: string;
pixelRatio?: number;
}
Loading
Loading