diff --git a/.github/workflows/js-build.yml b/.github/workflows/js-build.yml index 4e4b082d..92ff4b83 100644 --- a/.github/workflows/js-build.yml +++ b/.github/workflows/js-build.yml @@ -25,7 +25,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 18.x - - name: Build with lerna + - name: Build run: | corepack enable yarn install diff --git a/visual-js/.changeset/early-jokes-report.md b/visual-js/.changeset/early-jokes-report.md new file mode 100644 index 00000000..641f0079 --- /dev/null +++ b/visual-js/.changeset/early-jokes-report.md @@ -0,0 +1,7 @@ +--- +"@saucelabs/visual-storybook": minor +"@saucelabs/visual-playwright": patch +"@saucelabs/visual": patch +--- + +Rebase Storybook integration onto Playwright diff --git a/visual-js/.dockerignore b/visual-js/.dockerignore new file mode 100644 index 00000000..dd87e2d7 --- /dev/null +++ b/visual-js/.dockerignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/visual-js/package.json b/visual-js/package.json index 13226fe4..8ebe7f8b 100644 --- a/visual-js/package.json +++ b/visual-js/package.json @@ -3,11 +3,11 @@ "private": true, "workspaces": [ "visual", - "visual-storybook", "visual-wdio", "visual-cypress", "visual-nightwatch", - "visual-playwright" + "visual-playwright", + "visual-storybook" ], "lint-staged": { "**/*.{js,jsx,ts,tsx}": [ diff --git a/visual-js/visual-playwright/README.md b/visual-js/visual-playwright/README.md new file mode 100644 index 00000000..ff1b78f6 --- /dev/null +++ b/visual-js/visual-playwright/README.md @@ -0,0 +1,39 @@ +# Sauce Labs Visual Playwright Integration + +An extension for [Playwright](https://playwright.dev/) to integrate effortless visual testing with Sauce Labs Visual. + +## Installation & Usage + +View installation and usage instructions on the [Sauce Docs website](https://docs.saucelabs.com/visual-testing/integrations/playwright/). + +## Building + +Install the dependencies + +```sh +npm install +``` + +Build [Sauce Labs Visual client library](../visual/) since Sauce Labs Visual Playwright Integration depends on it + +```sh +npm --prefix ../visual run build +``` + +Finally build Sauce Labs Visual Playwright Integration + +```sh +npm run build +``` + +## Linting + +```sh +npm run lint +``` + +## Running the tests + +```sh +npm run test +``` diff --git a/visual-js/visual-playwright/jest.config.js b/visual-js/visual-playwright/jest.config.js new file mode 100644 index 00000000..36bc7a80 --- /dev/null +++ b/visual-js/visual-playwright/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/integration-tests'], +}; diff --git a/visual-js/visual-playwright/package.json b/visual-js/visual-playwright/package.json index 3af4d8c6..0275644a 100644 --- a/visual-js/visual-playwright/package.json +++ b/visual-js/visual-playwright/package.json @@ -29,7 +29,8 @@ "scripts": { "build": "tsup-node", "watch": "tsc-watch --declaration -p .", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"" + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "test": "jest --collect-coverage" }, "dependencies": { "@playwright/test": "^1.42.1", diff --git a/visual-js/visual-storybook/src/api.spec.ts b/visual-js/visual-playwright/src/api.spec.ts similarity index 76% rename from visual-js/visual-storybook/src/api.spec.ts rename to visual-js/visual-playwright/src/api.spec.ts index 8ab0f55a..e9009da1 100644 --- a/visual-js/visual-storybook/src/api.spec.ts +++ b/visual-js/visual-playwright/src/api.spec.ts @@ -1,20 +1,21 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; - -import { getApi as getVisualApi, waitForBuildResult } from './api'; -import * as utils from './utils'; +import type { MockInstance } from 'jest-mock'; import * as sauceVisual from '@saucelabs/visual'; -import { BuildStatus, getApi } from '@saucelabs/visual'; -import { MockInstance } from 'jest-mock'; +import { BuildStatus, getEnvOpts, VisualApi } from '@saucelabs/visual'; +import VisualPlaywright from './api'; +import * as utils from './utils'; -jest.mock('@saucelabs/visual', () => ({ - ...jest.requireActual('@saucelabs/visual'), - getApi: () => ({ +jest.mock('@saucelabs/visual', () => { + const apiResult = { buildStatus: jest.fn(), - }), -})); + }; + return { + ...jest.requireActual('@saucelabs/visual'), + getApi: () => apiResult, + }; +}); describe('api', () => { - jest.spyOn(utils, 'getOpts').mockReturnValue(utils.getEnvOpts()); const consoleInfoSpy = jest .spyOn(console, 'info') // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -23,18 +24,18 @@ describe('api', () => { .spyOn(console, 'error') // eslint-disable-next-line @typescript-eslint/no-empty-function .mockImplementation(() => {}); - const apiMock = getVisualApi(); - const buildStatusSpy = apiMock.buildStatus as unknown as MockInstance< - ReturnType['buildStatus'] - >; + jest.spyOn(utils, 'getOpts').mockReturnValue({ + ...getEnvOpts(), + externalBuildId: true, + }); + const buildStatusSpy = VisualPlaywright.api + .buildStatus as unknown as MockInstance; beforeEach(() => { jest.clearAllMocks(); }); - const fakeBuild: Awaited< - ReturnType['buildStatus']> - > = { + const fakeBuild: Awaited> = { __typename: 'Build', status: BuildStatus.Running, url: 'https://fake-url', @@ -45,7 +46,7 @@ describe('api', () => { describe('waitForBuildResult', () => { it('should log a console error when a build is not found, and only query the API once', async () => { buildStatusSpy.mockResolvedValue(null); - await waitForBuildResult(''); + await VisualPlaywright.waitForBuildResult(''); expect(buildStatusSpy).toBeCalledTimes(1); expect(consoleErrorSpy).toBeCalledTimes(1); expect(consoleErrorSpy).toBeCalledWith( @@ -64,7 +65,7 @@ describe('api', () => { ...fakeBuild, status: BuildStatus.Equal, }); - await waitForBuildResult(''); + await VisualPlaywright.waitForBuildResult(''); expect(buildStatusSpy).toBeCalledTimes(2); expect(consoleInfoSpy).toBeCalledTimes(1); expect(consoleInfoSpy).toBeCalledWith( @@ -86,7 +87,7 @@ describe('api', () => { ...fakeBuild, status: BuildStatus.Unapproved, }); - await waitForBuildResult(''); + await VisualPlaywright.waitForBuildResult(''); // Should retry as many times as buildstatus === running expect(buildStatusSpy).toBeCalledTimes(3); @@ -97,7 +98,7 @@ describe('api', () => { ...fakeBuild, status: BuildStatus.Equal, }); - await waitForBuildResult(''); + await VisualPlaywright.waitForBuildResult(''); // Should retry as many times as buildstatus === running expect(buildStatusSpy).toBeCalledTimes(1); diff --git a/visual-js/visual-playwright/src/api.ts b/visual-js/visual-playwright/src/api.ts index dcb71e90..87abf074 100644 --- a/visual-js/visual-playwright/src/api.ts +++ b/visual-js/visual-playwright/src/api.ts @@ -1,340 +1,378 @@ import type { Page } from 'playwright-core'; import { BuildStatus, + downloadDomScript, getApi as getVisualApi, getDomScript, RegionIn, + removeDomScriptFile, + VisualEnvOpts, } from '@saucelabs/visual'; import { backOff } from 'exponential-backoff'; import { SauceVisualParams } from './types'; -import { buildSnapshotMetadata, getOpts } from './utils'; -import { TestInfo } from '@playwright/test'; +import { buildSnapshotMetadata, getOpts, parseOpts, setOpts } from './utils'; const clientVersion = 'PKG_VERSION'; -export const createVisualApi = (client: string) => { - const { user, key, region } = getOpts(); +export class VisualPlaywright { + constructor(public client: string = `visual-playwright/${clientVersion}`) {} - return getVisualApi( - { - user, - key, - region, - }, - { - userAgent: client, - }, - ); -}; - -export const getApi = (client = `visual-playwright/${clientVersion}`) => { - let api = globalThis.visualApi; - if (!api) { - api = createVisualApi(client); - globalThis.visualApi = api; + public get api() { + let api = globalThis.visualApi; + if (!api) { + api = this.createVisualApi(this.client); + } + return api; } - return api; -}; - -export const getBuildIdForCustomId = async ( - customId: string, -): Promise => { - const build = await getApi().buildByCustomId(customId); - return build?.id ?? null; -}; - -export const createBuild = async () => { - const { buildName, project, branch, customId, defaultBranch } = getOpts(); - const build = await getApi().createBuild({ - name: buildName, - branch, - defaultBranch, - project, - customId, - }); - - if (!build) { - throw new Error( - "Failed to create Sauce Visual build. Please check that you've supplied valid credentials and selected an available region for your account and try again.", - ); + + createVisualApi(client?: string) { + const { user, key, region } = getOpts(); + + return (globalThis.visualApi = getVisualApi( + { + user, + key, + region, + }, + { + userAgent: client, + }, + )); } - console.info( - `View your in-progress build on the Sauce Labs dashboard: + public async getBuildIdForCustomId(customId: string) { + const build = await this.api.buildByCustomId(customId); + return build?.id ?? null; + } + + public async createBuild() { + const { buildName, project, branch, customId, defaultBranch } = getOpts(); + const build = await this.api.createBuild({ + name: buildName, + branch, + defaultBranch, + project, + customId, + }); + + if (!build) { + throw new Error( + "Failed to create Sauce Visual build. Please check that you've supplied valid credentials and selected an available region for your account and try again.", + ); + } + + console.info( + `View your in-progress build on the Sauce Labs dashboard: ${build.url} `, - ); - - return build.id; -}; - -export const finishBuild = async (id: string) => { - return await getApi().finishBuild({ - uuid: id, - }); -}; - -/** - * Wait for the result of the build. Retries gracefully until a build status !== running is met. - * Resolves with an exit code of 1 if unreviewed changes are found. - * @param buildId - */ -export const waitForBuildResult = async (buildId: string) => { - const buildInProgressError = new Error('Build execution in progress.'); - const buildNotFoundError = new Error( - 'Build has been deleted or you do not have access to view it.', - ); - - try { - const fetchBuild = async () => { - const build = await getApi().buildStatus(buildId); - - if (!build) { - throw buildNotFoundError; - } - - if (build.status !== BuildStatus.Running) { - return build; - } + ); - throw buildInProgressError; - }; + return build.id; + } - const build = await backOff(fetchBuild, { - maxDelay: 10000, - numOfAttempts: 20, - // continues to retry on true, on false will short-circuit and end retry early - retry: (e) => e !== buildNotFoundError, + public async finishBuild(id: string) { + return await this.api.finishBuild({ + uuid: id, }); + } - console.info( - `Your Sauce Visual Build is ready for review: + /** + * Wait for the result of the build. Retries gracefully until a build status !== running is met. + * Resolves with an exit code of 1 if unreviewed changes are found. + */ + public async waitForBuildResult(buildId: string) { + const buildInProgressError = new Error('Build execution in progress.'); + const buildNotFoundError = new Error( + 'Build has been deleted or you do not have access to view it.', + ); + + try { + const fetchBuild = async () => { + const build = await this.api.buildStatus(buildId); + + if (!build) { + throw buildNotFoundError; + } + + if (build.status !== BuildStatus.Running) { + return build; + } + + throw buildInProgressError; + }; + + const build = await backOff(fetchBuild, { + maxDelay: 10000, + numOfAttempts: 20, + // continues to retry on true, on false will short-circuit and end retry early + retry: (e) => e !== buildNotFoundError, + }); + + console.info( + `Your Sauce Visual Build is ready for review: ${build.url} `, - ); + ); - if (build.unapprovedCount > 0 || build.errorCount > 0) { - process.exitCode = 1; - } - } catch (e) { - console.error( - `Failed to determine build completion status. This could be due to an abnormally long-running build, or an error in communication with the server. See the error message below for more details: + if (build.unapprovedCount > 0 || build.errorCount > 0) { + process.exitCode = 1; + } + } catch (e) { + console.error( + `Failed to determine build completion status. This could be due to an abnormally long-running build, or an error in communication with the server. See the error message below for more details: ${e instanceof Error ? e.message : JSON.stringify(e)} `, - ); + ); - process.exitCode = 1; + process.exitCode = 1; + } } -}; - -/** - * Takes a snapshot of the current viewport and uploads it to Sauce Visual. - */ -export const sauceVisualCheck = async ( - page: Page, - testInfo: TestInfo, - name: string, - options?: Partial, -) => - await takePlaywrightScreenshot( - page, - { - deviceName: testInfo.project.name, - testName: testInfo.title, - suiteName: testInfo.titlePath.slice(0, -1).join('/'), + + public async takePlaywrightScreenshot( + page: Page, + info: { + testName: string | undefined; + suiteName: string | undefined; + deviceName: string | undefined; }, - name, - options, - ); - -export const takePlaywrightScreenshot = async ( - page: Page, - info: { - testName: string | undefined; - suiteName: string | undefined; - deviceName: string | undefined; - }, - name: string, - options?: Partial, -) => { - const { testName, suiteName, deviceName } = info; - const { buildId } = getOpts(); - - if (!buildId) { - console.warn('No Sauce Visual build present, skipping Visual snapshot.'); - return; - } + name: string, + options?: Partial, + ) { + const { testName, suiteName, deviceName } = info; + const { buildId } = getOpts(); + + if (!buildId) { + console.warn('No Sauce Visual build present, skipping Visual snapshot.'); + return; + } - const browser = page.context().browser(); - const { - screenshotOptions = {}, - clipSelector, - delay, - captureDom, - ignoreRegions: userIgnoreRegions, - diffingMethod, - } = options ?? {}; - const { animations = 'disabled', caret } = screenshotOptions; - let ignoreRegions: RegionIn[] = []; - - const promises: Promise[] = [ - // Wait for all fonts to be loaded & ready - page.evaluate(() => document.fonts.ready), - ]; - - if (delay) { - // If a delay has been configured by the user, append it to our promises - promises.push(new Promise((resolve) => setTimeout(resolve, delay))); - } + const browser = page.context().browser(); + const { + screenshotOptions = {}, + clipSelector, + delay, + captureDom, + ignoreRegions: userIgnoreRegions, + diffingMethod, + } = options ?? {}; + const { animations = 'disabled', caret } = screenshotOptions; + let ignoreRegions: RegionIn[] = []; + + const promises: Promise[] = [ + // Wait for all fonts to be loaded & ready + page.evaluate(() => document.fonts.ready), + ]; + + if (delay) { + // If a delay has been configured by the user, append it to our promises + promises.push(new Promise((resolve) => setTimeout(resolve, delay))); + } - if (userIgnoreRegions) { - promises.push( - (async (): Promise => { - const filterIgnoreRegion = ( - region: RegionIn | string, - ): region is RegionIn => typeof region !== 'string'; - const filterIgnoreSelector = ( - region: RegionIn | string, - ): region is string => typeof region === 'string'; - - const selectors = userIgnoreRegions.filter(filterIgnoreSelector); - let selectorRegions: RegionIn[] = []; - - if (selectors.length) { - selectorRegions = await page.evaluate( - ({ selectors }) => { - const selectorRegions: RegionIn[] = []; - selectors.forEach((selector) => { - const elements = document.querySelectorAll(selector); - elements.forEach((element) => { - const rect = element.getBoundingClientRect(); - - selectorRegions.push({ - name: selector, - x: Math.round(rect.x), - y: Math.round(rect.y), - height: Math.round(rect.height), - width: Math.round(rect.width), + if (userIgnoreRegions) { + promises.push( + (async (): Promise => { + const filterIgnoreRegion = ( + region: RegionIn | string, + ): region is RegionIn => typeof region !== 'string'; + const filterIgnoreSelector = ( + region: RegionIn | string, + ): region is string => typeof region === 'string'; + + const selectors = userIgnoreRegions.filter(filterIgnoreSelector); + let selectorRegions: RegionIn[] = []; + + if (selectors.length) { + selectorRegions = await page.evaluate( + ({ selectors }) => { + const selectorRegions: RegionIn[] = []; + selectors.forEach((selector) => { + const elements = document.querySelectorAll(selector); + elements.forEach((element) => { + const rect = element.getBoundingClientRect(); + + selectorRegions.push({ + name: selector, + x: Math.round(rect.x), + y: Math.round(rect.y), + height: Math.round(rect.height), + width: Math.round(rect.width), + }); }); }); - }); - return selectorRegions; - }, - { selectors }, + return selectorRegions; + }, + { selectors }, + ); + } + + ignoreRegions = [ + ...userIgnoreRegions.filter(filterIgnoreRegion), + ...selectorRegions, + ].filter( + (region) => + 0 < Math.max(region.width, 0) * Math.max(region.height, 0), ); - } + })(), + ); + } + + // Await all queued / concurrent promises before resuming + await Promise.all(promises); + + const clip = clipSelector + ? await page.evaluate( + ({ clipSelector }) => { + // Clip the screenshot to the dims of the requested clipSelector. + const clipElement = document.querySelector(clipSelector); + if (!clipElement) { + return undefined; + } + + const clientDims = clipElement.getBoundingClientRect(); + let { x, y, height, width } = clientDims; + + // corrected coordinates + const cX = x < 0 ? Math.abs(x) : 0; + const cY = y < 0 ? Math.abs(y) : 0; + + ({ x, y, width, height } = { + x: Math.max(x, 0), + y: Math.max(y, 0), + width: cX > 0 ? width - cX : width, + height: cY > 0 ? height - cY : height, + }); + + // If any values are < 0, then do not clip as those are invalid options for + // playwright + if (x < 0 || y < 0 || height <= 0 || width <= 0) { + return undefined; + } + + return { + x, + y, + height, + width, + }; + }, + { clipSelector }, + ) + : undefined; + + const devicePixelRatio = await page.evaluate(() => window.devicePixelRatio); + + const screenshotBuffer = await page.screenshot({ + fullPage: true, + animations, + caret, + clip, + }); + + // Inject scripts to get dom snapshot + let dom: string | undefined; + const script = await getDomScript(); - ignoreRegions = [ - ...userIgnoreRegions.filter(filterIgnoreRegion), - ...selectorRegions, - ].filter( - (region) => - 0 < Math.max(region.width, 0) * Math.max(region.height, 0), + if (captureDom && script) { + let error: string | undefined; + try { + const result = await page.evaluate( + `(function({ clipSelector }){${script}})({ clipSelector: '${clipSelector}' })`, ); - })(), - ); - } - // Await all queued / concurrent promises before resuming - await Promise.all(promises); + if (typeof result === 'string') { + dom = result; + } else { + error = `Dom type should be string not ${typeof result}.`; + } + } catch (err: unknown) { + error = `Unable to capture the dom.\n${err}`; + } + + if (error) { + console.error(error); + } + } - const clip = clipSelector - ? await page.evaluate( - ({ clipSelector }) => { - // Clip the screenshot to the dims of the requested clipSelector. - const clipElement = document.querySelector(clipSelector); - if (!clipElement) { - return undefined; - } + const uploadId = await this.api.uploadSnapshot({ + buildId, + image: { data: screenshotBuffer }, + dom: dom ? { data: Buffer.from(dom) } : undefined, + }); - const clientDims = clipElement.getBoundingClientRect(); - let { x, y, height, width } = clientDims; + const meta = buildSnapshotMetadata({ + browserVersion: browser?.version(), + browserName: browser?.browserType().name(), + devicePixelRatio, + deviceName: deviceName, + buildId, + name, + ignoreRegions, + diffingMethod, + }); - // corrected coordinates - const cX = x < 0 ? Math.abs(x) : 0; - const cY = y < 0 ? Math.abs(y) : 0; + await this.api.createSnapshot({ + ...meta, + testName, + suiteName, + uploadUuid: uploadId, + }); + } - ({ x, y, width, height } = { - x: Math.max(x, 0), - y: Math.max(y, 0), - width: cX > 0 ? width - cX : width, - height: cY > 0 ? height - cY : height, - }); + public async setup(opts?: VisualEnvOpts) { + parseOpts(); - // If any values are < 0, then do not clip as those are invalid options for - // playwright - if (x < 0 || y < 0 || height <= 0 || width <= 0) { - return undefined; - } + if (opts) { + setOpts(opts); + } - return { - x, - y, - height, - width, - }; - }, - { clipSelector }, - ) - : undefined; - - const devicePixelRatio = await page.evaluate(() => window.devicePixelRatio); - - const screenshotBuffer = await page.screenshot({ - fullPage: true, - animations, - caret, - clip, - }); - - // Inject scripts to get dom snapshot - let dom: string | undefined; - const script = await getDomScript(); - - if (captureDom && script) { - let error: string | undefined; - try { - const result = await page.evaluate( - `(function({ clipSelector }){${script}})({ clipSelector: '${clipSelector}' })`, - ); + await downloadDomScript(this.api); + + const { buildId: passedBuildId, customId } = getOpts(); + let buildId = passedBuildId; + let customBuildId = null; + + if (!buildId && customId) { + customBuildId = await this.getBuildIdForCustomId(customId); - if (typeof result === 'string') { - dom = result; - } else { - error = `Dom type should be string not ${typeof result}.`; + console.info(`USING CUSTOM ID ${customId} for build ${customBuildId}`); + + if (customBuildId) { + buildId = customBuildId; + setOpts({ + buildId, + externalBuildId: true, + }); } - } catch (err: unknown) { - error = `Unable to capture the dom.\n${err}`; } - if (error) { - console.error(error); + if (!buildId) { + const newBuildId = await this.createBuild(); + + setOpts({ + buildId: newBuildId, + externalBuildId: false, + }); + } + } + + public async teardown() { + await removeDomScriptFile(); + + const { buildId, externalBuildId } = getOpts(); + // Only finish the build automatically if we created it during globalSetup (if it's not external). + if (!externalBuildId && buildId) { + const finishedBuild = await this.finishBuild(buildId); + + if (!finishedBuild) { + throw new Error('Failed to finalize build.'); + } + + await this.waitForBuildResult(finishedBuild.id); } } +} + +const _VisualPlaywright = new VisualPlaywright(); - const api = getApi(); - const uploadId = await api.uploadSnapshot({ - buildId, - image: { data: screenshotBuffer }, - dom: dom ? { data: Buffer.from(dom) } : undefined, - }); - - const meta = buildSnapshotMetadata({ - browserVersion: browser?.version(), - browserName: browser?.browserType().name(), - devicePixelRatio, - deviceName: deviceName, - buildId, - name, - ignoreRegions, - diffingMethod, - }); - - await api.createSnapshot({ - ...meta, - testName, - suiteName, - uploadUuid: uploadId, - }); -}; +export default _VisualPlaywright; diff --git a/visual-js/visual-playwright/src/fixtures.ts b/visual-js/visual-playwright/src/fixtures.ts index 4a3e4a02..41206d08 100644 --- a/visual-js/visual-playwright/src/fixtures.ts +++ b/visual-js/visual-playwright/src/fixtures.ts @@ -1,7 +1,7 @@ import { TestFixture, TestInfo } from '@playwright/test'; import { Page } from 'playwright-core'; import { SauceVisualParams } from './types'; -import { sauceVisualCheck } from './api'; +import { sauceVisualCheck } from './playwright'; export type SauceVisualFixtures = { sauceVisual: { diff --git a/visual-js/visual-playwright/src/index.ts b/visual-js/visual-playwright/src/index.ts index 6f9990a6..e5539560 100644 --- a/visual-js/visual-playwright/src/index.ts +++ b/visual-js/visual-playwright/src/index.ts @@ -1,19 +1,20 @@ -export { sauceVisualCheck } from './api'; -export { sauceVisualSetup, sauceVisualTeardown } from './setup-teardown'; +export { + sauceVisualSetup, + sauceVisualTeardown, + sauceVisualCheck, +} from './playwright'; export { sauceVisualFixtures, SauceVisualFixtures } from './fixtures'; export { SauceVisualParams } from './types'; - -import { takePlaywrightScreenshot, getApi } from './api'; -import { getOpts, parseOpts, setOpts } from './utils'; +import { VisualPlaywright } from './api'; +import { getOpts, setOpts, parseOpts } from './utils'; /** * One or more internal functions / utilities exported to reduce code duplication. Not intended for * end users. */ export const internals = { - takePlaywrightScreenshot, + VisualPlaywright, getOpts, - getApi, - parseOpts, setOpts, + parseOpts, }; diff --git a/visual-js/visual-playwright/src/playwright.ts b/visual-js/visual-playwright/src/playwright.ts new file mode 100644 index 00000000..e1c192d3 --- /dev/null +++ b/visual-js/visual-playwright/src/playwright.ts @@ -0,0 +1,24 @@ +import VisualPlaywright from './api'; +import type { Page } from 'playwright-core'; +import { TestInfo } from '@playwright/test'; +import { SauceVisualParams } from './types'; + +export const sauceVisualSetup = VisualPlaywright.setup.bind(VisualPlaywright); +export const sauceVisualTeardown = + VisualPlaywright.teardown.bind(VisualPlaywright); +export const sauceVisualCheck = async ( + page: Page, + testInfo: TestInfo, + name: string, + options?: Partial, +) => + await VisualPlaywright.takePlaywrightScreenshot( + page, + { + deviceName: testInfo.project.name, + testName: testInfo.title, + suiteName: testInfo.titlePath.slice(0, -1).join('/'), + }, + name, + options, + ); diff --git a/visual-js/visual-playwright/src/setup-teardown.ts b/visual-js/visual-playwright/src/setup-teardown.ts deleted file mode 100644 index d04a674a..00000000 --- a/visual-js/visual-playwright/src/setup-teardown.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - downloadDomScript, - getEnvOpts, - removeDomScriptFile, - VisualEnvOpts, -} from '@saucelabs/visual'; -import { - createBuild, - finishBuild, - getApi, - getBuildIdForCustomId, - waitForBuildResult, -} from './api'; -import { getOpts, parseOpts, setOpts } from './utils'; - -export const sauceVisualSetup = async (opts?: VisualEnvOpts) => { - parseOpts(); - - if (opts) { - setOpts(opts); - } - - await downloadDomScript(getApi()); - - const { buildId: passedBuildId, customId } = getEnvOpts(); - let buildId = passedBuildId; - let customBuildId = null; - - if (!buildId && customId) { - customBuildId = await getBuildIdForCustomId(customId); - - console.info(`USING CUSTOM ID ${customId} for build ${customBuildId}`); - - if (customBuildId) { - buildId = customBuildId; - setOpts({ - buildId, - externalBuildId: true, - }); - } - } - - if (!buildId) { - const newBuildId = await createBuild(); - - setOpts({ - buildId: newBuildId, - externalBuildId: false, - }); - } -}; - -export const sauceVisualTeardown = async () => { - await removeDomScriptFile(); - - const { buildId } = getOpts(); - const externalBuildId = false; - // Only finish the build automatically if we created it during globalSetup (if it's not external). - if (!externalBuildId && buildId) { - const finishedBuild = await finishBuild(buildId); - - if (!finishedBuild) { - throw new Error('Failed to finalize build.'); - } - - await waitForBuildResult(finishedBuild.id); - } -}; diff --git a/visual-js/visual-playwright/src/types.ts b/visual-js/visual-playwright/src/types.ts index a3c80d2a..79c1bbba 100644 --- a/visual-js/visual-playwright/src/types.ts +++ b/visual-js/visual-playwright/src/types.ts @@ -30,5 +30,5 @@ export interface PlaywrightEnvOpts extends VisualEnvOpts { /** * Whether this build was created externally and provided via an ENV (sharding, concurrency). */ - externalBuildId?: boolean; + externalBuildId: boolean; } diff --git a/visual-js/visual-playwright/src/utils.ts b/visual-js/visual-playwright/src/utils.ts index 6409974a..9f880b70 100644 --- a/visual-js/visual-playwright/src/utils.ts +++ b/visual-js/visual-playwright/src/utils.ts @@ -15,7 +15,10 @@ import { PlaywrightEnvOpts } from './types'; * playwright setup & runtime. */ export const parseOpts = () => { - serializeOpts(getEnvOpts()); + serializeOpts({ + ...getEnvOpts(), + externalBuildId: true, + }); }; const serializeOpts = (opts: T) => { diff --git a/visual-js/visual-storybook/Dockerfile b/visual-js/visual-storybook/Dockerfile index c7cf663f..75e02fb5 100644 --- a/visual-js/visual-storybook/Dockerfile +++ b/visual-js/visual-storybook/Dockerfile @@ -2,26 +2,16 @@ FROM node:18 AS runner WORKDIR app -COPY tsconfig.prod.json tsconfig.json package.json ./ +RUN corepack enable -COPY ./visual-storybook/src ./visual-storybook/src -COPY ./visual-storybook/package.json ./visual-storybook/tsconfig.json ./visual-storybook/ +COPY . ./ -RUN npm install --workspace=visual-storybook -RUN npm run build --workspace=visual-storybook +RUN yarn install && npm run build --workspaces --if-present -COPY ./visual-storybook/integration-tests/src ./integration-tests/src -COPY ./visual-storybook/integration-tests/.storybook ./integration-tests/.storybook - -COPY ./visual-storybook/integration-tests/package.json \ - ./visual-storybook/integration-tests/package-lock.json \ - ./visual-storybook/integration-tests/test-runner-jest.config.js \ - ./visual-storybook/integration-tests/tsconfig.json \ - ./integration-tests/ +COPY ./visual-storybook/integration-tests/ ./integration-tests/ WORKDIR integration-tests -RUN npm install -RUN npx playwright install --with-deps +RUN npm install && npx playwright install --with-deps chromium -ENTRYPOINT ["npm", "run", "test-storybook:ci"] \ No newline at end of file +ENTRYPOINT ["npm", "run", "test-storybook:ci"] diff --git a/visual-js/visual-storybook/package.json b/visual-js/visual-storybook/package.json index c05a9146..630dc54f 100644 --- a/visual-js/visual-storybook/package.json +++ b/visual-js/visual-storybook/package.json @@ -11,7 +11,7 @@ ], "type": "module", "engines": { - "node": "^16.13 || >=18" + "node": ">=18" }, "typeScriptVersion": "3.8.3", "keywords": [ @@ -38,11 +38,11 @@ "scripts": { "build": "tsup", "watch": "tsc-watch --declaration -p .", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", - "test": "jest --collect-coverage" + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"" }, "dependencies": { "@saucelabs/visual": "^0.8.0", + "@saucelabs/visual-playwright": "^0.1.0", "@storybook/test-runner": ">=0.13.0", "exponential-backoff": "^3.1.1", "jest-playwright-preset": "^2.0.0 || ^3.0.0" diff --git a/visual-js/visual-storybook/src/api.ts b/visual-js/visual-storybook/src/api.ts index 24191b2b..e9d5eed8 100644 --- a/visual-js/visual-storybook/src/api.ts +++ b/visual-js/visual-storybook/src/api.ts @@ -1,305 +1,45 @@ import type { TestContext } from '@storybook/test-runner'; import { getStoryContext } from '@storybook/test-runner'; import type { Page } from 'playwright-core'; -import { - BuildStatus, - getApi as getVisualApi, - RegionIn, -} from '@saucelabs/visual'; -import { buildSnapshotMetadata, getDomScript, getOpts } from './utils'; -import { backOff } from 'exponential-backoff'; +import { internals } from '@saucelabs/visual-playwright'; import { SauceVisualParams } from './types'; -const clientVersion = 'PKG_VERSION'; - -export const createVisualApi = () => { - const { user, key, region } = getOpts(); - - return getVisualApi( - { - user, - key, - region, - }, - { - userAgent: `visual-storybook/${clientVersion}`, - }, - ); -}; - -export const getApi = () => { - let api = globalThis.visualApi; - if (!api) { - api = createVisualApi(); - globalThis.visualApi = api; - } - return api; -}; - -export const getBuildIdForCustomId = async ( - customId: string, -): Promise => { - const build = await getApi().buildByCustomId(customId); - return build?.id ?? null; -}; - -export const createBuild = async () => { - const { buildName, project, branch, customId, defaultBranch } = getOpts(); - const build = await getApi().createBuild({ - name: buildName, - branch, - defaultBranch, - project, - customId, - }); - - if (!build) { - throw new Error( - "Failed to create Sauce Visual build. Please check that you've supplied valid credentials and selected an available region for your account and try again.", - ); - } - - console.info( - `View your in-progress build on the Sauce Labs dashboard: - ${build.url} -`, - ); - - return build.id; -}; - -export const finishBuild = async (id: string) => { - return await getApi().finishBuild({ - uuid: id, - }); -}; - -/** - * Wait for the result of the build. Retries gracefully until a build status !== running is met. - * Resolves with an exit code of 1 if unreviewed changes are found. - * @param buildId - */ -export const waitForBuildResult = async (buildId: string) => { - const buildInProgressError = new Error('Build execution in progress.'); - const buildNotFoundError = new Error( - 'Build has been deleted or you do not have access to view it.', - ); - - try { - const fetchBuild = async () => { - const build = await getApi().buildStatus(buildId); - - if (!build) { - throw buildNotFoundError; - } - - if (build.status !== BuildStatus.Running) { - return build; - } - - throw buildInProgressError; - }; - - const build = await backOff(fetchBuild, { - maxDelay: 10000, - numOfAttempts: 20, - // continues to retry on true, on false will short-circuit and end retry early - retry: (e) => e !== buildNotFoundError, - }); +const { VisualPlaywright } = internals; - console.info( - `Your Sauce Visual Build is ready for review: - ${build.url} -`, - ); - - if (build.unapprovedCount > 0) { - process.exitCode = 1; - } - } catch (e) { - console.error( - `Failed to determine build completion status. This could be due to an abnormally long-running build, or an error in communication with the server. See the error message below for more details: - -${e instanceof Error ? e.message : JSON.stringify(e)} -`, - ); +const clientVersion = 'PKG_VERSION'; - process.exitCode = 1; - } -}; +export const VisualStorybook = new VisualPlaywright( + `visual-storybook/${clientVersion}`, +); /** * Used in Storybook's test runner config file (test-runner.js/ts) for the `postVisit` hook. Takes * a snapshot of the current viewport and uploads it to Sauce Visual. */ export const postVisit = async (page: Page, context: TestContext) => { - const { buildId } = getOpts(); - - if (!buildId) { - return; - } - const storyContext = await getStoryContext(page, context); const sauceVisualParams = storyContext.parameters.sauceVisual as | Partial | undefined; - const browser = page.context().browser(); const { - screenshotOptions = {}, clip: shouldClip = true, clipSelector = '#storybook-root', - delay, - captureDom, - ignoreRegions: userIgnoreRegions, - diffingMethod, + ...otherOptions } = sauceVisualParams ?? {}; - const { animations = 'disabled', caret } = screenshotOptions; - let ignoreRegions: RegionIn[] = []; - - const promises: Promise[] = [ - // Wait for all fonts to be loaded & ready - page.evaluate(() => document.fonts.ready), - ]; - - if (delay) { - // If a delay has been configured by the user, append it to our promises - promises.push(new Promise((resolve) => setTimeout(resolve, delay))); - } - - if (userIgnoreRegions) { - promises.push( - (async () => { - const filterIgnoreRegion = ( - region: RegionIn | string, - ): region is RegionIn => typeof region !== 'string'; - const filterIgnoreSelector = ( - region: RegionIn | string, - ): region is string => typeof region === 'string'; - - const selectors = userIgnoreRegions.filter(filterIgnoreSelector); - let selectorRegions: RegionIn[] = []; - - if (selectors.length) { - selectorRegions = await page.evaluate( - ({ selectors }) => { - const selectorRegions: RegionIn[] = []; - selectors.forEach((selector) => { - const elements = document.querySelectorAll(selector); - elements.forEach((element) => { - const rect = element.getBoundingClientRect(); - - selectorRegions.push({ - name: selector, - x: Math.round(rect.x), - y: Math.round(rect.y), - height: Math.round(rect.height), - width: Math.round(rect.width), - }); - }); - }); - return selectorRegions; - }, - { selectors }, - ); - } - - ignoreRegions = [ - ...userIgnoreRegions.filter(filterIgnoreRegion), - ...selectorRegions, - ].filter((region) => 0 < region.width * region.height); - })(), - ); - } - - // Await all queued / concurrent promises before resuming - await Promise.all(promises); - - const clip = shouldClip - ? await page.evaluate( - ({ clipSelector }) => { - // Clip the screenshot to the dims of the storybook root to bypass additional whitespace that's - // likely unnecessary. - const storybookRoot = document.querySelector(clipSelector); - if (!storybookRoot) { - return undefined; - } - - const clientDims = storybookRoot.getBoundingClientRect(); - const { x, y, height, width } = clientDims; - // If any values are falsy (or zero), then do not clip as those are invalid options for - // playwright - if (!x || !y || !height || !width) { - return undefined; - } - - return { - x, - y, - height, - width, - }; - }, - { clipSelector }, - ) - : undefined; - - const devicePixelRatio = await page.evaluate(() => window.devicePixelRatio); - - const screenshotBuffer = await page.screenshot({ - fullPage: true, - animations, - caret, - clip, - }); - - // Inject scripts to get dom snapshot - let dom: string | undefined; - const script = await getDomScript(); - - if (captureDom && script) { - let error: string | undefined; - try { - const result = await page.evaluate( - `(function({ clipSelector }){${script}})({ clipSelector: '${clipSelector}' })`, - ); - - if (typeof result === 'string') { - dom = result; - } else { - error = `Dom type should be string not ${typeof result}.`; - } - } catch (err: unknown) { - error = `Unable to capture the dom.\n${err}`; - } - - if (error) { - console.error(error); - } - } - - const api = getApi(); - const uploadId = await api.uploadSnapshot({ - buildId, - image: { data: screenshotBuffer }, - dom: dom ? { data: Buffer.from(dom) } : undefined, - }); - - const meta = buildSnapshotMetadata({ - browserVersion: browser?.version(), - browserName: browser?.browserType().name(), - devicePixelRatio, - deviceName: deviceName || undefined, - buildId, - name: `${context.title}/${context.name}`, - ignoreRegions, - diffingMethod, - }); - - await api.createSnapshot({ - ...meta, - uploadUuid: uploadId, - }); + await VisualStorybook.takePlaywrightScreenshot( + page, + { + testName: undefined, + suiteName: undefined, + deviceName: deviceName || undefined, + }, + `${context.title}/${context.name}`, + { + clipSelector: shouldClip ? clipSelector : undefined, + ...otherOptions, + }, + ); }; /** diff --git a/visual-js/visual-storybook/src/config.ts b/visual-js/visual-storybook/src/config.ts index 12863706..69906db7 100644 --- a/visual-js/visual-storybook/src/config.ts +++ b/visual-js/visual-storybook/src/config.ts @@ -1,7 +1,9 @@ import type { Config } from '@jest/types'; -import { parseOpts, setOpts } from './utils'; +import { internals } from '@saucelabs/visual-playwright'; import { VisualOpts } from './types'; +const { setOpts, parseOpts } = internals; + export const getVisualTestConfig = ( opts?: Partial, ): Partial => { diff --git a/visual-js/visual-storybook/src/config/global-setup.ts b/visual-js/visual-storybook/src/config/global-setup.ts index 48172270..61caeeb2 100644 --- a/visual-js/visual-storybook/src/config/global-setup.ts +++ b/visual-js/visual-storybook/src/config/global-setup.ts @@ -1,36 +1,8 @@ -import { createBuild, getBuildIdForCustomId } from '../api'; import { globalSetup } from 'jest-playwright-preset'; import { Config } from '@jest/types'; -import { downloadDomScript, getOpts, setOpts } from '../utils'; +import { VisualStorybook } from '../api'; module.exports = async function (globalConfig: Config.GlobalConfig) { await globalSetup(globalConfig); - await downloadDomScript(); - - const { buildId: passedBuildId, customId } = getOpts(); - let buildId = passedBuildId; - let customBuildId = null; - - if (!buildId && customId) { - customBuildId = await getBuildIdForCustomId(customId); - - console.info(`USING CUSTOM ID ${customId} for build ${customBuildId}`); - - if (customBuildId) { - buildId = customBuildId; - setOpts({ - buildId, - externalBuildId: true, - }); - } - } - - if (!buildId) { - const newBuildId = await createBuild(); - - setOpts({ - buildId: newBuildId, - externalBuildId: false, - }); - } + await VisualStorybook.setup(); }; diff --git a/visual-js/visual-storybook/src/config/global-teardown.ts b/visual-js/visual-storybook/src/config/global-teardown.ts index ed222e5b..f3ea9ead 100644 --- a/visual-js/visual-storybook/src/config/global-teardown.ts +++ b/visual-js/visual-storybook/src/config/global-teardown.ts @@ -1,21 +1,8 @@ -import { finishBuild, waitForBuildResult } from '../api'; import { globalTeardown } from 'jest-playwright-preset'; import { Config } from '@jest/types'; -import { getOpts, removeDomScriptFile } from '../utils'; +import { VisualStorybook } from '../api'; module.exports = async function (globalConfig: Config.GlobalConfig) { await globalTeardown(globalConfig); - await removeDomScriptFile(); - - const { buildId, externalBuildId } = getOpts(); - // Only finish the build automatically if we created it during globalSetup (if it's not external). - if (!externalBuildId && buildId) { - const finishedBuild = await finishBuild(buildId); - - if (!finishedBuild) { - throw new Error('Failed to finalize build.'); - } - - await waitForBuildResult(finishedBuild.id); - } + await VisualStorybook.teardown(); }; diff --git a/visual-js/visual-storybook/src/types.ts b/visual-js/visual-storybook/src/types.ts index b7d511a7..66b77a08 100644 --- a/visual-js/visual-storybook/src/types.ts +++ b/visual-js/visual-storybook/src/types.ts @@ -1,37 +1,7 @@ -import { DiffingMethod, RegionIn, SauceRegion } from '@saucelabs/visual'; -import { PageScreenshotOptions } from 'playwright-core'; +import { SauceRegion } from '@saucelabs/visual'; +import { SauceVisualParams as PlaywrightParams } from '@saucelabs/visual-playwright'; -export interface SauceVisualParams { - screenshotOptions?: Pick; - /** - * Whether we should capture a dom snapshot. - */ - captureDom?: boolean; - /** - * Whether we should clip the story reduce whitespaces in snapshots. - */ - clip?: boolean; - /** - * A custom selector to clip to. Defaults to Storybook's default root element, `#storybook-root`. - */ - clipSelector?: string; - /** - * A number, in ms, that we should delay the snapshot by. Useful if the beginning of the story - * has unavoidable / javascript animations. - */ - delay?: number; - /** - * One or more regions on the page to ignore. Used to block dynamic or ever-changing content you - * don't want to diff. - */ - ignoreRegions?: (RegionIn | string)[]; - /** - * The diffing method we should use when finding visual changes. Defaults to DiffingMethod.Balanced - */ - diffingMethod?: DiffingMethod; -} - -export interface VisualOpts { +export interface VisualOpts extends PlaywrightParams { user: string | undefined; key: string | undefined; region: SauceRegion | undefined; @@ -49,3 +19,10 @@ export interface VisualOpts { defaultBranch: string | null; customId: string | null; } + +export interface SauceVisualParams extends PlaywrightParams { + /** + * Whether we should clip the story reduce whitespaces in snapshots. + */ + clip?: boolean; +} diff --git a/visual-js/visual-storybook/src/utils.spec.ts b/visual-js/visual-storybook/src/utils.spec.ts deleted file mode 100644 index 367d138f..00000000 --- a/visual-js/visual-storybook/src/utils.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { getOpts } from './utils'; -import { DEFAULT_OPTS, OPTS_ENV_KEY } from './constants'; - -describe('utils', () => { - describe('getOpts', () => { - it('should throw if ENV value is empty', () => { - expect(() => { - getOpts(); - }).toThrow('Sauce Visual configuration is empty.'); - }); - - it('should throw if ENV value is unserializable', () => { - process.env[OPTS_ENV_KEY] = '`'; - expect(() => { - getOpts(); - }).toThrow( - /Sauce Visual configuration not detected or could not be parsed/, - ); - }); - - it('should parse the ENV values', () => { - process.env[OPTS_ENV_KEY] = JSON.stringify({ - ...DEFAULT_OPTS, - user: 'SAUCE_USERNAME', - key: 'SAUCE_ACCESS_KEY', - }); - - const result = getOpts(); - - expect(result.key).toEqual('SAUCE_ACCESS_KEY'); - expect(result.user).toEqual('SAUCE_USERNAME'); - }); - }); -}); diff --git a/visual-js/visual-storybook/src/utils.ts b/visual-js/visual-storybook/src/utils.ts deleted file mode 100644 index ff18071b..00000000 --- a/visual-js/visual-storybook/src/utils.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { VisualOpts } from './types'; -import * as os from 'os'; -import { - Browser, - DiffingMethod, - OperatingSystem, - SauceRegion, - SnapshotIn, -} from '@saucelabs/visual'; -import { OPTS_ENV_KEY } from './constants'; -import fs from 'fs/promises'; -import { getApi } from './api'; - -/** - * Parse ENVs & set options object for visual API usage. - */ -export const getEnvOpts = (): VisualOpts => { - const { - SAUCE_USERNAME, - SAUCE_ACCESS_KEY, - SAUCE_REGION, - SAUCE_BUILD_NAME, - SAUCE_BRANCH_NAME, - SAUCE_DEFAULT_BRANCH_NAME, - SAUCE_PROJECT_NAME, - SAUCE_VISUAL_BUILD_NAME, - SAUCE_VISUAL_PROJECT, - SAUCE_VISUAL_BRANCH, - SAUCE_VISUAL_DEFAULT_BRANCH, - SAUCE_VISUAL_BUILD_ID, - SAUCE_VISUAL_CUSTOM_ID, - } = process.env; - - if (SAUCE_BUILD_NAME) { - console.warn( - 'Sauce Labs Visual: SAUCE_BUILD_NAME is deprecated and will be removed in a future version. Please use SAUCE_VISUAL_BUILD_NAME instead', - ); - } - if (SAUCE_BRANCH_NAME) { - console.warn( - 'Sauce Labs Visual: SAUCE_BRANCH_NAME is deprecated and will be removed in a future version. Please use SAUCE_VISUAL_BRANCH instead', - ); - } - if (SAUCE_DEFAULT_BRANCH_NAME) { - console.warn( - 'Sauce Labs Visual: SAUCE_DEFAULT_BRANCH_NAME is deprecated and will be removed in a future version. Please use SAUCE_VISUAL_DEFAULT_BRANCH instead', - ); - } - if (SAUCE_PROJECT_NAME) { - console.warn( - 'Sauce Labs Visual: SAUCE_PROJECT_NAME is deprecated and will be removed in a future version. Please use SAUCE_VISUAL_PROJECT instead', - ); - } - - // Validation for fields is already done inside api package - return { - branch: SAUCE_VISUAL_BRANCH || SAUCE_BRANCH_NAME || null, - defaultBranch: - SAUCE_VISUAL_DEFAULT_BRANCH || SAUCE_DEFAULT_BRANCH_NAME || null, - buildId: SAUCE_VISUAL_BUILD_ID ?? null, - externalBuildId: true, - project: SAUCE_VISUAL_PROJECT || SAUCE_PROJECT_NAME || null, - user: SAUCE_USERNAME ?? undefined, - key: SAUCE_ACCESS_KEY ?? undefined, - region: (SAUCE_REGION ?? 'us-west-1') as SauceRegion, - buildName: SAUCE_VISUAL_BUILD_NAME || SAUCE_BUILD_NAME || 'Storybook Build', - customId: SAUCE_VISUAL_CUSTOM_ID ?? null, - }; -}; - -/** - * Parses options / envs and stores them into a settings ENV key to be passed through jest into - * playwright setup & runtime. - */ -export const parseOpts = () => { - serializeOpts(getEnvOpts()); -}; - -const serializeOpts = (opts: VisualOpts) => { - process.env[OPTS_ENV_KEY] = JSON.stringify(opts); -}; - -export const setOpts = (opts: Partial) => { - serializeOpts({ - ...getOpts(), - ...opts, - }); -}; - -export const getOpts = (): VisualOpts => { - const envOpts = process.env[OPTS_ENV_KEY]; - try { - if (!envOpts) { - throw new Error('Sauce Visual configuration is empty.'); - } - - return JSON.parse(envOpts); - } catch (error) { - let message; - if (error instanceof Error) { - message = error.message; - } - - throw new Error( - `Sauce Visual configuration not detected or could not be parsed. Please check the @saucelabs/visual-storybook docs and ensure \`getVisualTestConfig()\` has been bootstrapped in your \`test-runner-jest.config.js\`${ - message ? `Full message: ${message}` : '' - }`, - ); - } -}; - -/** - * Parses Playwright's browserType.name() to convert known strings into enums. - */ -export const getKnownBrowserType = ( - browserType?: string | 'chromium' | 'firefox' | 'webkit', -): Browser | null => { - switch (browserType) { - case 'chromium': - return Browser.Chrome; - case 'firefox': - return Browser.Firefox; - case 'webkit': - return Browser.PlaywrightWebkit; - default: - return null; - } -}; - -/** - * Parses the current OS platform as a known enum type. - */ -export const getKnownOsType = (): OperatingSystem | null => { - switch (os.platform()) { - case 'android': - return OperatingSystem.Android; - case 'darwin': - return OperatingSystem.Macos; - case 'win32': - case 'cygwin': - return OperatingSystem.Windows; - case 'freebsd': - case 'linux': - case 'openbsd': - case 'netbsd': - return OperatingSystem.Linux; - default: - return null; - } -}; - -/** - * Aggregates snapshot metadata into a snapshot input object for the graphql API. - */ -export const buildSnapshotMetadata = ({ - browserName, - browserVersion, - deviceName, - devicePixelRatio, - buildId, - name, - ignoreRegions, - diffingMethod, -}: { - browserName: string | undefined; - browserVersion: string | undefined; - deviceName: string | undefined; - devicePixelRatio: number; - buildId: string; - name: string; - ignoreRegions: SnapshotIn['ignoreRegions']; - diffingMethod: DiffingMethod | undefined; -}): Omit => { - return { - diffingMethod: diffingMethod || DiffingMethod.Balanced, - browser: getKnownBrowserType(browserName), - browserVersion: browserVersion ? `Playwright - ${browserVersion}` : null, - buildUuid: buildId, - device: deviceName || 'Desktop', - devicePixelRatio, - ignoreRegions, - name, - operatingSystem: getKnownOsType(), - operatingSystemVersion: null, - suiteName: null, - testName: null, - }; -}; - -/** - * Dom capturing script path - */ - -export const domScriptPath = `${os.tmpdir()}/sauce-visual-dom-capture.js`; - -/** - * Fetch and save to tmp file dom capturing script - */ - -export const downloadDomScript = async () => { - try { - const script = await getApi().domCaptureScript(); - script && (await fs.writeFile(domScriptPath, script)); - } catch (err: unknown) { - console.error(`Cannot get dom capturing script.\n${err}`); - } -}; - -/** - * Get dom capturing script from saved file - */ - -export const getDomScript = async () => { - try { - return (await fs.readFile(domScriptPath)).toString(); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(err); - } - } -}; - -export const removeDomScriptFile = async () => { - try { - await fs.unlink(domScriptPath); - } catch (err) { - console.error(err); - } -}; diff --git a/visual-js/visual/src/utils.ts b/visual-js/visual/src/utils.ts index 9ea4d512..817c2112 100644 --- a/visual-js/visual/src/utils.ts +++ b/visual-js/visual/src/utils.ts @@ -164,7 +164,7 @@ export const removeDomScriptFile = async () => { try { await fs.unlink(domScriptPath); } catch (err) { - console.error(err); + // Catch and log no errors. If the file isn't present this is noop. } }; diff --git a/visual-js/yarn.lock b/visual-js/yarn.lock index 6f556ca1..479032c8 100644 --- a/visual-js/yarn.lock +++ b/visual-js/yarn.lock @@ -3577,13 +3577,13 @@ __metadata: languageName: unknown linkType: soft -"@saucelabs/visual-playwright@workspace:visual-playwright": +"@saucelabs/visual-playwright@^0.1.0, @saucelabs/visual-playwright@workspace:visual-playwright": version: 0.0.0-use.local resolution: "@saucelabs/visual-playwright@workspace:visual-playwright" dependencies: "@jest/globals": ^28.0.0 || ^29.0.0 "@playwright/test": ^1.42.1 - "@saucelabs/visual": ^0.8.0 + "@saucelabs/visual": ^0.8.1 "@storybook/types": ^8.0.2 "@tsconfig/node18": ^2.0.0 "@types/node": ^18.13.0 @@ -3613,6 +3613,7 @@ __metadata: dependencies: "@jest/globals": ^28.0.0 || ^29.0.0 "@saucelabs/visual": ^0.8.0 + "@saucelabs/visual-playwright": ^0.1.0 "@storybook/test-runner": ">=0.13.0" "@storybook/types": ^8.0.2 "@tsconfig/node18": ^2.0.0 @@ -3641,7 +3642,7 @@ __metadata: languageName: unknown linkType: soft -"@saucelabs/visual@^0.8.0, @saucelabs/visual@workspace:visual": +"@saucelabs/visual@^0.8.0, @saucelabs/visual@^0.8.1, @saucelabs/visual@workspace:visual": version: 0.0.0-use.local resolution: "@saucelabs/visual@workspace:visual" dependencies: