diff --git a/package-lock.json b/package-lock.json index cb6aa141bb2..20889430339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4608,6 +4608,11 @@ "@octokit/openapi-types": "^18.0.0" } }, + "node_modules/@open-draft/until": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", + "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==" + }, "node_modules/@originjs/vite-plugin-commonjs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@originjs/vite-plugin-commonjs/-/vite-plugin-commonjs-1.0.3.tgz", @@ -4675,6 +4680,38 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@percy/appium-app": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@percy/appium-app/-/appium-app-2.0.3.tgz", + "integrity": "sha512-6INeUJSyK2LzWV4Cc9bszNqKr3/NLcjFelUC2grjPnm6+jLA29inBF4ZE3PeTfLeCSw/0jyCGWV5fr9AyxtzCA==", + "dependencies": { + "@percy/sdk-utils": "^1.27.0-beta.0", + "tmp": "^0.2.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/sdk-utils": { + "version": "1.27.5", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.27.5.tgz", + "integrity": "sha512-MoL/PegKdx9xPgxmvG3xuEMBUYwKO3s/8CnqFXvLNURc5Es6XRqCol7SDI/scsQSpkOaUKc/0rYhmMCRQOt+EA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/selenium-webdriver": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.0.3.tgz", + "integrity": "sha512-JfLJVRkwNfqVofe7iGKtoQbOcKSSj9t4pWFbSUk95JfwAA7b9/c+dlBsxgIRrdrMYzLRjnJkYAFSZkJ4F4A19A==", + "dependencies": { + "@percy/sdk-utils": "^1.27.2", + "node-request-interceptor": "^0.6.3" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -15753,6 +15790,11 @@ "tslib": "^2.0.3" } }, + "node_modules/headers-utils": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.5.tgz", + "integrity": "sha512-DAzV5P/pk3wTU/8TLZN+zFTDv4Xa1QDTU8pRvovPetcOMbmqq8CwsAvZBLPZHH6usxyy31zMp7I4aCYb6XIf6w==" + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -20657,6 +20699,17 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, + "node_modules/node-request-interceptor": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.6.3.tgz", + "integrity": "sha512-8I2V7H2Ch0NvW7qWcjmS0/9Lhr0T6x7RD6PDirhvWEkUQvy83x8BA4haYMr09r/rig7hcgYSjYh6cd4U7G1vLA==", + "dependencies": { + "@open-draft/until": "^1.0.3", + "debug": "^4.3.0", + "headers-utils": "^1.2.0", + "strict-event-emitter": "^0.1.0" + } + }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -26776,6 +26829,11 @@ "queue-tick": "^1.0.1" } }, + "node_modules/strict-event-emitter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.1.0.tgz", + "integrity": "sha512-8hSYfU+WKLdNcHVXJ0VxRXiPESalzRe7w1l8dg9+/22Ry+iZQUoQuoJ27R30GMD1TiyYINWsIEGY05WrskhSKw==" + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -30947,6 +31005,8 @@ "version": "8.27.2", "license": "MIT", "dependencies": { + "@percy/appium-app": "^2.0.1", + "@percy/selenium-webdriver": "^2.0.3", "@types/gitconfiglocal": "^2.0.1", "@wdio/logger": "8.24.12", "@wdio/reporter": "8.27.2", @@ -30960,6 +31020,7 @@ "got": "^12.6.1", "uuid": "^9.0.0", "webdriverio": "8.27.2", + "yauzl": "^2.10.0", "winston-transport": "^4.5.0" }, "devDependencies": { diff --git a/packages/wdio-browserstack-service/package.json b/packages/wdio-browserstack-service/package.json index 673d71f4c78..157232bb7c3 100644 --- a/packages/wdio-browserstack-service/package.json +++ b/packages/wdio-browserstack-service/package.json @@ -43,6 +43,9 @@ "got": "^12.6.1", "uuid": "^9.0.0", "webdriverio": "8.27.2", + "yauzl": "^2.10.0", + "@percy/appium-app": "^2.0.1", + "@percy/selenium-webdriver": "^2.0.3", "winston-transport": "^4.5.0" }, "peerDependencies": { 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..227c9627196 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/Percy-Handler.ts @@ -0,0 +1,183 @@ +import type { Capabilities } from '@wdio/types' +import type { BeforeCommandArgs, AfterCommandArgs } from '@wdio/reporter' + +import { + o11yClassErrorHandler, + sleep +} from '../util.js' +import PercyCaptureMap from './PercyCaptureMap.js' + +import * as PercySDK from './PercySDK.js' +import { PercyLogger } from './PercyLogger.js' + +import { PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS, CAPTURE_MODES } from '../constants.js' + +class _PercyHandler { + private _testMetadata: { [key: string]: any } = {} + private _sessionName?: string + 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: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, + private _capabilities: Capabilities.RemoteCapability, + private _isAppAutomate?: boolean, + private _framework?: string + ) { + 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)) : await PercySDK.screenshot(this._browser, this._percyCaptureMap?.getName( sessionName ? sessionName : (this._sessionName as string), eventName))) + this._percyScreenshotCounter -= 1 + } + } catch (err: any) { + 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 { + /* + Percy screenshots which are to be taken on events such as send keys, element click & screenshot are deferred until + another DOM changing command is seen such that any DOM processing post the previous command is completed + */ + return ( + typeof args.method === 'string' && typeof args.endpoint === 'string' && + ( + ( + args.method === 'POST' && + ( + PERCY_DOM_CHANGING_COMMANDS_ENDPOINTS.includes(args.endpoint) || + ( + /* click / clear element */ + args.endpoint.includes('/session/:sessionId/element') && + ( + args.endpoint.includes('click') || + args.endpoint.includes('clear') + ) + ) || + /* execute script sync / async */ + (args.endpoint.includes('/session/:sessionId/execute') && args.body?.script) || + /* Touch action for Appium */ + (args.endpoint.includes('/session/:sessionId/touch')) + ) + ) || + ( args.method === 'DELETE' && args.endpoint === '/session/:sessionId' ) + ) + ) + } + + async cleanupDeferredScreenshots() { + this._isPercyCleanupProcessingUnderway = true + for (const entry of this._percyDeferredScreenshots) { + await this.percyAutoCapture(entry.eventName, entry.sessionName) + } + this._percyDeferredScreenshots = [] + this._isPercyCleanupProcessingUnderway = false + } + + async browserBeforeCommand (args: BeforeCommandArgs) { + try { + if (!this.isDOMChangingCommand(args)) { + return + } + do { + await sleep(1000) + } while (this._percyScreenshotInterval) + this._percyScreenshotInterval = setInterval(async () => { + if (!this._isPercyCleanupProcessingUnderway) { + clearInterval(this._percyScreenshotInterval) + await this.cleanupDeferredScreenshots() + this._percyScreenshotInterval = null + } + }, 1000) + } catch (err: any) { + PercyLogger.error(`Error while trying to cleanup deferred screenshots ${err}`) + } + } + + async browserAfterCommand (args: BeforeCommandArgs & AfterCommandArgs) { + try { + if (!args.endpoint || !this._percyAutoCaptureMode) { + return + } + let eventName = null + const endpoint = args.endpoint as string + if (endpoint.includes('click') && ['click', 'auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'click' + } else if (endpoint.includes('screenshot') && ['screenshot', 'auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'screenshot' + } else if (endpoint.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 (endpoint.includes('/session/:sessionId/element') && endpoint.includes('value') && ['auto'].includes(this._percyAutoCaptureMode as string)) { + eventName = 'keys' + } + if (eventName) { + this.deferCapture(this._sessionName as string, eventName) + } + } catch (err: any) { + 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..e5c74267a4b --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/Percy.ts @@ -0,0 +1,169 @@ +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, sleep } from '../util.js' +import { PercyLogger } from './PercyLogger.js' + +import PercyBinary from './PercyBinary.js' + +import type { BrowserstackConfig, UserConfig } from '../types.js' +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 + } + + async #getBinaryPath(): Promise { + if (!this.#binaryPath) { + const pb = new PercyBinary() + this.#binaryPath = await pb.getBinaryPath(this.#config) + } + return this.#binaryPath + } + + 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 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 = 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..846b3a1ce9b --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyBinary.ts @@ -0,0 +1,166 @@ +import url from 'node:url' +import yauzl from 'yauzl' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import got from 'got' + +import path from 'node:path' +import os from 'node:os' +import { spawn } from 'node:child_process' +import { PercyLogger } from './PercyLogger.js' +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' + } + } + + async #makePath(path: string) { + if (await this.#checkPath(path)) { + return true + } + return fsp.mkdir(path).then(() => true).catch(() => false) + } + + async #checkPath(path: string) { + try { + const hasDir = await fsp.access(path).then(() => true, () => false) + if (hasDir) { + return true + } + } catch (err) { + return false + } + } + + 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+/ + /* eslint-disable @typescript-eslint/no-unused-vars */ + return new Promise((resolve, reject) => { + 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) + + const options: any = url.parse(this.#httpPath) + + 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..2493088eefd --- /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.js' + +import type { Options } from '@wdio/types' + +import { PercyLogger } from './PercyLogger.js' +import Percy from './Percy.js' + +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 WebdriverIO.Capabilities).browserName + if ((caps.capabilities as WebdriverIO.Capabilities)['bstack:options']) { + currBrowserName = (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']?.browserName || currBrowserName + } + if (!bestBrowser || !bestPlatformCaps || (bestPlatformCaps.deviceName || bestPlatformCaps['bstack:options']?.deviceName)) { + bestBrowser = currBrowserName + bestPlatformCaps = (caps.capabilities as WebdriverIO.Capabilities) + } else if (currBrowserName && percyBrowserPreference[currBrowserName.toLowerCase()] < percyBrowserPreference[bestBrowser.toLowerCase()]) { + bestBrowser = currBrowserName + bestPlatformCaps = (caps.capabilities as WebdriverIO.Capabilities) + } + }) + return bestPlatformCaps + } + } catch (err: any) { + 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..9846c134086 --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercyLogger.ts @@ -0,0 +1,78 @@ +import path from 'node:path' +import fs from 'node:fs' +import chalk from 'chalk' + +import logger from '@wdio/logger' + +import { PERCY_LOGS_FILE } from '../constants.js' +import { COLORS } from '../util.js' + +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 `${chalk.gray(new Date().toISOString())} ${chalk[COLORS[level]](level.toUpperCase())} ${chalk.whiteBright('@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() { + if (fs.existsSync(this.logFilePath)) { + fs.truncateSync(this.logFilePath) + } + } +} 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..0904e5d59de --- /dev/null +++ b/packages/wdio-browserstack-service/src/Percy/PercySDK.ts @@ -0,0 +1,44 @@ +import { PercyLogger } from './PercyLogger.js' + +const tryRequire = async function (pkg: string, fallback: any) { + try { + return (await import(pkg)).default + } catch { + return fallback + } +} + +const percySnapshot = await tryRequire('@percy/selenium-webdriver', null) + +const percyAppScreenshot = await tryRequire('@percy/appium-app', {}) + +/* eslint-disable @typescript-eslint/no-unused-vars */ +let snapshotHandler = (...args: any[]) => { + PercyLogger.error('Unsupported driver for percy') +} +if (percySnapshot) { + snapshotHandler = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, 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/bstackLogger.ts b/packages/wdio-browserstack-service/src/bstackLogger.ts index 95db6003631..8a203f18e85 100644 --- a/packages/wdio-browserstack-service/src/bstackLogger.ts +++ b/packages/wdio-browserstack-service/src/bstackLogger.ts @@ -9,7 +9,7 @@ import { COLORS } from './util.js' const log = logger('@wdio/browserstack-service') -export abstract class BStackLogger { +export class BStackLogger { public static logFilePath = path.join(process.cwd(), LOGS_FILE) private static logFolderPath = path.join(process.cwd(), 'logs') private static logFileStream: fs.WriteStream | null diff --git a/packages/wdio-browserstack-service/src/constants.ts b/packages/wdio-browserstack-service/src/constants.ts index 15c29583f8f..11243022e08 100644 --- a/packages/wdio-browserstack-service/src/constants.ts +++ b/packages/wdio-browserstack-service/src/constants.ts @@ -46,3 +46,17 @@ export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTa export const LOGS_FILE = 'logs/bstack-wdio-service.log' export const UPLOAD_LOGS_ADDRESS = 'https://upload-observability.browserstack.com' export const UPLOAD_LOGS_ENDPOINT = 'client-logs/upload' + +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 7154bcd99b0..3affa6671c1 100644 --- a/packages/wdio-browserstack-service/src/index.ts +++ b/packages/wdio-browserstack-service/src/index.ts @@ -11,6 +11,9 @@ export const launcher = BrowserstackLauncher export const log4jsAppender = { configure } export const BStackTestOpsLogger = logReportingAPI +import * as Percy from './Percy/PercySDK.js' +export const PercySDK = Percy + export * from './types.js' declare global { diff --git a/packages/wdio-browserstack-service/src/launcher.ts b/packages/wdio-browserstack-service/src/launcher.ts index 10bdd85e70c..0d2ed1fa100 100644 --- a/packages/wdio-browserstack-service/src/launcher.ts +++ b/packages/wdio-browserstack-service/src/launcher.ts @@ -14,7 +14,9 @@ import * as BrowserstackLocalLauncher from 'browserstack-local' import type { Capabilities, Services, Options } from '@wdio/types' import PerformanceTester from './performance-tester.js' -import type { BrowserstackConfig, App, AppConfig, AppUploadResponse } from './types.js' +import { startPercy, stopPercy, getBestPlatformForPercySnapshot } from './Percy/PercyHelper.js' + +import type { BrowserstackConfig, App, AppConfig, AppUploadResponse, UserConfig } from './types.js' import { BSTACK_SERVICE_VERSION, NOT_ALLOWED_KEYS_IN_CAPS, VALID_APP_EXTENSION } from './constants.js' import { launchTestSession, @@ -30,15 +32,18 @@ import { getBrowserStackUser, getBrowserStackKey, uploadLogs, + ObjectsAreEqual, setupExitHandlers } from './util.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' +import { PercyLogger } from './Percy/PercyLogger.js' import { FileStream } from './fileStream.js' +import type Percy from './Percy/Percy.js' type BrowserstackLocal = BrowserstackLocalLauncher.Local & { - pid?: number; - stop(callback: (err?: any) => void): void; + pid?: number + stop(callback: (err?: Error) => void): void } export default class BrowserstackLauncherService implements Services.ServiceInstance { @@ -48,6 +53,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 static _testOpsBuildStopped?: boolean constructor ( @@ -56,6 +63,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst private _config: Options.Testrunner ) { BStackLogger.clearLogFile() + PercyLogger.clearLogFile() setupExitHandlers() // added to maintain backward compatibility with webdriverIO v5 this._config || (this._config = _options) @@ -159,6 +167,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}`) + } + } + async onPrepare (config?: Options.Testrunner, capabilities?: Capabilities.RemoteCapabilities) { /** * Upload app to BrowserStack if valid file path to app is given. @@ -252,6 +273,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 BStackLogger.info('browserstackLocal is not enabled - skipping...') } @@ -335,6 +368,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst BStackLogger.clearLogger() + if (this._options.percy) { + await this.stopPercy() + } + + PercyLogger.clearLogger() + if (!this.browserstackLocal || !this.browserstackLocal.isRunning()) { return } @@ -369,6 +408,42 @@ 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 { BStackLogger.info(`uploading app ${app.app} ${app.customId? `and custom_id: ${app.customId}` : ''} to browserstack`) @@ -427,7 +502,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst BStackLogger.logToFile(`Response - ${format(response)}`, 'debug') } - _updateObjectTypeCaps(capabilities?: Capabilities.RemoteCapabilities, capType?: string, value?: { [key: string]: any; }) { + _updateObjectTypeCaps(capabilities?: Capabilities.RemoteCapabilities, capType?: string, value?: { [key: string]: any }) { try { if (Array.isArray(capabilities)) { capabilities diff --git a/packages/wdio-browserstack-service/src/service.ts b/packages/wdio-browserstack-service/src/service.ts index eeac6213a05..d34de2221e3 100644 --- a/packages/wdio-browserstack-service/src/service.ts +++ b/packages/wdio-browserstack-service/src/service.ts @@ -19,6 +19,7 @@ import { DEFAULT_OPTIONS } from './constants.js' import CrashReporter from './crash-reporter.js' import AccessibilityHandler from './accessibility-handler.js' import { BStackLogger } from './bstackLogger.js' +import PercyHandler from './Percy/Percy-Handler.js' export default class BrowserstackService implements Services.ServiceInstance { private _sessionBaseUrl = 'https://api.browserstack.com/automate/sessions' @@ -36,6 +37,8 @@ export default class BrowserstackService implements Services.ServiceInstance { private _insightsHandler?: InsightsHandler private _accessibility private _accessibilityHandler?: AccessibilityHandler + private _percy + private _percyHandler?: PercyHandler private _turboScale constructor ( @@ -48,6 +51,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) { @@ -67,6 +71,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: WebdriverIO.Capabilities | Capabilities.DesiredCapabilities) => void) { @@ -111,37 +119,66 @@ export default class BrowserstackService implements Services.ServiceInstance { this._scenariosThatRan = [] - if (this._observability && this._browser) { - patchConsoleLogs() - try { - this._insightsHandler = new InsightsHandler( + if (this._browser) { + if (this._percy) { + this._percyHandler = new PercyHandler( + this._options.percyCaptureMode, this._browser, + this._caps, this._isAppAutomate(), - this._config.framework, - this._caps + this._config.framework ) - await this._insightsHandler.before() + this._percyHandler.before() + } + try { + const sessionId = this._browser.sessionId + if (this._observability) { + patchConsoleLogs() + + this._insightsHandler = new InsightsHandler( + this._browser, + this._isAppAutomate(), + this._config.framework + ) + await this._insightsHandler.before() + } /** * register command event */ - const sessionId = this._browser.sessionId - this._browser.on('command', (command) => this._insightsHandler?.browserCommand( - 'client:beforeCommand', - Object.assign(command, { sessionId }), - this._currentTest - )) + this._browser.on('command', async (command) => { + if (this._observability) { + this._insightsHandler?.browserCommand( + 'client:beforeCommand', + Object.assign(command, { sessionId }), + this._currentTest + ) + } + await this._percyHandler?.browserBeforeCommand( + Object.assign(command, { sessionId }), + ) + }) + /** * register result event */ - this._browser.on('result', (result) => this._insightsHandler?.browserCommand( - 'client:afterCommand', - Object.assign(result, { sessionId }), - this._currentTest - )) + this._browser.on('result', (result) => { + if (this._observability) { + this._insightsHandler?.browserCommand( + 'client:afterCommand', + Object.assign(result, { sessionId }), + this._currentTest + ) + } + this._percyHandler?.browserAfterCommand( + Object.assign(result, { sessionId }), + ) + }) } catch (err) { BStackLogger.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) + } } } @@ -220,6 +257,7 @@ export default class BrowserstackService implements Services.ServiceInstance { this._failReasons.push((error && error.message) || 'Unknown Error') } await this._insightsHandler?.afterTest(test, results) + await this._percyHandler?.afterTest() await this._accessibilityHandler?.afterTest(this._suiteTitle, test) } @@ -243,6 +281,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([ @@ -297,6 +337,7 @@ export default class BrowserstackService implements Services.ServiceInstance { } await this._insightsHandler?.afterScenario(world) + await this._percyHandler?.afterScenario() await this._accessibilityHandler?.afterScenario(world) } @@ -451,6 +492,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 44ae956358b..f0e437cb641 100644 --- a/packages/wdio-browserstack-service/src/types.ts +++ b/packages/wdio-browserstack-service/src/types.ts @@ -61,6 +61,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 4f688688023..0684a56f558 100644 --- a/packages/wdio-browserstack-service/src/util.ts +++ b/packages/wdio-browserstack-service/src/util.ts @@ -194,7 +194,7 @@ export async function nodeRequest(requestType: Method, apiEndpoint: string, opti BStackLogger.debug(`Failed to fire api request due to ${error} - ${error.stack}`) return } - BStackLogger.error(`Failed to fire api request due to ${error} - ${error.stack}`) + BStackLogger.debug(`Failed to fire api request due to ${error} - ${error.stack}`) throw error } } @@ -1173,6 +1173,27 @@ export async function uploadLogs(user: string | undefined, key: string | undefin return response } +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 +} + export function setupExitHandlers() { process.on('exit', (code) => { if (!!process.env.BS_TESTOPS_JWT && !BrowserstackLauncherService._testOpsBuildStopped) { 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..7b488148b80 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/Percy-Handler.test.ts @@ -0,0 +1,277 @@ +/// +/// +import path from 'node:path' + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import got from 'got' +import logger from '@wdio/logger' + +import PercyHandler from '../src/Percy/Percy-Handler.js' +import PercyCaptureMap from '../src/Percy/PercyCaptureMap.js' +import * as PercySDK from '../src/Percy/PercySDK.js' +import type { Capabilities } from '@wdio/types' +import * as PercyLogger from '../src/Percy/PercyLogger.js' + +import type { BeforeCommandArgs, AfterCommandArgs } from '@wdio/reporter' + +const log = logger('test') +let percyHandler: PercyHandler +let browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser +let caps: Capabilities.RemoteCapability + +vi.mock('got') +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +vi.useFakeTimers().setSystemTime(new Date('2020-01-01')) +vi.mock('uuid', () => ({ v4: () => '123456789' })) + +const PercyLoggerSpy = vi.spyOn(PercyLogger.PercyLogger, 'logToFile') +PercyLoggerSpy.mockImplementation(() => {}) + +beforeEach(() => { + vi.mocked(log.info).mockClear() + vi.mocked(got).mockClear() + vi.mocked(got.put).mockClear() + vi.mocked(got).mockResolvedValue({ + body: { + automation_session: { + browser_url: 'https://www.browserstack.com/automate/builds/1/sessions/2' + } + } + }) + vi.mocked(got.put).mockResolvedValue({}) + + 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: vi.fn().mockImplementation((browserName: string) => browser[browserName]), + browserB: {}, + execute: vi.fn(), + executeAsync: async () => { 'done' }, + getUrl: () => { return 'https://www.google.com/'}, + on: vi.fn(), + } as any as WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser + 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) +}) + +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('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) + /* eslint-disable @typescript-eslint/no-unused-vars */ + }).catch((err: any) => { + expect(percyHandler['_percyScreenshotCounter']).not.equal(0) + }) + }) +}) + +describe('afterScenario', () => { + let percyAutoCaptureSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('manual', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureSpy = vi.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 = vi.spyOn(PercyHandler.prototype, 'percyAutoCapture') + }) + + it('should not call percyAutoCapture', async () => { + await percyHandler.afterTest() + expect(percyAutoCaptureSpy).not.toBeCalled() + }) + + it('should call percyAutoCapture', async () => { + percyHandler['_percyAutoCaptureMode'] = 'testcase' + await percyHandler.afterTest() + expect(percyAutoCaptureSpy).toBeCalledTimes(1) + }) + + afterEach(() => { + percyAutoCaptureSpy.mockClear() + }) +}) + +describe('percyAutoCapture', () => { + let percyScreenshotSpy: any + let percyScreenshotAppSpy: any + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler._setSessionName('1234') + percyHandler.before() + + percyScreenshotSpy = vi.spyOn(PercySDK, 'screenshot').mockImplementation(() => Promise.resolve()) + percyScreenshotAppSpy = vi.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('browserCommand', () => { + let percyAutoCaptureSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureSpy = vi.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) + }) + + afterEach(() => { + percyAutoCaptureSpy.mockClear() + }) +}) + +describe('percyCaptureMap', () => { + let percyAutoCaptureMapGetNameSpy: any + let percyAutoCaptureMapIncrementSpy: any + let percyHandler: PercyHandler + + beforeEach(() => { + percyHandler = new PercyHandler('auto', browser, caps, false, 'framework') + percyHandler.before() + percyAutoCaptureMapGetNameSpy = vi.spyOn(PercyCaptureMap.prototype, 'getName') + percyAutoCaptureMapIncrementSpy = vi.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/PercyHelper.test.ts b/packages/wdio-browserstack-service/tests/PercyHelper.test.ts new file mode 100644 index 00000000000..31f4217319d --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyHelper.test.ts @@ -0,0 +1,213 @@ +/// +/// +import path from 'node:path' + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import got from 'got' +import logger from '@wdio/logger' + +import * as PercyHelper from '../src/Percy/PercyHelper.js' +import Percy from '../src/Percy/Percy.js' +import * as PercyLogger from '../src/Percy/PercyLogger.js' + +const log = logger('test') +let browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser + +vi.mock('got') +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +vi.useFakeTimers().setSystemTime(new Date('2020-01-01')) +vi.mock('uuid', () => ({ v4: () => '123456789' })) + +const PercyLoggerSpy = vi.spyOn(PercyLogger.PercyLogger, 'logToFile') +PercyLoggerSpy.mockImplementation(() => {}) + +beforeEach(() => { + vi.mocked(log.info).mockClear() + vi.mocked(got).mockClear() + vi.mocked(got.put).mockClear() + vi.mocked(got).mockResolvedValue({ + body: { + automation_session: { + browser_url: 'https://www.browserstack.com/automate/builds/1/sessions/2' + } + } + }) + vi.mocked(got.put).mockResolvedValue({}) + + 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: vi.fn().mockImplementation((browserName: string) => browser[browserName]), + browserB: {}, + execute: vi.fn(), + executeAsync: async () => { 'done' }, + getUrl: () => { return 'https://www.google.com/'}, + on: vi.fn(), + } as any as WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser +}) + +describe('startPercy', () => { + let percyStartSpy: any + + beforeEach(() => { + percyStartSpy = vi.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 = vi.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..080be022518 --- /dev/null +++ b/packages/wdio-browserstack-service/tests/PercyLogger.test.ts @@ -0,0 +1,92 @@ +import path from 'node:path' +import fs from 'node:fs' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +import logger from '@wdio/logger' +import { PercyLogger } from '../src/Percy/PercyLogger.js' + +const log = logger('test') + +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +vi.mock('node:fs/promises', () => ({ + default: { + createReadStream: vi.fn().mockReturnValue({ pipe: vi.fn() }), + createWriteStream: vi.fn().mockReturnValue( + { + pipe: vi.fn(), + write: vi.fn() + }), + stat: vi.fn().mockReturnValue(Promise.resolve({ size: 123 })), + } +})) +vi.mock('node:fs', () => ({ + default: { + readFileSync: vi.fn().mockReturnValue('1234\nsomepath'), + existsSync: vi.fn(), + truncateSync: vi.fn(), + mkdirSync: vi.fn() + } +})) + +describe('logToFile', () => { + it('creates new stream if writeStream directly if stream is not defined and directory exists', () => { + const existsSyncMock = vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true) + PercyLogger.logToFile('This is test for method logToFile', 'info') + expect(existsSyncMock).toHaveBeenCalled() + }) + + it('creates new stream if writeStream is currently null', () => { + vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false) + const mkdirSyncMock = vi.spyOn(fs, 'mkdirSync') + PercyLogger.logToFile('This is test for method logToFile', 'info') + expect(mkdirSyncMock).toHaveBeenCalled() + }) +}) + +describe('PercyLogger Log methods', () => { + let logToFileSpy: any + beforeEach(() => { + logToFileSpy = vi.spyOn(PercyLogger, 'logToFile') + }) + + it('should write to file and console - info', () => { + const logInfoMock = vi.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 = vi.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 = vi.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 = vi.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 = vi.spyOn(log, 'error') + + PercyLogger.error('This is the test for log.error') + expect(logToFileSpy).toBeCalled() + expect(logDebugMock).toBeCalled() + }) +}) +