Skip to content

Commit 651e506

Browse files
committed
Add support for Selective Region in Cypress
1 parent b09e091 commit 651e506

File tree

4 files changed

+170
-80
lines changed

4 files changed

+170
-80
lines changed

Diff for: visual-js/visual-cypress/src/commands.ts

+86-57
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88

99
/* eslint-disable @typescript-eslint/no-namespace */
1010
import {
11+
PlainRegion,
12+
ResolvedVisualRegion,
1113
SauceVisualViewport,
1214
ScreenshotMetadata,
1315
VisualCheckOptions,
1416
VisualRegion,
15-
VisualRegionWithRatio,
1617
} from './types';
1718

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

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

54-
/**
55-
* Note: Even if looks like promises, it is not. Cypress makes it run in a consistent and deterministic way.
56-
* As a result, item.then() will be resolved before the cy.screenshot() and cy.task() is executed.
57-
* That makes us be sure that ignoredRegion is populated correctly before the metadata being sent back to
58-
* Cypress main process.
59-
*
60-
* https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#You-cannot-race-or-run-multiple-commands-at-the-same-time
61-
*/
55+
export function intoElement<R extends Omit<object, 'unknown>'>>(
56+
region: VisualRegion<R>,
57+
): R {
58+
return 'element' in region ? region.element : region;
59+
}
60+
61+
export function getElementDimensions(elem: HTMLElement): Cypress.Dimensions {
62+
const rect = elem.getBoundingClientRect();
63+
return {
64+
x: Math.floor(rect.left),
65+
y: Math.floor(rect.top),
66+
width: Math.floor(rect.width),
67+
height: Math.floor(rect.height),
68+
};
69+
}
70+
71+
export function resolveChainables(
72+
item: PlainRegion | Cypress.Chainable<HTMLElement[]>,
73+
): Promise<PlainRegion[] | null> {
74+
return new Promise((resolve) => {
75+
if (isChainable(item)) {
76+
item.then(($el: HTMLElement[]) => {
77+
const regions: PlainRegion[] = [];
78+
for (const elem of $el) {
79+
regions.push(getElementDimensions(elem));
80+
}
81+
resolve(regions);
82+
});
83+
} else if (isRegion(item)) {
84+
resolve([item]);
85+
} else {
86+
resolve(null);
87+
}
88+
});
89+
}
90+
6291
const sauceVisualCheckCommand = (
6392
screenshotName: string,
6493
options?: VisualCheckOptions,
@@ -83,16 +112,6 @@ const sauceVisualCheckCommand = (
83112
viewport.height = win.innerHeight;
84113
});
85114

86-
const getElementDimensions = (elem: HTMLElement) => {
87-
const rect = elem.getBoundingClientRect();
88-
return {
89-
x: Math.floor(rect.left),
90-
y: Math.floor(rect.top),
91-
width: Math.floor(rect.width),
92-
height: Math.floor(rect.height),
93-
} satisfies Cypress.Dimensions;
94-
};
95-
96115
if (clipSelector) {
97116
cy.get(clipSelector).then((elem) => {
98117
const firstMatch = elem.get().find((item) => item);
@@ -106,33 +125,40 @@ const sauceVisualCheckCommand = (
106125
}
107126

108127
/* Remap ignore area */
109-
const providedIgnoredRegions = options?.ignoredRegions ?? [];
110-
const ignoredRegions: VisualRegionWithRatio[] = [];
111-
for (const idx in providedIgnoredRegions) {
112-
const item = providedIgnoredRegions[idx];
113-
if (isRegion(item)) {
114-
ignoredRegions.push({
115-
applyScalingRatio: false,
116-
...item,
117-
});
118-
continue;
119-
}
120-
121-
if (isChainable(item)) {
122-
item.then(($el: HTMLElement[]) => {
123-
for (const elem of $el) {
124-
const rect = getElementDimensions(elem);
125-
ignoredRegions.push({
126-
applyScalingRatio: true,
127-
...rect,
128-
});
129-
}
130-
});
131-
continue;
128+
const visualRegions: VisualRegion[] = [
129+
...(options?.ignoredRegions ?? []).map((r) => ({
130+
enableOnly: [],
131+
element: r,
132+
})),
133+
...(options?.regions ?? []),
134+
];
135+
136+
const regionsPromise: Promise<ResolvedVisualRegion[]> = (async () => {
137+
const result = [];
138+
for (const idx in visualRegions) {
139+
const visualRegion = visualRegions[idx];
140+
141+
const resolvedElements: PlainRegion[] | null = await resolveChainables(
142+
intoElement(visualRegion),
143+
);
144+
if (resolvedElements === null)
145+
throw new Error(`ignoreRegion[${idx}] has an unknown type`);
146+
147+
const applyScalingRatio = !isRegion(visualRegion.element);
148+
149+
for (const region of resolvedElements) {
150+
result.push({
151+
...visualRegion,
152+
element: region,
153+
applyScalingRatio,
154+
} satisfies ResolvedVisualRegion);
155+
}
132156
}
133-
134-
throw new Error(`ignoreRegion[${idx}] has an unknown type`);
135-
}
157+
return result;
158+
})().catch((e) => {
159+
visualLog(`sauce-visual: ${e}`);
160+
return [];
161+
});
136162

137163
const id = randomId();
138164
cy.get<Cypress.Dimensions | undefined>('@clipToBounds').then(
@@ -167,17 +193,20 @@ const sauceVisualCheckCommand = (
167193
}
168194
};
169195

170-
cy.task('visual-register-screenshot', {
171-
id: `sauce-visual-${id}`,
172-
name: screenshotName,
173-
suiteName: Cypress.currentTest.titlePath.slice(0, -1).join(' '),
174-
testName: Cypress.currentTest.title,
175-
ignoredRegions,
176-
diffingMethod: options?.diffingMethod,
177-
devicePixelRatio: win.devicePixelRatio,
178-
viewport: realViewport,
179-
dom: getDom() ?? undefined,
180-
} satisfies ScreenshotMetadata);
196+
regionsPromise.then((regions) => {
197+
cy.task('visual-register-screenshot', {
198+
id: `sauce-visual-${id}`,
199+
name: screenshotName,
200+
suiteName: Cypress.currentTest.titlePath.slice(0, -1).join(' '),
201+
testName: Cypress.currentTest.title,
202+
regions,
203+
diffingMethod: options?.diffingMethod,
204+
diffingOptions: options?.diffingOptions,
205+
devicePixelRatio: win.devicePixelRatio,
206+
viewport: realViewport,
207+
dom: getDom() ?? undefined,
208+
} satisfies ScreenshotMetadata);
209+
});
181210
});
182211
});
183212
};

