Skip to content
Closed
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
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