diff --git a/.circleci/config.yml b/.circleci/config.yml index 95c7e5a5..e890eb42 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -43,9 +43,6 @@ jobs: - run: name: Upload command test command: yarn test-upload-command - - run: - name: Setup integration test .percy.yml - command: mv .ci.percy.yml .percy.yml - run: name: Integration tests command: $NYC yarn test-integration diff --git a/package.json b/package.json index b7eea5c7..e2044021 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "body-parser": "^1.18.3", "colors": "^1.3.2", "cors": "^2.8.4", + "cosmiconfig": "^5.2.1", "cross-spawn": "^6.0.5", "deepmerge": "^4.0.0", "express": "^4.16.3", @@ -34,7 +35,7 @@ "globby": "^10.0.1", "image-size": "^0.8.2", "js-yaml": "^3.13.1", - "percy-client": "^3.0.3", + "percy-client": "^3.1.0", "puppeteer": "^1.13.0", "retry-axios": "^1.0.1", "winston": "^3.0.0" @@ -144,7 +145,7 @@ "preversion": "yarn clean", "test": "yarn build-client && PERCY_TOKEN=abc mocha --forbid-only \"test/**/*.test.ts\" --exclude \"test/percy-agent-client/**/*.test.ts\" --exclude \"test/integration/**/*\"", "test-client": "karma start ./test/percy-agent-client/karma.conf.js", - "test-integration": "yarn build-client && node ./bin/run exec -h *.localtest.me -- mocha test/integration/**/*.test.ts", + "test-integration": "yarn build-client && node ./bin/run exec -h *.localtest.me -c .ci.percy.yml -- mocha test/integration/**/*.test.ts", "test-snapshot-command": "./bin/run snapshot test/integration/test-static-site -b /dummy-base-url -i '(red-keep)' -s '\\.(html)$'", "test-upload-command": "./bin/run upload test/integration/test-static-images", "version": "oclif-dev readme && git add README.md", diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 93396eed..aeca454f 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -1,7 +1,7 @@ import { flags } from '@oclif/command' import * as spawn from 'cross-spawn' import { DEFAULT_CONFIGURATION } from '../configuration/configuration' -import ConfigurationService from '../services/configuration-service' +import config from '../utils/configuration' import PercyCommand from './percy-command' export default class Exec extends PercyCommand { @@ -30,6 +30,10 @@ export default class Exec extends PercyCommand { default: DEFAULT_CONFIGURATION.agent.port, description: 'port', }), + 'config': flags.string({ + char: 'c', + description: 'Path to percy config file', + }), } async run() { @@ -46,7 +50,7 @@ export default class Exec extends PercyCommand { } if (this.percyWillRun()) { - await this.start(flags) + await this.start(config(flags)) } // Even if Percy will not run, continue to run the subprocess diff --git a/src/commands/percy-command.ts b/src/commands/percy-command.ts index 5cf9479d..2dc8e8e0 100644 --- a/src/commands/percy-command.ts +++ b/src/commands/percy-command.ts @@ -1,7 +1,7 @@ import { Command } from '@oclif/command' import * as winston from 'winston' +import { Configuration } from '../configuration/configuration' import { AgentService } from '../services/agent-service' -import ConfigurationService from '../services/configuration-service' import ProcessService from '../services/process-service' import logger from '../utils/logger' @@ -47,9 +47,8 @@ export default class PercyCommand extends Command { this.logger.info('percy has started.') } - async start(flags: any) { + async start(configuration: Configuration) { if (this.percyWillRun()) { - const configuration = new ConfigurationService().applyFlags(flags) await this.agentService.start(configuration) this.logStart() diff --git a/src/commands/snapshot.ts b/src/commands/snapshot.ts index b50dcc12..d9c801cc 100644 --- a/src/commands/snapshot.ts +++ b/src/commands/snapshot.ts @@ -1,8 +1,8 @@ import { flags } from '@oclif/command' import { existsSync } from 'fs' import { DEFAULT_CONFIGURATION } from '../configuration/configuration' -import ConfigurationService from '../services/configuration-service' import StaticSnapshotService from '../services/static-snapshot-service' +import config from '../utils/configuration' import logger from '../utils/logger' import PercyCommand from './percy-command' @@ -55,17 +55,17 @@ export default class Snapshot extends PercyCommand { default: DEFAULT_CONFIGURATION.agent.port, description: 'Port', }), + 'config': flags.string({ + char: 'c', + description: 'Path to percy config file', + }), } async run() { await super.run() const { args, flags } = this.parse(Snapshot) - - const configurationService = new ConfigurationService() - configurationService.applyFlags(flags) - configurationService.applyArgs(args) - const configuration = configurationService.configuration + const configuration = config(flags, args) // exit gracefully if percy will not run if (!this.percyWillRun()) { this.exit(0) } @@ -85,7 +85,7 @@ export default class Snapshot extends PercyCommand { } // start agent service and attach process handlers - await this.start(flags) + await this.start(configuration) const staticSnapshotService = new StaticSnapshotService(configuration['static-snapshots']) diff --git a/src/commands/start.ts b/src/commands/start.ts index 9d59eeb7..dd596e1f 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -1,7 +1,7 @@ -import {flags} from '@oclif/command' +import { flags } from '@oclif/command' import * as path from 'path' import { DEFAULT_CONFIGURATION } from '../configuration/configuration' -import ConfigurationService from '../services/configuration-service' +import config from '../utils/configuration' import healthCheck from '../utils/health-checker' import PercyCommand from './percy-command' @@ -34,6 +34,10 @@ export default class Start extends PercyCommand { default: DEFAULT_CONFIGURATION.agent.port, description: 'port', }), + 'config': flags.string({ + char: 'c', + description: 'Path to percy config file', + }), } async run() { @@ -47,7 +51,7 @@ export default class Start extends PercyCommand { if (flags.detached) { this.runDetached(flags) } else { - await this.start(flags) + await this.start(config(flags)) } await healthCheck(flags.port!) diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 68652cf1..91c09bff 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -1,9 +1,9 @@ -import {flags} from '@oclif/command' +import { flags } from '@oclif/command' import Axios from 'axios' -import {DEFAULT_CONFIGURATION} from '../configuration/configuration' -import {STOP_PATH} from '../services/agent-service-constants' -import ConfigurationService from '../services/configuration-service' -import {logError} from '../utils/logger' +import { DEFAULT_CONFIGURATION } from '../configuration/configuration' +import { STOP_PATH } from '../services/agent-service-constants' +import config from '../utils/configuration' +import { logError } from '../utils/logger' import PercyCommand from './percy-command' export default class Stop extends PercyCommand { @@ -29,18 +29,18 @@ export default class Stop extends PercyCommand { // If Percy is disabled or is missing a token, gracefully exit here if (!this.percyWillRun()) { this.exit(0) } - const {flags} = this.parse(Stop) - const configuration = new ConfigurationService().applyFlags(flags).agent + const { flags } = this.parse(Stop) + const configuration = config(flags) if (this.processService.isRunning()) { - await this.postToRunningAgent(STOP_PATH, configuration.port) + await this.postToRunningAgent(STOP_PATH, configuration.agent.port) } else { this.logger.warn('percy is already stopped.') } } private async postToRunningAgent(path: string, port: number) { - await Axios(`http://localhost:${port}${path}`, {method: 'POST'}) + await Axios(`http://localhost:${port}${path}`, { method: 'POST' }) .catch((error: any) => { if (error.message === 'socket hang up') { // We expect a hangup this.logger.info('percy stopped.') diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 8201b089..f3483ff1 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -1,7 +1,7 @@ import { Command, flags } from '@oclif/command' import { DEFAULT_CONFIGURATION } from '../configuration/configuration' -import ConfigurationService from '../services/configuration-service' import ImageSnapshotService from '../services/image-snapshot-service' +import config from '../utils/configuration' export default class Upload extends Command { static description = 'Upload a directory containing static snapshot images.' @@ -29,6 +29,10 @@ export default class Upload extends Command { description: 'Glob or comma-seperated string of globs for matching the files and directories to ignore.', default: DEFAULT_CONFIGURATION['image-snapshots'].ignore, }), + config: flags.string({ + char: 'c', + description: 'Path to percy config file', + }), } percyToken: string = process.env.PERCY_TOKEN || '' @@ -45,11 +49,7 @@ export default class Upload extends Command { } const { args, flags } = this.parse(Upload) - - const configurationService = new ConfigurationService() - configurationService.applyFlags(flags) - configurationService.applyArgs(args) - const configuration = configurationService.configuration + const configuration = config(flags, args) // upload snapshot images const imageSnapshotService = new ImageSnapshotService(configuration['image-snapshots']) diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 6c56d146..15651ec3 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -5,11 +5,11 @@ import { Server } from 'http' import * as os from 'os' import * as path from 'path' import { Configuration } from '../configuration/configuration' +import { SnapshotConfiguration } from '../configuration/snapshot-configuration' import { SnapshotOptions } from '../percy-agent-client/snapshot-options' import logger, { createFileLogger, profile } from '../utils/logger' import { HEALTHCHECK_PATH, SNAPSHOT_PATH, STOP_PATH } from './agent-service-constants' import BuildService from './build-service' -import ConfigurationService from './configuration-service' import Constants from './constants' import ProcessService from './process-service' import SnapshotService from './snapshot-service' @@ -21,6 +21,7 @@ export class AgentService { private readonly app: express.Application private readonly publicDirectory: string = `${__dirname}/../../dist/public` private snapshotCreationPromises: any[] = [] + private snapshotConfig: SnapshotConfiguration | any = {} private server: Server | null = null private buildId: number | null = null @@ -28,8 +29,8 @@ export class AgentService { this.app = express() this.app.use(cors()) - this.app.use(bodyParser.urlencoded({extended: true})) - this.app.use(bodyParser.json({limit: '50mb'})) + this.app.use(bodyParser.urlencoded({ extended: true })) + this.app.use(bodyParser.json({ limit: '50mb' })) this.app.use(express.static(this.publicDirectory)) this.app.post(SNAPSHOT_PATH, this.handleSnapshot.bind(this)) @@ -40,6 +41,7 @@ export class AgentService { } async start(configuration: Configuration) { + this.snapshotConfig = configuration.snapshot this.buildId = await this.buildService.create() if (this.buildId !== null) { @@ -91,19 +93,18 @@ export class AgentService { snapshotLogger.debug(`-> environmentInfo: ${request.body.environmentInfo}`) snapshotLogger.debug(`-> domSnapshot: ${domSnapshotLog}`) - if (!this.snapshotService) { return response.json({success: false}) } + if (!this.snapshotService) { return response.json({ success: false }) } - const configuration = new ConfigurationService().configuration // trim the string of whitespace and concat per-snapshot CSS with the globally specified CSS - const percySpecificCSS = configuration.snapshot['percy-css'].concat(request.body.percyCSS || '').trim() + const percySpecificCSS = this.snapshotConfig['percy-css'].concat(request.body.percyCSS || '').trim() const hasWidths = !!request.body.widths && request.body.widths.length const snapshotOptions: SnapshotOptions = { percyCSS: percySpecificCSS, - widths: hasWidths ? request.body.widths : configuration.snapshot.widths, + widths: hasWidths ? request.body.widths : this.snapshotConfig.widths, enableJavaScript: request.body.enableJavaScript != null ? request.body.enableJavaScript - : configuration.snapshot['enable-javascript'], - minHeight: request.body.minHeight || configuration.snapshot['min-height'], + : this.snapshotConfig['enable-javascript'], + minHeight: request.body.minHeight || this.snapshotConfig['min-height'], } let domSnapshot = request.body.domSnapshot @@ -149,16 +150,16 @@ export class AgentService { logger.info(`snapshot taken: '${request.body.name}'`) profile('agentService.handleSnapshot') - return response.json({success: true}) + return response.json({ success: true }) } private async handleStop(_: express.Request, response: express.Response) { await this.stop() new ProcessService().kill() - return response.json({success: true}) + return response.json({ success: true }) } private async handleHealthCheck(_: express.Request, response: express.Response) { - return response.json({success: true}) + return response.json({ success: true }) } } diff --git a/src/services/configuration-service.ts b/src/services/configuration-service.ts deleted file mode 100644 index 8377a4c9..00000000 --- a/src/services/configuration-service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import * as deepmerge from 'deepmerge' -import * as fs from 'fs' -import * as yaml from 'js-yaml' -import * as path from 'path' -import logger from '../utils/logger' -import { Configuration, DEFAULT_CONFIGURATION } from './../configuration/configuration' - -export default class ConfigurationService { - static DEFAULT_FILE = '.percy.yml' - - configuration: Configuration - - constructor(configurationFile: string = ConfigurationService.DEFAULT_FILE) { - // We start with the default configuration - this.configuration = DEFAULT_CONFIGURATION - - // Next we merge in configuration from .percy.yml if we have it - this.applyFile(configurationFile) - } - - applyFile(configurationFile: string): Configuration { - try { - const userConfigFilePath = path.join(process.cwd(), configurationFile) - const userConf = yaml.safeLoad(fs.readFileSync(userConfigFilePath, 'utf8')) - logger.debug(`Current config file path: ${userConfigFilePath}`) - - // apply a deep overwrite merge to userConf and this.configuration - const overwriteMerge = (destinationArray: any, sourceArray: any, options: any) => sourceArray - this.configuration = deepmerge(this.configuration, userConf, { arrayMerge: overwriteMerge }) - } catch (error) { - logger.debug(`.percy.yml configuration file not supplied or failed to be loaded and parsed: ${error}`) - } - - return this.configuration - } - - applyFlags(flags: any): Configuration { - if (flags.port) { - this.configuration.agent.port = flags.port - } - - if (flags['allowed-hostname']) { - this.configuration.agent['asset-discovery']['allowed-hostnames'] = - flags['allowed-hostname'].concat(this.configuration.agent['asset-discovery']['allowed-hostnames']) - } - - if (flags['network-idle-timeout']) { - this.configuration.agent['asset-discovery']['network-idle-timeout'] = flags['network-idle-timeout'] - } - - if (flags['base-url']) { - this.configuration['static-snapshots']['base-url'] = flags['base-url'] - } - - if (flags['snapshot-files']) { - this.configuration['static-snapshots']['snapshot-files'] = flags['snapshot-files'] - } - - if (flags['ignore-files']) { - this.configuration['static-snapshots']['ignore-files'] = flags['ignore-files'] - } - - if (flags.files) { - this.configuration['image-snapshots'].files = flags.files - } - - if (flags.ignore) { - this.configuration['image-snapshots'].ignore = flags.ignore - } - - return this.configuration - } - - applyArgs(args: any): Configuration { - if (args.snapshotDirectory) { - this.configuration['static-snapshots'].path = args.snapshotDirectory - } - - if (args.uploadDirectory) { - this.configuration['image-snapshots'].path = args.uploadDirectory - } - - return this.configuration - } -} diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts new file mode 100644 index 00000000..06f44b41 --- /dev/null +++ b/src/utils/configuration.ts @@ -0,0 +1,85 @@ +// @ts-ignore missing type defs +import * as cosmiconfig from 'cosmiconfig' +import * as merge from 'deepmerge' +import { inspect } from 'util' +import { Configuration, DEFAULT_CONFIGURATION } from '../configuration/configuration' +import logger from './logger' + +const { isArray } = Array +const { assign, keys } = Object +const explorer = cosmiconfig('percy', { + searchPlaces: [ + 'package.json', + '.percyrc', + '.percy.json', + '.percy.yaml', + '.percy.yml', + '.percy.js', + 'percy.config.js', + ], +}) + +function removeUndefined(obj: any): any { + if (isArray(obj)) { return obj } + + return keys(obj).reduce((o: any, key) => { + const val = typeof obj[key] === 'object' + ? removeUndefined(obj[key]) + : obj[key] + + return val !== undefined + ? assign(o || {}, { [key]: val }) + : o + }, undefined) +} + +function transform(flags: any, args: any) { + return removeUndefined({ + 'agent': { + 'port': flags.port, + 'asset-discovery': { + 'allowed-hostnames': flags['allowed-hostname'], + 'network-idle-timeout': flags['network-idle-timeout'], + }, + }, + 'static-snapshots': { + 'path': args.snapshotDirectory, + 'base-url': flags['base-url'], + 'snapshot-files': flags['snapshot-files'], + 'ignore-files': flags['ignore-files'], + }, + 'image-snapshots': { + path: args.uploadDirectory, + files: flags.files, + ignore: flags.ignore, + }, + }) +} + +export default function config({ config, ...flags }: any, args: any = {}) { + let loaded + + try { + const result = config + ? explorer.loadSync(config) + : explorer.searchSync() + + if (result && result.config) { + logger.debug(`Current config file path: ${result.filepath}`) + loaded = result.config + } else { + logger.debug('Config file not found') + } + } catch (error) { + logger.debug(`Failed to load or parse config file: ${error}`) + } + + const provided = transform(flags, args) + const overrides = loaded && provided ? merge(loaded, provided) : (loaded || provided) + + if (overrides) { + logger.debug(`Using config: ${inspect(overrides, { depth: null })}`) + } + + return merge.all([DEFAULT_CONFIGURATION, overrides].filter(Boolean)) as Configuration +} diff --git a/test/commands/snapshot.test.ts b/test/commands/snapshot.test.ts index f936f5b2..e7eb5507 100644 --- a/test/commands/snapshot.test.ts +++ b/test/commands/snapshot.test.ts @@ -39,7 +39,7 @@ describe('snapshot', () => { const staticSnapshotServiceStub = StaticSnapshotServiceStub() const stdout = await captureStdOut(async () => { - await Snapshot.run(['./']) + await Snapshot.run(['.']) }) chai.expect(agentServiceStub.start).to.be.calledWith(DEFAULT_CONFIGURATION) diff --git a/test/services/configuration-service.test.ts b/test/services/configuration-service.test.ts deleted file mode 100644 index 7f9c04ee..00000000 --- a/test/services/configuration-service.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { expect } from 'chai' -import { DEFAULT_CONFIGURATION } from '../../src/configuration/configuration' -import ConfigurationService from '../../src/services/configuration-service' - -describe('ConfigurationService', () => { - describe('#configuration', () => { - it('returns default configuration by default', () => { - const subject = new ConfigurationService().configuration - expect(subject).to.eql(DEFAULT_CONFIGURATION) - }) - }) - - describe('#applyFile', () => { - it('parses valid configuration', () => { - const subject = new ConfigurationService().applyFile('test/support/.percy.yml') - - expect(subject.version).to.eql(1) - expect(subject.snapshot.widths).to.eql([375, 1280]) - expect(subject.snapshot['min-height']).to.eql(1024) - expect(subject.snapshot['enable-javascript']).to.eql(true) - expect(subject.snapshot['percy-css']).to.eql('iframe {\n display: none;\n}\n') - expect(subject['static-snapshots'].path).to.eql('_site/') - expect(subject['static-snapshots'].port).to.eql(9999) - expect(subject['static-snapshots']['base-url']).to.eql('/blog/') - expect(subject['static-snapshots']['snapshot-files']).to.eql('**/*.html') - expect(subject['static-snapshots']['ignore-files']).to.eql('**/*.htm') - expect(subject.agent.port).to.eql(1111) - expect(subject.agent['asset-discovery']['allowed-hostnames']).to.eql(['localassets.dev']) - expect(subject.agent['asset-discovery']['network-idle-timeout']).to.eql(50) - expect(subject.agent['asset-discovery']['page-pool-size-min']).to.eql(5) - expect(subject.agent['asset-discovery']['page-pool-size-max']).to.eql(20) - }) - - it('gracefully falls back to default configuration when file does not exist', () => { - const subject = new ConfigurationService().applyFile('test/support/.file-does-not-exist.yml') - expect(subject).to.eql(DEFAULT_CONFIGURATION) - }) - }) - - describe('#applyFlags', () => { - it('applies flags with a .percy.yml', () => { - const flags = { - 'network-idle-timeout': 51, - 'base-url': '/flag/', - 'snapshot-files': 'flags/*.html', - 'ignore-files': 'ignore-flags/*.html', - 'allowed-hostname': ['additional-hostname.local'], - } - const subject = new ConfigurationService('test/support/.percy.yml').applyFlags(flags) - - expect(subject['static-snapshots']['base-url']).to.eql('/flag/') - expect(subject['static-snapshots']['snapshot-files']).to.eql('flags/*.html') - expect(subject['static-snapshots']['ignore-files']).to.eql('ignore-flags/*.html') - expect(subject.agent['asset-discovery']['network-idle-timeout']).to.eql(51) - expect(subject.agent['asset-discovery']['allowed-hostnames'][1]).to.eql('localassets.dev') - expect(subject.agent['asset-discovery']['allowed-hostnames'][0]).to.eql('additional-hostname.local') - }) - - it('applies flags without a .percy.yml', () => { - const flags = { - 'network-idle-timeout': 51, - 'base-url': '/flag/', - 'snapshot-files': 'flags/*.html', - 'ignore-files': 'ignore-flags/*.html', - 'allowed-hostname': ['additional-hostname.local'], - } - const subject = new ConfigurationService('test/support/doesnt-exist/.percy.yml').applyFlags(flags) - - expect(subject['static-snapshots']['base-url']).to.eql('/flag/') - expect(subject['static-snapshots']['snapshot-files']).to.eql('flags/*.html') - expect(subject['static-snapshots']['ignore-files']).to.eql('ignore-flags/*.html') - expect(subject.agent['asset-discovery']['network-idle-timeout']).to.eql(51) - expect(subject.agent['asset-discovery']['allowed-hostnames'][0]).to.eql('additional-hostname.local') - }) - }) - - describe('#applyArgs', () => { - it('applies args', () => { - const args = { - snapshotDirectory: '/from/arg', - } - const subject = new ConfigurationService('test/support/.percy.yml').applyArgs(args) - - expect(subject['static-snapshots'].path).to.eql('/from/arg') - }) - }) -}) diff --git a/yarn.lock b/yarn.lock index 746eda01..989c0d06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3527,6 +3527,11 @@ dotenv@^5.0.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef" integrity sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow== +dotenv@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.1.0.tgz#d811e178652bfb8a1e593c6dd704ec7e90d85ea2" + integrity sha512-GUE3gqcDCaMltj2++g6bRQ5rBJWtkWTmqmD0fo1RnnMuUqHNCt2oTPeDnS9n6fKYvlhn7AeBkb38lymBtWBQdA== + duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -7793,14 +7798,15 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= -percy-client@^3.0.3: - version "3.0.13" - resolved "https://registry.yarnpkg.com/percy-client/-/percy-client-3.0.13.tgz#86f48ac39bca8474a02917cea1756978d0214b83" - integrity sha512-XJllub665ccrxK2qLnrweBma1aMSu0Qkj4EYepw6dxsMp1kcE41HMpL3vpjwSnMO7wOvhVYdv7Jx3o5EcMAxLw== +percy-client@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/percy-client/-/percy-client-3.1.0.tgz#f072e3c3e9c978a1666f77bb950ab804b9d1efa1" + integrity sha512-OzKg+o0dtt/LxcCNHNpe7B+oZ196fMiPFzySvFz1ouXNoEBITiborIGN49e8dN5puwIEILoePlX91q6C834XGg== dependencies: base64-js "^1.2.3" bluebird "^3.5.1" bluebird-retry "^0.11.0" + dotenv "^8.1.0" es6-promise-pool "^2.5.0" jssha "^2.1.0" regenerator-runtime "^0.13.1"