Skip to content

Commit

Permalink
Add support for Selective Region in Cypress
Browse files Browse the repository at this point in the history
  • Loading branch information
FriggaHel authored and ebekebe committed May 21, 2024
1 parent 9ffe814 commit 7291959
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 80 deletions.
143 changes: 86 additions & 57 deletions visual-js/visual-cypress/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

/* eslint-disable @typescript-eslint/no-namespace */
import {
PlainRegion,
ResolvedVisualRegion,
SauceVisualViewport,
ScreenshotMetadata,
VisualCheckOptions,
VisualRegion,
VisualRegionWithRatio,
} from './types';

declare global {
Expand All @@ -39,7 +40,7 @@ declare global {
const visualLog = (msg: string, level: 'info' | 'warn' | 'error' = 'error') =>
cy.task('visual-log', { level, msg });

export function isRegion(elem: any): elem is VisualRegion {
export function isRegion<T extends object>(elem: T): elem is PlainRegion & T {
if ('x' in elem && 'y' in elem && 'width' in elem && 'height' in elem) {
return true;
}
Expand All @@ -51,14 +52,42 @@ export function isChainable(elem: any): elem is Cypress.Chainable {
return 'chainerId' in elem;
}

/**
* Note: Even if looks like promises, it is not. Cypress makes it run in a consistent and deterministic way.
* As a result, item.then() will be resolved before the cy.screenshot() and cy.task() is executed.
* That makes us be sure that ignoredRegion is populated correctly before the metadata being sent back to
* Cypress main process.
*
* https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#You-cannot-race-or-run-multiple-commands-at-the-same-time
*/
export function intoElement<R extends Omit<object, 'unknown>'>>(
region: VisualRegion<R>,
): R {
return 'element' in region ? region.element : region;
}

export function getElementDimensions(elem: HTMLElement): Cypress.Dimensions {
const rect = elem.getBoundingClientRect();
return {
x: Math.floor(rect.left),
y: Math.floor(rect.top),
width: Math.floor(rect.width),
height: Math.floor(rect.height),
};
}

export function resolveChainables(
item: PlainRegion | Cypress.Chainable<HTMLElement[]>,
): Promise<PlainRegion[] | null> {
return new Promise((resolve) => {
if (isChainable(item)) {
item.then(($el: HTMLElement[]) => {
const regions: PlainRegion[] = [];
for (const elem of $el) {
regions.push(getElementDimensions(elem));
}
resolve(regions);
});
} else if (isRegion(item)) {
resolve([item]);
} else {
resolve(null);
}
});
}

const sauceVisualCheckCommand = (
screenshotName: string,
options?: VisualCheckOptions,
Expand All @@ -83,16 +112,6 @@ const sauceVisualCheckCommand = (
viewport.height = win.innerHeight;
});

const getElementDimensions = (elem: HTMLElement) => {
const rect = elem.getBoundingClientRect();
return {
x: Math.floor(rect.left),
y: Math.floor(rect.top),
width: Math.floor(rect.width),
height: Math.floor(rect.height),
} satisfies Cypress.Dimensions;
};

if (clipSelector) {
cy.get(clipSelector).then((elem) => {
const firstMatch = elem.get().find((item) => item);
Expand All @@ -106,33 +125,40 @@ const sauceVisualCheckCommand = (
}

/* Remap ignore area */
const providedIgnoredRegions = options?.ignoredRegions ?? [];
const ignoredRegions: VisualRegionWithRatio[] = [];
for (const idx in providedIgnoredRegions) {
const item = providedIgnoredRegions[idx];
if (isRegion(item)) {
ignoredRegions.push({
applyScalingRatio: false,
...item,
});
continue;
}

if (isChainable(item)) {
item.then(($el: HTMLElement[]) => {
for (const elem of $el) {
const rect = getElementDimensions(elem);
ignoredRegions.push({
applyScalingRatio: true,
...rect,
});
}
});
continue;
const visualRegions: VisualRegion[] = [
...(options?.ignoredRegions ?? []).map((r) => ({
enableOnly: [],
element: r,
})),
...(options?.regions ?? []),
];

const regionsPromise: Promise<ResolvedVisualRegion[]> = (async () => {
const result = [];
for (const idx in visualRegions) {
const visualRegion = visualRegions[idx];

const resolvedElements: PlainRegion[] | null = await resolveChainables(
intoElement(visualRegion),
);
if (resolvedElements === null)
throw new Error(`ignoreRegion[${idx}] has an unknown type`);

const applyScalingRatio = !isRegion(visualRegion.element);

for (const region of resolvedElements) {
result.push({
...visualRegion,
element: region,
applyScalingRatio,
} satisfies ResolvedVisualRegion);
}
}

throw new Error(`ignoreRegion[${idx}] has an unknown type`);
}
return result;
})().catch((e) => {
visualLog(`sauce-visual: ${e}`);
return [];
});

const id = randomId();
cy.get<Cypress.Dimensions | undefined>('@clipToBounds').then(
Expand Down Expand Up @@ -167,17 +193,20 @@ const sauceVisualCheckCommand = (
}
};

cy.task('visual-register-screenshot', {
id: `sauce-visual-${id}`,
name: screenshotName,
suiteName: Cypress.currentTest.titlePath.slice(0, -1).join(' '),
testName: Cypress.currentTest.title,
ignoredRegions,
diffingMethod: options?.diffingMethod,
devicePixelRatio: win.devicePixelRatio,
viewport: realViewport,
dom: getDom() ?? undefined,
} satisfies ScreenshotMetadata);
regionsPromise.then((regions) => {
cy.task('visual-register-screenshot', {
id: `sauce-visual-${id}`,
name: screenshotName,
suiteName: Cypress.currentTest.titlePath.slice(0, -1).join(' '),
testName: Cypress.currentTest.title,
regions,
diffingMethod: options?.diffingMethod,
diffingOptions: options?.diffingOptions,
devicePixelRatio: win.devicePixelRatio,
viewport: realViewport,
dom: getDom() ?? undefined,
} satisfies ScreenshotMetadata);
});
});
});
};
Expand Down
40 changes: 24 additions & 16 deletions visual-js/visual-cypress/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ import {
DiffingMethod,
VisualApiRegion,
BuildMode,
DiffingOptionsIn,
selectiveRegionOptionsToDiffingOptions,
RegionIn,
} from '@saucelabs/visual';
import {
HasSauceConfig,
ScreenshotMetadata,
SauceVisualOptions,
SauceVisualViewport,
ResolvedVisualRegion,
} from './types';
import { Logger } from './logger';
import { buildUrl, screenshotSectionStart } from './messages';
Expand Down Expand Up @@ -99,6 +103,7 @@ class CypressSauceVisual {
// A build can be managed externally (e.g by CLI) or by the Sauce Visual Cypress plugin
private isBuildExternal = false;
private diffingMethod: DiffingMethod | undefined;
private diffingOptions: DiffingOptionsIn | undefined;
private screenshotsMetadata: { [key: string]: ScreenshotMetadata } = {};

private api: VisualApi;
Expand Down Expand Up @@ -126,6 +131,7 @@ class CypressSauceVisual {
},
);
this.diffingMethod = config.saucelabs?.diffingMethod;
this.diffingOptions = config.saucelabs?.diffingOptions;
this.domCaptureScript = this.api.domCaptureScript();
}

Expand Down Expand Up @@ -351,10 +357,10 @@ Sauce Labs Visual: Unable to create new build.
return;
}

metadata.ignoredRegions ??= [];
metadata.regions ??= [];

// Check if there is a need to compute a ratio. Otherwise just use 1.
const needRatioComputation = metadata.ignoredRegions
const needRatioComputation = metadata.regions
.map((region) => region.applyScalingRatio)
.reduce((prev, next) => prev || next, false);

Expand All @@ -365,20 +371,22 @@ Sauce Labs Visual: Unable to create new build.
})
: 1;

