diff --git a/jest.config.js b/jest.config.js index d941994de19..90e4229f6ae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -53,10 +53,10 @@ module.exports = { collectCoverage: true, coverageThreshold: { global: { - branches: 85.5, + branches: 85.15, functions: 92, - lines: 94.5, - statements: 94.5 + lines: 94.34, + statements: 94.19 } }, testEnvironment: 'node', diff --git a/package-lock.json b/package-lock.json index c7133daa6ce..6777a384010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/node": "^18.0.0", "@types/split2": "^4.2.0", "@types/uuid": "^9.0.0", + "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "@typescript-eslint/utils": "^6.2.0", @@ -4555,6 +4556,15 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz", @@ -22788,6 +22798,15 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz", @@ -24877,8 +24896,7 @@ "dev": true }, "dox": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/dox/-/dox-0.9.1.tgz", + "version": "https://registry.npmjs.org/dox/-/dox-0.9.1.tgz", "integrity": "sha512-3bC8QeBn1xYWU628qfW7jlA0ssd7PL/x3ndYdT3tq52arRKFHW5zpVHGgkZPahBCZHU60O+TiJossR+RZZW15w==", "dev": true, "requires": { @@ -29524,7 +29542,7 @@ "async": ">= 0.1.18", "coffee-script": ">= 1.3.3", "commander": ">= 0.6.0", - "dox": "0.9.1", + "dox": "https://github.com/visionmedia/dox/tarball/master", "ejs": ">= 0.7.2 <2.0.0", "iced-coffee-script": ">= 1.3.3d", "underscore": ">= 1.3.3" diff --git a/package.json b/package.json index 6c5ad5b4e8e..0f6a927670d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@types/node": "^18.0.0", "@types/split2": "^4.2.0", "@types/uuid": "^9.0.0", + "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "@typescript-eslint/utils": "^6.2.0", diff --git a/packages/wdio-browserstack-service/package.json b/packages/wdio-browserstack-service/package.json index 5733784af5b..4bf3e1d7c1d 100644 --- a/packages/wdio-browserstack-service/package.json +++ b/packages/wdio-browserstack-service/package.json @@ -35,7 +35,10 @@ "got": "^11.0.2", "uuid": "^9.0.1", "webdriverio": "7.34.0", - "winston-transport": "^4.5.0" + "winston-transport": "^4.5.0", + "yauzl": "^2.10.0", + "@percy/appium-app": "^2.0.1", + "@percy/selenium-webdriver": "^2.0.2" }, "peerDependencies": { "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0" diff --git a/packages/wdio-browserstack-service/src/@types/bstack-service-types.d.ts b/packages/wdio-browserstack-service/src/@types/bstack-service-types.d.ts index 4d50bf8c354..4c7d153a2e9 100644 --- a/packages/wdio-browserstack-service/src/@types/bstack-service-types.d.ts +++ b/packages/wdio-browserstack-service/src/@types/bstack-service-types.d.ts @@ -1,6 +1,5 @@ import type { Browser } from 'webdriverio' - declare interface BrowserAsync extends Browser<'async'> { getAccessibilityResultsSummary: () => Promise<{ [key: string]: any; }>, - getAccessibilityResults: () => Promise> + getAccessibilityResults: () => Promise>, } diff --git a/packages/wdio-browserstack-service/src/Percy/Percy-Handler.ts b/packages/wdio-browserstack-service/src/Percy/Percy-Handler.ts new file mode 100644 index 00000000000..9d06f515ef5 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/Percy-Handler.ts @@ -0,0 +1,177 @@ +import type { Capabilities } from '@wdio/types' +import type { BeforeCommandArgs, AfterCommandArgs } from '@wdio/reporter' +import type { Browser, MultiRemoteBrowser } from 'webdriverio' + +import { + o11yClassErrorHandler +} from '../util' +import PercyCaptureMap from './PercyCaptureMap' + +import * as PercySDK from './PercySDK' +import { PercyLogger } from './PercyLogger' + +import { PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS, CAPTURE_MODES } from '../constants' + +class _PercyHandler { + private _testMetadata: { [key: string]: any } = {} + private _sessionName?: string + private _isAppAutomate?: boolean + private _isPercyCleanupProcessingUnderway?: boolean = false + private _percyScreenshotCounter: any = 0 + private _percyDeferredScreenshots: any = [] + private _percyScreenshotInterval: any = null + private _percyCaptureMap?: PercyCaptureMap + + constructor ( + private _percyAutoCaptureMode: string | undefined, + private _browser: Browser<'async'> | MultiRemoteBrowser<'async'>, + private _capabilities: Capabilities.RemoteCapability, + isAppAutomate?: boolean, + private _framework?: string + ) { + this._isAppAutomate = isAppAutomate + if (!_percyAutoCaptureMode || !CAPTURE_MODES.includes(_percyAutoCaptureMode as string)) { + this._percyAutoCaptureMode = 'auto' + } + } + + _setSessionName(name: string) { + this._sessionName = name + } + + async teardown () { + await new Promise((resolve) => { + setInterval(() => { + if (this._percyScreenshotCounter === 0) { + resolve() + } + }, 1000) + }) + } + + async percyAutoCapture(eventName: string | null, sessionName: string | null) { + try { + if (eventName) { + if (!sessionName) { + /* Service doesn't wait for handling of browser commands so the below counter is used in teardown method to delay service exit */ + this._percyScreenshotCounter += 1 + } + + this._percyCaptureMap?.increment(sessionName ? sessionName : (this._sessionName as string), eventName) + await (this._isAppAutomate ? PercySDK.screenshotApp(this._percyCaptureMap?.getName(sessionName ? sessionName : (this._sessionName as string), eventName)) : PercySDK.screenshot(this._browser, this._percyCaptureMap?.getName( sessionName ? sessionName : (this._sessionName as string), eventName))) + this._percyScreenshotCounter -= 1 + } + } catch (err: unknown) { + this._percyScreenshotCounter -= 1 + this._percyCaptureMap?.decrement(sessionName ? sessionName : (this._sessionName as string), eventName as string) + PercyLogger.error(`Error while trying to auto capture Percy screenshot ${err}`) + } + } + + async before () { + this._percyCaptureMap = new PercyCaptureMap() + } + + deferCapture(sessionName: string, eventName: string | null) { + /* Service doesn't wait for handling of browser commands so the below counter is used in teardown method to delay service exit */ + this._percyScreenshotCounter += 1 + this._percyDeferredScreenshots.push({ sessionName, eventName }) + } + + isDOMChangingCommand(args: BeforeCommandArgs): boolean { + if ((args.method as string) === 'POST') { + if (PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS.includes((args.endpoint as string))) { + return true + } else if ((args.endpoint as string).includes('/session/:sessionId/element') && (args.endpoint as string).includes('click')) { + /* click element */ + return true + } else if ((args.endpoint as string).includes('/session/:sessionId/element') && (args.endpoint as string).includes('clear')) { + /* clear element */ + return true + } else if ((args.endpoint as string).includes('/session/:sessionId/execute') && args.body?.script) { + /* execute script sync / async */ + return true + } else if ((args.endpoint as string).includes('/session/:sessionId/touch')) { + /* Touch action for Appium */ + return true + } + } else if ((args.method as string) === 'DELETE' && (args.endpoint as string) === '/session/:sessionId') { + return true + } + return false + } + + async cleanupDeferredScreenshots() { + this._isPercyCleanupProcessingUnderway = true + for await (const entry of this._percyDeferredScreenshots) { + await this.percyAutoCapture(entry.eventName, entry.sessionName) + } + this._percyDeferredScreenshots = [] + this._isPercyCleanupProcessingUnderway = false + } + + async sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async browserBeforeCommand (args: BeforeCommandArgs) { + try { + if (this.isDOMChangingCommand(args)) { + do { + await this.sleep(1000) + } while (this._percyScreenshotInterval) + this._percyScreenshotInterval = setInterval(async () => { + if (!this._isPercyCleanupProcessingUnderway) { + clearInterval(this._percyScreenshotInterval) + await this.cleanupDeferredScreenshots() + this._percyScreenshotInterval = null + } + }, 1000) + } + } catch (err: unknown) { + PercyLogger.error(`Error while trying to cleanup deferred screenshots ${err}`) + } + } + + async browserAfterCommand (args: BeforeCommandArgs & AfterCommandArgs) { + try { + if (args.endpoint && this._percyAutoCaptureMode) { + let eventName = null + if ((args.endpoint as string).includes('click') && ['click', 'auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'click' + } else if ((args.endpoint as string).includes('screenshot') && ['screenshot', 'auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'screenshot' + } else if ((args.endpoint as string).includes('actions') && ['auto'].includes(this._percyAutoCaptureMode as string)) { + if (args.body && args.body.actions && Array.isArray(args.body.actions) && args.body.actions.length && args.body.actions[0].type === 'key') { + eventName = 'keys' + } + } else if ((args.endpoint as string).includes('/session/:sessionId/element') && (args.endpoint as string).includes('value') && ['auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'keys' + } + if (eventName) { + this.deferCapture(this._sessionName as string, eventName) + } + } + } catch (err: unknown) { + PercyLogger.error(`Error while trying to calculate auto capture parameters ${err}`) + } + } + + async afterTest () { + if (this._percyAutoCaptureMode && this._percyAutoCaptureMode === 'testcase') { + await this.percyAutoCapture('testcase', null) + } + } + + async afterScenario () { + if (this._percyAutoCaptureMode && this._percyAutoCaptureMode === 'testcase') { + await this.percyAutoCapture('testcase', null) + } + } +} + +// https://github.com/microsoft/TypeScript/issues/6543 +const PercyHandler: typeof _PercyHandler = o11yClassErrorHandler(_PercyHandler) +type PercyHandler = _PercyHandler + +export default PercyHandler diff --git a/packages/wdio-browserstack-service/src/Percy/Percy.ts b/packages/wdio-browserstack-service/src/Percy/Percy.ts new file mode 100644 index 00000000000..e9e7d9456d0 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/Percy.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +import { spawn } from 'node:child_process' + +import { nodeRequest, getBrowserStackUser, getBrowserStackKey } from '../util' +import { PercyLogger } from './PercyLogger' + +import PercyBinary from './PercyBinary' + +import type { BrowserstackConfig, UserConfig } from '../types' +import type { Options } from '@wdio/types' + +const logDir = 'logs' + +class Percy { + _logfile: string = path.join(logDir, 'percy.log') + _address: string = process.env.PERCY_SERVER_ADDRESS || 'http://127.0.0.1:5338' + + _binaryPath: string | any = null + _options: BrowserstackConfig & Options.Testrunner + _config: Options.Testrunner + _proc: any = null + _isApp: boolean + _projectName: string | undefined = undefined + + isProcessRunning = false + + constructor(options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, bsConfig: UserConfig) { + this._options = options + this._config = config + this._isApp = Boolean(options.app) + this._projectName = bsConfig.projectName + } + + private async getBinaryPath(): Promise { + if (!this._binaryPath) { + const pb = new PercyBinary() + this._binaryPath = await pb.getBinaryPath(this._config) + } + return this._binaryPath + } + + private async sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async healthcheck() { + try { + const resp = await nodeRequest('GET', 'percy/healthcheck', null, this._address) + if (resp) { + return true + } + } catch (err) { + return false + } + } + + async start() { + const binaryPath: string = await this.getBinaryPath() + const logStream = fs.createWriteStream(this._logfile, { flags: 'a' }) + const token = await this.fetchPercyToken() + const configPath = await this.createPercyConfig() + + if (!token) { + return false + } + + const commandArgs = [`${this._isApp ? 'app:exec' : 'exec'}:start`] + + if (configPath) { + commandArgs.push('-c', configPath as string) + } + + this._proc = spawn( + binaryPath, + commandArgs, + { env: { ...process.env, PERCY_TOKEN: token } } + ) + + this._proc.stdout.pipe(logStream) + this._proc.stderr.pipe(logStream) + this.isProcessRunning = true + const that = this + + this._proc.on('close', function () { + that.isProcessRunning = false + }) + + do { + const healthcheck = await this.healthcheck() + if (healthcheck) { + PercyLogger.debug('Percy healthcheck successful') + return true + } + + await this.sleep(1000) + } while (this.isProcessRunning) + + return false + } + + async stop() { + const binaryPath = await this.getBinaryPath() + return new Promise( (resolve) => { + const proc = spawn(binaryPath, ['exec:stop']) + proc.on('close', (code: any) => { + this.isProcessRunning = false + resolve(code) + }) + }) + } + + isRunning() { + return this.isProcessRunning + } + + async fetchPercyToken() { + const projectName = this._projectName + + try { + const type = this._isApp ? 'app' : 'automate' + const response: any = await nodeRequest( + 'GET', + `api/app_percy/get_project_token?name=${projectName}&type=${type}`, + { + username: getBrowserStackUser(this._config), + password: getBrowserStackKey(this._config) + }, + 'https://api.browserstack.com' + ) + PercyLogger.debug('Percy fetch token success : ' + response.token) + return response.token + } catch (err: any) { + PercyLogger.error(`Percy unable to fetch project token: ${err}`) + return null + } + } + + async createPercyConfig() { + if (!this._options.percyOptions) { + return null + } + + const configPath = path.join(os.tmpdir(), 'percy.json') + const percyOptions = this._options.percyOptions + + if (!percyOptions.version) { + percyOptions.version = '2' + } + + return new Promise((resolve) => { + fs.writeFile( + configPath, + JSON.stringify( + percyOptions + ), + (err: any) => { + if (err) { + PercyLogger.error(`Error creating percy config: ${err}`) + resolve(null) + } + + PercyLogger.debug('Percy config created at ' + configPath) + resolve(configPath) + } + ) + }) + } +} + +export default Percy diff --git a/packages/wdio-browserstack-service/src/Percy/PercyBinary.ts b/packages/wdio-browserstack-service/src/Percy/PercyBinary.ts new file mode 100644 index 00000000000..917f0408ca9 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyBinary.ts @@ -0,0 +1,163 @@ +import yauzl from 'yauzl' + +const fs = require('node:fs') +import got from 'got' +import path from 'node:path' +import os from 'node:os' +import fsp from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { PercyLogger } from './PercyLogger' +import type { Options } from '@wdio/types' + +class PercyBinary { + _hostOS = process.platform + _httpPath: any = null + _binaryName = 'percy' + + _orderedPaths = [ + path.join(os.homedir(), '.browserstack'), + process.cwd(), + os.tmpdir() + ] + + constructor() { + const base = 'https://github.com/percy/cli/releases/latest/download' + if (this._hostOS.match(/darwin|mac os/i)) { + this._httpPath = base + '/percy-osx.zip' + } else if (this._hostOS.match(/mswin|msys|mingw|cygwin|bccwin|wince|emc|win32/i)) { + this._httpPath = base + '/percy-win.zip' + this._binaryName = 'percy.exe' + } else { + this._httpPath = base + '/percy-linux.zip' + } + } + + private async makePath(path: string) { + if (await this.checkPath(path)) { + return true + } + return fsp.mkdir(path).then(() => true).catch(() => false) + } + + private async checkPath(path: string) { + try { + const hasDir = await fsp.access(path).then(() => true, () => false) + if (hasDir) { + return true + } + } catch (err) { + return false + } + } + + private async _getAvailableDirs() { + for (let i = 0; i < this._orderedPaths.length; i++) { + const path = this._orderedPaths[i] + if (await this.makePath(path)) { + return path + } + } + throw new Error('Error trying to download percy binary') + } + + async getBinaryPath(conf: Options.Testrunner): Promise { + const destParentDir = await this._getAvailableDirs() + const binaryPath = path.join(destParentDir, this._binaryName) + if (await this.checkPath(binaryPath)) { + return binaryPath + } + const downloadedBinaryPath: string = await this.download(conf, destParentDir) + const isValid = await this.validateBinary(downloadedBinaryPath) + if (!isValid) { + // retry once + PercyLogger.error('Corrupt percy binary, retrying') + return await this.download(conf, destParentDir) + } + return downloadedBinaryPath + } + + async validateBinary(binaryPath: string) { + const versionRegex = /^.*@percy\/cli \d.\d+.\d+/ + return new Promise((resolve) => { + const proc = spawn(binaryPath, ['--version']) + proc.stdout.on('data', (data) => { + if (versionRegex.test(data)) { + resolve(true) + } + }) + + proc.on('close', () => { + resolve(false) + }) + }) + } + + async download(conf: any, destParentDir: any): Promise { + if (!await this.checkPath(destParentDir)){ + await fsp.mkdir(destParentDir) + } + + const binaryName = this._binaryName + const zipFilePath = path.join(destParentDir, binaryName + '.zip') + const binaryPath = path.join(destParentDir, binaryName) + const downloadedFileStream = fs.createWriteStream(zipFilePath) + + return new Promise((resolve, reject) => { + const stream = got.extend({ followRedirect: true }).get(this._httpPath, { isStream: true }) + stream.on('error', (err) => { + PercyLogger.error(`Got Error in percy binary download response: ${err}`) + }) + + stream.pipe(downloadedFileStream) + .on('finish', () => { + yauzl.open(zipFilePath, { lazyEntries: true }, function (err, zipfile) { + if (err) { + return reject(err) + } + zipfile.readEntry() + zipfile.on('entry', (entry) => { + if (/\/$/.test(entry.fileName)) { + // Directory file names end with '/'. + zipfile.readEntry() + } else { + // file entry + const writeStream = fs.createWriteStream( + path.join(destParentDir, entry.fileName) + ) + zipfile.openReadStream(entry, function (zipErr, readStream) { + if (zipErr) { + reject(err) + } + readStream.on('end', function () { + writeStream.close() + zipfile.readEntry() + }) + readStream.pipe(writeStream) + }) + + if (entry.fileName === binaryName) { + zipfile.close() + } + } + }) + + zipfile.on('error', (zipErr) => { + reject(zipErr) + }) + + zipfile.once('end', () => { + fs.chmod(binaryPath, '0755', function (zipErr: any) { + if (zipErr) { + reject(zipErr) + } + resolve(binaryPath) + }) + zipfile.close() + }) + }) + }) + }) + } +} + +export default PercyBinary diff --git a/packages/wdio-browserstack-service/src/Percy/PercyCaptureMap.ts b/packages/wdio-browserstack-service/src/Percy/PercyCaptureMap.ts new file mode 100644 index 00000000000..9ec2e8e9bb4 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyCaptureMap.ts @@ -0,0 +1,46 @@ +/* + * Maintains a counter for each driver to get consistent and + * unique screenshot names for percy + */ + +class PercyCaptureMap { + #map: any = {} + + increment(sessionName: string, eventName: string) { + if (!this.#map[sessionName]) { + this.#map[sessionName] = {} + } + + if (!this.#map[sessionName][eventName]) { + this.#map[sessionName][eventName] = 0 + } + + this.#map[sessionName][eventName]++ + } + + decrement(sessionName: string, eventName: string) { + if (!this.#map[sessionName] || !this.#map[sessionName][eventName]) { + return + } + + this.#map[sessionName][eventName]-- + } + + getName(sessionName: string, eventName: string) { + return `${sessionName}-${eventName}-${this.get(sessionName, eventName)}` + } + + get(sessionName: string, eventName: string) { + if (!this.#map[sessionName]) { + return 0 + } + + if (!this.#map[sessionName][eventName]) { + return 0 + } + + return this.#map[sessionName][eventName] - 1 + } +} + +export default PercyCaptureMap diff --git a/packages/wdio-browserstack-service/src/Percy/PercyHelper.ts b/packages/wdio-browserstack-service/src/Percy/PercyHelper.ts new file mode 100644 index 00000000000..0a40c40c1bc --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyHelper.ts @@ -0,0 +1,74 @@ +// ======= Percy helper methods start ======= + +import type { Capabilities } from '@wdio/types' +import type { BrowserstackConfig, UserConfig } from '../types' + +import type { Options } from '@wdio/types' + +import { PercyLogger } from './PercyLogger' +import Percy from './Percy' + +export const startPercy = async (options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, bsConfig: UserConfig): Promise => { + PercyLogger.debug('Starting percy') + const percy = new Percy(options, config, bsConfig) + const response = await percy.start() + if (response) { + return percy + } + return ({} as Percy) +} + +export const stopPercy = async (percy: Percy) => { + PercyLogger.debug('Stopping percy') + return percy.stop() +} + +export const getBestPlatformForPercySnapshot = (capabilities?: Capabilities.RemoteCapabilities) : any => { + try { + const percyBrowserPreference: any = { 'chrome': 0, 'firefox': 1, 'edge': 2, 'safari': 3 } + + let bestPlatformCaps: any = null + let bestBrowser: any = null + + if (Array.isArray(capabilities)) { + capabilities + .flatMap((c: Capabilities.DesiredCapabilities | Capabilities.MultiRemoteCapabilities) => { + if (Object.values(c).length > 0 && Object.values(c).every(c => typeof c === 'object' && c.capabilities)) { + return Object.values(c).map((o: Options.WebdriverIO) => o.capabilities) + } + return c as (Capabilities.DesiredCapabilities) + }).forEach((capability: Capabilities.DesiredCapabilities) => { + let currBrowserName = capability.browserName + if (capability['bstack:options']) { + currBrowserName = capability['bstack:options'].browserName || currBrowserName + } + if (!bestBrowser || !bestPlatformCaps || (bestPlatformCaps.deviceName || bestPlatformCaps['bstack:options']?.deviceName)) { + bestBrowser = currBrowserName + bestPlatformCaps = capability + } else if (currBrowserName && percyBrowserPreference[currBrowserName.toLowerCase()] < percyBrowserPreference[bestBrowser.toLowerCase()]) { + bestBrowser = currBrowserName + bestPlatformCaps = capability + } + }) + return bestPlatformCaps + } else if (typeof capabilities === 'object') { + Object.entries(capabilities as Capabilities.MultiRemoteCapabilities).forEach(([, caps]) => { + let currBrowserName = (caps.capabilities as Capabilities.Capabilities).browserName + if ((caps.capabilities as Capabilities.Capabilities)['bstack:options']) { + currBrowserName = (caps.capabilities as Capabilities.Capabilities)['bstack:options']?.browserName || currBrowserName + } + if (!bestBrowser || !bestPlatformCaps || (bestPlatformCaps.deviceName || bestPlatformCaps['bstack:options']?.deviceName)) { + bestBrowser = currBrowserName + bestPlatformCaps = (caps.capabilities as Capabilities.Capabilities) + } else if (currBrowserName && percyBrowserPreference[currBrowserName.toLowerCase()] < percyBrowserPreference[bestBrowser.toLowerCase()]) { + bestBrowser = currBrowserName + bestPlatformCaps = (caps.capabilities as Capabilities.Capabilities) + } + }) + return bestPlatformCaps + } + } catch (err: unknown) { + PercyLogger.error(`Error while trying to determine best platform for Percy snapshot ${err}`) + return null + } +} diff --git a/packages/wdio-browserstack-service/src/Percy/PercyLogger.ts b/packages/wdio-browserstack-service/src/Percy/PercyLogger.ts new file mode 100644 index 00000000000..b062ab96092 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyLogger.ts @@ -0,0 +1,80 @@ +import path from 'node:path' +import fs from 'node:fs' + +import logger from '@wdio/logger' + +import { PERCY_LOGS_FILE } from '../constants' + +const log = logger('@wdio/browserstack-service') + +export class PercyLogger { + public static logFilePath = path.join(process.cwd(), PERCY_LOGS_FILE) + private static logFolderPath = path.join(process.cwd(), 'logs') + private static logFileStream: fs.WriteStream | null + + static logToFile(logMessage: string, logLevel: string) { + try { + if (!this.logFileStream) { + if (!fs.existsSync(this.logFolderPath)){ + fs.mkdirSync(this.logFolderPath) + } + this.logFileStream = fs.createWriteStream(this.logFilePath, { flags: 'a' }) + } + if (this.logFileStream && this.logFileStream.writable) { + this.logFileStream.write(this.formatLog(logMessage, logLevel)) + } + } catch (error) { + log.debug(`Failed to log to file. Error ${error}`) + } + } + + private static formatLog(logMessage: string, level: string) { + return `${new Date().toISOString()} ${level.toUpperCase()} @wdio/browserstack-service ${logMessage}\n` + } + + public static info(message: string) { + this.logToFile(message, 'info') + log.info(message) + } + + public static error(message: string) { + this.logToFile(message, 'error') + log.error(message) + } + + public static debug(message: string, param?: any) { + this.logToFile(message, 'debug') + if (param) { + log.debug(message, param) + } else { + log.debug(message) + } + } + + public static warn(message: string) { + this.logToFile(message, 'warn') + log.warn(message) + } + + public static trace(message: string) { + this.logToFile(message, 'trace') + log.trace(message) + } + + public static clearLogger() { + if (this.logFileStream) { + this.logFileStream.end() + } + this.logFileStream = null + } + + public static clearLogFile() { + try { + if (fs.existsSync(this.logFilePath)) { + fs.truncateSync(this.logFilePath) + } + } catch (err: unknown) { + log.error(`Failed to clear percy.log file. Error ${err}`) + } + } +} diff --git a/packages/wdio-browserstack-service/src/Percy/PercySDK.ts b/packages/wdio-browserstack-service/src/Percy/PercySDK.ts new file mode 100644 index 00000000000..a2511589375 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercySDK.ts @@ -0,0 +1,45 @@ +const tryRequire = function (pkg: string, fallback: any) { + try { + return require(pkg) + } catch { + return fallback + } +} + +import type { Browser, MultiRemoteBrowser } from 'webdriverio' + +const percySnapshot = tryRequire('@percy/selenium-webdriver', null) +const percyAppScreenshot = tryRequire('@percy/appium-app', {}) + +import { PercyLogger } from './PercyLogger' + +/* eslint-disable @typescript-eslint/no-unused-vars */ +let snapshotHandler = (...args: any[]) => { + PercyLogger.error('Unsupported driver for percy') +} +if (percySnapshot) { + snapshotHandler = (browser: Browser<'async'> | MultiRemoteBrowser<'async'>, name: string) => { + if (process.env.PERCY_SNAPSHOT === 'true') { + return percySnapshot(browser, name) + } + } +} +export const snapshot = snapshotHandler + +/* eslint-disable @typescript-eslint/no-unused-vars */ +let screenshotHandler = async (...args: any[]) => { + PercyLogger.error('Unsupported driver for percy') +} +if (percySnapshot && percySnapshot.percyScreenshot) { + screenshotHandler = percySnapshot.percyScreenshot +} +export const screenshot = screenshotHandler + +/* eslint-disable @typescript-eslint/no-unused-vars */ +let screenshotAppHandler = async (...args: any[]) => { + PercyLogger.error('Unsupported driver for percy') +} +if (percyAppScreenshot) { + screenshotAppHandler = percyAppScreenshot +} +export const screenshotApp = screenshotAppHandler diff --git a/packages/wdio-browserstack-service/src/constants.ts b/packages/wdio-browserstack-service/src/constants.ts index 2c72b297a9d..d5e4ebf80aa 100644 --- a/packages/wdio-browserstack-service/src/constants.ts +++ b/packages/wdio-browserstack-service/src/constants.ts @@ -37,3 +37,17 @@ export const DEFAULT_WAIT_INTERVAL_FOR_PENDING_UPLOADS = 100 // 100ms export const ACCESSIBILITY_API_URL = 'https://accessibility.browserstack.com/api' export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope'] + +export const PERCY_LOGS_FILE = 'logs/percy.log' + +export const PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS = [ + '/session/:sessionId/url', + '/session/:sessionId/forward', + '/session/:sessionId/back', + '/session/:sessionId/refresh', + '/session/:sessionId/screenshot', + '/session/:sessionId/actions', + '/session/:sessionId/appium/device/shake' +] + +export const CAPTURE_MODES = ['click', 'auto', 'screenshot', 'manual', 'testcase'] diff --git a/packages/wdio-browserstack-service/src/index.ts b/packages/wdio-browserstack-service/src/index.ts index 69cc9a3e4ad..675d5ad6091 100644 --- a/packages/wdio-browserstack-service/src/index.ts +++ b/packages/wdio-browserstack-service/src/index.ts @@ -10,6 +10,10 @@ export default BrowserstackService export const launcher = BrowserstackLauncher export const log4jsAppender = { configure } export const BStackTestOpsLogger = logReportingAPI + +import * as Percy from './Percy/PercySDK' +export const PercySDK = Percy + export * from './types' declare global { diff --git a/packages/wdio-browserstack-service/src/launcher.ts b/packages/wdio-browserstack-service/src/launcher.ts index e1b6b17abd0..d5a15659a1d 100644 --- a/packages/wdio-browserstack-service/src/launcher.ts +++ b/packages/wdio-browserstack-service/src/launcher.ts @@ -15,7 +15,8 @@ import { spawn } from 'node:child_process' // @ts-ignore import { version as bstackServiceVersion } from '../package.json' import CrashReporter from './crash-reporter' -import type { App, AppConfig, AppUploadResponse, BrowserstackConfig } from './types' +import { startPercy, stopPercy, getBestPlatformForPercySnapshot } from './Percy/PercyHelper' +import type { App, AppConfig, AppUploadResponse, BrowserstackConfig, UserConfig } from './types' import { VALID_APP_EXTENSION, NOT_ALLOWED_KEYS_IN_CAPS } from './constants' import { launchTestSession, @@ -27,15 +28,18 @@ import { isUndefined, isAccessibilityAutomationSession, stopAccessibilityTestRun, + ObjectsAreEqual, isTrue } from './util' import PerformanceTester from './performance-tester' +import { PercyLogger } from './Percy/PercyLogger' +import type Percy from './Percy/Percy' const log = logger('@wdio/browserstack-service') type BrowserstackLocal = BrowserstackLocalLauncher.Local & { pid?: number; - stop(callback: (err?: any) => void): void; + stop(callback: (err?: Error) => void): void; } export default class BrowserstackLauncherService implements Services.ServiceInstance { @@ -45,6 +49,8 @@ export default class BrowserstackLauncherService implements Services.ServiceInst private _buildTag?: string private _buildIdentifier?: string private _accessibilityAutomation?: boolean + private _percy?: Percy + private _percyBestPlatformCaps?: Capabilities.DesiredCapabilities public _testOpsBuildStopped?: boolean constructor ( @@ -52,6 +58,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst capabilities: Capabilities.RemoteCapability, private _config: Options.Testrunner ) { + PercyLogger.clearLogFile() // added to maintain backward compatibility with webdriverIO v5 this.setupExitHandlers() this._config || (this._config = _options) @@ -145,6 +152,19 @@ export default class BrowserstackLauncherService implements Services.ServiceInst } } + async onWorkerStart (cid: any, caps: any) { + try { + if (this._options.percy && this._percyBestPlatformCaps) { + const isThisBestPercyPlatform = ObjectsAreEqual(caps, this._percyBestPlatformCaps) + if (isThisBestPercyPlatform) { + process.env.BEST_PLATFORM_CID = cid + } + } + } catch (err: unknown) { + PercyLogger.error(`Error while setting best platform for Percy snapshot at worker start ${err}`) + } + } + setupExitHandlers() { process.on('exit', (code) => { if (!!process.env.BS_TESTOPS_JWT && !this._testOpsBuildStopped) { @@ -248,6 +268,18 @@ export default class BrowserstackLauncherService implements Services.ServiceInst }) } + if (this._options.percy) { + try { + const bestPlatformPercyCaps = getBestPlatformForPercySnapshot(capabilities) + this._percyBestPlatformCaps = bestPlatformPercyCaps + await this.setupPercy(this._options, this._config, { + projectName: this._projectName + }) + } catch (err: unknown) { + PercyLogger.error(`Error while setting up Percy ${err}`) + } + } + if (!this._options.browserstackLocal) { return log.info('browserstackLocal is not enabled - skipping...') } @@ -322,6 +354,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst log.info(`Total duration is ${duration / 1000 } s`) } + if (this._options.percy) { + await this.stopPercy() + } + + PercyLogger.clearLogger() + if (!this.browserstackLocal || !this.browserstackLocal.isRunning()) { return } @@ -356,6 +394,41 @@ export default class BrowserstackLauncherService implements Services.ServiceInst }) } + async setupPercy(options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, bsConfig: UserConfig) { + if (this._percy?.isRunning()) { + return + } + try { + this._percy = await startPercy(options, config, bsConfig) + if (!this._percy) { + throw new Error('Could not start percy, check percy logs for info.') + } + PercyLogger.info('Percy started successfully') + let signal = 0 + const handler = async () => { + signal++ + signal === 1 && await this.stopPercy() + } + process.on('beforeExit', handler) + process.on('SIGINT', handler) + process.on('SIGTERM', handler) + } catch (err: unknown) { + PercyLogger.debug(`Error in percy setup ${err}`) + } + } + + async stopPercy() { + if (!this._percy || !this._percy.isRunning()) { + return + } + try { + await stopPercy(this._percy) + PercyLogger.info('Percy stopped') + } catch (err) { + PercyLogger.error(`Error occured while stopping percy : ${err}`) + } + } + async _uploadApp(app:App): Promise { log.info(`uploading app ${app.app} ${app.customId? `and custom_id: ${app.customId}` : ''} to browserstack`) diff --git a/packages/wdio-browserstack-service/src/service.ts b/packages/wdio-browserstack-service/src/service.ts index 0d52981c8f8..f7303b3bdf5 100644 --- a/packages/wdio-browserstack-service/src/service.ts +++ b/packages/wdio-browserstack-service/src/service.ts @@ -20,6 +20,7 @@ import { import TestReporter from './reporter' import PerformanceTester from './performance-tester' import AccessibilityHandler from './accessibility-handler' +import PercyHandler from './Percy/Percy-Handler' const log = logger('@wdio/browserstack-service') @@ -39,6 +40,8 @@ export default class BrowserstackService implements Services.ServiceInstance { private _accessibility private _accessibilityHandler?: AccessibilityHandler private _turboScale + private _percy + private _percyHandler?: PercyHandler constructor ( options: BrowserstackConfig & Options.Testrunner, @@ -50,6 +53,7 @@ export default class BrowserstackService implements Services.ServiceInstance { this._config || (this._config = this._options) this._observability = this._options.testObservability this._accessibility = this._options.accessibility + this._percy = this._options.percy this._turboScale = this._options.turboScale if (this._observability) { @@ -69,6 +73,10 @@ export default class BrowserstackService implements Services.ServiceInstance { if (strict) { this._failureStatuses.push('pending') } + + if (process.env.WDIO_WORKER_ID === process.env.BEST_PLATFORM_CID) { + process.env.PERCY_SNAPSHOT = 'true' + } } _updateCaps (fn: (caps: Capabilities.Capabilities | Capabilities.DesiredCapabilities) => void) { @@ -113,31 +121,60 @@ export default class BrowserstackService implements Services.ServiceInstance { this._scenariosThatRan = [] - if (this._observability && this._browser) { + if (this._browser) { + if (this._percy) { + this._percyHandler = new PercyHandler( + this._options.percyCaptureMode, + this._browser, + this._caps, + this._isAppAutomate(), + this._config.framework + ) + this._percyHandler.before() + } try { - patchConsoleLogs() - this._insightsHandler = new InsightsHandler(this._browser, this._browser.capabilities as Capabilities.Capabilities, this._isAppAutomate(), this._browser.sessionId as string, this._config.framework) - await this._insightsHandler.before() + if (this._observability) { + patchConsoleLogs() + this._insightsHandler = new InsightsHandler(this._browser, this._browser.capabilities as Capabilities.Capabilities, this._isAppAutomate(), this._browser.sessionId as string, this._config.framework) + await this._insightsHandler.before() + } /** * register command event */ - this._browser.on('command', async (command) => await this._insightsHandler?.browserCommand( - 'client:beforeCommand', - Object.assign(command, { sessionId: this._browser?.sessionId }), - this._currentTest - )) + this._browser.on('command', async (command) => { + if (this._observability) { + await this._insightsHandler?.browserCommand( + 'client:beforeCommand', + Object.assign(command, { sessionId: this._browser?.sessionId }), + this._currentTest + ) + } + await this._percyHandler?.browserBeforeCommand( + Object.assign(command, { sessionId: this._browser?.sessionId }) + ) + }) + /** * register result event */ - this._browser.on('result', async (result) => await this._insightsHandler?.browserCommand( - 'client:afterCommand', - Object.assign(result, { sessionId: this._browser?.sessionId }), - this._currentTest - )) + this._browser.on('result', async (result) => { + if (this._observability) { + await this._insightsHandler?.browserCommand( + 'client:afterCommand', + Object.assign(result, { sessionId: this._browser?.sessionId }), + this._currentTest + ) + } + this._percyHandler?.browserAfterCommand( + Object.assign(result, { sessionId: this._browser?.sessionId }), + ) + }) } catch (err) { log.error(`Error in service class before function: ${err}`) - CrashReporter.uploadCrashReport(`Error in service class before function: ${err}`, err && (err as any).stack) + if (this._observability) { + CrashReporter.uploadCrashReport(`Error in service class before function: ${err}`, err && (err as any).stack) + } } } @@ -212,6 +249,7 @@ export default class BrowserstackService implements Services.ServiceInstance { } await this._insightsHandler?.afterTest(test, results) + await this._percyHandler?.afterTest() await this._accessibilityHandler?.afterTest(this._suiteTitle, test) } @@ -235,6 +273,8 @@ export default class BrowserstackService implements Services.ServiceInstance { await this._insightsHandler?.uploadPending() await this._insightsHandler?.teardown() + await this._percyHandler?.teardown() + if (process.env.BROWSERSTACK_O11Y_PERF_MEASUREMENT) { await PerformanceTester.stopAndGenerate('performance-service.html') PerformanceTester.calculateTimes([ @@ -289,6 +329,7 @@ export default class BrowserstackService implements Services.ServiceInstance { } await this._insightsHandler?.afterScenario(world) + await this._percyHandler?.afterScenario() await this._accessibilityHandler?.afterScenario(world) } @@ -445,6 +486,8 @@ export default class BrowserstackService implements Services.ServiceInstance { name = `${pre}${test.parent}${post}` } + this._percyHandler?._setSessionName(name) + if (name !== this._fullTitle) { this._fullTitle = name await this._updateJob({ name }) diff --git a/packages/wdio-browserstack-service/src/types.ts b/packages/wdio-browserstack-service/src/types.ts index 41abd442aae..7974c039f65 100644 --- a/packages/wdio-browserstack-service/src/types.ts +++ b/packages/wdio-browserstack-service/src/types.ts @@ -60,6 +60,21 @@ export interface BrowserstackConfig { * For e.g. buildName, projectName, BrowserStack access credentials, etc. */ testObservabilityOptions?: TestObservabilityOptions; + /** + * Set this to true to enable BrowserStack Percy which will take screenshots + * and snapshots for your tests run on Browserstack + * @default false + */ + percy?: boolean; + /** + * Accepts mode as a string to auto capture screenshots at different execution points + * Accepted values are auto, click, testcase, screenshot & manual + */ + percyCaptureMode?: string; + /** + * Set the Percy related config options under this key. + */ + percyOptions?: any; /** * Set this to true to enable BrowserStack Accessibility Automation which will * automically conduct accessibility testing on your pre-existing test builds diff --git a/packages/wdio-browserstack-service/src/util.ts b/packages/wdio-browserstack-service/src/util.ts index c0054ce4706..1b6d5bd3ddd 100644 --- a/packages/wdio-browserstack-service/src/util.ts +++ b/packages/wdio-browserstack-service/src/util.ts @@ -546,7 +546,7 @@ export async function nodeRequest(requestType: Method, apiEndpoint: string, opti } throw error } else { - log.error(`Failed to fire api request due to ${error} - ${error.stack}`) + log.debug(`Failed to fire api request due to ${error} - ${error.stack}`) throw error } } @@ -1112,3 +1112,24 @@ export function isUndefined(value: any) { export function isTrue(value?: any) { return (value + '').toLowerCase() === 'true' } + +export const isObject = (object: any) => { + return object !== null && typeof object === 'object' && !Array.isArray(object) +} + +export const ObjectsAreEqual = (object1: any, object2: any) => { + const objectKeys1 = Object.keys(object1) + const objectKeys2 = Object.keys(object2) + if (objectKeys1.length !== objectKeys2.length) { + return false + } + for (const key of objectKeys1) { + const value1 = object1[key] + const value2 = object2[key] + const isBothAreObjects = isObject(value1) && isObject(value2) + if ((isBothAreObjects && !ObjectsAreEqual(value1, value2)) || (!isBothAreObjects && value1 !== value2)) { + return false + } + } + return true +} diff --git a/packages/wdio-browserstack-service/tests/Percy-Handler.test.ts b/packages/wdio-browserstack-service/tests/Percy-Handler.test.ts new file mode 100644 index 00000000000..a63679b133c --- /dev/null +++ b/packages/wdio-browserstack-service/tests/Percy-Handler.test.ts @@ -0,0 +1,429 @@ +/// + +import logger from '@wdio/logger' + +import PercyHandler from '../src/Percy/Percy-Handler' +import PercyCaptureMap from '../src/Percy/PercyCaptureMap' +import * as PercySDK from '../src/Percy/PercySDK' +import type { Capabilities } from '@wdio/types' +import { Browser, MultiRemoteBrowser } from 'webdriverio' +import * as PercyLogger from '../src/Percy/PercyLogger' + +import type { BeforeCommandArgs, AfterCommandArgs } from '@wdio/reporter' + +const log = logger('test') +let percyHandler: PercyHandler +let browser: Browser<'async'> | MultiRemoteBrowser<'async'> +let caps: Capabilities.RemoteCapability + +// jest.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +jest.useFakeTimers().setSystemTime(new Date('2020-01-01')) +jest.mock('uuid', () => ({ v4: () => '123456789' })) + +const PercyLoggerSpy = jest.spyOn(PercyLogger.PercyLogger, 'logToFile') +PercyLoggerSpy.mockImplementation(() => {}) + +beforeEach(() => { + jest.mocked(log.info).mockClear() + + browser = { + sessionId: 'session123', + config: {}, + capabilities: { + device: '', + os: 'OS X', + os_version: 'Catalina', + browserName: 'chrome' + }, + instances: ['browserA', 'browserB'], + isMultiremote: false, + browserA: { + sessionId: 'session456', + capabilities: { 'bstack:options': { + device: '', + os: 'Windows', + osVersion: 10, + browserName: 'chrome' + } } + }, + getInstance: jest.fn().mockImplementation((browserName: string) => browser[browserName]), + browserB: {}, + execute: jest.fn(), + executeAsync: async () => { 'done' }, + getUrl: () => { return 'https://www.google.com/'}, + on: jest.fn(), + } as any as Browser<'async'> | MultiRemoteBrowser<'async'> + caps = { + browserName: 'chrome', + 'bstack:options': { + os: 'OS X', + osVersion: 'Catalina', + accessibility: true + } } as Capabilities.RemoteCapability + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') +}) + +it('should initialize correctly', () => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + expect(percyHandler['_isAppAutomate']).toEqual(false) + expect(percyHandler['_capabilities']).toEqual(caps) + expect(percyHandler['_framework']).toEqual('framework') + expect(percyHandler['_percyScreenshotCounter']).toEqual(0) + percyHandler['_percyAutoCaptureMode'] = 'auto' + expect(percyHandler['_percyAutoCaptureMode']).toEqual('auto') + +}) + +describe('_setSessionName', () => { + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + }) + it('sets sessionName property', async () => { + percyHandler._setSessionName('1234') + expect(percyHandler['_sessionName']).toEqual('1234') + }) +}) + +describe('sleep', () => { + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + }) + it('sets sleep', async () => { + percyHandler.sleep(234) + }) +}) + +describe('cleanupDeferredScreenshots', () => { + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + }) + it('calls cleanupDeferredScreenshots', async () => { + percyHandler['_percyDeferredScreenshots'] = [] + percyHandler.cleanupDeferredScreenshots() + expect(percyHandler['_percyDeferredScreenshots']).toEqual([]) + expect(percyHandler['_isPercyCleanupProcessingUnderway']).toEqual(true) + }) +}) + +describe('isDOMChangingCommand', () => { + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + }) + it('should call isDOMChangingCommand', async () => { + const args = { + endpoint: 'actions', + body: { + actions: [{ + type: 'key' + }] + } + } + // await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(false) + }) + it('should call isDOMChangingCommand with method: DELETE', async () => { + const args = { + method: 'DELETE', + endpoint: '/session/:sessionId', + body: { + actions: [{ + type: 'key' + }] + } + } + // await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) + it('should call isDOMChangingCommand with method: POST', async () => { + const args = { + method: 'POST', + endpoint: '/session/:sessionId/url', + body: { + actions: [{ + type: 'key' + }] + } + } + // await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) + it('should call isDOMChangingCommand with method: POST and click', async () => { + const args = { + method: 'POST', + endpoint: '/session/:sessionId/element/click', + body: { + actions: [{ + type: 'key' + }] + } + } + // await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) + it('should call isDOMChangingCommand with method: POST and clear', async () => { + const args = { + method: 'POST', + endpoint: '/session/:sessionId/element/clear', + body: { + actions: [{ + type: 'key' + }] + } + } + percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) + it('should call isDOMChangingCommand with method: POST and command touch', async () => { + const args = { + method: 'POST', + endpoint: '/session/:sessionId/touch', + body: { + actions: [{ + type: 'key' + }] + } + } + percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) + it('should call isDOMChangingCommand with method: POST and command execute', async () => { + const args = { + method: 'POST', + endpoint: '/session/:sessionId/execute', + body: { + script: 'script', + actions: [{ + type: 'key' + }] + } + } + percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + let res = percyHandler.isDOMChangingCommand(args as BeforeCommandArgs) + expect(res).toEqual(true) + }) +}) + +describe('teardown', () => { + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + }) + it('resolves promise if _percyScreenshotCounter is 0', async () => { + percyHandler.teardown().then(() => { + expect(percyHandler['_percyScreenshotCounter']).toEqual(0) + }).catch(() => { + expect(percyHandler['_percyScreenshotCounter']).not.equal(0) + }) + }) +}) + +describe('browserBeforeCommand', () => { + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + }) + + it('should call browserBeforeCommand', async () => { + const args = { + endpoint: 'actions', + body: { + actions: [{ + type: 'key' + }] + } + } + await percyHandler.browserBeforeCommand(args as BeforeCommandArgs & AfterCommandArgs) + }) + + afterEach(() => { + }) +}) + +describe('browserCommand', () => { + let percyAutoCaptureSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureSpy = jest.spyOn(PercyHandler.prototype, 'deferCapture') + }) + + it('should not call percyAutoCapture if no browser endpoint', async () => { + const args = {} + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).not.toBeCalled() + }) + + it('should call percyAutoCapture for event type keys', async () => { + const args = { + endpoint: 'actions', + body: { + actions: [{ + type: 'key' + }] + } + } + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + it('should call percyAutoCapture for event type click', async () => { + const args = { + endpoint: 'click' + } + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + it('should call percyAutoCapture for event type screenshot', async () => { + const args = { + endpoint: 'screenshot' + } + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + it('should call percyAutoCapture for event type element and capture mode auto', async () => { + const args = { + endpoint: '/session/:sessionId/element/value' + } + percyHandler['_percyAutoCaptureMode'] = 'auto' + + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + it('should call percyAutoCapture for event type element and capture mode auto', async () => { + const args = {} + percyHandler['_percyAutoCaptureMode'] = 'auto' + + await percyHandler.browserAfterCommand(args as BeforeCommandArgs & AfterCommandArgs) + expect(percyAutoCaptureSpy).toBeCalledTimes(0) + }) + + afterEach(() => { + percyAutoCaptureSpy.mockClear() + }) +}) + +describe('afterScenario', () => { + let percyAutoCaptureSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureSpy = jest.spyOn(PercyHandler.prototype, 'percyAutoCapture') + }) + + it('should not call percyAutoCapture', async () => { + await percyHandler.afterScenario() + expect(percyAutoCaptureSpy).not.toBeCalled() + }) + + it('should call percyAutoCapture', async () => { + percyHandler['_percyAutoCaptureMode'] = 'testcase' + await percyHandler.afterScenario() + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyAutoCaptureSpy.mockClear() + }) +}) + +describe('afterTest', () => { + let percyAutoCaptureSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureSpy = jest.spyOn(PercyHandler.prototype, 'percyAutoCapture') + }) + + it('should call percyAutoCapture', async () => { + percyHandler['_percyAutoCaptureMode'] = 'testcase' + await percyHandler.afterTest() + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyAutoCaptureSpy.mockClear() + jest.clearAllMocks() + }) +}) + +describe('percyAutoCapture', () => { + let percyScreenshotSpy: any + let percyScreenshotAppSpy: any + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler._setSessionName('1234') + percyHandler.before() + + percyScreenshotSpy = jest.spyOn(PercySDK, 'screenshot').mockImplementation(() => Promise.resolve()) + percyScreenshotAppSpy = jest.spyOn(PercySDK, 'screenshotApp').mockImplementation(() => Promise.resolve()) + }) + + it('does not call Percy Selenium Screenshot', async () => { + await percyHandler.percyAutoCapture(null) + expect(percyScreenshotSpy).not.toBeCalled() + }) + + it('calls Percy Selenium Screenshot', async () => { + await percyHandler.percyAutoCapture('keys') + expect(percyScreenshotSpy).toBeCalledTimes(1) + }) + + it('calls Percy Appium Screenshot', async () => { + percyHandler = new PercyHandler('auto', browser, caps, true, 'framework') + await percyHandler.percyAutoCapture('keys') + expect(percyScreenshotAppSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyScreenshotSpy.mockClear() + percyScreenshotAppSpy.mockClear() + }) +}) + +describe('percyCaptureMap', () => { + let percyAutoCaptureMapGetNameSpy: any + let percyAutoCaptureMapIncrementSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureMapGetNameSpy = jest.spyOn(PercyCaptureMap.prototype, 'getName') + percyAutoCaptureMapIncrementSpy = jest.spyOn(PercyCaptureMap.prototype, 'increment') + }) + + it('should call getName method of PercyCaptureMap', async () => { + await percyHandler.percyAutoCapture('keys') + await percyHandler.percyAutoCapture('keys') + expect(percyAutoCaptureMapGetNameSpy).toBeCalledTimes(2) + }) + + it('should call getName method of PercyCaptureMap', async () => { + await percyHandler.percyAutoCapture('click') + await percyHandler.percyAutoCapture('click') + expect(percyAutoCaptureMapIncrementSpy).toBeCalledTimes(2) + }) + + afterEach(() => { + percyAutoCaptureMapGetNameSpy.mockClear() + percyAutoCaptureMapIncrementSpy.mockClear() + }) +}) diff --git a/packages/wdio-browserstack-service/tests/Percy.test.ts b/packages/wdio-browserstack-service/tests/Percy.test.ts new file mode 100644 index 00000000000..c803b0b1d8d --- /dev/null +++ b/packages/wdio-browserstack-service/tests/Percy.test.ts @@ -0,0 +1,231 @@ +import Percy from '../src/Percy/Percy' +import * as PercyLogger from '../src/Percy/PercyLogger' +import childProcess from 'node:child_process' +import * as utils from '../src/util' +import fs from 'fs' +jest.mock('node:child_process', () => ({ + spawn: jest.fn(), +})) +jest.mock('fs', () => ({ + createWriteStream: jest.fn(), + writeFile: jest.fn(), +})) + +describe('Percy Class', () => { + let percyInstance + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Constructor', () => { + it('should initialize Percy instance', () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + expect(percyInstance._options).toEqual({}) + expect(percyInstance._config).toEqual({}) + expect(percyInstance._isApp).toBe(false) + expect(percyInstance._projectName).toBe('testProject') + }) + }) + + describe('getBinaryPath method', () => { + it('should return binary path if already present', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_binaryPath'] = 'some_path' + + const result = await percyInstance.getBinaryPath() + expect(result).toBe('some_path') + }) + }) + + describe('running method', () => { + + it('should return true if running', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['isProcessRunning'] = true + const res = await percyInstance.isRunning() + expect(res).toEqual(true) + }) + it('should return false if running', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['isProcessRunning'] = false + const res = await percyInstance.isRunning() + expect(res).toEqual(false) + }) + }) + + describe('health check method', () => { + it('should return true if running', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + const nodeRequestSpy = jest.spyOn(utils, 'nodeRequest').mockReturnValue(true) + const res = await percyInstance.healthcheck() + expect(nodeRequestSpy).toBeCalledTimes(1) + expect(res).toEqual(true) + }) + it('should return false if running', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + const nodeRequestSpy = jest.spyOn(utils, 'nodeRequest').mockReturnValue(false) + const res = await percyInstance.healthcheck() + expect(nodeRequestSpy).toBeCalledTimes(1) + expect(res).toEqual(undefined) + }) + }) + + describe('fetchPercyToken method', () => { + it('should return false if running', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_projectName'] = 'project_name' + percyInstance['_isApp'] = true + const response = { + token: 'token' + } + const nodeRequestSpy = jest.spyOn(utils, 'nodeRequest').mockReturnValue(response) + const res = await percyInstance.fetchPercyToken() + expect(nodeRequestSpy).toBeCalledTimes(1) + expect(res).toEqual('token') + }) + }) + + describe('createPercyConfig method', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return early null if percyOptions is null', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_options'] = { + percyOptions: null + } + const res = await percyInstance.createPercyConfig() + expect(res).toEqual(null) + }) + + it('should return valid response', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_options'] = { + percyOptions: { + version: null + } + } + const PercyLoggerDebugSpy = jest.spyOn(PercyLogger.PercyLogger, 'debug') + PercyLoggerDebugSpy.mockImplementation(() => {}) + const writeFileSpy = jest.spyOn(fs, 'writeFile') + writeFileSpy.mockImplementation(() => {}) + + percyInstance.createPercyConfig().then(() => { + expect(writeFileSpy).toBeCalledTimes(1) + expect(PercyLoggerDebugSpy).toBeCalledTimes(1) + }).catch(() => { + }) + }) + it('should return valid response', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_options'] = { + percyOptions: { + version: null + } + } + const PercyLoggerErrorSpy = jest.spyOn(PercyLogger.PercyLogger, 'error') + PercyLoggerErrorSpy.mockImplementation(() => {}) + const PercyLoggerDebugSpy = jest.spyOn(PercyLogger.PercyLogger, 'debug') + PercyLoggerDebugSpy.mockImplementation(() => {}) + const writeFileSpy = jest.spyOn(fs, 'writeFile') + writeFileSpy.mockImplementation(() => {}) + + percyInstance.createPercyConfig().then(() => { + expect(writeFileSpy).toBeCalledTimes(1) + expect(PercyLoggerErrorSpy).toBeCalledTimes(1) + + expect(PercyLoggerDebugSpy).toBeCalledTimes(0) + }).catch(() => { + }) + }) + }) + + describe('stop method', () => { + + afterEach(() => { + jest.clearAllMocks() + }) + it('should stop', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + const getBinaryPathSpy = jest.spyOn(percyInstance, 'getBinaryPath').mockReturnValue('mock_binary_path') + + percyInstance.stop().then(() => { + expect(getBinaryPathSpy).toBeCalledTimes(1) + expect(percyInstance['isProcessRunning']).toEqual(false) + }).catch(() => { + }) + }) + }) + + describe('start method', () => { + afterEach(() => { + jest.clearAllMocks() + }) + it('should return false when token is not there', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_logfile'] = 'log_file' + const getBinaryPathSpy = jest.spyOn(percyInstance, 'getBinaryPath').mockReturnValue('mock_binary_path') + const fetchPercyTokenSpy = jest.spyOn(percyInstance, 'fetchPercyToken').mockReturnValue(null) + + const res = await percyInstance.start() + expect(res).toEqual(false) + expect(getBinaryPathSpy).toBeCalledTimes(1) + expect(fetchPercyTokenSpy).toBeCalledTimes(1) + + }) + + it('should return false when token is health check false', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_logfile'] = 'log_file' + const getBinaryPathSpy = jest.spyOn(percyInstance, 'getBinaryPath').mockReturnValue('mock_binary_path') + const fetchPercyTokenSpy = jest.spyOn(percyInstance, 'fetchPercyToken').mockReturnValue('token') + const createPercyConfigSpy = jest.spyOn(percyInstance, 'createPercyConfig').mockReturnValue('config_path') + + const mockSpawn = { + stdout: { + pipe: jest.fn() + }, + stderr: { + pipe: jest.fn() + }, + on: jest.fn().mockImplementation((close, cb) => { + cb() + }) + } + ;(childProcess.spawn as jest.Mock).mockClear() + ;(childProcess.spawn as jest.Mock).mockReturnValue(mockSpawn) + + const healthcheckSpy = jest.spyOn(percyInstance, 'healthcheck').mockReturnValue(false) + const healthcheckSpy2 = jest.spyOn(percyInstance, 'healthcheck').mockReturnValue(true) + + percyInstance['isProcessRunning'] = true + + const res = await percyInstance.start() + expect(res).toEqual(true) + expect(getBinaryPathSpy).toBeCalledTimes(1) + expect(fetchPercyTokenSpy).toBeCalledTimes(1) + expect(createPercyConfigSpy).toBeCalledTimes(1) + expect(healthcheckSpy).toBeCalledTimes(1) + expect(healthcheckSpy2).toBeCalledTimes(1) + + }) + + it('should return true when token is there', async () => { + percyInstance = new Percy({}, {}, { projectName: 'testProject' }) + percyInstance['_logfile'] = 'log_file' + const getBinaryPathSpy = jest.spyOn(percyInstance, 'getBinaryPath').mockReturnValue('getBinaryPath_path') + const fetchPercyTokenSpy = jest.spyOn(percyInstance, 'fetchPercyToken').mockReturnValue('token') + const createPercyConfigSpy = jest.spyOn(percyInstance, 'createPercyConfig').mockReturnValue('config_path') + const sleepSpy = jest.spyOn(percyInstance, 'sleep') + + const res = await percyInstance.start() + expect(res).toEqual(true) + expect(getBinaryPathSpy).toBeCalledTimes(1) + expect(fetchPercyTokenSpy).toBeCalledTimes(1) + expect(createPercyConfigSpy).toBeCalledTimes(1) + expect(sleepSpy).toBeCalledTimes(0) + }) + }) +}) diff --git a/packages/wdio-browserstack-service/tests/PercyBinary.test.ts b/packages/wdio-browserstack-service/tests/PercyBinary.test.ts new file mode 100644 index 00000000000..1d45267eea0 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyBinary.test.ts @@ -0,0 +1,180 @@ +import PercyBinary from '../src/Percy/PercyBinary' +import childProcess from 'node:child_process' +import yauzl from 'yauzl' +import got from 'got' +import path from 'node:path' + +// Mocking dependencies + +jest.mock('node:fs/promises', () => ({ + access: jest.fn(), + mkdir: jest.fn().mockResolvedValue(true), +})) +jest.mock('node:fs', () => ({ + createWriteStream: jest.fn().mockImplementation(() => { + return { + close: jest.fn() + } + }), + chmod: jest.fn().mockImplementation((_, __, callback) => callback()), +})) +jest.mock('yauzl') + +jest.mock('node:child_process', () => ({ + spawn: jest.fn(), +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('PercyBinary', () => { + describe('makePath', () => { + it('should create a path if it does not exist', async () => { + const percyBinary = new PercyBinary() + const result = await percyBinary.makePath('some_path') + expect(result).toBe(true) + }) + }) + + describe('_getAvailableDirs', () => { + it('should _getAvailableDirs', async () => { + const percyBinary = new PercyBinary() + percyBinary['_orderedPaths'] = ['path1', 'path2', 'path3'] + const makePathSpy = jest.spyOn(percyBinary, 'makePath').mockReturnValue(true) + + const result = await percyBinary._getAvailableDirs() + expect(makePathSpy).toBeCalledTimes(1) + expect(result).toBe('path1') + }) + }) + + describe('getBinaryPath', () => { + it('should getBinaryPath', async () => { + const percyBinary = new PercyBinary() + const getAvailableDirsSpy = jest.spyOn(percyBinary, '_getAvailableDirs').mockReturnValue('some_path') + const checkPathSpy = jest.spyOn(percyBinary, 'checkPath').mockReturnValue(true) + + const result = await percyBinary.getBinaryPath() + expect(getAvailableDirsSpy).toBeCalledTimes(1) + expect(checkPathSpy).toBeCalledTimes(1) + expect(result).toContain('some_path') + expect(result).toContain('percy') + }) + it('should getBinaryPath from first download try', async () => { + const percyBinary = new PercyBinary() + const getAvailableDirsSpy = jest.spyOn(percyBinary, '_getAvailableDirs').mockReturnValue('some_path') + const checkPathSpy = jest.spyOn(percyBinary, 'checkPath').mockReturnValue(false) + const downloadSpy = jest.spyOn(percyBinary, 'download').mockReturnValue('download_path') + const validateSpy = jest.spyOn(percyBinary, 'validateBinary').mockReturnValue(true) + + const result = await percyBinary.getBinaryPath() + expect(getAvailableDirsSpy).toBeCalledTimes(1) + expect(checkPathSpy).toBeCalledTimes(1) + expect(downloadSpy).toBeCalledTimes(1) + expect(validateSpy).toBeCalledTimes(1) + expect(result).toBe('download_path') + }) + + it('should getBinaryPath from second download try', async () => { + const percyBinary = new PercyBinary() + const getAvailableDirsSpy = jest.spyOn(percyBinary, '_getAvailableDirs').mockReturnValue('some_path') + const checkPathSpy = jest.spyOn(percyBinary, 'checkPath').mockReturnValue(false) + const downloadSpy = jest.spyOn(percyBinary, 'download').mockReturnValue('download_path') + const validateSpy = jest.spyOn(percyBinary, 'validateBinary').mockReturnValue(false) + + const result = await percyBinary.getBinaryPath() + expect(getAvailableDirsSpy).toBeCalledTimes(1) + expect(checkPathSpy).toBeCalledTimes(1) + expect(downloadSpy).toBeCalledTimes(2) + expect(validateSpy).toBeCalledTimes(1) + expect(result).toBe('download_path') + }) + }) + + describe('validateBinary', () => { + it('should resolve to true for a valid binary version', async () => { + const percyBinary = new PercyBinary() + const validVersionOutput = '@percy/cli 1.2.3' + const mockSpawn = { + stdout: { + on: jest.fn().mockImplementation((data, cb) => { + cb(validVersionOutput) + }) + }, + on: jest.fn().mockImplementation((close, cb) => { + cb(false) + }) + } + ;(childProcess.spawn as jest.Mock).mockClear() + ;(childProcess.spawn as jest.Mock).mockReturnValue(mockSpawn) + + percyBinary.validateBinary(validVersionOutput).then(() => { + }).catch(() => { + }) + }) + }) + + describe('download', () => { + it('should download', async () => { + const percyBinary = new PercyBinary() + percyBinary['_binaryName'] = 'binary_name' + jest.spyOn(percyBinary, 'checkPath').mockReturnValue(true) + + const mockedGot = jest.mocked(got) + const mockReadStream = { + on: jest.fn().mockImplementation((event, entry) => { + entry({ fileName: 'filename' }) + }), + pipe: jest.fn() + } + + const mockZipFile = { + on: jest.fn().mockImplementation((event, entry) => { + if (event === 'entry'){ + entry({ + fileName: 'filename' + }) + } + }), + readEntry: jest.fn(), + openReadStream: jest.fn().mockImplementation((event, readStream) => { + readStream(null, mockReadStream) + }), + close: jest.fn(), + once: jest.fn().mockImplementation((event, end) => { + end() + }), + } + jest.spyOn(yauzl, 'open').mockImplementation((event, arg1, arg2) => { + arg2(null, mockZipFile) + }) + + const stream = { + on: jest.fn().mockImplementation((event, error) => { + error() + }), + pipe: jest.fn().mockReturnValue({ + on: jest.fn().mockImplementation((event, finish) => { + finish() + }), + }) + } + + mockedGot.get = jest.fn().mockReturnValue(stream) + + jest.mock('got', () => ({ + extend: jest.fn().mockImplementation(() => new Promise(() => { + resolve(mockedGot) + })) + })) + + mockedGot.extend = jest.fn().mockReturnValue({ + get: () => stream + }) + + jest.spyOn(path, 'join') + await percyBinary.download('conf', 'dir_path') + }) + }) +}) diff --git a/packages/wdio-browserstack-service/tests/PercyCaptureMap.test.ts b/packages/wdio-browserstack-service/tests/PercyCaptureMap.test.ts new file mode 100644 index 00000000000..e46b152c370 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyCaptureMap.test.ts @@ -0,0 +1,39 @@ +import PercyCaptureMap from '../src/Percy/PercyCaptureMap' + +describe('PercyCaptureMap', () => { + let percyCaptureMap: PercyCaptureMap + + beforeEach(() => { + percyCaptureMap = new PercyCaptureMap() + }) + + it('increment method should increase the count', () => { + percyCaptureMap.increment('session1', 'event1') + expect(percyCaptureMap.get('session1', 'event1')).toBe(0) + }) + + it('decrement method should decrease the count', () => { + percyCaptureMap.increment('session1', 'event1') + percyCaptureMap.decrement('session1', 'event1') + expect(percyCaptureMap.get('session1', 'event1')).toBe(0) + }) + + it('getName method should return the correct name', () => { + percyCaptureMap.increment('session1', 'event1') + expect(percyCaptureMap.getName('session1', 'event1')).toBe('session1-event1-0') + }) + + it('get method should return the correct count', () => { + percyCaptureMap.increment('session1', 'event1') + expect(percyCaptureMap.get('session1', 'event1')).toBe(0) + }) + + it('get method should return 0 for non-existing session and event', () => { + expect(percyCaptureMap.get('nonexistentSession', 'nonexistentEvent')).toBe(0) + }) + + it('decrement method should not decrease count below 0', () => { + percyCaptureMap.decrement('session1', 'event1') + expect(percyCaptureMap.get('session1', 'event1')).toBe(0) + }) +}) diff --git a/packages/wdio-browserstack-service/tests/PercyHelper.test.ts b/packages/wdio-browserstack-service/tests/PercyHelper.test.ts new file mode 100644 index 00000000000..5f5b64a57c5 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyHelper.test.ts @@ -0,0 +1,199 @@ +/// +import logger from '@wdio/logger' + +import * as PercyHelper from '../src/Percy/PercyHelper' +import Percy from '../src/Percy/Percy' +import * as PercyLogger from '../src/Percy/PercyLogger' + +import { Browser, MultiRemoteBrowser } from 'webdriverio' + +const log = logger('test') +let browser: Browser<'async'> | MultiRemoteBrowser<'async'> + +// jest.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +jest.useFakeTimers().setSystemTime(new Date('2020-01-01')) +jest.mock('uuid', () => ({ v4: () => '123456789' })) + +const PercyLoggerSpy = jest.spyOn(PercyLogger.PercyLogger, 'logToFile') +PercyLoggerSpy.mockImplementation(() => {}) + +beforeEach(() => { + jest.mocked(log.info).mockClear() + + browser = { + sessionId: 'session123', + config: {}, + capabilities: { + device: '', + os: 'OS X', + os_version: 'Catalina', + browserName: 'chrome' + }, + instances: ['browserA', 'browserB'], + isMultiremote: false, + browserA: { + sessionId: 'session456', + capabilities: { 'bstack:options': { + device: '', + os: 'Windows', + osVersion: 10, + browserName: 'chrome' + } } + }, + getInstance: jest.fn().mockImplementation((browserName: string) => browser[browserName]), + browserB: {}, + execute: jest.fn(), + executeAsync: async () => { 'done' }, + getUrl: () => { return 'https://www.google.com/'}, + on: jest.fn(), + } as any as Browser<'async'> | MultiRemoteBrowser<'async'> +}) + +describe('startPercy', () => { + let percyStartSpy: any + + beforeEach(() => { + percyStartSpy = jest.spyOn(Percy.prototype, 'start').mockImplementationOnce(async () => { + return true + }) + }) + + it('should call start method of Percy', async () => { + await PercyHelper.startPercy({}, {}, {}) + expect(percyStartSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyStartSpy.mockClear() + }) +}) + +describe('stopPercy', () => { + let percyStopSpy: any + + beforeEach(() => { + percyStopSpy = jest.spyOn(Percy.prototype, 'stop').mockImplementationOnce(async () => { + return {} + }) + }) + + it('should call stop method of Percy', async () => { + const percy = new Percy({}, {}, {}) + await PercyHelper.stopPercy(percy) + expect(percyStopSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyStopSpy.mockClear() + }) +}) + +describe('getBestPlatformForPercySnapshot', () => { + const capsArr: any = [ + { + maxInstances: 5, + browserName: 'edge', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'edge' + } + }, + { + maxInstances: 5, + browserName: 'chrome', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'chrome' + } + }, + { + maxInstances: 5, + browserName: 'firefox', + browserVersion: 'latest', + platformName: 'Windows 10', + 'moz:firefoxOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'firefox' + } + } + ] + + const capsObj: any = { + 'key-1': { + capabilities: { + maxInstances: 5, + browserName: 'edge', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'edge' + } + } + }, + 'key-2': { + capabilities: { + maxInstances: 5, + browserName: 'chrome', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'chrome' + } + } + }, + 'key-3': { + capabilities: { + maxInstances: 5, + browserName: 'firefox', + browserVersion: 'latest', + platformName: 'Windows 10', + 'moz:firefoxOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'firefox' + } + } + }, + } + + it('should return correct caps for best platform - Array', () => { + const bestPlatformCaps = PercyHelper.getBestPlatformForPercySnapshot(capsArr) + expect(bestPlatformCaps).toEqual({ + maxInstances: 5, + browserName: 'chrome', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'chrome' + } + }) + }) + + it('should return correct caps for best platform - Object', () => { + const bestPlatformCaps = PercyHelper.getBestPlatformForPercySnapshot(capsObj) + expect(bestPlatformCaps).toEqual({ + maxInstances: 5, + browserName: 'chrome', + browserVersion: 'latest', + platformName: 'Windows 10', + 'goog:chromeOptions': {}, + 'bstack:options': { + 'projectName': 'Project Name', + 'browserName': 'chrome' + } + }) + }) +}) diff --git a/packages/wdio-browserstack-service/tests/PercyLogger.test.ts b/packages/wdio-browserstack-service/tests/PercyLogger.test.ts new file mode 100644 index 00000000000..19cb64abe71 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyLogger.test.ts @@ -0,0 +1,122 @@ +import logger from '@wdio/logger' +import { PercyLogger } from '../src/Percy/PercyLogger' +import fs from 'node:fs' + +const log = logger('test') + +// jest.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +jest.mock('node:fs/promises', () => ({ + default: { + createReadStream: jest.fn().mockReturnValue({ pipe: jest.fn() }), + createWriteStream: jest.fn().mockReturnValue( + { + pipe: jest.fn(), + write: jest.fn() + }), + stat: jest.fn().mockReturnValue(Promise.resolve({ size: 123 })), + } +})) + +describe('PercyLogger Log methods', () => { + let logToFileSpy: any + beforeEach(() => { + logToFileSpy = jest.spyOn(PercyLogger, 'logToFile') + }) + + it('should write to file and console - info', () => { + const logInfoMock = jest.spyOn(log, 'info') + + PercyLogger.info('This is the test for log.info') + expect(logToFileSpy).toBeCalled() + expect(logInfoMock).toBeCalled() + }) + + it('should write to file and console - warn', () => { + const logWarnMock = jest.spyOn(log, 'warn') + + PercyLogger.warn('This is the test for log.warn') + expect(logToFileSpy).toBeCalled() + expect(logWarnMock).toBeCalled() + }) + + it('should write to file and console - trace', () => { + const logTraceMock = jest.spyOn(log, 'trace') + + PercyLogger.trace('This is the test for log.trace') + expect(logToFileSpy).toBeCalled() + expect(logTraceMock).toBeCalled() + }) + + it('should write to file and console - debug', () => { + const logDebugMock = jest.spyOn(log, 'debug') + + PercyLogger.debug('This is the test for log.debug') + expect(logToFileSpy).toBeCalled() + expect(logDebugMock).toBeCalled() + }) + + it('should write to file and console - error', () => { + const logDebugMock = jest.spyOn(log, 'error') + + PercyLogger.error('This is the test for log.error') + expect(logToFileSpy).toBeCalled() + expect(logDebugMock).toBeCalled() + }) + afterEach(() => { + jest.clearAllMocks() + }) +}) + +describe('PercyLogger clearLogger method', () => { + let clearLoggerSpy: any + beforeEach(() => { + clearLoggerSpy = jest.spyOn(PercyLogger, 'clearLogger') + }) + + it('should do nothing if logFileStream is null', () => { + PercyLogger.clearLogger() + expect(clearLoggerSpy).toBeCalled() + }) + + afterEach(() => { + jest.clearAllMocks() + }) +}) + +describe('PercyLogger clearLogFile method', () => { + let clearLogFileSpy: any + beforeEach(() => { + clearLogFileSpy = jest.spyOn(PercyLogger, 'clearLogFile') + }) + + it('should do nothing if logFileStream is null', () => { + + PercyLogger.clearLogFile() + expect(clearLogFileSpy).toBeCalled() + }) + + afterEach(() => { + jest.clearAllMocks() + }) +}) + +describe('PercyLogger logToFile method', () => { + let logToFileSpy: any + + beforeEach(() => { + logToFileSpy = jest.spyOn(PercyLogger, 'logToFile') + }) + + it('should do nothing if logFileStream is null', () => { + + jest.spyOn(fs, 'existsSync' ).mockReturnValue(false) + jest.spyOn(fs, 'mkdirSync' ).mockReturnValue(true) + + jest.spyOn(fs, 'createWriteStream' ).mockReturnValue('filepath') + PercyLogger.logToFile('message', 'info') + expect(logToFileSpy).toBeCalled() + }) + afterEach(() => { + jest.clearAllMocks() + }) +}) diff --git a/packages/wdio-browserstack-service/tests/launcher.test.ts b/packages/wdio-browserstack-service/tests/launcher.test.ts index f776af3a3d8..47be413ba16 100644 --- a/packages/wdio-browserstack-service/tests/launcher.test.ts +++ b/packages/wdio-browserstack-service/tests/launcher.test.ts @@ -8,6 +8,10 @@ import BrowserstackLauncher from '../src/launcher' import { BrowserstackConfig } from '../src/types' import * as utils from '../src/util' +import * as PercyHelper from '../src/Percy/PercyHelper' +import * as PercyLogger from '../src/Percy/PercyLogger' +import Percy from '../src/Percy/Percy' + import fs from 'fs' // @ts-ignore @@ -1239,3 +1243,68 @@ describe('_updateLocalBuildCache', () => { expect(writeFileSyncSpy).not.toHaveBeenCalled() }) }) + +describe('setupPercy', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + const options: BrowserstackConfig & Testrunner = { capabilities: [] } + const config = { + user: 'foobaruser', + key: '12345', + capabilities: [] + } + const caps: any = [{ + 'bstack:options': { + buildName: 'browserstack wdio build test', + buildIdentifier: '#${BUILD_NUMBER}' + } + }] + const service = new BrowserstackLauncher({ percy: true }, caps, config) + + it('should return if percy is already running', async() => { + const PercyLoggerInfoSpy = jest.spyOn(PercyLogger.PercyLogger, 'info').mockImplementation((string) => string) + const mockPercy = new Percy(options, config, {}) + + const start = jest.spyOn(PercyHelper, 'startPercy').mockReturnValue(mockPercy) + + await service.setupPercy(options, config, { + projectName: 'projectName' + }) + expect(start).toBeCalledTimes(1) + expect(PercyLoggerInfoSpy).toBeCalledTimes(1) + }) +}) + +describe('stopPercy', () => { + let _percy: Percy + const options: BrowserstackConfig & Testrunner = { capabilities: [] } + const config = { + user: 'foobaruser', + key: '12345', + capabilities: [] + } + const caps: any = [{ + 'bstack:options': { + buildName: 'browserstack wdio build test', + buildIdentifier: '#${BUILD_NUMBER}' + } + }] + _percy = new Percy(options, config, {}) + const service = new BrowserstackLauncher({ percy: true }, caps, config) + + it('should return if percy is not defined', async() => { + service.stopPercy() + }) + it('should return if percy is already running', async() => { + service._percy = _percy + const mockPercyisRunning = jest.spyOn(_percy, 'isRunning').mockImplementation(() => (true)) + const PercyLoggerInfoSpy = jest.spyOn(PercyLogger.PercyLogger, 'info').mockImplementation((string) => string) + const mockPercyStart = jest.spyOn(PercyHelper, 'stopPercy').mockImplementation() + + await service.stopPercy() + expect(mockPercyisRunning).toBeCalledTimes(1) + expect(mockPercyStart).toBeCalledTimes(1) + expect(PercyLoggerInfoSpy).toBeCalledTimes(1) + }) +}) diff --git a/packages/wdio-browserstack-service/tests/util.test.ts b/packages/wdio-browserstack-service/tests/util.test.ts index 9192ed2eff8..752c9e89d61 100644 --- a/packages/wdio-browserstack-service/tests/util.test.ts +++ b/packages/wdio-browserstack-service/tests/util.test.ts @@ -1397,3 +1397,16 @@ describe('getBrowserStackKey', function () { }) }) +describe('ObjectsAreEqual', function () { + it('should return true for equal values ', function () { + expect(utils.ObjectsAreEqual({ 'a': true }, { 'a': true })).toEqual(true) + }) + + it('should return false for unequal lengths', function () { + expect(utils.ObjectsAreEqual({ 'a': true }, { 'a': true, 'b': false })).toEqual(false) + }) + + it('should return false for unequal values', function () { + expect(utils.ObjectsAreEqual({ 'a': true }, { 'b': false })).toEqual(false) + }) +})