diff --git a/cache.js b/cache.js new file mode 100644 index 0000000..e76ab50 --- /dev/null +++ b/cache.js @@ -0,0 +1,65 @@ +const { Undefined } = require('./validations'); + +class Cache { + static cache = {}; + + static capabilities = 'capabilities'; + static sessionCapabilities = 'session_capabilites'; + static commandExecutorUrl = 'command_executor_url'; + + static lastTime = Date.now(); + static timeout = 5 * 60 * 1000; + + static async withCache(store, key, func, cacheExceptions = false) { + this.maintain(); + if (Undefined(this.cache[store])) this.cache[store] = {}; + + store = this.cache[store]; + if (store[key]) { + if (store[key].success) { + return store[key].val; + } else { + throw store[key].val; + } + } + + const obj = { success: false, val: null, time: Date.now() }; + try { + obj.val = await func(); + obj.success = true; + } catch (e) { + obj.val = e; + } + + // We seem to have correct coverage for both flows but nyc is marking it as missing + // branch coverage anyway + /* istanbul ignore next */ + if (obj.success || cacheExceptions) { + store[key] = obj; + } + + if (!obj.success) throw obj.val; + return obj.val; + } + + static maintain() { + if (this.lastTime + this.timeout > Date.now()) return; + + for (const [, store] of Object.entries(this.cache)) { + for (const [key, item] of Object.entries(store)) { + if (item.time + this.timeout < Date.now()) { + delete store[key]; + } + } + } + this.lastTime = Date.now(); + } + + static reset() { + this.cache = {}; + } +} + +module.exports = { + Cache +}; diff --git a/driverMetadata.js b/driverMetadata.js new file mode 100644 index 0000000..eb1bed0 --- /dev/null +++ b/driverMetadata.js @@ -0,0 +1,61 @@ +// const utils = require('@percy/sdk-utils'); +const { Cache } = require('./cache'); +const { RequestInterceptor } = require('node-request-interceptor'); +const withDefaultInterceptors = require('node-request-interceptor/lib/presets/default'); + +class DriverMetadata { + constructor(driver) { + this.driver = driver; + this.sessionId = null; + if (this.driver.constructor.name === 'Browser') { + this.type = 'wdio'; + } else { + this.type = 'wd'; + } + } + + async getSessionId() { + if (!this.sessionId) { + if (this.type === 'wdio') this.sessionId = await this.driver.sessionId; + if (this.type === 'wd') this.sessionId = await (await this.driver.getSession()).getId(); + } + return this.sessionId; + } + + async getCapabilities() { + return await Cache.withCache(Cache.capabilities, await this.getSessionId(), async () => { + if (this.type === 'wdio') { + return await this.driver.capabilities; + } else { + const session = await this.driver.getSession(); + const capabilities = Object.fromEntries(session.getCapabilities().map_); + return capabilities; + } + }); + } + + async getCommandExecutorUrl() { + return await Cache.withCache(Cache.commandExecutorUrl, await this.getSessionId(), async () => { + if (this.type === 'wdio') { + return `${this.driver.options.protocol}://${this.driver.options.hostname}${this.driver.options.path}`; + } else { + // To intercept request from driver. used to get remote server url + const interceptor = new RequestInterceptor(withDefaultInterceptors.default); + let commandExecutorUrl = ''; + interceptor.use((req) => { + const url = req.url.href; + commandExecutorUrl = url.split('/session')[0]; + }); + // making a call so we can intercept commandExecutorUrl + await this.driver.getCurrentUrl(); + // To stop intercepting request + interceptor.restore(); + return commandExecutorUrl; + } + }); + } +} + +module.exports = { + DriverMetadata +}; diff --git a/index.js b/index.js index eb30385..2e73b7d 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,7 @@ const seleniumPkg = require('selenium-webdriver/package.json'); const CLIENT_INFO = `${sdkPkg.name}/${sdkPkg.version}`; const ENV_INFO = `${seleniumPkg.name}/${seleniumPkg.version}`; const utils = require('@percy/sdk-utils'); -const { RequestInterceptor } = require('node-request-interceptor'); -const withDefaultInterceptors = require('node-request-interceptor/lib/presets/default'); +const { DriverMetadata } = require('./driverMetadata'); // Take a DOM snapshot and post it to the snapshot endpoint module.exports = async function percySnapshot(driver, name, options) { @@ -74,28 +73,7 @@ module.exports.percyScreenshot = async function percyScreenshot(driver, name, op } try { - let sessionId, capabilities, commandExecutorUrl; - if (driver.constructor.name === 'Browser') { // Logic for wdio - sessionId = driver.sessionId; - capabilities = driver.capabilities; - commandExecutorUrl = `${driver.options.protocol}://${driver.options.hostname}${driver.options.path}`; - } else { // Logic for selenium-webdriver - const session = await driver.getSession(); - sessionId = session.getId(); - capabilities = Object.fromEntries(session.getCapabilities().map_); - - // To intercept request from driver. used to get remote server url - const interceptor = new RequestInterceptor(withDefaultInterceptors.default); - interceptor.use((req) => { - const url = req.url.href; - commandExecutorUrl = url.split('/session')[0]; - }); - // making a call so we can intercept commandExecutorUrl - await driver.getCurrentUrl(); - // To stop intercepting request - interceptor.restore(); - } - + const driverData = new DriverMetadata(driver); if (options) { if ('ignoreRegionSeleniumElements' in options) { options.ignore_region_selenium_elements = options.ignoreRegionSeleniumElements; @@ -117,9 +95,9 @@ module.exports.percyScreenshot = async function percyScreenshot(driver, name, op await module.exports.request({ environmentInfo: ENV_INFO, clientInfo: CLIENT_INFO, - sessionId: sessionId, - commandExecutorUrl: commandExecutorUrl, - capabilities: capabilities, + sessionId: await driverData.getSessionId(), + commandExecutorUrl: await driverData.getCommandExecutorUrl(), + capabilities: await driverData.getCapabilities(), snapshotName: name, options: options }); diff --git a/package.json b/package.json index 262c734..aae08c4 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,12 @@ "@percy/cli": "^1.27.1", "@types/selenium-webdriver": "^4.0.9", "cross-env": "^7.0.2", - "eslint": "^7.11.0", - "eslint-config-standard": "^16.0.1", + "eslint": "^8.27.0", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-n": "^15.5.1", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^5.1.0", + "eslint-plugin-promise": "^6.1.1", "eslint-plugin-standard": "^5.0.0", "geckodriver": "^3.0.2", "jasmine": "^4.4.0", diff --git a/test/cache.test.mjs b/test/cache.test.mjs new file mode 100644 index 0000000..01adc3a --- /dev/null +++ b/test/cache.test.mjs @@ -0,0 +1,157 @@ +import { Cache } from '../cache.js' + +describe('Cache', () => { + const store = 'abc'; + const key = 'key'; + + beforeEach(async () => { + Cache.reset(); + }); + + describe('withCache', () => { + it('caches response', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + }); + + describe('with different key but same store', () => { + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const key2 = 'key2'; + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key2, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // test both cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key2, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + }); + }); + + describe('with different store but same key', () => { + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const store2 = 'store2'; + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store2, key, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // test both cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store2, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + }); + }); + + describe('with cacheExceptions', () => { + it('caches exceptions', async () => { + const expectedError = new Error('Some error'); + const func = jasmine.createSpy('func').and.throwError(expectedError); + + let actualError = null; + try { + await Cache.withCache(store, key, func, true); + } catch (e) { + actualError = e; + } + + expect(func.calls.count()).toEqual(1); + expect(actualError).toEqual(expectedError); + + try { + await Cache.withCache(store, key, func, true); + } catch (e) { + actualError = e; + } + + expect(func.calls.count()).toEqual(1); + expect(actualError).toEqual(expectedError); + }); + }); + + describe('with expired cache', () => { + const originalCacheTimeout = Cache.timeout; + beforeAll(() => { + Cache.timeout = 7; // 7ms + }); + + afterAll(() => { + Cache.timeout = originalCacheTimeout; + }); + + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + // wait for expiry + await new Promise((resolve) => setTimeout(resolve, 10)); + + // create a test entry that should not get deleted + Cache.cache.random_store = {}; + Cache.cache.random_store.some_new_key = { val: 1, time: Date.now(), success: true }; + + // test expired cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // Not deleted + expect(Cache.cache.random_store.some_new_key).toBeTruthy(); + }); + + it('it invalidates all expired keys on any call', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const key2 = 'key2'; + const store2 = 'store2'; + + await Cache.withCache(store, key, func); + await Cache.withCache(store, key2, func); + await Cache.withCache(store2, key, func); + + // wait for expiry + await new Promise((resolve) => setTimeout(resolve, 10)); + + // test expired cache + await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(4); + + // check internal to avoid calling via withCache + expect(Cache.cache[store2][key]).toBeUndefined(); + expect(Cache.cache[store2][key2]).toBeUndefined(); + }); + }); + }); +}); diff --git a/test/driverMetadata.test.mjs b/test/driverMetadata.test.mjs new file mode 100644 index 0000000..413cd6b --- /dev/null +++ b/test/driverMetadata.test.mjs @@ -0,0 +1,52 @@ +import { DriverMetadata } from '../driverMetadata.js'; +import { Cache } from '../cache.js'; + +describe('DriverMetadata', () => { + + class Browser { // Mocking WDIO driver + constructor() { + this.sessionId = '123'; + this.capabilities = { browserName: 'chrome' }; + this.options = { protocol: 'https', path: '/wd/hub', hostname: 'hub-cloud.browserstack.com' }; + } + } + let driver; + + beforeAll(async function() { + driver = { + getSession: () => { + return new Promise((resolve, _) => resolve({ + getId: () => { return '123'; }, + getCapabilities: () => { return { map_: new Map([['browserName', 'chrome'], ['platform', 'WINDOWS'], ['version', '123']]) }; } + })); + }, + getCurrentUrl: () => { return new Promise((resolve, _) => resolve(helpers.mockGetCurrentUrl())); } + }; + }); + + beforeEach(async () => { + Cache.reset(); + }); + + + describe('getSessionId', () => { + it ('returns the sessionId', async () => { + const driverMetadata = new DriverMetadata(driver); + await expectAsync(driverMetadata.getSessionId()).toBeResolvedTo('123'); + }); + }); + + describe('getCapabilities', () => { + it('returns the capabilities', async () => { + const driverMetadata = new DriverMetadata(driver); + await expectAsync(driverMetadata.getCapabilities()).toBeResolvedTo({ browserName: 'chrome', platform: 'WINDOWS', version: '123' }); + }); + }); + + describe('getCommandExecutorUrl', () => { + it('return the command executor url', async () => { + const driverMetadata = new DriverMetadata(new Browser()); + await expectAsync(driverMetadata.getCommandExecutorUrl()).toBeResolvedTo('https://hub-cloud.browserstack.com/wd/hub'); + }); + }); +}); diff --git a/test/index.test.mjs b/test/index.test.mjs index ea8f496..17ecf63 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -2,6 +2,7 @@ import webdriver from 'selenium-webdriver'; import helpers from '@percy/sdk-utils/test/helpers'; import percySnapshot from '../index.js'; import utils from '@percy/sdk-utils'; +import { Cache } from '../cache.js'; const { percyScreenshot } = percySnapshot; describe('percySnapshot', () => { @@ -104,6 +105,7 @@ describe('percyScreenshot', () => { await helpers.setupTest(); spyOn(percySnapshot, 'isPercyEnabled').and.returnValue(Promise.resolve(true)); utils.percy.type = 'automate'; + Cache.reset(); }); it('throws an error when a driver is not provided', async () => { diff --git a/validations.js b/validations.js new file mode 100644 index 0000000..18ca203 --- /dev/null +++ b/validations.js @@ -0,0 +1,7 @@ +function Undefined(obj) { + return obj === undefined; +} + +module.exports = { + Undefined +};