Diff for: visual-js/visual-cypress/src/index.ts

+24-16
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ import {
1717
DiffingMethod,
1818
VisualApiRegion,
1919
BuildMode,
20+
DiffingOptionsIn,
21+
selectiveRegionOptionsToDiffingOptions,
22+
RegionIn,
2023
} from '@saucelabs/visual';
2124
import {
2225
HasSauceConfig,
2326
ScreenshotMetadata,
2427
SauceVisualOptions,
2528
SauceVisualViewport,
29+
ResolvedVisualRegion,
2630
} from './types';
2731
import { Logger } from './logger';
2832
import { buildUrl, screenshotSectionStart } from './messages';
@@ -99,6 +103,7 @@ class CypressSauceVisual {
99103
// A build can be managed externally (e.g by CLI) or by the Sauce Visual Cypress plugin
100104
private isBuildExternal = false;
101105
private diffingMethod: DiffingMethod | undefined;
106+
private diffingOptions: DiffingOptionsIn | undefined;
102107
private screenshotsMetadata: { [key: string]: ScreenshotMetadata } = {};
103108

104109
private api: VisualApi;
@@ -126,6 +131,7 @@ class CypressSauceVisual {
126131
},
127132
);
128133
this.diffingMethod = config.saucelabs?.diffingMethod;
134+
this.diffingOptions = config.saucelabs?.diffingOptions;
129135
this.domCaptureScript = this.api.domCaptureScript();
130136
}
131137

@@ -351,10 +357,10 @@ Sauce Labs Visual: Unable to create new build.
351357
return;
352358
}
353359

354-
metadata.ignoredRegions ??= [];
360+
metadata.regions ??= [];
355361

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

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

368-
const ignoreRegions = metadata.ignoredRegions.map((region) => {
369-
const ratio = region.applyScalingRatio ? scalingRatio : 1;
370-
return {
371-
x: Math.floor(region.x * ratio),
372-
y: Math.floor(region.y * ratio),
373-
height: Math.ceil(
374-
(region.y + region.height) * ratio - Math.floor(region.y * ratio),
375-
),
376-
width: Math.ceil(
377-
(region.x + region.width) * ratio - Math.floor(region.x * ratio),
378-
),
379-
name: '',
380-
};
381-
});
374+
const ignoreRegions = metadata.regions.map(
375+
(resolvedRegion: ResolvedVisualRegion): RegionIn => {
376+
const { x, y, height, width } = resolvedRegion.element;
377+
378+
const ratio = resolvedRegion.applyScalingRatio ? scalingRatio : 1;
379+
return {
380+
x: Math.floor(x * ratio),
381+
y: Math.floor(y * ratio),
382+
height: Math.ceil((y + height) * ratio - Math.floor(y * ratio)),
383+
width: Math.ceil((x + width) * ratio - Math.floor(x * ratio)),
384+
name: '',
385+
diffingOptions:
386+
selectiveRegionOptionsToDiffingOptions(resolvedRegion),
387+
};
388+
},
389+
);
382390

