diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 1e6ed84e8..2d19b688d 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -59,6 +59,8 @@ export const exec = command('exec', { log.warn('Percy is disabled'); } else { try { + percy.projectType = percy.client.tokenType(); + percy.skipDiscovery = percy.shouldSkipAssetDiscovery(percy.projectType); yield* percy.yield.start(); } catch (error) { if (error.name === 'AbortError') throw error; diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 19cfcdf83..ffc630b59 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -84,9 +84,9 @@ export class PercyClient { } // Checks for a Percy token and returns it. - getToken() { + getToken(raiseIfMissing = true) { let token = this.token || this.env.token; - if (!token) throw new Error('Missing Percy token'); + if (!token && raiseIfMissing) throw new Error('Missing Percy token'); return token; } @@ -366,9 +366,9 @@ export class PercyClient { return snapshot; } - async createComparison(snapshotId, { tag, tiles = [], externalDebugUrl, ignoredElementsData } = {}) { + async createComparison(snapshotId, { tag, tiles = [], externalDebugUrl, ignoredElementsData, domInfoSha } = {}) { validateId('snapshot', snapshotId); - + // Remove post percy api deploy this.log.debug(`Creating comparision: ${tag.name}...`); for (let tile of tiles) { @@ -386,7 +386,8 @@ export class PercyClient { type: 'comparisons', attributes: { 'external-debug-url': externalDebugUrl || null, - 'ignore-elements-data': ignoredElementsData || null + 'ignore-elements-data': ignoredElementsData || null, + 'dom-info-sha': domInfoSha || null }, relationships: { tag: { @@ -398,7 +399,9 @@ export class PercyClient { height: tag.height || null, 'os-name': tag.osName || null, 'os-version': tag.osVersion || null, - orientation: tag.orientation || null + orientation: tag.orientation || null, + browser_name: tag.browserName || null, + browser_version: tag.browserVersion || null } } }, @@ -504,6 +507,25 @@ export class PercyClient { await this.finalizeComparison(comparison.data.id); return comparison; } + + // decides project type + tokenType() { + let token = this.getToken(false) || ''; + + const type = token.split('_')[0]; + switch (type) { + case 'auto': + return 'automate'; + case 'web': + return 'web'; + case 'app': + return 'app'; + case 'ss': + return 'generic'; + default: + return 'web'; + } + } } export default PercyClient; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 84b264203..06dcdd674 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -810,7 +810,9 @@ describe('PercyClient', () => { height: 1024, osName: 'fooOS', osVersion: '0.1.0', - orientation: 'portrait' + orientation: 'portrait', + browserName: 'chrome', + browserVersion: '111.0.0' }, tiles: [{ statusBarHeight: 40, @@ -835,7 +837,8 @@ describe('PercyClient', () => { sha: sha256hash('somesha') }], externalDebugUrl: 'http://debug.localhost', - ignoredElementsData: ignoredElementsData + ignoredElementsData: ignoredElementsData, + domInfoSha: 'abcd=' })).toBeResolved(); expect(api.requests['/snapshots/4567/comparisons'][0].body).toEqual({ @@ -843,7 +846,8 @@ describe('PercyClient', () => { type: 'comparisons', attributes: { 'external-debug-url': 'http://debug.localhost', - 'ignore-elements-data': ignoredElementsData + 'ignore-elements-data': ignoredElementsData, + 'dom-info-sha': 'abcd=' }, relationships: { tag: { @@ -855,7 +859,9 @@ describe('PercyClient', () => { height: 1024, 'os-name': 'fooOS', 'os-version': '0.1.0', - orientation: 'portrait' + orientation: 'portrait', + browser_name: 'chrome', + browser_version: '111.0.0' } } }, @@ -907,7 +913,8 @@ describe('PercyClient', () => { type: 'comparisons', attributes: { 'external-debug-url': null, - 'ignore-elements-data': null + 'ignore-elements-data': null, + 'dom-info-sha': null }, relationships: { tag: { @@ -919,7 +926,9 @@ describe('PercyClient', () => { height: null, 'os-name': null, 'os-version': null, - orientation: null + orientation: null, + browser_name: null, + browser_version: null } } }, @@ -1156,7 +1165,8 @@ describe('PercyClient', () => { type: 'comparisons', attributes: { 'external-debug-url': null, - 'ignore-elements-data': null + 'ignore-elements-data': null, + 'dom-info-sha': null }, relationships: { tag: { @@ -1168,7 +1178,9 @@ describe('PercyClient', () => { height: null, 'os-name': null, 'os-version': null, - orientation: null + orientation: null, + browser_name: null, + browser_version: null } } }, @@ -1207,4 +1219,65 @@ describe('PercyClient', () => { expect(api.requests['/comparisons/891011/finalize']).toBeDefined(); }); }); + + describe('#tokenType', () => { + let client; + + beforeEach(() => { + client = new PercyClient({ + token: 'PERCY_TOKEN' + }); + }); + + it('should return web for default token', () => { + client.token = '<>'; + expect(client.tokenType()).toBe('web'); + }); + + it('should return web for web tokens', () => { + client.token = 'web_abc'; + expect(client.tokenType()).toBe('web'); + }); + + it('should return app for app tokens', () => { + client.token = 'app_abc'; + expect(client.tokenType()).toBe('app'); + }); + + it('should return automate for auto tokens', () => { + client.token = 'auto_abc'; + expect(client.tokenType()).toBe('automate'); + }); + + it('should return generic for ss tokens', () => { + client.token = 'ss_abc'; + expect(client.tokenType()).toBe('generic'); + }); + + it('should return web for default token', () => { + client.token = 'abcdef123'; + expect(client.tokenType()).toBe('web'); + }); + + it('should return web for no token', () => { + client.token = ''; + expect(client.tokenType()).toBe('web'); + }); + }); + + describe('#getToken', () => { + it('should throw error when called with true', () => { + const client = new PercyClient({}); + expect(() => { + client.getToken(); + }).toThrowError('Missing Percy token'); + }); + + it('should not throw error when called with false', () => { + const client = new PercyClient({ + token: 'PERCY_TOKEN' + }); + expect(client.getToken(false)).toBe('PERCY_TOKEN'); + }); + }); }); diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 848e836f3..a1124f566 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -3,7 +3,7 @@ import path from 'path'; import { createRequire } from 'module'; import logger from '@percy/logger'; import { normalize } from '@percy/config/utils'; -import { getPackageJSON, Server } from './utils.js'; +import { getPackageJSON, Server, percyAutomateRequestHandler } from './utils.js'; // TODO Remove below esline disable once we publish webdriver-util import WebdriverUtils from '@percy/webdriver-utils'; // eslint-disable-line import/no-extraneous-dependencies @@ -63,7 +63,8 @@ export function createPercyServer(percy, port) { build: percy.testing?.build ?? percy.build, loglevel: percy.loglevel(), config: percy.config, - success: true + success: true, + type: percy.client.tokenType() })) // get or set config options .route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, { @@ -117,9 +118,12 @@ export function createPercyServer(percy, port) { .route('post', '/percy/flush', async (req, res) => res.json(200, { success: await percy.flush(req.body).then(() => true) })) - .route('post', '/percy/automateScreenshot', async (req, res) => res.json(200, { - success: await (percy.upload(await new WebdriverUtils(req.body).automateScreenshot())).then(() => true) - })) + .route('post', '/percy/automateScreenshot', async (req, res) => { + req = percyAutomateRequestHandler(req); + res.json(200, { + success: await (percy.upload(await new WebdriverUtils(req.body).automateScreenshot())).then(() => true) + }); + }) // stops percy at the end of the current event loop .route('/percy/stop', (req, res) => { setImmediate(() => percy.stop()); diff --git a/packages/core/src/config.js b/packages/core/src/config.js index d13fa4d8b..032f04758 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -397,6 +397,7 @@ export const comparisonSchema = { properties: { name: { type: 'string' }, externalDebugUrl: { type: 'string' }, + domInfoSha: { type: 'string' }, tag: { type: 'object', additionalProperties: false, @@ -418,7 +419,9 @@ export const comparisonSchema = { orientation: { type: 'string', enum: ['portrait', 'landscape'] - } + }, + browserName: { type: 'string' }, + browserVersion: { type: 'string' } } }, tiles: { diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 74ee75551..2a9489aca 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -360,6 +360,11 @@ export class Percy { } }.call(this)); } + + shouldSkipAssetDiscovery(tokenType) { + if (this.testing && JSON.stringify(this.testing) === JSON.stringify({})) { return true; } + return tokenType !== 'web'; + } } export default Percy; diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 1eb9fddc7..5464a1c15 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -23,6 +23,20 @@ export function normalizeURL(url) { return `${protocol}//${host}${pathname}${search}`; } +// Returns the body for automateScreenshot in structure +export function percyAutomateRequestHandler(req) { + if (req.body.client_info) { + req.body.clientInfo = req.body.client_info; + } + if (req.body.environment_info) { + req.body.environmentInfo = req.body.environment_info; + } + if (!req.body.options) { + req.body.options = {}; + } + return req; +} + // Creates a local resource object containing the resource URL, mimetype, content, sha, and any // other additional resources attributes. export function createResource(url, content, mimetype, attrs) { diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index 370917064..d68a43f24 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -54,7 +54,8 @@ describe('API Server', () => { id: '123', number: 1, url: 'https://percy.io/test/test/123' - } + }, + type: percy.client.tokenType() }); }); @@ -326,10 +327,15 @@ describe('API Server', () => { const req = p => request(`${addr}${p}`, { retries: 0 }, false); beforeEach(async () => { + process.env.PERCY_TOKEN = 'TEST_TOKEN'; percy = await Percy.start({ testing: true }); logger.instance.messages.clear(); }); + afterEach(() => { + delete process.env.PERCY_TOKEN; + }); + it('implies loglevel silent and dryRun', () => { expect(percy.testing).toBeDefined(); expect(percy.loglevel()).toEqual('silent'); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index a29c881cc..42d65e7bf 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -843,4 +843,50 @@ describe('Percy', () => { expect(api.requests['/builds/123/snapshots']).toBeUndefined(); }); }); + + describe('#shouldSkipAssetDiscovery', () => { + it('should return true if testing is true', () => { + percy = new Percy({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info', + testing: true + }); + expect(percy.shouldSkipAssetDiscovery(percy.client.tokenType())).toBe(true); + }); + + it('should return false if token is not set', () => { + percy = new Percy({ + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info' + }); + expect(percy.shouldSkipAssetDiscovery(percy.client.tokenType())).toBe(false); + }); + + it('should return false if web token is set', () => { + percy = new Percy({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info' + }); + expect(percy.shouldSkipAssetDiscovery(percy.client.tokenType())).toBe(false); + }); + + it('should return true if auto token is set', () => { + percy = new Percy({ + token: 'auto_PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 }, + clientInfo: 'client-info', + environmentInfo: 'env-info' + }); + expect(percy.shouldSkipAssetDiscovery(percy.client.tokenType())).toBe(true); + }); + }); }); diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 354dc1bb7..8804274c1 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -2,7 +2,8 @@ import { generatePromise, AbortController, yieldTo, - yieldAll + yieldAll, + percyAutomateRequestHandler } from '../../src/utils.js'; describe('Unit / Utils', () => { @@ -165,4 +166,32 @@ describe('Unit / Utils', () => { { done: true, value: [2, 4, null, 3, 6] }); }); }); + + describe('percyAutomateRequestHandler', () => { + let req; + beforeAll(() => { + req = { + body: { + name: 'abc', + client_info: 'client', + environment_info: 'environment' + } + }; + }); + + it('converts client_info to clientInfo', () => { + const nreq = percyAutomateRequestHandler(req); + expect(nreq.body.clientInfo).toBe('client'); + }); + + it('converts environment_info to environmentInfo', () => { + const nreq = percyAutomateRequestHandler(req); + expect(nreq.body.environmentInfo).toBe('environment'); + }); + + it('adds options', () => { + const nreq = percyAutomateRequestHandler(req); + expect(nreq.body.options).toEqual({}); + }); + }); }); diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 7f21b786e..eab18c3eb 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -8,6 +8,8 @@ import postSnapshot from './post-snapshot.js'; import postComparison from './post-comparison.js'; import flushSnapshots from './flush-snapshots.js'; import captureAutomateScreenshot from './post-screenshot.js'; +import TimeIt from './timing.js'; +import Undefined from './validations.js'; export { logger, @@ -19,7 +21,9 @@ export { postSnapshot, postComparison, flushSnapshots, - captureAutomateScreenshot + captureAutomateScreenshot, + TimeIt, + Undefined }; // export the namespace by default diff --git a/packages/sdk-utils/src/timing.js b/packages/sdk-utils/src/timing.js new file mode 100644 index 000000000..c5dd874b2 --- /dev/null +++ b/packages/sdk-utils/src/timing.js @@ -0,0 +1,53 @@ +import { Undefined } from './validations.js'; + +export default class TimeIt { + static data = {}; + + static enabled = process.env.PERCY_METRICS === 'true'; + + static async run(store, func) { + if (!this.enabled) return await func(); + + const t1 = Date.now(); + try { + return await func(); + } finally { + if (Undefined(this.data[store])) this.data[store] = []; + this.data[store].push(Date.now() - t1); + } + } + + static min(store) { + return Math.min(...this.data[store]); + } + + static max(store) { + return Math.max(...this.data[store]); + } + + static avg(store) { + const vals = this.data[store]; + + return vals.reduce((a, b) => a + b, 0) / vals.length; + } + + static summary({ + includeVals + } = {}) { + const agg = {}; + for (const key of Object.keys(this.data)) { + agg[key] = { + min: this.min(key), + max: this.max(key), + avg: this.avg(key), + count: this.data[key].length + }; + if (includeVals) agg[key].vals = this.data[key]; + } + return agg; + } + + static reset() { + this.data = {}; + } +}; diff --git a/packages/sdk-utils/src/validations.js b/packages/sdk-utils/src/validations.js new file mode 100644 index 000000000..a2af8ee44 --- /dev/null +++ b/packages/sdk-utils/src/validations.js @@ -0,0 +1,5 @@ +export function Undefined(obj) { + return obj === undefined; +} + +export default Undefined; diff --git a/packages/sdk-utils/test/timing.test.js b/packages/sdk-utils/test/timing.test.js new file mode 100644 index 000000000..a717afeac --- /dev/null +++ b/packages/sdk-utils/test/timing.test.js @@ -0,0 +1,93 @@ +import TimeIt from '../src/timing.js'; + +describe('TimeIt', () => { + const store = 'store'; + + const sleep = (t) => new Promise((resolve) => setTimeout(resolve, t)); + const func10 = () => sleep(10); + const func100 = () => sleep(100); + const func200 = () => sleep(200); + + const expectedVal = 1234; + const funcReturns = () => sleep(100).then(() => expectedVal); + const expectedError = new Error('expected'); + const funcThrows = () => sleep(100).then(() => { throw expectedError; }); + + beforeAll(() => { + TimeIt.enabled = true; + }); + + afterAll(() => { + TimeIt.enabled = false; + }); + + beforeEach(async () => { + TimeIt.reset(); + }); + + describe('run', () => { + describe('run when disabled', () => { + const funFunc = { funcReturns }; + let funcReturnsSpy; + TimeIt.enabled = false; + + beforeEach(() => { + funcReturnsSpy = spyOn(funFunc, 'funcReturns'); + }); + + afterEach(() => { + TimeIt.reset(); + }); + + it('returns the func which is passed', async () => { + TimeIt.enabled = false; + await TimeIt.run(store, funcReturnsSpy); + expect(funcReturnsSpy).toHaveBeenCalledTimes(1); + TimeIt.enabled = true; + }); + }); + + it('runs func and returns result', async () => { + const val = await TimeIt.run(store, funcReturns); + expect(val).toEqual(expectedVal); + }); + + it('runs func and throws inner exception', async () => { + let actualError = null; + try { + await TimeIt.run(store, funcThrows); + } catch (e) { + actualError = e; + } + expect(actualError).toEqual(expectedError); + }); + }); + + describe('summary', () => { + it('returns summary of calls', async () => { + await TimeIt.run('funcReturns', funcReturns); + await TimeIt.run('funcReturns', funcReturns); + await TimeIt.run('funcReturns', funcReturns); + + await TimeIt.run('funcVariableTime', func10); + await TimeIt.run('funcVariableTime', func100); + await TimeIt.run('funcVariableTime', func200); + + const summary = TimeIt.summary({ includeVals: true }); + TimeIt.summary(); // also without vals + expect(Object.keys(summary).length).toEqual(2); + + // funcReturns + expect(summary.funcReturns.min - 100).toBeLessThan(15.0); // adding buffer for win test + expect(summary.funcReturns.max - 100).toBeLessThan(15.0); // adding buffer for win test + expect(summary.funcReturns.avg - 100).toBeLessThan(10.0); + expect(summary.funcReturns.vals.length).toEqual(3); + + // funcVariableTime + expect(summary.funcVariableTime.min - 10).toBeLessThan(10.0); + expect(summary.funcVariableTime.max - 200).toBeLessThan(10.0); + expect(summary.funcVariableTime.avg - 103).toBeLessThan(10.0); + expect(summary.funcVariableTime.vals.length).toEqual(3); + }); + }); +}); diff --git a/packages/webdriver-utils/src/index.js b/packages/webdriver-utils/src/index.js index a7939a886..29b573e96 100644 --- a/packages/webdriver-utils/src/index.js +++ b/packages/webdriver-utils/src/index.js @@ -4,7 +4,17 @@ import { camelcase } from '@percy/config/utils'; export default class WebdriverUtils { log = utils.logger('webdriver-utils:main'); - constructor({ sessionId, commandExecutorUrl, capabilities, sessionCapabilites, snapshotName, options = {} }) { + constructor( + { + sessionId, + commandExecutorUrl, + capabilities, + sessionCapabilites, + snapshotName, + clientInfo, + environmentInfo, + options = {} + }) { this.sessionId = sessionId; this.commandExecutorUrl = commandExecutorUrl; this.capabilities = capabilities; @@ -16,11 +26,13 @@ export default class WebdriverUtils { camelCasedOptions[newKey] = options[key]; }); this.options = camelCasedOptions; + this.clientInfo = clientInfo; + this.environmentInfo = environmentInfo; } async automateScreenshot() { this.log.info('Starting automate screenshot'); - const automate = ProviderResolver.resolve(this.sessionId, this.commandExecutorUrl, this.capabilities, this.sessionCapabilites); + const automate = ProviderResolver.resolve(this.sessionId, this.commandExecutorUrl, this.capabilities, this.sessionCapabilites, this.clientInfo, this.environmentInfo, this.options); await automate.createDriver(); return await automate.screenshot(this.snapshotName, this.options); } diff --git a/packages/webdriver-utils/src/metadata/desktopMetaData.js b/packages/webdriver-utils/src/metadata/desktopMetaData.js index 0977060b4..b4fe64419 100644 --- a/packages/webdriver-utils/src/metadata/desktopMetaData.js +++ b/packages/webdriver-utils/src/metadata/desktopMetaData.js @@ -8,21 +8,26 @@ export default class DesktopMetaData { return this.capabilities.browserName.toLowerCase(); } + browserVersion() { + return this.capabilities.browserVersion.split('.')[0]; + } + osName() { - let osName = this.capabilities.osVersion; + let osName = this.capabilities.os; if (osName) return osName.toLowerCase(); osName = this.capabilities.platform; return osName; } - // desktop will show this as browser version + // showing major version osVersion() { - return this.capabilities.version.split('.')[0]; + return this.capabilities.osVersion.toLowerCase(); } + // combination of browserName + browserVersion + osVersion + osName deviceName() { - return this.browserName() + '_' + this.osVersion() + '_' + this.osName(); + return this.browserName() + '_' + this.browserVersion() + '_' + this.osVersion() + '_' + this.osName(); } orientation() { diff --git a/packages/webdriver-utils/src/metadata/mobileMetaData.js b/packages/webdriver-utils/src/metadata/mobileMetaData.js index fa839251b..95e90009a 100644 --- a/packages/webdriver-utils/src/metadata/mobileMetaData.js +++ b/packages/webdriver-utils/src/metadata/mobileMetaData.js @@ -8,6 +8,14 @@ export default class MobileMetaData { return this.capabilities.browserName.toLowerCase(); } + browserVersion() { + const bsVersion = this.capabilities.browserVersion?.split('.'); + if (bsVersion?.length > 0) { + return bsVersion[0]; + } + return this.capabilities.version.split('.')[0]; + } + osName() { let osName = this.capabilities.os.toLowerCase(); if (osName === 'mac' && this.browserName() === 'iphone') { diff --git a/packages/webdriver-utils/src/providers/automateProvider.js b/packages/webdriver-utils/src/providers/automateProvider.js index 0369ac592..b8b356315 100644 --- a/packages/webdriver-utils/src/providers/automateProvider.js +++ b/packages/webdriver-utils/src/providers/automateProvider.js @@ -1,11 +1,128 @@ +import utils from '@percy/sdk-utils'; import GenericProvider from './genericProvider.js'; import Cache from '../util/cache.js'; +import Tile from '../util/tile.js'; + +const log = utils.logger('webdriver-utils:automateProvider'); +const { TimeIt } = utils; export default class AutomateProvider extends GenericProvider { + constructor( + sessionId, + commandExecutorUrl, + capabilities, + sessionCapabilites, + clientInfo, + environmentInfo, + options + ) { + super( + sessionId, + commandExecutorUrl, + capabilities, + sessionCapabilites, + clientInfo, + environmentInfo, + options + ); + this._markedPercy = false; + } + static supports(commandExecutorUrl) { return commandExecutorUrl.includes(process.env.AA_DOMAIN || 'browserstack'); } + async screenshot(name, { + ignoreRegionXpaths = [], + ignoreRegionSelectors = [], + ignoreRegionElements = [], + customIgnoreRegions = [] + }) { + let response = null; + let error; + try { + let result = await this.percyScreenshotBegin(name); + this.setDebugUrl(result); + response = await super.screenshot(name, { ignoreRegionXpaths, ignoreRegionSelectors, ignoreRegionElements, customIgnoreRegions }); + } catch (e) { + error = e; + throw e; + } finally { + await this.percyScreenshotEnd(name, response?.body?.link, `${error}`); + } + return response; + } + + async percyScreenshotBegin(name) { + return await TimeIt.run('percyScreenshotBegin', async () => { + try { + let result = await this.browserstackExecutor('percyScreenshot', { + name, + percyBuildId: process.env.PERCY_BUILD_ID, + percyBuildUrl: process.env.PERCY_BUILD_URL, + state: 'begin' + }); + this._markedPercy = result.success; + return result; + } catch (e) { + log.debug(`[${name}] Could not mark Automate session as percy`); + log.error(`[${name}] error: ${e.toString()}`); + return null; + } + }); + } + + async percyScreenshotEnd(name, percyScreenshotUrl, statusMessage = null) { + return await TimeIt.run('percyScreenshotEnd', async () => { + try { + await this.browserstackExecutor('percyScreenshot', { + name, + percyScreenshotUrl, + status: percyScreenshotUrl ? 'success' : 'failure', + statusMessage, + state: 'end' + }); + } catch (e) { + log.debug(`[${name}] Could not mark Automate session as percy`); + } + }); + } + + async getTiles(fullscreen) { + if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); + + const response = await TimeIt.run('percyScreenshot:screenshot', async () => { + return await this.browserstackExecutor('percyScreenshot', { + state: 'screenshot', + percyBuildId: process.env.PERCY_BUILD_ID, + screenshotType: 'singlepage', + scaleFactor: await this.driver.executeScript({ script: 'return window.devicePixelRatio;', args: [] }), + options: this.options + }); + }); + + const responseValue = JSON.parse(response.value); + if (!responseValue.success) { + throw new Error('Failed to get screenshots from Automate.' + + ' Check dashboard for error.'); + } + + const tiles = []; + const tileResponse = JSON.parse(responseValue.result); + + for (let tileData of tileResponse.sha) { + tiles.push(new Tile({ + statusBarHeight: 0, + navBarHeight: 0, + headerHeight: 0, + footerHeight: 0, + fullscreen, + sha: tileData.split('-')[0] // drop build id + })); + } + return { tiles: tiles, domInfoSha: tileResponse.dom_sha }; + } + async browserstackExecutor(action, args) { if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); let options = args ? { action, arguments: args } : { action }; diff --git a/packages/webdriver-utils/src/providers/genericProvider.js b/packages/webdriver-utils/src/providers/genericProvider.js index 36114e3f5..0b2ee8b0b 100644 --- a/packages/webdriver-utils/src/providers/genericProvider.js +++ b/packages/webdriver-utils/src/providers/genericProvider.js @@ -5,21 +5,27 @@ import Tile from '../util/tile.js'; import Driver from '../driver.js'; const log = utils.logger('webdriver-utils:genericProvider'); -// TODO: Need to pass parameter from sdk and catch in cli -const CLIENT_INFO = 'local-poc-poa'; -const ENV_INFO = 'staging-poc-poa'; export default class GenericProvider { + clientInfo = new Set(); + environmentInfo = new Set(); + options = {}; constructor( sessionId, commandExecutorUrl, capabilities, - sessionCapabilites + sessionCapabilites, + clientInfo, + environmentInfo, + options ) { this.sessionId = sessionId; this.commandExecutorUrl = commandExecutorUrl; this.capabilities = capabilities; this.sessionCapabilites = sessionCapabilites; + this.addClientInfo(clientInfo); + this.addEnvironmentInfo(environmentInfo); + this.options = options; this.driver = null; this.metaData = null; this.debugUrl = null; @@ -35,6 +41,32 @@ export default class GenericProvider { return true; } + addClientInfo(info) { + for (let i of [].concat(info)) { + if (i) this.clientInfo.add(i); + } + } + + addEnvironmentInfo(info) { + for (let i of [].concat(info)) { + if (i) this.environmentInfo.add(i); + } + } + + async addPercyCSS(userCSS) { + const createStyleElement = `const e = document.createElement('style'); + e.setAttribute('data-percy-specific-css', true); + e.innerHTML = '${userCSS}'; + document.body.appendChild(e);`; + await this.driver.executeScript({ script: createStyleElement, args: [] }); + } + + async removePercyCSS() { + const removeStyleElement = `const n = document.querySelector('[data-percy-specific-css]'); + n.remove();`; + await this.driver.executeScript({ script: removeStyleElement, args: [] }); + } + async screenshot(name, { ignoreRegionXpaths = [], ignoreRegionSelectors = [], @@ -43,12 +75,16 @@ export default class GenericProvider { }) { let fullscreen = false; + const percyCSS = this.options.percyCSS || ''; + await this.addPercyCSS(percyCSS); const tag = await this.getTag(); + const tiles = await this.getTiles(fullscreen); const ignoreRegions = await this.findIgnoredRegions( ignoreRegionXpaths, ignoreRegionSelectors, ignoreRegionElements, customIgnoreRegions ); await this.setDebugUrl(); + await this.removePercyCSS(); log.debug(`${name} : Tag ${JSON.stringify(tag)}`); log.debug(`${name} : Tiles ${JSON.stringify(tiles)}`); @@ -56,29 +92,40 @@ export default class GenericProvider { return { name, tag, - tiles, + tiles: tiles.tiles, // TODO: Fetch this one for bs automate, check appium sdk externalDebugUrl: this.debugUrl, ignoredElementsData: ignoreRegions, - environmentInfo: ENV_INFO, - clientInfo: CLIENT_INFO + environmentInfo: [...this.environmentInfo].join('; '), + clientInfo: [...this.clientInfo].join(' '), + domInfoSha: tiles.domInfoSha }; } + // TODO: get dom sha for non-automate + async getDomContent() { + // execute script and return dom content + return 'dummyValue'; + } + async getTiles(fullscreen) { if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); const base64content = await this.driver.takeScreenshot(); - return [ - new Tile({ - content: base64content, - // TODO: Need to add method to fetch these attr - statusBarHeight: 0, - navBarHeight: 0, - headerHeight: 0, - footerHeight: 0, - fullscreen - }) - ]; + return { + tiles: [ + new Tile({ + content: base64content, + // TODO: Need to add method to fetch these attr + statusBarHeight: 0, + navBarHeight: 0, + headerHeight: 0, + footerHeight: 0, + fullscreen + }) + ], + // TODO: Add Generic support sha for contextual diff + domInfoSha: this.getDomContent() + }; } async getTag() { @@ -93,11 +140,11 @@ export default class GenericProvider { height, orientation: orientation, browserName: this.metaData.browserName(), - // TODO - browserVersion: 'unknown' + browserVersion: this.metaData.browserVersion() }; } + // TODO: Add Debugging Url async setDebugUrl() { this.debugUrl = 'https://localhost/v1'; } @@ -144,11 +191,11 @@ export default class GenericProvider { try { const element = await this.driver.findElement(findBy, elements[idx]); const selector = `${findBy}: ${elements[idx]}`; - const ignoredRegion = await this.ignoreElementObject(selector, element.ELEMENT); + const ignoredRegion = await this.ignoreElementObject(selector, element[Object.keys(element)[0]]); ignoredElementsArray.push(ignoredRegion); } catch (e) { log.warn(`Selenium Element with ${findBy}: ${elements[idx]} not found. Ignoring this ${findBy}.`); - log.debug(e.toString()); + log.error(e.toString()); } } return ignoredElementsArray; diff --git a/packages/webdriver-utils/src/providers/providerResolver.js b/packages/webdriver-utils/src/providers/providerResolver.js index d0d3c6a92..b7f446b61 100644 --- a/packages/webdriver-utils/src/providers/providerResolver.js +++ b/packages/webdriver-utils/src/providers/providerResolver.js @@ -2,9 +2,9 @@ import GenericProvider from './genericProvider.js'; import AutomateProvider from './automateProvider.js'; export default class ProviderResolver { - static resolve(sessionId, commandExecutorUrl, capabilities, sessionCapabilities) { + static resolve(sessionId, commandExecutorUrl, capabilities, sessionCapabilities, clientInfo, environmentInfo, options) { // We can safely do [0] because GenericProvider is catch all const Klass = [AutomateProvider, GenericProvider].filter(x => x.supports(commandExecutorUrl))[0]; - return new Klass(sessionId, commandExecutorUrl, capabilities, sessionCapabilities); + return new Klass(sessionId, commandExecutorUrl, capabilities, sessionCapabilities, clientInfo, environmentInfo, options); } } diff --git a/packages/webdriver-utils/src/util/cache.js b/packages/webdriver-utils/src/util/cache.js index b5cc99ea8..fd3bd3c76 100644 --- a/packages/webdriver-utils/src/util/cache.js +++ b/packages/webdriver-utils/src/util/cache.js @@ -1,6 +1,6 @@ -import validations from './validations.js'; -const { Undefined } = validations; +import utils from '@percy/sdk-utils'; +const { Undefined } = utils; export default class Cache { static cache = {}; diff --git a/packages/webdriver-utils/src/util/validations.js b/packages/webdriver-utils/src/util/validations.js deleted file mode 100644 index dd1555e2e..000000000 --- a/packages/webdriver-utils/src/util/validations.js +++ /dev/null @@ -1,10 +0,0 @@ -function Undefined(obj) { - return obj === undefined; -} - -export { - Undefined -}; - -// export the namespace by default -export * as default from './validations.js'; diff --git a/packages/webdriver-utils/test/metadata/desktopMetaData.test.js b/packages/webdriver-utils/test/metadata/desktopMetaData.test.js index 6cbd24be1..74684b926 100644 --- a/packages/webdriver-utils/test/metadata/desktopMetaData.test.js +++ b/packages/webdriver-utils/test/metadata/desktopMetaData.test.js @@ -11,8 +11,10 @@ describe('DesktopMetaData', () => { executeScriptSpy = spyOn(Driver.prototype, 'executeScript'); desktopMetaData = new DesktopMetaData(new Driver('123', 'http:executorUrl'), { browserName: 'Chrome', + browserVersion: '111.12.32', version: '111.0', - platform: 'win' + platform: 'win', + osVersion: '10' }); }); @@ -22,6 +24,12 @@ describe('DesktopMetaData', () => { }); }); + describe('browserVersion', () => { + it('calculates browserVersion', () => { + expect(desktopMetaData.browserVersion()).toEqual('111'); + }); + }); + describe('osName', () => { it('calculates osName', () => { expect(desktopMetaData.osName()).toEqual('win'); @@ -30,27 +38,29 @@ describe('DesktopMetaData', () => { it('calculates alternate osName', () => { desktopMetaData = new DesktopMetaData(new Driver('123', 'http:executorUrl'), { browserName: 'Chrome', + browserVersion: '111.12.32', version: '111.0', + os: 'win', osVersion: '10' }); - expect(desktopMetaData.osName()).toEqual('10'); + expect(desktopMetaData.osName()).toEqual('win'); }); }); - describe('osVersin', () => { - it('calculates OsVersion', () => { - expect(desktopMetaData.osVersion()).toEqual('111'); + describe('osVersion', () => { + it('calculates osVersion', () => { + expect(desktopMetaData.osVersion()).toEqual('10'); }); }); describe('deviceName', () => { it('calculates deviceName', () => { - expect(desktopMetaData.deviceName()).toEqual('chrome_111_win'); + expect(desktopMetaData.deviceName()).toEqual('chrome_111_10_win'); }); }); describe('orientation', () => { - it('calculates browserName', () => { + it('calculates orientation', () => { expect(desktopMetaData.orientation()).toEqual('landscape'); }); }); diff --git a/packages/webdriver-utils/test/metadata/mobileMetaData.test.js b/packages/webdriver-utils/test/metadata/mobileMetaData.test.js index 38bb0f2f3..bcba86656 100644 --- a/packages/webdriver-utils/test/metadata/mobileMetaData.test.js +++ b/packages/webdriver-utils/test/metadata/mobileMetaData.test.js @@ -26,6 +26,25 @@ describe('MobileMetaData', () => { }); }); + describe('browserVersion', () => { + it('calculates browserVersion', () => { + expect(mobileMetaData.browserVersion()).toEqual('111'); + }); + + it('calculates alternate browserVersion', () => { + mobileMetaData = new MobileMetaData(new Driver('123', 'http:executorUrl'), { + osVersion: '12.0', + browserName: 'iphone', + os: 'mac', + browserVersion: '108.0', + orientation: 'landscape', + deviceName: 'SamsungS21-XYZ', + platform: 'win' + }); + expect(mobileMetaData.browserVersion()).toEqual('108'); + }); + }); + describe('osName', () => { it('calculates osName', () => { expect(mobileMetaData.osName()).toEqual('android'); @@ -45,7 +64,7 @@ describe('MobileMetaData', () => { }); }); - describe('osVersin', () => { + describe('osVersion', () => { it('calculates OsVersion', () => { expect(mobileMetaData.osVersion()).toEqual('12'); }); diff --git a/packages/webdriver-utils/test/providers/automateProvider.test.js b/packages/webdriver-utils/test/providers/automateProvider.test.js index 8dbb22e8b..94b8c41e3 100644 --- a/packages/webdriver-utils/test/providers/automateProvider.test.js +++ b/packages/webdriver-utils/test/providers/automateProvider.test.js @@ -1,7 +1,19 @@ import Driver from '../../src/driver.js'; +import GenericProvider from '../../src/providers/genericProvider.js'; import AutomateProvider from '../../src/providers/automateProvider.js'; +import Tile from '../../src/util/tile.js'; describe('AutomateProvider', () => { + let superScreenshotSpy; + + beforeEach(async () => { + superScreenshotSpy = spyOn(GenericProvider.prototype, 'screenshot'); + }); + + afterEach(() => { + superScreenshotSpy.calls.reset(); + }); + describe('browserstackExecutor', () => { let executeScriptSpy; @@ -66,4 +78,122 @@ describe('AutomateProvider', () => { expect(AutomateProvider.supports('http:outside')).toEqual(false); }); }); + + describe('screenshot', () => { + let percyScreenshotBeginSpy; + let percyScreenshotEndSpy; + const ignoreRegionOptions = { ignoreRegionXpaths: [], ignoreRegionSelectors: [], ignoreRegionElements: [], customIgnoreRegions: [] }; + const automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + + beforeEach(async () => { + percyScreenshotBeginSpy = spyOn(AutomateProvider.prototype, + 'percyScreenshotBegin').and.returnValue(true); + percyScreenshotEndSpy = spyOn(AutomateProvider.prototype, + 'percyScreenshotEnd').and.returnValue(true); + spyOn(Driver.prototype, 'getCapabilites'); + }); + + it('test call with default args', async () => { + await automateProvider.createDriver(); + superScreenshotSpy.and.resolveTo({ body: { link: 'link to screenshot' } }); + await automateProvider.screenshot('abc', { }); + + expect(percyScreenshotBeginSpy).toHaveBeenCalledWith('abc'); + expect(superScreenshotSpy).toHaveBeenCalledWith('abc', ignoreRegionOptions); + expect(percyScreenshotEndSpy).toHaveBeenCalledWith('abc', 'link to screenshot', 'undefined'); + }); + + it('passes exception message to percyScreenshotEnd in case of exception', async () => { + await automateProvider.createDriver(); + const errorMessage = 'Some error occured'; + superScreenshotSpy.and.rejectWith(new Error(errorMessage)); + percyScreenshotEndSpy.and.rejectWith(new Error(errorMessage)); + await expectAsync(automateProvider.screenshot('abc', ignoreRegionOptions)).toBeRejectedWithError(errorMessage); + expect(percyScreenshotBeginSpy).toHaveBeenCalledWith('abc'); + expect(percyScreenshotEndSpy).toHaveBeenCalledWith('abc', undefined, `Error: ${errorMessage}`); + }); + }); + + describe('percyScreenshotBegin', () => { + beforeEach(async () => { + spyOn(Driver.prototype, 'getCapabilites'); + }); + + it('supresses exception and does not throw', async () => { + const automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await automateProvider.createDriver(); + automateProvider.driver.executeScript = jasmine.createSpy().and.rejectWith(new Error('Random network error')); + await automateProvider.percyScreenshotBegin('abc'); + }); + + it('marks the percy session as success', async () => { + const automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await automateProvider.createDriver(); + automateProvider.driver.executeScript = jasmine.createSpy().and.returnValue(Promise.resolve({ success: true })); + await automateProvider.percyScreenshotBegin('abc'); + expect(automateProvider._markedPercy).toBeTruthy(); + }); + }); + + describe('percyScreenshotEnd', () => { + const automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + + beforeEach(async () => { + spyOn(Driver.prototype, 'getCapabilites'); + }); + + it('supresses exception and does not throw', async () => { + await automateProvider.createDriver(); + automateProvider.driver = jasmine.createSpy().and.rejectWith(new Error('Random network error')); + await automateProvider.percyScreenshotEnd('abc', 'url'); + }); + + it('marks status as failed if screenshot url is not present', async () => { + await automateProvider.createDriver(); + automateProvider.driver.executeScript = jasmine.createSpy().and.rejectWith(new Error('Random network error')); + await automateProvider.percyScreenshotEnd('abc'); + + expect(automateProvider.driver.executeScript).toHaveBeenCalledWith({ script: 'browserstack_executor: {"action":"percyScreenshot","arguments":{"name":"abc","status":"failure","statusMessage":null,"state":"end"}}', args: [] }); + }); + }); + + describe('getTiles', () => { + let browserstackExecutorSpy; + let executeScriptSpy; + const automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + + beforeEach(async () => { + spyOn(Driver.prototype, 'getCapabilites'); + browserstackExecutorSpy = spyOn(AutomateProvider.prototype, 'browserstackExecutor') + .and.returnValue(Promise.resolve({ value: '{ "result": "{\\"dom_sha\\": \\"abc\\", \\"sha\\": [\\"abc-1\\", \\"xyz-2\\"]}", "success":true }' })); + executeScriptSpy = spyOn(Driver.prototype, 'executeScript') + .and.returnValue(Promise.resolve(1)); + }); + + it('should return tiles when success', async () => { + await automateProvider.createDriver(); + const res = await automateProvider.getTiles(false); + expect(browserstackExecutorSpy).toHaveBeenCalledTimes(1); + expect(executeScriptSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(res).length).toEqual(2); + expect(res.domInfoSha).toBe('abc'); + expect(res.tiles.length).toEqual(2); + expect(res.tiles[0]).toBeInstanceOf(Tile); + expect(res.tiles[1]).toBeInstanceOf(Tile); + expect(res.tiles[0].sha).toEqual('abc'); + expect(res.tiles[1].sha).toEqual('xyz'); + }); + + it('throws error when response is false', async () => { + await automateProvider.createDriver(); + browserstackExecutorSpy.and.returnValue(Promise.resolve({ value: '{ "error": "Random Error", "success":false }' })); + await expectAsync(automateProvider.getTiles(false)).toBeRejectedWithError('Failed to get screenshots from Automate.' + + ' Check dashboard for error.'); + }); + + it('throws error when driver is null', async () => { + automateProvider.driver = null; + await expectAsync(automateProvider.getTiles(false)).toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); + }); + }); }); diff --git a/packages/webdriver-utils/test/providers/genericProvider.test.js b/packages/webdriver-utils/test/providers/genericProvider.test.js index c1485013a..5c3b8422e 100644 --- a/packages/webdriver-utils/test/providers/genericProvider.test.js +++ b/packages/webdriver-utils/test/providers/genericProvider.test.js @@ -22,7 +22,7 @@ describe('GenericProvider', () => { }); it('creates driver', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', {}, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', {}); await genericProvider.createDriver(); expect(genericProvider.driver).toEqual(expectedDriver); expect(capabilitiesSpy).toHaveBeenCalledTimes(1); @@ -36,14 +36,15 @@ describe('GenericProvider', () => { }); it('creates tiles from screenshot', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); genericProvider.createDriver(); const tiles = await genericProvider.getTiles(false); - expect(tiles.length).toEqual(1); + expect(tiles.tiles.length).toEqual(1); + expect(Object.keys(tiles)).toContain('domInfoSha'); }); it('throws error if driver not initailized', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); await expectAsync(genericProvider.getTiles(false)).toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); }); }); @@ -62,10 +63,12 @@ describe('GenericProvider', () => { .and.returnValue('mockOsVersion'); spyOn(DesktopMetaData.prototype, 'browserName') .and.returnValue('mockBrowserName'); + spyOn(DesktopMetaData.prototype, 'browserVersion') + .and.returnValue('111'); }); it('returns correct tag', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); await genericProvider.createDriver(); const tag = await genericProvider.getTag(); expect(tag).toEqual({ @@ -76,12 +79,12 @@ describe('GenericProvider', () => { height: 1000, orientation: 'landscape', browserName: 'mockBrowserName', - browserVersion: 'unknown' + browserVersion: '111' }); }); it('throws error if driver not initailized', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); await expectAsync(genericProvider.getTag()).toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); }); }); @@ -89,20 +92,26 @@ describe('GenericProvider', () => { describe('screenshot', () => { let getTagSpy; let getTilesSpy; + let addPercyCSSSpy; + let removePercyCSSSpy; beforeEach(() => { getTagSpy = spyOn(GenericProvider.prototype, 'getTag').and.returnValue(Promise.resolve('mock-tag')); - getTilesSpy = spyOn(GenericProvider.prototype, 'getTiles').and.returnValue(Promise.resolve('mock-tile')); + getTilesSpy = spyOn(GenericProvider.prototype, 'getTiles').and.returnValue(Promise.resolve({ tiles: 'mock-tile', domInfoSha: 'mock-dom-sha' })); + addPercyCSSSpy = spyOn(GenericProvider.prototype, 'addPercyCSS').and.returnValue(Promise.resolve(true)); + removePercyCSSSpy = spyOn(GenericProvider.prototype, 'removePercyCSS').and.returnValue(Promise.resolve(true)); spyOn(DesktopMetaData.prototype, 'windowSize') .and.returnValue(Promise.resolve({ width: 1920, height: 1080 })); }); it('calls correct funcs', async () => { - genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); await genericProvider.createDriver(); let res = await genericProvider.screenshot('mock-name', {}); + expect(addPercyCSSSpy).toHaveBeenCalledTimes(1); expect(getTagSpy).toHaveBeenCalledTimes(1); expect(getTilesSpy).toHaveBeenCalledOnceWith(false); + expect(removePercyCSSSpy).toHaveBeenCalledTimes(1); expect(res).toEqual({ name: 'mock-name', tag: 'mock-tag', @@ -110,11 +119,47 @@ describe('GenericProvider', () => { externalDebugUrl: 'https://localhost/v1', environmentInfo: 'staging-poc-poa', ignoredElementsData: { ignoreElementsData: [] }, - clientInfo: 'local-poc-poa' + clientInfo: 'local-poc-poa', + domInfoSha: 'mock-dom-sha' }); }); }); + describe('addPercyCSS', () => { + beforeEach(() => { + spyOn(Driver.prototype, 'executeScript').and.returnValue(Promise.resolve(true)); + }); + + it('should call executeScript to add style', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); + await genericProvider.createDriver(); + const percyCSS = 'h1{color:green !important;}'; + await genericProvider.addPercyCSS(percyCSS); + const expectedArgs = `const e = document.createElement('style'); + e.setAttribute('data-percy-specific-css', true); + e.innerHTML = '${percyCSS}'; + document.body.appendChild(e);`; + expect(genericProvider.driver.executeScript).toHaveBeenCalledTimes(1); + expect(genericProvider.driver.executeScript).toHaveBeenCalledWith({ script: expectedArgs, args: [] }); + }); + }); + + describe('removePercyCSS', () => { + beforeEach(() => { + spyOn(Driver.prototype, 'executeScript').and.returnValue(Promise.resolve(true)); + }); + + it('should call executeScript to add style', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}, 'local-poc-poa', 'staging-poc-poa', {}); + await genericProvider.createDriver(); + await genericProvider.removePercyCSS(); + const expectedArgs = `const n = document.querySelector('[data-percy-specific-css]'); + n.remove();`; + expect(genericProvider.driver.executeScript).toHaveBeenCalledTimes(1); + expect(genericProvider.driver.executeScript).toHaveBeenCalledWith({ script: expectedArgs, args: [] }); + }); + }); + describe('ignoreElementObject', () => { let provider; let mockLocation = { x: 10, y: 20, width: 100, height: 200 };