From 8572e1b08f1f4415d811f6d89c1c64c2d2d7829f Mon Sep 17 00:00:00 2001 From: paweltomaszewskisaucelabs <119342898+paweltomaszewskisaucelabs@users.noreply.github.com> Date: Fri, 4 Oct 2024 08:43:12 +0200 Subject: [PATCH] add scrollElement fps option for native apps (#140) --- visual-js/.changeset/lucky-ladybugs-allow.md | 7 ++ visual-js/visual-nightwatch/Dockerfile | 8 +- .../nightwatch/commands/sauceVisualCheck.ts | 12 ++- visual-js/visual-nightwatch/src/types.ts | 2 +- visual-js/visual-wdio/Dockerfile | 23 +--- .../visual-wdio/src/SauceVisualService.ts | 19 +++- visual-js/visual-wdio/src/guarded-types.ts | 9 +- .../src/graphql/__generated__/graphql.ts | 2 + visual-js/visual/src/types.ts | 6 +- visual-js/visual/src/utils.spec.ts | 102 +++++++++++------- visual-js/visual/src/utils.ts | 23 ++-- 11 files changed, 138 insertions(+), 75 deletions(-) create mode 100644 visual-js/.changeset/lucky-ladybugs-allow.md diff --git a/visual-js/.changeset/lucky-ladybugs-allow.md b/visual-js/.changeset/lucky-ladybugs-allow.md new file mode 100644 index 00000000..5b3c161b --- /dev/null +++ b/visual-js/.changeset/lucky-ladybugs-allow.md @@ -0,0 +1,7 @@ +--- +"@saucelabs/nightwatch-sauce-visual-service": minor +"@saucelabs/wdio-sauce-visual-service": minor +"@saucelabs/visual": minor +--- + +scrollElement native fps diff --git a/visual-js/visual-nightwatch/Dockerfile b/visual-js/visual-nightwatch/Dockerfile index a43b65c0..7173e342 100644 --- a/visual-js/visual-nightwatch/Dockerfile +++ b/visual-js/visual-nightwatch/Dockerfile @@ -4,10 +4,14 @@ WORKDIR app COPY tsconfig.prod.json tsconfig.json package.json ./ +COPY ./visual/src ./visual/src +COPY ./visual/package.json ./visual/tsconfig.json ./visual/ COPY ./visual-nightwatch/src ./visual-nightwatch/src -COPY ./visual-nightwatch/package.json ./visual-nightwatch/tsconfig.json ./visual-nightwatch +COPY ./visual-nightwatch/package.json ./visual-nightwatch/tsconfig.json ./visual-nightwatch/ RUN npm install --save-dev @tsconfig/node18 +RUN npm install --workspace=visual +RUN npm run build --workspace=visual RUN npm install --workspace=visual-nightwatch RUN npm run build --workspace=visual-nightwatch @@ -22,4 +26,4 @@ WORKDIR integration-tests RUN npm install -ENTRYPOINT ["npm", "run", "external"] \ No newline at end of file +ENTRYPOINT ["npm", "run", "external"] diff --git a/visual-js/visual-nightwatch/src/nightwatch/commands/sauceVisualCheck.ts b/visual-js/visual-nightwatch/src/nightwatch/commands/sauceVisualCheck.ts index 905dde81..11473121 100644 --- a/visual-js/visual-nightwatch/src/nightwatch/commands/sauceVisualCheck.ts +++ b/visual-js/visual-nightwatch/src/nightwatch/commands/sauceVisualCheck.ts @@ -10,7 +10,11 @@ import { } from '@saucelabs/visual'; import { getMetaInfo, getVisualApi } from '../../utils/api'; import { VISUAL_BUILD_ID_KEY } from '../../utils/constants'; -import { NightwatchAPI, NightwatchCustomCommandsModel } from 'nightwatch'; +import { + NightwatchAPI, + NightwatchCustomCommandsModel, + ScopedElement, +} from 'nightwatch'; import { CheckOptions, NightwatchIgnorable, RunnerSettings } from '../../types'; import type { Runnable } from 'mocha'; @@ -148,7 +152,11 @@ class SauceVisualCheck implements NightwatchCustomCommandsModel { sessionMetadata: metaInfo, suiteName, testName, - fullPageConfig: getFullPageConfig(fullPage, options.fullPage), + fullPageConfig: await getFullPageConfig( + fullPage, + options.fullPage, + async (el) => await el.getId(), + ), clipElement: (await options.clipElement?.getId()) ?? clipElementFromClipSelector, captureDom: options.captureDom ?? globalCaptureDom, diff --git a/visual-js/visual-nightwatch/src/types.ts b/visual-js/visual-nightwatch/src/types.ts index ef700947..de7ecbab 100644 --- a/visual-js/visual-nightwatch/src/types.ts +++ b/visual-js/visual-nightwatch/src/types.ts @@ -31,7 +31,7 @@ export interface CheckOptions { ignore?: NightwatchIgnorable[]; regions?: RegionType[]; diffingMethod?: DiffingMethod; - fullPage?: FullPageScreenshotOptions; + fullPage?: FullPageScreenshotOptions; /** * Whether we should take a snapshot of the DOM to compare with as a part of the diffing process. */ diff --git a/visual-js/visual-wdio/Dockerfile b/visual-js/visual-wdio/Dockerfile index e5d2707d..84a7d955 100644 --- a/visual-js/visual-wdio/Dockerfile +++ b/visual-js/visual-wdio/Dockerfile @@ -2,27 +2,14 @@ FROM node:18 AS runner WORKDIR app -COPY tsconfig.prod.json . -COPY tsconfig.json . -COPY package.json . +RUN corepack enable -COPY ./visual-wdio/src ./visual-wdio/src -COPY ./visual-wdio/package.json ./visual-wdio/package.json -COPY ./visual-wdio/tsconfig.json ./visual-wdio/tsconfig.json -COPY ./visual-wdio/tsconfig.build.json ./visual-wdio/tsconfig.build.json +COPY . ./ -RUN npm install --workspace=visual-wdio -RUN npm run build --workspace=visual-wdio +RUN yarn install && npm run build --workspaces --if-present -COPY ./visual-wdio/integration-tests/configs ./integration-tests/configs -COPY ./visual-wdio/integration-tests/helpers ./integration-tests/helpers -COPY ./visual-wdio/integration-tests/pages ./integration-tests/pages -COPY ./visual-wdio/integration-tests/specs ./integration-tests/specs - -COPY ./visual-wdio/integration-tests/package.json ./integration-tests/package.json - -WORKDIR integration-tests +WORKDIR ./visual-wdio/integration-tests RUN npm install -ENTRYPOINT ["npm", "run", "login-test"] \ No newline at end of file +ENTRYPOINT ["npm", "run", "login-test"] diff --git a/visual-js/visual-wdio/src/SauceVisualService.ts b/visual-js/visual-wdio/src/SauceVisualService.ts index 74846e72..8a1ed9f6 100644 --- a/visual-js/visual-wdio/src/SauceVisualService.ts +++ b/visual-js/visual-wdio/src/SauceVisualService.ts @@ -28,7 +28,12 @@ import { import logger from '@wdio/logger'; import chalk from 'chalk'; -import { Ignorable, isWdioElement, WdioElement } from './guarded-types.js'; +import { + FullPageScreenshotWdioOptions, + Ignorable, + isWdioElement, + WdioElement, +} from './guarded-types.js'; import { backOff } from 'exponential-backoff'; import type { Test } from '@wdio/types/build/Frameworks'; @@ -95,7 +100,7 @@ export type SauceVisualServiceOptions = { clipSelector?: string; clipElement?: WdioElement; region?: SauceRegion; - fullPage?: FullPageScreenshotOptions; + fullPage?: FullPageScreenshotWdioOptions; baselineOverride?: BaselineOverrideIn; }; @@ -131,7 +136,7 @@ export type CheckOptions = { captureDom?: boolean; diffingMethod?: DiffingMethod; disable?: (keyof DiffingOptionsIn)[]; - fullPage?: FullPageScreenshotOptions; + fullPage?: FullPageScreenshotWdioOptions; baselineOverride?: BaselineOverrideIn; }; @@ -165,7 +170,7 @@ export default class SauceVisualService implements Services.ServiceInstance { captureDom: boolean | undefined; clipSelector: string | undefined; clipElement: WdioElement | undefined; - fullPage?: FullPageScreenshotOptions; + fullPage?: FullPageScreenshotWdioOptions; apiClient: VisualApi; baselineOverride?: BaselineOverrideIn; @@ -442,7 +447,11 @@ export default class SauceVisualService implements Services.ServiceInstance { options.diffingMethod || this.diffingMethod || DiffingMethod.Balanced, suiteName: this.test?.parent, testName: this.test?.title, - fullPageConfig: getFullPageConfig(this.fullPage, options.fullPage), + fullPageConfig: await getFullPageConfig( + this.fullPage, + options.fullPage, + (el) => el.elementId, + ), baselineOverride: options.baselineOverride || this.baselineOverride, }); uploadedDiffIds.push(...result.diffs.nodes.flatMap((diff) => diff.id)); diff --git a/visual-js/visual-wdio/src/guarded-types.ts b/visual-js/visual-wdio/src/guarded-types.ts index 66dbdb79..9f8601ba 100644 --- a/visual-js/visual-wdio/src/guarded-types.ts +++ b/visual-js/visual-wdio/src/guarded-types.ts @@ -1,8 +1,15 @@ import { type } from 'arktype'; -import { makeValidate, RegionIn } from '@saucelabs/visual'; +import { + FullPageScreenshotOptions, + makeValidate, + RegionIn, +} from '@saucelabs/visual'; export type WdioElement = WebdriverIO.Element; +export type FullPageScreenshotWdioOptions = + FullPageScreenshotOptions; + const wdioElementType = type({ elementId: 'string', selector: 'string', diff --git a/visual-js/visual/src/graphql/__generated__/graphql.ts b/visual-js/visual/src/graphql/__generated__/graphql.ts index 70274b66..fb378029 100644 --- a/visual-js/visual/src/graphql/__generated__/graphql.ts +++ b/visual-js/visual/src/graphql/__generated__/graphql.ts @@ -774,6 +774,8 @@ export type FullPageConfigIn = { hideElementsAfterFirstScroll?: InputMaybe>; /** Hide all scrollbars in the app. */ hideScrollBars?: InputMaybe; + /** @experimental Define custom scrollable element */ + scrollElement?: InputMaybe; /** * Limit the number of screenshots taken for scrolling and stitching. * Default and max value is 10 diff --git a/visual-js/visual/src/types.ts b/visual-js/visual/src/types.ts index fd068655..6d78c0b5 100644 --- a/visual-js/visual/src/types.ts +++ b/visual-js/visual/src/types.ts @@ -2,7 +2,7 @@ import { RegionIn } from './graphql/__generated__/graphql'; import { SelectiveRegionOptions } from './common/selective-region'; import { SauceRegion } from './common/regions'; -export type FullPageScreenshotOptions = +export type FullPageScreenshotOptions = | boolean | { /** @@ -34,6 +34,10 @@ export type FullPageScreenshotOptions = * Limit the number of screenshots taken for scrolling and stitching. */ scrollLimit?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + /** + * Element used for scrolling (available only in native apps) + */ + scrollElement?: T | Promise; }; export type Ignorable = T | T[] | Promise | Promise | RegionIn; diff --git a/visual-js/visual/src/utils.spec.ts b/visual-js/visual/src/utils.spec.ts index 4e8b7ec3..8c797428 100644 --- a/visual-js/visual/src/utils.spec.ts +++ b/visual-js/visual/src/utils.spec.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from '@jest/globals'; import { getFullPageConfig, parseRegionsForAPI } from './utils'; -import { FullPageConfigIn, RegionIn } from './graphql/__generated__/graphql'; +import { RegionIn } from './graphql/__generated__/graphql'; +import { FullPageScreenshotOptions } from './types'; -const configDelay: FullPageConfigIn = { +type MockElement = { elementId: string }; + +const configDelay: FullPageScreenshotOptions = { delayAfterScrollMs: 1500, }; -const configDelayBig: FullPageConfigIn = { +const configDelayBig: FullPageScreenshotOptions = { delayAfterScrollMs: 5000, }; @@ -23,66 +26,91 @@ const resolveForTest = async (itemPromise: string | Promise) => { describe('utils', () => { describe('getFullPageConfig', () => { describe('returns undefined', () => { - test('when main is true and local is false', () => { - expect(getFullPageConfig(true, false)).toBeUndefined(); + test('when main is true and local is false', async () => { + expect(await getFullPageConfig(true, false)).toBeUndefined(); }); - test('when main is false and local is false', () => { - expect(getFullPageConfig(false, false)).toBeUndefined(); + test('when main is false and local is false', async () => { + expect(await getFullPageConfig(false, false)).toBeUndefined(); }); - test('when main is false and local is false', () => { - expect(getFullPageConfig(false, false)).toBeUndefined(); + test('when main is false and local is false', async () => { + expect(await getFullPageConfig(false, false)).toBeUndefined(); }); - test('when main is object and local is false', () => { - expect(getFullPageConfig(configDelay, false)).toBeUndefined(); + test('when main is object and local is false', async () => { + expect(await getFullPageConfig(configDelay, false)).toBeUndefined(); }); - test('when main is undefined and local is false', () => { - expect(getFullPageConfig(undefined, false)).toBeUndefined(); + test('when main is undefined and local is false', async () => { + expect(await getFullPageConfig(undefined, false)).toBeUndefined(); }); - test('when main is undefined and local is undefined', () => { - expect(getFullPageConfig(undefined, undefined)).toBeUndefined(); + test('when main is undefined and local is undefined', async () => { + expect(await getFullPageConfig(undefined, undefined)).toBeUndefined(); }); - test('when main is false and local is undefined', () => { - expect(getFullPageConfig(false, undefined)).toBeUndefined(); + test('when main is false and local is undefined', async () => { + expect(await getFullPageConfig(false, undefined)).toBeUndefined(); }); }); describe('returns empty config', () => { - test('when main is true and local is true', () => { - expect(getFullPageConfig(true, undefined)).toEqual({}); + test('when main is true and local is true', async () => { + expect(await getFullPageConfig(true, undefined)).toEqual({}); }); - test('when main is false and local is true', () => { - expect(getFullPageConfig(true, undefined)).toEqual({}); + test('when main is false and local is true', async () => { + expect(await getFullPageConfig(true, undefined)).toEqual({}); }); - test('when main is undefined and local is true', () => { - expect(getFullPageConfig(true, undefined)).toEqual({}); + test('when main is undefined and local is true', async () => { + expect(await getFullPageConfig(true, undefined)).toEqual({}); }); - test('when main is true and local is undefined', () => { - expect(getFullPageConfig(true, undefined)).toEqual({}); + test('when main is true and local is undefined', async () => { + expect(await getFullPageConfig(true, undefined)).toEqual({}); }); }); describe('returns config', () => { - test('when main is config and local is true', () => { - expect(getFullPageConfig(configDelay, true)).toEqual(configDelay); + test('when main is config and local is true', async () => { + expect(await getFullPageConfig(configDelay, true)).toEqual(configDelay); }); - test('when main is true and local is config', () => { - expect(getFullPageConfig(true, configDelay)).toEqual(configDelay); + test('when main is true and local is config', async () => { + expect(await getFullPageConfig(true, configDelay)).toEqual(configDelay); }); - test('when main is false and local is config', () => { - expect(getFullPageConfig(true, configDelay)).toEqual(configDelay); + test('when main is false and local is config', async () => { + expect(await getFullPageConfig(true, configDelay)).toEqual(configDelay); }); - test('when main is config and local is config', () => { - expect(getFullPageConfig(configDelay, configDelay)).toEqual( + test('when main is config and local is config', async () => { + expect(await getFullPageConfig(configDelay, configDelay)).toEqual( configDelay, ); }); - test('and local overwrites main config', () => { - expect(getFullPageConfig(configDelay, configDelayBig)).toEqual( + test('and local overwrites main config', async () => { + expect(await getFullPageConfig(configDelay, configDelayBig)).toEqual( configDelayBig, ); }); - test('with merged local and main config', () => { + test('with merged local and main config', async () => { const main = { delayAfterScrollMs: 500 }; const local = { disableCSSAnimation: false }; - expect(getFullPageConfig(main, local)).toEqual({ ...main, ...local }); + expect(await getFullPageConfig(main, local)).toEqual({ + ...main, + ...local, + }); + }); + test('with scrollElement when scrollElement is a promise', async () => { + const elementId = 'elementId'; + const main = {}; + const local = { + scrollElement: Promise.resolve({ elementId: elementId }), + }; + expect( + await getFullPageConfig(main, local, (el) => el.elementId), + ).toEqual({ + scrollElement: elementId, + }); + }); + test('with scrollElement when scrollElement is an object', async () => { + const elementId = 'elementId'; + const main = { scrollElement: { elementId: elementId } }; + const local = {}; + expect( + await getFullPageConfig(main, local, (el) => el.elementId), + ).toEqual({ + scrollElement: elementId, + }); }); }); }); diff --git a/visual-js/visual/src/utils.ts b/visual-js/visual/src/utils.ts index 817c2112..6472ea4a 100644 --- a/visual-js/visual/src/utils.ts +++ b/visual-js/visual/src/utils.ts @@ -6,17 +6,18 @@ import { InputMaybe, RegionIn, } from './graphql/__generated__/graphql'; -import { RegionType, VisualEnvOpts } from './types'; +import { FullPageScreenshotOptions, RegionType, VisualEnvOpts } from './types'; import { selectiveRegionOptionsToDiffingOptions } from './common/selective-region'; import { getApi } from './common/api'; import fs from 'fs/promises'; import * as os from 'node:os'; import { SauceRegion } from './common/regions'; -export const getFullPageConfig: ( - main?: FullPageConfigIn | boolean, - local?: FullPageConfigIn | boolean, -) => FullPageConfigIn | undefined = (main, local) => { +export const getFullPageConfig: ( + main?: FullPageScreenshotOptions | boolean, + local?: FullPageScreenshotOptions | boolean, + getId?: (el: T) => Promise | string, +) => Promise = async (main, local, getId) => { const isNoConfig = !main && !local; const isLocalOff = local === false; @@ -24,9 +25,15 @@ export const getFullPageConfig: ( return; } - const globalCfg = typeof main === 'object' ? main : {}; - const localCfg = typeof local === 'object' ? local : {}; - return { ...globalCfg, ...localCfg }; + const globalCfg: typeof main = typeof main === 'object' ? main : {}; + const localCfg: typeof main = typeof local === 'object' ? local : {}; + const { scrollElement, ...rest } = { ...globalCfg, ...localCfg }; + const result: FullPageConfigIn = rest; + if (scrollElement && getId) { + result.scrollElement = await getId(await scrollElement); + } + + return result; }; export const isSkipMode = (): boolean => {