diff --git a/visual-js/visual-cypress/src/commands.ts b/visual-js/visual-cypress/src/commands.ts index 078ba30d..e91b8b84 100644 --- a/visual-js/visual-cypress/src/commands.ts +++ b/visual-js/visual-cypress/src/commands.ts @@ -8,11 +8,12 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { + PlainRegion, + ResolvedVisualRegion, SauceVisualViewport, ScreenshotMetadata, VisualCheckOptions, VisualRegion, - VisualRegionWithRatio, } from './types'; declare global { @@ -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(elem: T): elem is PlainRegion & T { if ('x' in elem && 'y' in elem && 'width' in elem && 'height' in elem) { return true; } @@ -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'>>( + region: VisualRegion, +): 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, +): Promise { + 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, @@ -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); @@ -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 = (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('@clipToBounds').then( @@ -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); + }); }); }); }; diff --git a/visual-js/visual-cypress/src/index.ts b/visual-js/visual-cypress/src/index.ts index 5ebcb35c..e52e90f7 100644 --- a/visual-js/visual-cypress/src/index.ts +++ b/visual-js/visual-cypress/src/index.ts @@ -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'; @@ -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; @@ -126,6 +131,7 @@ class CypressSauceVisual { }, ); this.diffingMethod = config.saucelabs?.diffingMethod; + this.diffingOptions = config.saucelabs?.diffingOptions; this.domCaptureScript = this.api.domCaptureScript(); } @@ -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); @@ -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 { diff --git a/visual-js/visual-cypress/src/types.ts b/visual-js/visual-cypress/src/types.ts index 7217c2bb..c3a20812 100644 --- a/visual-js/visual-cypress/src/types.ts +++ b/visual-js/visual-cypress/src/types.ts @@ -1,4 +1,9 @@ -import { DiffingMethod, SauceRegion } from '@saucelabs/visual'; +import { + DiffingMethod, + DiffingOptionsIn, + SauceRegion, + SelectiveRegionOptions, +} from '@saucelabs/visual'; export interface SauceConfig { buildName: string; @@ -9,6 +14,7 @@ export interface SauceConfig { user?: string; key?: string; diffingMethod?: DiffingMethod; + diffingOptions?: DiffingOptionsIn; } export interface HasSauceConfig { @@ -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 = PlainRegion | Cypress.Chainable, +> = { element: R } & SelectiveRegionOptions; + +export type ResolvedVisualRegion = { applyScalingRatio?: boolean; -} & VisualRegion; +} & VisualRegion; 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; @@ -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. */ diff --git a/visual-js/visual/src/selective-region.ts b/visual-js/visual/src/selective-region.ts index f06a41d9..80dbc306 100644 --- a/visual-js/visual/src/selective-region.ts +++ b/visual-js/visual/src/selective-region.ts @@ -1,6 +1,6 @@ import { - DiffingOption, DiffingOptionsIn, + Rect, RegionIn, } from './graphql/__generated__/graphql'; @@ -11,6 +11,40 @@ export type SelectiveRegionOptions = export type SelectiveRegion = Omit & SelectiveRegionOptions; +export type VisualRegion = { + element: E; +} & SelectiveRegionOptions; + +export async function selectiveRegionsToRegionIn( + regions: VisualRegion[], + fn: (region: E) => Promise, +): Promise { + const awaited = await Promise.all( + regions.map(async (region: VisualRegion): Promise => { + const resolved = await fn(region.element); + return resolved.map((rect) => ({ + ...rect, + diffingOptions: selectiveRegionOptionsToDiffingOptions(region), + })); + }), + ); + return awaited.flatMap((x) => x); +} + +export function selectiveRegionsToRegionInSync( + regions: VisualRegion[], + 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 {