383391
// Publish image
384392
try {

Diff for: visual-js/visual-cypress/src/types.ts

+25-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { DiffingMethod, SauceRegion } from '@saucelabs/visual';
1+
import {
2+
DiffingMethod,
3+
DiffingOptionsIn,
4+
SauceRegion,
5+
SelectiveRegionOptions,
6+
} from '@saucelabs/visual';
27

38
export interface SauceConfig {
49
buildName: string;
@@ -9,6 +14,7 @@ export interface SauceConfig {
914
user?: string;
1015
key?: string;
1116
diffingMethod?: DiffingMethod;
17+
diffingOptions?: DiffingOptionsIn;
1218
}
1319

1420
export interface HasSauceConfig {
@@ -19,24 +25,29 @@ export type SauceVisualOptions = {
1925
region: SauceRegion;
2026
};
2127

22-
export type VisualRegion = {
28+
export type PlainRegion = {
2329
x: number;
2430
y: number;
2531
width: number;
2632
height: number;
2733
};
2834

29-
export type VisualRegionWithRatio = {
35+
export type VisualRegion<
36+
R extends Omit<object, 'element'> = PlainRegion | Cypress.Chainable,
37+
> = { element: R } & SelectiveRegionOptions;
38+
39+
export type ResolvedVisualRegion = {
3040
applyScalingRatio?: boolean;
31-
} & VisualRegion;
41+
} & VisualRegion<PlainRegion>;
3242

3343
export type ScreenshotMetadata = {
3444
id: string;
3545
name: string;
3646
testName: string;
3747
suiteName: string;
38-
ignoredRegions?: VisualRegionWithRatio[];
48+
regions?: ResolvedVisualRegion[];
3949
diffingMethod?: DiffingMethod;
50+
diffingOptions?: DiffingOptionsIn;
4051
viewport: SauceVisualViewport | undefined;
4152
devicePixelRatio: number;
4253
dom?: string;
@@ -51,11 +62,19 @@ export type VisualCheckOptions = {
5162
/**
5263
* An array of ignore regions or Cypress elements to ignore.
5364
*/
54-
ignoredRegions?: (VisualRegion | Cypress.Chainable)[];
65+
ignoredRegions?: (PlainRegion | Cypress.Chainable)[];
5566
/**
5667
* The diffing method we should use when finding visual changes. Defaults to DiffingMethod.Simple.
5768
*/
5869
diffingMethod?: DiffingMethod;
70+
/**
71+
* The diffing options that should be applied by default.
72+
*/
73+
diffingOptions?: DiffingOptionsIn;
74+
/**
75+
* The diffing method we should use when finding visual changes. Defaults to DiffingMethod.Simple.
76+
*/
77+
regions?: VisualRegion[];
5978
/**
6079
* Enable DOM capture for DOM Inspection and insights.
6180
*/

Diff for: visual-js/visual/src/selective-region.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
2-
DiffingOption,
32
DiffingOptionsIn,
3+
Rect,
44
RegionIn,
55
} from './graphql/__generated__/graphql';
66

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

14+
export type VisualRegion<E> = {
15+
element: E;
16+
} & SelectiveRegionOptions;
17+
18+
export async function selectiveRegionsToRegionIn<E>(
19+
regions: VisualRegion<E>[],
20+
fn: (region: E) => Promise<Rect[]>,
21+
): Promise<RegionIn[]> {
22+
const awaited = await Promise.all(
23+
regions.map(async (region: VisualRegion<E>): Promise<RegionIn[]> => {
24+
const resolved = await fn(region.element);
25+
return resolved.map((rect) => ({
26+
...rect,
27+
diffingOptions: selectiveRegionOptionsToDiffingOptions(region),
28+
}));
29+
}),
30+
);
31+
return awaited.flatMap((x) => x);
32+
}
33+
34+
export function selectiveRegionsToRegionInSync<E>(
35+
regions: VisualRegion<E>[],
36+
fn: (region: E) => Rect[],
37+
): RegionIn[] {
38+
let result;
39+
selectiveRegionsToRegionIn(regions, (r) => Promise.resolve(fn(r))).then(
40+
(r) => {
41+
result = r;
42+
},
43+
);
44+
if (result === undefined) throw new Error('internal logic error');
45+
return result;
46+
}
47+
1448
export function selectiveRegionOptionsToDiffingOptions(
1549
opt: SelectiveRegionOptions,
1650
): DiffingOptionsIn {

0 commit comments

Comments
 (0)