diff --git a/.github/.cache-key b/.github/.cache-key index 3eeb4db39..7f9905cf4 100644 --- a/.github/.cache-key +++ b/.github/.cache-key @@ -1 +1 @@ -Times we have broken CI: 1 +Times we have broken CI: 2 diff --git a/packages/cli-command/src/command.js b/packages/cli-command/src/command.js index e40ca5b7f..16edc2c95 100644 --- a/packages/cli-command/src/command.js +++ b/packages/cli-command/src/command.js @@ -81,6 +81,7 @@ export default class PercyCommand extends Command { // set config: false to prevent core from reloading config return Object.assign(config, { skipUploads: this.flags.debug, + dryRun: this.flags['dry-run'], config: false }); } diff --git a/packages/cli-command/src/flags.js b/packages/cli-command/src/flags.js index 0fe8767fd..01fc7084e 100644 --- a/packages/cli-command/src/flags.js +++ b/packages/cli-command/src/flags.js @@ -35,6 +35,10 @@ const discovery = { description: 'disable asset discovery caches', percyrc: 'discovery.disableCache' }), + 'dry-run': flags.boolean({ + char: 'd', + description: 'print logs only, do not run asset discovery or upload snapshots' + }), debug: flags.boolean({ description: 'debug asset discovery and do not upload snapshots' }) diff --git a/packages/cli-command/test/command.test.js b/packages/cli-command/test/command.test.js index a052b1cf4..8b5394c8b 100644 --- a/packages/cli-command/test/command.test.js +++ b/packages/cli-command/test/command.test.js @@ -127,6 +127,7 @@ describe('PercyCommand', () => { '--allowed-hostname', '*.percy.io', '--network-idle-timeout', '150', '--disable-cache', + '--dry-run', '--debug', 'foo', 'bar' ])).toBeResolved(); @@ -135,6 +136,7 @@ describe('PercyCommand', () => { version: 2, config: false, skipUploads: true, + dryRun: true, snapshot: { widths: [375, 1280], minHeight: 1024, diff --git a/packages/cli-snapshot/src/commands/snapshot.js b/packages/cli-snapshot/src/commands/snapshot.js index b36c46bf9..f5a593d20 100644 --- a/packages/cli-snapshot/src/commands/snapshot.js +++ b/packages/cli-snapshot/src/commands/snapshot.js @@ -41,10 +41,6 @@ export class Snapshot extends Command { description: 'one or more globs/patterns matching snapshots to exclude', multiple: true }), - 'dry-run': flags.boolean({ - description: 'prints a list of snapshots without processing them', - char: 'd' - }), // static only flags 'clean-urls': flags.boolean({ @@ -105,27 +101,15 @@ export class Snapshot extends Command { (isStatic && await this.loadStaticSnapshots(arg)) || await this.loadSnapshotsFile(arg)); - let l = snapshots.length; - if (!l) this.error('No snapshots found'); + if (!snapshots.length) { + this.error('No snapshots found'); + } // start processing snapshots - let dry = this.flags['dry-run']; - if (!dry) await this.percy.start(); - else this.log.info(`Found ${l} snapshot${l === 1 ? '' : 's'}`); - - for (let snap of snapshots) { - if (dry) { - this.log.info(`Snapshot found: ${snap.name}`); - this.log.debug(`-> url: ${snap.url}`); - - for (let s of (snap.additionalSnapshots || [])) { - let name = s.name || `${s.prefix || ''}${snap.name}${s.suffix || ''}`; - this.log.info(`Snapshot found: ${name}`); - this.log.debug(`-> url: ${snap.url}`); - } - } else { - this.percy.snapshot(snap); - } + await this.percy.start(); + + for (let snapshot of snapshots) { + this.percy.snapshot(snapshot); } } diff --git a/packages/cli-snapshot/test/directory.test.js b/packages/cli-snapshot/test/directory.test.js index a0feab621..fb0e5d885 100644 --- a/packages/cli-snapshot/test/directory.test.js +++ b/packages/cli-snapshot/test/directory.test.js @@ -83,13 +83,16 @@ describe('percy snapshot ', () => { it('does not take snapshots and prints a list with --dry-run', async () => { await Snapshot.run(['./tmp', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 4 snapshots', + '[percy] Percy has started!', '[percy] Snapshot found: /test-1.html', '[percy] Snapshot found: /test-2.html', '[percy] Snapshot found: /test-3.html', - '[percy] Snapshot found: /test-index/index.html' + '[percy] Snapshot found: /test-index/index.html', + '[percy] Found 4 snapshots' ]); }); @@ -106,30 +109,36 @@ describe('percy snapshot ', () => { await Snapshot.run(['./tmp', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 4 snapshots', + '[percy] Percy has started!', '[percy] Snapshot found: First', - '[percy] Snapshot found: First (2)', + '[percy] Additional snapshot: First (2)', '[percy] Snapshot found: /test-2.html', - '[percy] Snapshot found: /test-2.html (2)', + '[percy] Additional snapshot: /test-2.html (2)', '[percy] Snapshot found: /test-3.html', - '[percy] Snapshot found: /test-3.html (2)', + '[percy] Additional snapshot: /test-3.html (2)', '[percy] Snapshot found: /test-index/index.html', - '[percy] Snapshot found: /test-index/index.html (2)' + '[percy] Additional snapshot: /test-index/index.html (2)', + '[percy] Found 8 snapshots' ]); }); it('rewrites file and index URLs with --clean-urls', async () => { await Snapshot.run(['./tmp', '--dry-run', '--clean-urls']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 4 snapshots', + '[percy] Percy has started!', '[percy] Snapshot found: /test-1', '[percy] Snapshot found: /test-2', '[percy] Snapshot found: /test-3', - '[percy] Snapshot found: /test-index' + '[percy] Snapshot found: /test-index', + '[percy] Found 4 snapshots' ]); }); @@ -144,13 +153,16 @@ describe('percy snapshot ', () => { await Snapshot.run(['./tmp', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 4 snapshots', + '[percy] Percy has started!', '[percy] Snapshot found: /test/1', '[percy] Snapshot found: /test/2', '[percy] Snapshot found: /test/3', - '[percy] Snapshot found: /test-index' + '[percy] Snapshot found: /test-index', + '[percy] Found 4 snapshots' ]); }); @@ -168,11 +180,14 @@ describe('percy snapshot ', () => { await Snapshot.run(['./tmp', '--dry-run']); - expect(logger.stderr).toEqual([]); - expect(logger.stdout).toEqual(jasmine.arrayContaining([ - '[percy] Found 2 snapshots', + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); + expect(logger.stdout).toEqual([ + '[percy] Percy has started!', '[percy] Snapshot found: /test-1', - '[percy] Snapshot found: /test-3' - ])); + '[percy] Snapshot found: /test-3', + '[percy] Found 2 snapshots' + ]); }); }); diff --git a/packages/cli-snapshot/test/file.test.js b/packages/cli-snapshot/test/file.test.js index 78e13c60c..e29cf9e74 100644 --- a/packages/cli-snapshot/test/file.test.js +++ b/packages/cli-snapshot/test/file.test.js @@ -186,21 +186,28 @@ describe('percy snapshot ', () => { it('does not take snapshots and prints a list with --dry-run', async () => { await Snapshot.run(['./pages.yml', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 1 snapshot', - '[percy] Snapshot found: YAML Snapshot' + '[percy] Percy has started!', + '[percy] Snapshot found: YAML Snapshot', + '[percy] Found 1 snapshot' ]); logger.reset(); await Snapshot.run(['./pages.js', '--dry-run']); - expect(logger.stderr).toEqual([]); + + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 1 snapshot', + '[percy] Percy has started!', '[percy] Snapshot found: JS Snapshot', - '[percy] Snapshot found: JS Snapshot 2', - '[percy] Snapshot found: Other JS Snapshot' + '[percy] Additional snapshot: JS Snapshot 2', + '[percy] Additional snapshot: Other JS Snapshot', + '[percy] Found 3 snapshots' ]); }); diff --git a/packages/cli-snapshot/test/sitemap.test.js b/packages/cli-snapshot/test/sitemap.test.js index 4ad111a40..0c866edbc 100644 --- a/packages/cli-snapshot/test/sitemap.test.js +++ b/packages/cli-snapshot/test/sitemap.test.js @@ -42,12 +42,15 @@ describe('percy snapshot ', () => { it('snapshots URLs listed by a sitemap', async () => { await Snapshot.run(['http://localhost:8000/sitemap.xml', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual([ - '[percy] Found 3 snapshots', + '[percy] Percy has started!', '[percy] Snapshot found: /', '[percy] Snapshot found: /test-1/', - '[percy] Snapshot found: /test-2/' + '[percy] Snapshot found: /test-2/', + '[percy] Found 3 snapshots' ]); }); diff --git a/packages/cli-upload/src/commands/upload.js b/packages/cli-upload/src/commands/upload.js index f4fbd1706..d7528a5f0 100644 --- a/packages/cli-upload/src/commands/upload.js +++ b/packages/cli-upload/src/commands/upload.js @@ -138,9 +138,8 @@ export class Upload extends Command { if (this.closing) return; this.closing = true; - await this.queue.empty(len => { - this.log.progress(`Uploading ${len}` + ( - ` snapshot${len !== 1 ? 's' : ''}...`), !!len); + await this.queue.empty(s => { + this.log.progress(`Uploading ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s); }); await this.client.finalizeBuild(this.build.id); diff --git a/packages/core/src/config.js b/packages/core/src/config.js index be17b2f4f..690ac7ea2 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -1,7 +1,3 @@ -import { strict as assert } from 'assert'; -import PercyConfig from '@percy/config'; -import { merge } from '@percy/config/dist/utils'; - // Common config options used in Percy commands export const configSchema = { snapshot: { @@ -263,52 +259,3 @@ export const migrations = [ ['/config', configMigration], ['/snapshot', snapshotMigration] ]; - -// Validate and merge per-snapshot configuration options with global configuration options. -export function getSnapshotConfig(options, { snapshot, discovery }, log) { - // prune client and env info from being validated - let { clientInfo, environmentInfo, ...config } = PercyConfig.migrate(options, '/snapshot'); - - // throw an error when missing required options - assert(config.url, 'Missing required URL for snapshot'); - assert((config.widths ?? snapshot.widths)?.length, 'Missing required widths for snapshot'); - - // validate and scrub according to dom snaphot presence - let errors = PercyConfig.validate(config, ( - config.domSnapshot ? '/snapshot/dom' : '/snapshot')); - - if (errors) { - log.warn('Invalid snapshot options:'); - for (let e of errors) log.warn(`- ${e.path}: ${e.message}`); - } - - // parse the URL to construct defaults - let url = new URL(options.url); - - // inherit options from the config - return merge([snapshot, { - // default to the URL /pathname?search#hash - name: `${url.pathname}${url.search}${url.hash}`, - // add back client and environment information - clientInfo, - environmentInfo, - // only specific discovery options are used per-snapshot - discovery: { - allowedHostnames: [url.hostname, ...discovery.allowedHostnames], - requestHeaders: discovery.requestHeaders, - authorization: discovery.authorization, - disableCache: discovery.disableCache, - userAgent: discovery.userAgent - } - }, config], (path, prev, next) => { - switch (path.join('.')) { - case 'widths': // override and sort widths - return [path, next.sort((a, b) => a - b)]; - case 'percyCSS': // concatenate percy css - return [path, [prev, next].filter(Boolean).join('\n')]; - case 'execute': // shorthand for execute.beforeSnapshot - return (Array.isArray(next) || typeof next !== 'object') - ? [path.concat('beforeSnapshot'), next] : [path]; - } - }); -} diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index ea35cb741..9d2874366 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -17,10 +17,10 @@ export function createRequestHandler(network, { disableCache, getResource }) { let resource = getResource(url); if (resource?.root) { - log.debug('-> Serving root resource', meta); + log.debug('- Serving root resource', meta); await request.respond(resource); } else if (resource && !disableCache) { - log.debug('-> Resource cache hit', meta); + log.debug('- Resource cache hit', meta); await request.respond(resource); } else { await request.continue(); @@ -41,7 +41,7 @@ export function createRequestFinishedHandler(network, { allowedHostnames, disableCache, getResource, - addResource + saveResource }) { let log = logger('core:discovery'); @@ -63,17 +63,17 @@ export function createRequestFinishedHandler(network, { /* istanbul ignore next: sanity check */ if (!response) { - return log.debug('-> Skipping no response', meta); + return log.debug('- Skipping no response', meta); } else if (!capture) { - return log.debug('-> Skipping remote resource', meta); + return log.debug('- Skipping remote resource', meta); } else if (!body.length) { - return log.debug('-> Skipping empty response', meta); + return log.debug('- Skipping empty response', meta); } else if (body.length > MAX_RESOURCE_SIZE) { - return log.debug('-> Skipping resource larger than 15MB', meta); + return log.debug('- Skipping resource larger than 15MB', meta); } else if (!ALLOWED_STATUSES.includes(response.status)) { - return log.debug(`-> Skipping disallowed status [${response.status}]`, meta); + return log.debug(`- Skipping disallowed status [${response.status}]`, meta); } else if (!enableJavaScript && !ALLOWED_RESOURCES.includes(request.type)) { - return log.debug(`-> Skipping disallowed resource type [${request.type}]`, meta); + return log.debug(`- Skipping disallowed resource type [${request.type}]`, meta); } resource = createResource(url, body, response.mimeType, { @@ -86,11 +86,11 @@ export function createRequestFinishedHandler(network, { ), {}) }); - log.debug(`-> sha: ${resource.sha}`, meta); - log.debug(`-> mimetype: ${resource.mimetype}`, meta); + log.debug(`- sha: ${resource.sha}`, meta); + log.debug(`- mimetype: ${resource.mimetype}`, meta); } - addResource(resource); + saveResource(resource); } catch (error) { log.debug(`Encountered an error processing resource: ${url}`, meta); log.debug(error); diff --git a/packages/core/src/network.js b/packages/core/src/network.js index b49cfcc35..83c446b69 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -88,7 +88,7 @@ export default class Network { if (this.log.shouldLog('debug')) { msg += `\n\n ${['Active requests:', ...requests.map(r => r.url) - ].join('\n -> ')}\n`; + ].join('\n - ')}\n`; } throw new Error(msg); diff --git a/packages/core/src/page.js b/packages/core/src/page.js index cc9c88b93..6eaa19563 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -162,6 +162,8 @@ export default class Page { execute, ...options }) { + this.log.debug(`Taking snapshot: ${name}`, this.meta); + // wait for any specified timeout if (waitForTimeout) { this.log.debug(`Wait for ${waitForTimeout}ms timeout`, this.meta); diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 97171ac94..8b8f4d493 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -4,15 +4,12 @@ import logger from '@percy/logger'; import Queue from './queue'; import Browser from './browser'; import createPercyServer from './server'; -import { getSnapshotConfig } from './config'; import { - createRootResource, - createLogResource, - createPercyCSSResource, - hostnameMatches, - injectPercyCSS -} from './utils'; + getSnapshotConfig, + debugSnapshotConfig, + discoverSnapshotResources +} from './snapshot'; // A Percy instance will create a new build when started, handle snapshot // creation, asset discovery, and resource uploads, and will finalize the build @@ -22,9 +19,9 @@ export default class Percy { log = logger('core'); readyState = null; - #cache = new Map(); #uploads = new Queue(); #snapshots = new Queue(); + #total = 0; // Static shortcut to create and start an instance in one call static async start(options) { @@ -40,6 +37,8 @@ export default class Percy { deferUploads, // run without uploading anything skipUploads, + // implies `skipUploads` and also skips asset discovery + dryRun, // configuration filepath config, // provided to @percy/client @@ -54,8 +53,10 @@ export default class Percy { ...options } = {}) { if (loglevel) this.loglevel(loglevel); - this.deferUploads = skipUploads || deferUploads; - this.skipUploads = skipUploads; + + this.dryRun = !!dryRun; + this.skipUploads = this.dryRun || !!skipUploads; + this.deferUploads = this.skipUploads || !!deferUploads; this.config = PercyConfig.load({ overrides: options, @@ -145,7 +146,7 @@ export default class Percy { // when not deferred, wait until the build is created first if (!this.deferUploads) await buildTask; // launch the discovery browser - await this.browser.launch(this.config.discovery.launchOptions); + if (!this.dryRun) await this.browser.launch(); // if there is a server, start listening await this.server?.listen(this.port); @@ -185,21 +186,24 @@ export default class Percy { if (force) this.log.info('Stopping percy...', meta); // close the snapshot queue and wait for it to empty - if (this.#snapshots.close().length) { - await this.#snapshots.empty(len => { - this.log.progress(`Processing ${len}` + ( - ` snapshot${len !== 1 ? 's' : ''}...`), !!len); - }); + if (this.#snapshots.close().size) { + await this.#snapshots.empty(s => !this.dryRun && ( + this.log.progress(`Processing ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s) + )); } // run, close, and wait for the upload queue to empty - if (!this.skipUploads && this.#uploads.run().close().length) { - await this.#uploads.empty(len => { - this.log.progress(`Uploading ${len}` + ( - ` snapshot${len !== 1 ? 's' : ''}...`), !!len); + if (!this.skipUploads && this.#uploads.run().close().size) { + await this.#uploads.empty(s => { + this.log.progress(`Uploading ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s); }); } + // if dry-running, print the total number of snapshots at the end + if (this.dryRun && this.#total) { + this.log.info(`Found ${this.#total} snapshot${this.#total !== 1 ? 's' : ''}`); + } + // close the any running server and browser await this.server?.close(); await this.browser.close(); @@ -235,171 +239,43 @@ export default class Percy { throw new Error('Not running'); } - let { - url, - name, - discovery, - domSnapshot, - execute, - waitForTimeout, - waitForSelector, - additionalSnapshots, - ...conf - } = getSnapshotConfig(options, this.config, this.log); - - let meta = { - snapshot: { name }, - build: this.build - }; - - let maybeDebug = (val, msg) => { - if (val != null) this.log.debug(msg(val), meta); - }; + // get derived snapshot config options + let snapshot = getSnapshotConfig(this, options); // clear any existing pending upload for the same snapshot (for retries) - this.#uploads.clear(`upload/${name}`); + this.#uploads.clear(`upload/${snapshot.name}`); // resolves after asset discovery has finished and the upload has been queued - return this.#snapshots.push(`snapshot/${name}`, async () => { - let resources = new Map(); - let root, page; + return this.#snapshots.push(`snapshot/${snapshot.name}`, async () => { + this.#total += (snapshot.additionalSnapshots?.length ?? 0) + 1; + debugSnapshotConfig(snapshot, this.dryRun); + if (this.dryRun) return; try { - this.log.debug('---------'); - this.log.debug('Handling snapshot:', meta); - this.log.debug(`-> name: ${name}`, meta); - this.log.debug(`-> url: ${url}`, meta); - maybeDebug(conf.widths, v => `-> widths: ${v.join('px, ')}px`); - maybeDebug(conf.minHeight, v => `-> minHeight: ${v}px`); - maybeDebug(conf.enableJavaScript, v => `-> enableJavaScript: ${v}`); - maybeDebug(options.discovery?.allowedHostnames, v => `-> discovery.allowedHostnames: ${v}`); - maybeDebug(options.discovery?.requestHeaders, v => `-> discovery.requestHeaders: ${JSON.stringify(v)}`); - maybeDebug(options.discovery?.authorization, v => `-> discovery.authorization: ${JSON.stringify(v)}`); - maybeDebug(options.discovery?.disableCache, v => `-> discovery.disableCache: ${v}`); - maybeDebug(options.discovery?.userAgent, v => `-> discovery.userAgent: ${v}`); - maybeDebug(options.waitForTimeout, v => `-> waitForTimeout: ${v}`); - maybeDebug(options.waitForSelector, v => `-> waitForSelector: ${v}`); - maybeDebug(options.execute, v => `-> execute: ${v}`); - maybeDebug(conf.clientInfo, v => `-> clientInfo: ${v}`); - maybeDebug(conf.environmentInfo, v => `-> environmentInfo: ${v}`); - - // create the root resource if a dom snapshot was provided - if (domSnapshot) { - root = createRootResource(url, domSnapshot); - } - - // open a new browser page - page = await this.browser.page({ - networkIdleTimeout: this.config.discovery.networkIdleTimeout, - enableJavaScript: conf.enableJavaScript ?? !domSnapshot, - requestHeaders: discovery.requestHeaders, - authorization: discovery.authorization, - userAgent: discovery.userAgent, - meta, - - // enable network inteception - intercept: { - disableCache: discovery.disableCache, - enableJavaScript: conf.enableJavaScript, - allowedHostnames: discovery.allowedHostnames, - getResource: url => url === root?.url ? root : ( - resources.get(url) || this.#cache.get(url) - ), - addResource: resource => { - if (resource.root) return; - resources.set(resource.url, resource); - this.#cache.set(resource.url, resource); - } - } - }); - - // copy widths to prevent mutation - let widths = conf.widths.slice(); - // resolves when the network is idle for discoverable assets - let discoveryIdle = () => page.network.idle(({ url }) => ( - hostnameMatches(discovery.allowedHostnames, url) - )); - - // set the initial page size - await page.resize({ - width: widths.shift(), - height: conf.minHeight + await discoverSnapshotResources(this, snapshot, (snapshot, resources) => { + this.log.info(`Snapshot taken: ${snapshot.name}`, snapshot.meta); + this._scheduleUpload(snapshot, resources); }); - - // navigate to the url - await page.goto(url); - - // evaluate any necessary javascript - await page.evaluate(execute?.afterNavigation); - - // trigger resize events for other widths - for (let width of widths) { - await page.evaluate(execute?.beforeResize); - await discoveryIdle(); // ensure discovery idles before resizing - await page.resize({ width, height: conf.minHeight }); - await page.evaluate(execute?.afterResize); - } - - // create and add a percy-css resource - let percyCSS = createPercyCSSResource(url, conf.percyCSS); - if (percyCSS) resources.set(percyCSS.url, percyCSS); - - if (root) { - // ensure discovery finishes before uploading - await discoveryIdle(); - root = injectPercyCSS(root, percyCSS); - this.log.info(`Snapshot taken: ${name}`, meta); - this._scheduleUpload(name, conf, [root, ...resources.values()]); - } else { - // capture additional snapshots sequentially - let rootSnapshot = { name, waitForTimeout, waitForSelector, execute }; - let allSnapshots = [rootSnapshot, ...(additionalSnapshots || [])]; - - for (let { name, prefix = '', suffix = '', ...opts } of allSnapshots) { - name ||= `${prefix}${rootSnapshot.name}${suffix}`; - this.log.debug(`Taking snapshot: ${name}`, meta); - - // will wait for timeouts, selectors, and additional network activity - let { url, dom } = await page.snapshot({ ...conf, ...opts }); - let root = injectPercyCSS(createRootResource(url, dom), percyCSS); - resources.delete(root.url); // remove any discovered root resource - - this.log.info(`Snapshot taken: ${name}`, meta); - this._scheduleUpload(name, conf, [root, ...resources.values()]); - } - } } catch (error) { - this.log.error(`Encountered an error taking snapshot: ${name}`, meta); - this.log.error(error, meta); - } finally { - await page?.close(); + this.log.error(`Encountered an error taking snapshot: ${snapshot.name}`, snapshot.meta); + this.log.error(error, snapshot.meta); } }); } // Queues a snapshot upload with the provided configuration options and resources - _scheduleUpload(name, conf, resources) { - this.#uploads.push(`upload/${name}`, async () => { + _scheduleUpload(snapshot, resources) { + this.#uploads.push(`upload/${snapshot.name}`, async () => { try { - // attach a log resource for debugging - resources = resources.concat( - createLogResource(logger.query(l => ( - l.meta.snapshot?.name === name - ))) - ); - - await this.client.sendSnapshot(this.build.id, { - ...conf, name, resources - }); + await this.client.sendSnapshot(this.build.id, { ...snapshot, resources }); } catch (error) { - let meta = { snapshot: { name }, build: this.build }; let failed = error.response?.status === 422 && ( error.response.body.errors.find(e => ( e.source?.pointer === '/data/attributes/build' ))); - this.log.error(`Encountered an error uploading snapshot: ${name}`, meta); - this.log.error(failed?.detail ?? error, meta); + this.log.error(`Encountered an error uploading snapshot: ${snapshot.name}`, snapshot.meta); + this.log.error(failed?.detail ?? error, snapshot.meta); // build failed at some point, stop accepting snapshots if (failed) { diff --git a/packages/core/src/queue.js b/packages/core/src/queue.js index c46b048c1..d978ad24c 100644 --- a/packages/core/src/queue.js +++ b/packages/core/src/queue.js @@ -33,12 +33,11 @@ export default class Queue { this.#queued.clear(); } - return this.length; + return this.size; } - get length() { - return this.#queued.size + - this.#pending.size; + get size() { + return this.#queued.size + this.#pending.size; } run() { @@ -68,8 +67,8 @@ export default class Queue { async empty(onCheck) { await waitFor(() => { - onCheck?.(this.length); - return !this.length; + onCheck?.(this.size); + return !this.size; }, { idle: 10 }); } diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js new file mode 100644 index 000000000..1cd27e45b --- /dev/null +++ b/packages/core/src/snapshot.js @@ -0,0 +1,242 @@ +import logger from '@percy/logger'; +import PercyConfig from '@percy/config'; +import { merge } from '@percy/config/dist/utils'; + +import { + hostnameMatches, + createRootResource, + createPercyCSSResource, + createLogResource +} from './utils'; + +// Validates and returns snapshot options merged with percy config options. +export function getSnapshotConfig(percy, options) { + if (!options.url) throw new Error('Missing required URL for snapshot'); + + let { config } = percy; + let uri = new URL(options.url); + let name = options.name || `${uri.pathname}${uri.search}${uri.hash}`; + let meta = { snapshot: { name }, build: percy.build }; + let log = logger('core:snapshot'); + + // migrate deprecated snapshot config options + let { clientInfo, environmentInfo, ...opts } = options; + let snapshot = PercyConfig.migrate(opts, '/snapshot'); + + // throw an error when missing required widths + if (!(snapshot.widths ?? percy.config.snapshot.widths)?.length) { + throw new Error('Missing required widths for snapshot'); + } + + // validate and scrub according to dom snaphot presence + let errors = PercyConfig.validate(snapshot, ( + snapshot.domSnapshot ? '/snapshot/dom' : '/snapshot')); + + if (errors) { + log.warn('Invalid snapshot options:', meta); + for (let e of errors) log.warn(`- ${e.path}: ${e.message}`, meta); + } + + // inherit options from the percy config + return merge([config.snapshot, { + name, + meta, + clientInfo, + environmentInfo, + + // only specific discovery options are used per-snapshot + discovery: { + allowedHostnames: [uri.hostname, ...config.discovery.allowedHostnames], + requestHeaders: config.discovery.requestHeaders, + authorization: config.discovery.authorization, + disableCache: config.discovery.disableCache, + userAgent: config.discovery.userAgent + } + }, snapshot], (path, prev, next) => { + switch (path.map(k => k.toString()).join('.')) { + case 'widths': // override and sort widths + return [path, next.sort((a, b) => a - b)]; + case 'percyCSS': // concatenate percy css + return [path, [prev, next].filter(Boolean).join('\n')]; + case 'execute': // shorthand for execute.beforeSnapshot + return (Array.isArray(next) || typeof next !== 'object') + ? [path.concat('beforeSnapshot'), next] : [path]; + } + }); +} + +// Returns a complete and valid snapshot config object and logs verbose debug logs detailing various +// snapshot options. When `showInfo` is true, specific messages will be logged as info logs rather +// than debug logs. +export function debugSnapshotConfig(snapshot, showInfo) { + let log = logger('core:snapshot'); + + // log snapshot info + log.debug('---------', snapshot.meta); + if (showInfo) log.info(`Snapshot found: ${snapshot.name}`, snapshot.meta); + else log.debug(`Handling snapshot: ${snapshot.name}`, snapshot.meta); + + // will log debug info for an object property if its value is defined + let debugProp = (obj, prop, format = String) => { + let val = prop.split('.').reduce((o, k) => o?.[k], obj); + + if (val != null) { + // join formatted array values with a space + val = [].concat(val).map(format).join(', '); + log.debug(`- ${prop}: ${val}`, snapshot.meta); + } + }; + + debugProp(snapshot, 'url'); + debugProp(snapshot, 'widths', v => `${v}px`); + debugProp(snapshot, 'minHeight', v => `${v}px`); + debugProp(snapshot, 'enableJavaScript'); + debugProp(snapshot, 'waitForTimeout'); + debugProp(snapshot, 'waitForSelector'); + debugProp(snapshot, 'execute.afterNavigation'); + debugProp(snapshot, 'execute.beforeResize'); + debugProp(snapshot, 'execute.afterResize'); + debugProp(snapshot, 'execute.beforeSnapshot'); + debugProp(snapshot, 'discovery.allowedHostnames'); + debugProp(snapshot, 'discovery.requestHeaders', JSON.stringify); + debugProp(snapshot, 'discovery.authorization', JSON.stringify); + debugProp(snapshot, 'discovery.disableCache'); + debugProp(snapshot, 'discovery.userAgent'); + debugProp(snapshot, 'clientInfo'); + debugProp(snapshot, 'environmentInfo'); + debugProp(snapshot, 'domSnapshot', Boolean); + + for (let { name, ...added } of (snapshot.additionalSnapshots || [])) { + name ||= `${added.prefix || ''}${snapshot.name}${added.suffix || ''}`; + log[showInfo ? 'info' : 'debug'](`Additional snapshot: ${name}`, snapshot.meta); + + debugProp(added, 'url'); + debugProp(added, 'waitForTimeout'); + debugProp(added, 'waitForSelector'); + debugProp(added, 'execute'); + } +} + +// Calls the provided callback with additional resources +function handleSnapshotResources(snapshot, map, callback) { + let resources = [...map.values()]; + + // sort the root resource first + let [root] = resources.splice(resources.findIndex(r => r.root), 1); + resources.unshift(root); + + // inject Percy CSS + if (snapshot.percyCSS) { + let css = createPercyCSSResource(root.url, snapshot.percyCSS); + resources.push(css); + + // replace root contents and associated properties + Object.assign(root, createRootResource(root.url, ( + root.content.replace(/(<\/body>)(?!.*\1)/is, ( + `` + ) + '$&')))); + } + + // include associated snapshot logs matched by meta information + resources.push(createLogResource(logger.query(log => ( + log.meta.snapshot?.name === snapshot.meta.snapshot.name + )))); + + return callback(snapshot, resources); +} + +// Wait for a page's asset discovery network to idle +function waitForDiscoveryNetworkIdle(page, options) { + let { allowedHostnames, networkIdleTimeout } = options; + let filter = r => hostnameMatches(allowedHostnames, r.url); + + return page.network.idle(filter, networkIdleTimeout); +} + +// Used to cache resources across core instances +const RESOURCE_CACHE_KEY = Symbol('resource-cache'); + +// Discovers resources for a snapshot using a browser page to intercept requests. The callback +// function will be called with the snapshot name (for additional snapshots) and an array of +// discovered resources. When additional snapshots are provided, the callback will be called once +// for each snapshot. +export async function discoverSnapshotResources(percy, snapshot, callback) { + // keep a global resource cache across snapshots + let cache = percy[RESOURCE_CACHE_KEY] ||= new Map(); + // copy widths to prevent mutation later + let widths = snapshot.widths.slice(); + + // preload the root resource for existing dom snapshots + let resources = new Map(snapshot.domSnapshot && ( + [createRootResource(snapshot.url, snapshot.domSnapshot)] + .map(resource => [resource.url, resource]) + )); + + // open a new browser page + let page = await percy.browser.page({ + enableJavaScript: snapshot.enableJavaScript ?? !snapshot.domSnapshot, + networkIdleTimeout: snapshot.discovery.networkIdleTimeout, + requestHeaders: snapshot.discovery.requestHeaders, + authorization: snapshot.discovery.authorization, + userAgent: snapshot.discovery.userAgent, + meta: snapshot.meta, + + // enable network inteception + intercept: { + enableJavaScript: snapshot.enableJavaScript, + disableCache: snapshot.discovery.disableCache, + allowedHostnames: snapshot.discovery.allowedHostnames, + getResource: u => resources.get(u) || cache.get(u), + saveResource: r => resources.set(r.url, r) && cache.set(r.url, r) + } + }); + + try { + // set the initial page size + await page.resize({ + width: widths.shift(), + height: snapshot.minHeight + }); + + // navigate to the url + await page.goto(snapshot.url); + await page.evaluate(snapshot.execute?.afterNavigation); + + // trigger resize events for other widths + for (let width of widths) { + await page.evaluate(snapshot.execute?.beforeResize); + await waitForDiscoveryNetworkIdle(page, snapshot.discovery); + await page.resize({ width, height: snapshot.minHeight }); + await page.evaluate(snapshot.execute?.afterResize); + } + + if (snapshot.domSnapshot) { + // ensure discovery has finished and handle resources + await waitForDiscoveryNetworkIdle(page, snapshot.discovery); + handleSnapshotResources(snapshot, resources, callback); + } else { + // capture snapshots sequentially + let allSnapshots = [snapshot, ...(snapshot.additionalSnapshots || [])]; + + for (let { name, prefix = '', suffix = '', ...snap } of allSnapshots) { + // default name and merge snapshot options + name ||= `${prefix}${snapshot.name}${suffix}`; + let options = { ...snapshot, ...snap, name }; + + // will wait for timeouts, selectors, and additional network activity + let { url, dom } = await page.snapshot(options); + + // handle resources and remove previously captured dom snapshots + resources.set(url, createRootResource(url, dom)); + handleSnapshotResources(options, resources, callback); + resources.delete(url); + } + } + + // page clean up + await page.close(); + } catch (error) { + await page.close(); + throw error; + } +} diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 58720bdd5..6c132c308 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -24,28 +24,15 @@ export function createRootResource(url, content) { return createResource(normalizeURL(url), content, 'text/html', { root: true }); } -// Creates a log resource object. -export function createLogResource(logs) { - return createResource(`/percy.${Date.now()}.log`, JSON.stringify(logs), 'text/plain'); -} - // Creates a Percy CSS resource object. export function createPercyCSSResource(url, css) { - if (css) { - let { href, pathname } = new URL(`/percy-specific.${Date.now()}.css`, url); - return createResource(href, css, 'text/css', { pathname }); - } + let { href, pathname } = new URL(`/percy-specific.${Date.now()}.css`, url); + return createResource(href, css, 'text/css', { pathname }); } -// returns a new root resource with the injected Percy CSS -export function injectPercyCSS(root, percyCSS) { - if (percyCSS) { - return createRootResource(root.url, root.content.replace(/(<\/body>)(?!.*\1)/is, ( - `` - ) + '$&')); - } else { - return root; - } +// Creates a log resource object. +export function createLogResource(logs) { + return createResource(`/percy.${Date.now()}.log`, JSON.stringify(logs), 'text/plain'); } // Polls for the predicate to be truthy within a timeout or the returned promise rejects. If diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 4adb78011..b51d09687 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -230,7 +230,7 @@ describe('Discovery', () => { ]); expect(logger.stderr).toContain( - '[percy:core:discovery] -> Skipping empty response' + '[percy:core:discovery] - Skipping empty response' ); }); @@ -324,7 +324,7 @@ describe('Discovery', () => { ])); expect(logger.stderr).toContain( - '[percy:core:discovery] -> Skipping disallowed status [202]' + '[percy:core:discovery] - Skipping disallowed status [202]' ); }); @@ -359,7 +359,7 @@ describe('Discovery', () => { ]); expect(logger.stderr).toContain( - '[percy:core:discovery] -> Skipping resource larger than 15MB' + '[percy:core:discovery] - Skipping resource larger than 15MB' ); }); @@ -438,14 +438,7 @@ describe('Discovery', () => { name: 'test snapshot', url: 'http://localhost:8000', domSnapshot: testDOM, - clientInfo: 'test client info', - environmentInfo: 'test env info', - widths: [400, 1200], - discovery: { - allowedHostnames: ['example.com'], - requestHeaders: { 'X-Foo': 'Bar' }, - disableCache: true - } + widths: [400, 1200] }); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -453,30 +446,19 @@ describe('Discovery', () => { ])); expect(logger.stderr).toEqual(jasmine.arrayContaining([ - '[percy:core] ---------', - '[percy:core] Handling snapshot:', - '[percy:core] -> name: test snapshot', - '[percy:core] -> url: http://localhost:8000', - '[percy:core] -> widths: 400px, 1200px', - '[percy:core] -> minHeight: 1024px', - '[percy:core] -> discovery.allowedHostnames: example.com', - '[percy:core] -> discovery.requestHeaders: {"X-Foo":"Bar"}', - '[percy:core] -> discovery.disableCache: true', - '[percy:core] -> clientInfo: test client info', - '[percy:core] -> environmentInfo: test env info', '[percy:core:page] Page created', '[percy:core:page] Resize page to 400x1024', '[percy:core:page] Navigate to: http://localhost:8000', '[percy:core:discovery] Handling request: http://localhost:8000/', - '[percy:core:discovery] -> Serving root resource', + '[percy:core:discovery] - Serving root resource', '[percy:core:discovery] Handling request: http://localhost:8000/style.css', '[percy:core:discovery] Handling request: http://localhost:8000/img.gif', '[percy:core:discovery] Processing resource: http://localhost:8000/style.css', - `[percy:core:discovery] -> sha: ${sha256hash(testCSS)}`, - '[percy:core:discovery] -> mimetype: text/css', + `[percy:core:discovery] - sha: ${sha256hash(testCSS)}`, + '[percy:core:discovery] - mimetype: text/css', '[percy:core:discovery] Processing resource: http://localhost:8000/img.gif', - `[percy:core:discovery] -> sha: ${sha256hash(pixel)}`, - '[percy:core:discovery] -> mimetype: image/gif', + `[percy:core:discovery] - sha: ${sha256hash(pixel)}`, + '[percy:core:discovery] - mimetype: image/gif', '[percy:core:page] Page navigated', '[percy:core:network] Wait for 100ms idle', '[percy:core:page] Resize page to 1200x1024', @@ -668,7 +650,7 @@ describe('Discovery', () => { '^\\[percy:core] Error: Timed out waiting for network requests to idle.', '', ' Active requests:', - ' -> http://localhost:8000/img.gif', + ' - http://localhost:8000/img.gif', '', '(?(.|\n)*)$' ].join('\n'))); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 71cf812ed..fee8eb359 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -251,6 +251,22 @@ describe('Percy', () => { ]); }); + it('does not launch the browser and skips uploads when dry-running', async () => { + percy = new Percy({ token: 'PERCY_TOKEN', dryRun: true }); + await expectAsync(percy.start()).toBeResolved(); + expect(percy.browser.isConnected()).toBe(false); + expect(mockAPI.requests['/builds']).toBeUndefined(); + + await percy.dispatch(); + await percy.stop(); + + expect(mockAPI.requests['/builds']).toBeUndefined(); + + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); + }); + it('stops accepting snapshots when a queued build fails to be created', async () => { server = await createTestServer({ default: () => [200, 'text/html', '

Snapshot

'] @@ -295,6 +311,13 @@ describe('Percy', () => { }); describe('#stop()', () => { + // stop the previously started instance and clear requests + async function reset(options) { + await percy.stop().then(() => logger.reset()); + Object.keys(mockAPI.requests).map(k => delete mockAPI.requests[k]); + percy = await Percy.start({ token: 'PERCY_TOKEN', ...options }); + } + beforeEach(async () => { await percy.start(); }); @@ -322,10 +345,7 @@ describe('Percy', () => { }); it('clears pending tasks and logs when force stopping', async () => { - await percy.stop(); // stop the previously started instance and clear requests - Object.keys(mockAPI.requests).map(k => delete mockAPI.requests[k]); - - percy = await Percy.start({ token: 'PERCY_TOKEN', deferUploads: true }); + await reset({ deferUploads: true }); await expectAsync(percy.stop(true)).toBeResolved(); // no build should be created or finalized @@ -358,6 +378,26 @@ describe('Percy', () => { ])); }); + it('logs the total number of snapshots when dry-running', async () => { + await reset({ dryRun: true }); + + percy.snapshot({ url: 'http://localhost:8000/one' }); + percy.snapshot({ url: 'http://localhost:8000/two' }); + percy.snapshot({ url: 'http://localhost:8000/three' }); + + await expectAsync(percy.stop()).toBeResolved(); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot found: /one', + '[percy] Snapshot found: /two', + '[percy] Snapshot found: /three', + '[percy] Found 3 snapshots' + ])); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); + }); + it('cleans up the server and browser before finalizing', async () => { mockAPI.reply('/builds/123/finalize', () => [401, { errors: [{ detail: 'finalize error' }] @@ -368,6 +408,23 @@ describe('Percy', () => { expect(percy.browser.isConnected()).toBe(false); }); + it('does not error if the browser was never launched', async () => { + await reset({ dryRun: true }); + + percy.snapshot({ url: 'http://localhost:8000' }); + + expect(percy.browser.isConnected()).toBe(false); + await expectAsync(percy.stop()).toBeResolved(); + expect(percy.browser.isConnected()).toBe(false); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Found 1 snapshot' + ])); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); + }); + it('logs when the build has failed upstream', async () => { mockAPI.reply('/builds/123/snapshots', () => [422, { errors: [ diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index 8a0532946..e3f207e2e 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -21,12 +21,12 @@ describe('Snapshot', () => { token: 'PERCY_TOKEN', snapshot: { widths: [1000] }, discovery: { concurrency: 1 }, - server: false, clientInfo: 'client-info', - environmentInfo: 'env-info' + environmentInfo: 'env-info', + server: false }); - logger.reset(); + logger.reset(true); }); afterEach(async () => { @@ -44,6 +44,12 @@ describe('Snapshot', () => { .toThrowError('Missing required URL for snapshot'); }); + it('errors when missing snapshot widths', () => { + percy.config.snapshot.widths = []; + expect(() => percy.snapshot({ url: 'http://localhost:8000' })) + .toThrowError('Missing required widths for snapshot'); + }); + it('warns when missing additional snapshot names', async () => { percy.close(); // close queues so snapshots fail @@ -197,6 +203,79 @@ describe('Snapshot', () => { ]); }); + it('logs detailed debug logs', async () => { + percy.loglevel('debug'); + + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + clientInfo: 'test client info', + environmentInfo: 'test env info', + widths: [400, 1200], + discovery: { + allowedHostnames: ['example.com'], + requestHeaders: { 'X-Foo': 'Bar' }, + disableCache: true + }, + additionalSnapshots: [ + { prefix: 'foo ', waitForTimeout: 100 }, + { prefix: 'foo ', suffix: ' bar', waitForTimeout: 200 }, + { name: 'foobar', waitForSelector: 'p', execute() {} } + ] + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy:core] Snapshot taken: test snapshot', + '[percy:core] Snapshot taken: foo test snapshot', + '[percy:core] Snapshot taken: foo test snapshot bar', + '[percy:core] Snapshot taken: foobar' + ])); + + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + '[percy:core:snapshot] ---------', + '[percy:core:snapshot] Handling snapshot: test snapshot', + '[percy:core:snapshot] - url: http://localhost:8000', + '[percy:core:snapshot] - widths: 400px, 1200px', + '[percy:core:snapshot] - minHeight: 1024px', + '[percy:core:snapshot] - discovery.allowedHostnames: localhost, example.com', + '[percy:core:snapshot] - discovery.requestHeaders: {"X-Foo":"Bar"}', + '[percy:core:snapshot] - discovery.disableCache: true', + '[percy:core:snapshot] - clientInfo: test client info', + '[percy:core:snapshot] - environmentInfo: test env info', + '[percy:core:snapshot] Additional snapshot: foo test snapshot', + '[percy:core:snapshot] - waitForTimeout: 100', + '[percy:core:snapshot] Additional snapshot: foo test snapshot bar', + '[percy:core:snapshot] - waitForTimeout: 200', + '[percy:core:snapshot] Additional snapshot: foobar', + '[percy:core:snapshot] - waitForSelector: p', + '[percy:core:snapshot] - execute: execute() {}' + ])); + }); + + it('logs alternate dry-run logs', async () => { + await percy.stop(true); + percy = await Percy.start({ dryRun: true }); + logger.reset(); + + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + additionalSnapshots: [ + { prefix: 'foo ', waitForTimeout: 100 }, + { prefix: 'foo ', suffix: ' bar', waitForTimeout: 200 }, + { name: 'foobar', waitForSelector: '.ready', execute() {} } + ] + }); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Snapshot found: test snapshot', + '[percy] Additional snapshot: foo test snapshot', + '[percy] Additional snapshot: foo test snapshot bar', + '[percy] Additional snapshot: foobar' + ]); + }); + it('handles the browser closing early', async () => { spyOn(percy.browser, 'page').and.callThrough(); diff --git a/packages/core/test/unit/queue.test.js b/packages/core/test/unit/queue.test.js index 7abebe8dd..95ae23273 100644 --- a/packages/core/test/unit/queue.test.js +++ b/packages/core/test/unit/queue.test.js @@ -70,13 +70,13 @@ describe('Unit / Tasks Queue', () => { }); }); - describe('#length', () => { + describe('#size', () => { it('returns the number of all incomplete tasks', async () => { let tasks = Array(10).fill().map((_, i) => q.push(i, task(100))); - expect(q.length).toBe(10); + expect(q.size).toBe(10); await tasks[1]; // wait for the first set of tasks to complete - expect(q.length).toBe(8); + expect(q.size).toBe(8); }); }); @@ -104,7 +104,7 @@ describe('Unit / Tasks Queue', () => { // 2 unless we wait for them to settle await tasks[1]; - expect(q.length).toBe(0); + expect(q.size).toBe(0); expect(tasks[2]).not.toHaveProperty('running'); }); }); diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 76c48f535..b34c510c7 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -44,18 +44,24 @@ export interface SnapshotOptions extends CommonSnapshotOptions { discovery?: DiscoveryOptions; } -export type PercyOptions = C & { - token?: string, +type ClientEnvInfo = { clientInfo?: string, - environmentInfo?: string, + environmentInfo?: string +} + +export type PercyConfigOptions = C & { + snapshot?: CommonSnapshotOptions, + discovery?: AllDiscoveryOptions +} + +export type PercyOptions = { + token?: string, server?: boolean, port?: number, concurrency?: number, loglevel?: LogLevel, - config?: undefined | string | false, - snapshot?: CommonSnapshotOptions, - discovery?: AllDiscoveryOptions -}; + config?: undefined | string | false +} & ClientEnvInfo & PercyConfigOptions; type SnapshotExec = () => void | Promise; @@ -70,6 +76,8 @@ declare class Percy { constructor(options?: PercyOptions); loglevel(): LogLevel; loglevel(level: LogLevel): void; + config: PercyConfigOptions; + setConfig(config: ClientEnvInfo & PercyConfigOptions): PercyConfigOptions; start(): Promise; stop(force?: boolean): Promise; idle(): Promise; diff --git a/packages/core/types/index.test-d.ts b/packages/core/types/index.test-d.ts index 063b3b33a..98b8136e3 100644 --- a/packages/core/types/index.test-d.ts +++ b/packages/core/types/index.test-d.ts @@ -1,5 +1,5 @@ import { expectType, expectError } from 'tsd'; -import Percy, { PercyOptions } from '@percy/core'; +import Percy, { PercyOptions, PercyConfigOptions } from '@percy/core'; // PercyOptions const percyOptions: PercyOptions = { @@ -45,6 +45,19 @@ expectType>(Percy.start(percyOptions)); expectType<'error' | 'warn' | 'info' | 'debug' | 'silent'>(percy.loglevel()); expectType(percy.loglevel('error')); +// #config +expectType(percy.config); +// #setConfig() +expectType(percy.setConfig({ + clientInfo: 'client/info', + environmentInfo: 'env/info', + snapshot: { widths: [1000] } +})); + +expectError(percy.setConfig({ + snapshot: { foo: 'bar' } +})); + // #start() expectType>(percy.start()); diff --git a/packages/logger/test/helpers.js b/packages/logger/test/helpers.js index 8b5649444..f3e91e479 100644 --- a/packages/logger/test/helpers.js +++ b/packages/logger/test/helpers.js @@ -85,8 +85,9 @@ const helpers = { } }, - reset() { - delete Logger.instance; + reset(soft) { + if (soft) Logger.instance.loglevel('info'); + else delete Logger.instance; helpers.stdout.length = 0; helpers.stderr.length = 0;