const ignoreRegions = metadata.ignoredRegions.map((region) => {
const ratio = region.applyScalingRatio ? scalingRatio : 1;
return {
x: Math.floor(region.x * ratio),
y: Math.floor(region.y * ratio),
height: Math.ceil(
(region.y + region.height) * ratio - Math.floor(region.y * ratio),
),
width: Math.ceil(
(region.x + region.width) * ratio - Math.floor(region.x * ratio),
),
name: '',
};
});
const ignoreRegions = metadata.regions.map(
(resolvedRegion: ResolvedVisualRegion): RegionIn => {
const { x, y, height, width } = resolvedRegion.element;

const ratio = resolvedRegion.applyScalingRatio ? scalingRatio : 1;
return {
x: Math.floor(x * ratio),
y: Math.floor(y * ratio),
height: Math.ceil((y + height) * ratio - Math.floor(y * ratio)),
width: Math.ceil((x + width) * ratio - Math.floor(x * ratio)),
name: '',
diffingOptions:
selectiveRegionOptionsToDiffingOptions(resolvedRegion),
};
},
);

// Publish image
try {
Expand Down
31 changes: 25 additions & 6 deletions visual-js/visual-cypress/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { DiffingMethod, SauceRegion } from '@saucelabs/visual';
import {
DiffingMethod,
DiffingOptionsIn,
SauceRegion,
SelectiveRegionOptions,
} from '@saucelabs/visual';

export interface SauceConfig {
buildName: string;
Expand All @@ -9,6 +14,7 @@ export interface SauceConfig {
user?: string;
key?: string;
diffingMethod?: DiffingMethod;
diffingOptions?: DiffingOptionsIn;
}

export interface HasSauceConfig {
Expand All @@ -19,24 +25,29 @@ export type SauceVisualOptions = {
region: SauceRegion;
};

export type VisualRegion = {
export type PlainRegion = {
x: number;
y: number;
width: number;
height: number;
};

export type VisualRegionWithRatio = {
export type VisualRegion<
R extends Omit<object, 'element'> = PlainRegion | Cypress.Chainable,
> = { element: R } & SelectiveRegionOptions;

export type ResolvedVisualRegion = {
applyScalingRatio?: boolean;
} & VisualRegion;
} & VisualRegion<PlainRegion>;

export type ScreenshotMetadata = {
id: string;
name: string;
testName: string;
suiteName: string;
ignoredRegions?: VisualRegionWithRatio[];
regions?: ResolvedVisualRegion[];
diffingMethod?: DiffingMethod;
diffingOptions?: DiffingOptionsIn;
viewport: SauceVisualViewport | undefined;
devicePixelRatio: number;
dom?: string;
Expand All @@ -51,11 +62,19 @@ export type VisualCheckOptions = {
/**
* An array of ignore regions or Cypress elements to ignore.
*/
ignoredRegions?: (VisualRegion | Cypress.Chainable)[];
ignoredRegions?: (PlainRegion | Cypress.Chainable)[];
/**
* The diffing method we should use when finding visual changes. Defaults to DiffingMethod.Simple.
*/
diffingMethod?: DiffingMethod;
/**
* The diffing options that should be applied by default.
*/
diffingOptions?: DiffingOptionsIn;
/**
* The diffing method we should use when finding visual changes. Defaults to DiffingMethod.Simple.
*/
regions?: VisualRegion[];
/**
* Enable DOM capture for DOM Inspection and insights.
*/
Expand Down
36 changes: 35 additions & 1 deletion visual-js/visual/src/selective-region.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
DiffingOption,
DiffingOptionsIn,
Rect,
RegionIn,
} from './graphql/__generated__/graphql';

Expand All @@ -11,6 +11,40 @@ export type SelectiveRegionOptions =
export type SelectiveRegion = Omit<RegionIn, 'diffingOptions'> &
SelectiveRegionOptions;

export type VisualRegion<E> = {
element: E;
} & SelectiveRegionOptions;

export async function selectiveRegionsToRegionIn<E>(
regions: VisualRegion<E>[],
fn: (region: E) => Promise<Rect[]>,
): Promise<RegionIn[]> {
const awaited = await Promise.all(
regions.map(async (region: VisualRegion<E>): Promise<RegionIn[]> => {
const resolved = await fn(region.element);
return resolved.map((rect) => ({
...rect,
diffingOptions: selectiveRegionOptionsToDiffingOptions(region),
}));
}),
);
return awaited.flatMap((x) => x);
}

export function selectiveRegionsToRegionInSync<E>(
regions: VisualRegion<E>[],
fn: (region: E) => Rect[],
): RegionIn[] {
let result;
selectiveRegionsToRegionIn(regions, (r) => Promise.resolve(fn(r))).then(
(r) => {
result = r;
},
);
if (result === undefined) throw new Error('internal logic error');
return result;
}

export function selectiveRegionOptionsToDiffingOptions(
opt: SelectiveRegionOptions,
): DiffingOptionsIn {
Expand Down

0 comments on commit 7291959

Please sign in to comment.