@percy/core
Version:
The core component of Percy's CLI and SDKs that handles creating builds, discovering snapshot assets, uploading snapshots, and finalizing builds. Uses `@percy/client` for API communication, a Chromium browser for asset discovery, and starts a local API se
534 lines (498 loc) • 20.5 kB
JavaScript
import logger from '@percy/logger';
import PercyConfig from '@percy/config';
import micromatch from 'micromatch';
import { configSchema } from './config.js';
import Queue from './queue.js';
import { request, hostnameMatches, yieldTo, snapshotLogName, decodeAndEncodeURLWithLogging, compareObjectTypes, normalizeOptions } from './utils.js';
import { JobData } from './wait-for-job.js';
// Throw a better error message for missing or invalid urls
function validURL(url, base) {
if (!url) {
throw new Error('Missing required URL for snapshot');
}
try {
return new URL(url, base);
} catch (e) {
throw new Error(`Invalid snapshot URL: ${e.input}`);
}
}
function validateAndFixSnapshotUrl(snapshot) {
let log = logger('core:snapshot');
// encoding snapshot url, if contians invalid URI characters/syntax
let modifiedURL = decodeAndEncodeURLWithLogging(snapshot.url, log, {
meta: {
snapshot: {
name: snapshot.name || snapshot.url
}
},
shouldLogWarning: true,
warningMessage: `Invalid URL detected for url: ${snapshot.url} - the snapshot may fail on Percy. Please confirm that your website URL is valid.`
});
if (modifiedURL !== snapshot.url) {
log.debug(`Snapshot URL modified to: ${modifiedURL}`);
snapshot.url = modifiedURL;
}
}
// used to deserialize regular expression strings
const RE_REGEXP = /^\/(.+)\/(\w+)?$/;
// Returns true or false if a snapshot matches the provided include and exclude predicates. A
// predicate can be an array of predicates, a regular expression, a glob pattern, or a function.
function snapshotMatches(snapshot, include, exclude) {
var _include, _include2;
// support an options object as the second argument
if ((_include = include) !== null && _include !== void 0 && _include.include || (_include2 = include) !== null && _include2 !== void 0 && _include2.exclude) ({
include,
exclude
} = include);
// recursive predicate test function
let test = (predicate, fallback) => {
if (predicate && typeof predicate === 'string') {
// snapshot name matches exactly or matches a glob
let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate);
// snapshot might match a string-based regexp pattern
if (!result) {
try {
let [, parsed, flags] = RE_REGEXP.exec(predicate) || [];
result = !!parsed && new RegExp(parsed, flags).test(snapshot.name);
} catch {}
}
return result;
} else if (predicate instanceof RegExp) {
// snapshot matches a regular expression
return predicate.test(snapshot.name);
} else if (typeof predicate === 'function') {
// advanced matching
return predicate(snapshot);
} else if (Array.isArray(predicate) && predicate.length) {
// array of predicates
return predicate.some(p => test(p));
} else {
// default fallback
return fallback;
}
};
// nothing to match, return true
if (!include && !exclude) return true;
// not excluded or explicitly included
return !test(exclude, false) && test(include, true);
}
// Accepts an array of snapshots to filter and map with matching options.
function mapSnapshotOptions(snapshots, context) {
if (!(snapshots !== null && snapshots !== void 0 && snapshots.length)) return [];
// reduce options into a single function
let applyOptions = [].concat((context === null || context === void 0 ? void 0 : context.options) || []).reduceRight((next, {
include,
exclude,
...opts
}) => snap => next(
// assign additional options to included snaphots
snapshotMatches(snap, include, exclude) ? Object.assign(snap, opts) : snap), snap => getSnapshotOptions(snap, context));
// reduce snapshots with options
return snapshots.reduce((acc, snapshot) => {
var _snapshot;
// transform snapshot URL shorthand into an object
if (typeof snapshot === 'string') snapshot = {
url: snapshot
};
if (process.env.PERCY_MODIFY_SNAPSHOT_URL !== 'false') validateAndFixSnapshotUrl(snapshot);
// normalize the snapshot url and use it for the default name
let url = validURL(snapshot.url, context === null || context === void 0 ? void 0 : context.baseUrl);
(_snapshot = snapshot).name || (_snapshot.name = `${url.pathname}${url.search}${url.hash}`);
snapshot.url = url.href;
// use the snapshot when matching include/exclude
if (snapshotMatches(snapshot, context)) {
acc.push(applyOptions(snapshot));
}
return acc;
}, []);
}
// Return snapshot options merged with defaults and global config.
function getSnapshotOptions(options, {
config,
meta
}) {
return PercyConfig.merge([{
widths: configSchema.snapshot.properties.widths.default,
discovery: {
allowedHostnames: [validURL(options.url).hostname]
},
meta: {
...meta,
snapshot: {
name: options.name,
testCase: options.testCase,
labels: options.labels
}
}
}, config.snapshot, {
// only specific discovery options are used per-snapshot
discovery: {
allowedHostnames: config.discovery.allowedHostnames,
disallowedHostnames: config.discovery.disallowedHostnames,
networkIdleTimeout: config.discovery.networkIdleTimeout,
waitForTimeout: config.discovery.waitForTimeout,
waitForSelector: config.discovery.waitForSelector,
devicePixelRatio: config.discovery.devicePixelRatio,
requestHeaders: config.discovery.requestHeaders,
authorization: config.discovery.authorization,
disableCache: config.discovery.disableCache,
captureMockedServiceWorker: config.discovery.captureMockedServiceWorker,
captureSrcset: config.discovery.captureSrcset,
userAgent: config.discovery.userAgent,
retry: config.discovery.retry,
scrollToBottom: config.discovery.scrollToBottom
}
}, options], (path, prev, next) => {
var _next, _next2, _next3;
switch (path.map(k => k.toString()).join('.')) {
case 'widths':
// dedup, sort, and override widths when not empty
return [path, !((_next = next) !== null && _next !== void 0 && _next.length) ? prev : [...new Set(next)].sort((a, b) => a - b)];
case 'browsers':
return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : [...new Set(next)]];
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];
case 'discovery.disallowedHostnames':
// prevent disallowing the root hostname
return [path, !((_next3 = next) !== null && _next3 !== void 0 && _next3.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
}
// ensure additional snapshots have complete names
if (path[0] === 'additionalSnapshots' && path.length === 2) {
let {
prefix = '',
suffix = '',
...n
} = next;
next = {
name: `${prefix}${options.name}${suffix}`,
...n
};
return [path, next];
}
});
}
// Validates and migrates snapshot options against the correct schema based on provided
// properties. Eagerly throws an error when missing a URL for any snapshot, and warns about all
// other invalid options which are also scrubbed from the returned migrated options.
export function validateSnapshotOptions(options) {
var _migrated$baseUrl, _migrated$domSnapshot;
let log = logger('core:snapshot');
// decide which schema to validate against
let schema = ['domSnapshot', 'dom-snapshot', 'dom_snapshot'].some(k => k in options) && '/snapshot/dom' || 'url' in options && '/snapshot' || 'sitemap' in options && '/snapshot/sitemap' || 'serve' in options && '/snapshot/server' || 'snapshots' in options && '/snapshot/list' || '/snapshot';
options = normalizeOptions(options);
let {
// normalize, migrate, and remove certain properties from validating
clientInfo,
environmentInfo,
snapshots,
...migrated
} = PercyConfig.migrate(options, schema);
// maintain a trailing slash for base URLs to normalize them
if (((_migrated$baseUrl = migrated.baseUrl) === null || _migrated$baseUrl === void 0 ? void 0 : _migrated$baseUrl.endsWith('/')) === false) migrated.baseUrl += '/';
let baseUrl = schema === '/snapshot/server' ? 'http://localhost/' : migrated.baseUrl;
// gather info for validating individual snapshot URLs
let isSnapshot = schema === '/snapshot/dom' || schema === '/snapshot';
let snaps = isSnapshot ? [migrated] : Array.isArray(snapshots) ? snapshots : [];
for (let snap of snaps) validURL(typeof snap === 'string' ? snap : snap.url, baseUrl);
// add back snapshots before validating and scrubbing; function snapshots are validated later
if (snapshots) migrated.snapshots = typeof snapshots === 'function' ? [] : snapshots;else if (!isSnapshot && options.snapshots) migrated.snapshots = [];
// parse json dom snapshots
if (schema === '/snapshot/dom' && typeof migrated.domSnapshot === 'string' && migrated.domSnapshot.startsWith('{') && migrated.domSnapshot.endsWith('}')) {
migrated.domSnapshot = JSON.parse(migrated.domSnapshot);
}
// log warnings encountered during dom serialization
let domWarnings = ((_migrated$domSnapshot = migrated.domSnapshot) === null || _migrated$domSnapshot === void 0 ? void 0 : _migrated$domSnapshot.warnings) || [];
if (domWarnings.length) {
log.warn('Encountered snapshot serialization warnings:');
for (let w of domWarnings) log.warn(`- ${w}`);
}
// warn on validation errors
let errors = PercyConfig.validate(migrated, schema);
if ((errors === null || errors === void 0 ? void 0 : errors.length) > 0) {
log.warn('Invalid snapshot options:');
for (let e of errors) log.warn(`- ${e.path}: ${e.message}`);
}
// add back the snapshots function if there was one
if (typeof snapshots === 'function') migrated.snapshots = snapshots;
// add back an empty array if all server snapshots were scrubbed
if ('serve' in options && 'snapshots' in options) migrated.snapshots ?? (migrated.snapshots = []);
return {
clientInfo,
environmentInfo,
...migrated
};
}
export async function handleSyncJob(jobPromise, percy, type) {
let data;
try {
const id = await jobPromise;
if (type === 'snapshot') {
data = await percy.client.getSnapshotDetails(id);
} else {
data = await percy.client.getComparisonDetails(id);
}
} catch (e) {
await percy.suggestionsForFix(e.message);
data = {
error: e.message
};
}
return data;
}
// Fetches a sitemap and parses it into a list of URLs for taking snapshots. Duplicate URLs,
// including a trailing slash, are removed from the resulting list.
async function getSitemapSnapshots(options) {
return request(options.sitemap, (body, res) => {
// validate sitemap content-type
let [contentType] = res.headers['content-type'].split(';');
if (!/^(application|text)\/xml$/.test(contentType)) {
throw new Error('The sitemap must be an XML document, ' + `but the content-type was "${contentType}"`);
}
// parse XML content into a list of URLs
let urls = body.match(/(?<=<loc>)(.*?)(?=<\/loc>)/ig) ?? [];
// filter out duplicate URLs that differ by a trailing slash
return urls.filter((url, i) => {
let match = urls.indexOf(url.replace(/\/$/, ''));
return match === -1 || match === i;
});
});
}
// Returns an array of derived snapshot options
export async function* gatherSnapshots(options, context) {
let {
baseUrl,
snapshots
} = options;
if ('url' in options) [snapshots, options] = [[options], {}];
if ('sitemap' in options) snapshots = yield getSitemapSnapshots(options);
// validate evaluated snapshots
if (typeof snapshots === 'function') {
snapshots = yield* yieldTo(snapshots(baseUrl));
snapshots = validateSnapshotOptions({
baseUrl,
snapshots
}).snapshots;
}
// map snapshots with snapshot options
snapshots = mapSnapshotOptions(snapshots, {
...options,
...context
});
if (!snapshots.length) throw new Error('No snapshots found');
return snapshots;
}
// Merges snapshots and deduplicates resource arrays. Duplicate log resources are replaced, root
// resources are deduplicated by widths, and all other resources are deduplicated by their URL.
function mergeSnapshotOptions(prev = {}, next) {
let {
resources: oldResources = [],
...existing
} = prev;
let {
resources: newResources = [],
widths = [],
width,
...incoming
} = next;
// prioritize singular widths over mutilple widths
widths = width ? [width] : widths;
// deduplicate resources by associated widths and url
let resources = oldResources.reduce((all, resource) => {
if (resource.log || resource.widths.every(w => widths.includes(w))) return all;
if (!resource.root && all.some(r => r.url === resource.url)) return all;
resource.widths = resource.widths.filter(w => !widths.includes(w));
return all.concat(resource);
}, newResources.map(r => ({
...r,
widths
})));
// sort resources after merging; roots first by min-width & logs last
resources.sort((a, b) => {
if (a.root && b.root) return Math.min(...b.widths) - Math.min(...a.widths);
return a.root || b.log ? -1 : a.log || b.root ? 1 : 0;
});
// overwrite resources and ensure unique widths
return PercyConfig.merge([existing, incoming, {
widths,
resources
}], (path, prev, next) => {
if (path[0] === 'resources') return [path, next];
if (path[0] === 'widths' && prev && next) {
return [path, [...new Set([...prev, ...next])]];
}
});
}
// Creates a snapshots queue that manages a Percy build and uploads snapshots.
export function createSnapshotsQueue(percy) {
let {
concurrency
} = percy.config.discovery;
let queue = new Queue('snapshot');
let build;
return queue.set({
concurrency
})
// on start, create a new Percy build
.handle('start', async () => {
try {
var _data$attributes;
build = percy.build = {};
let {
data
} = await percy.client.createBuild({
projectType: percy.projectType,
cliStartTime: percy.cliStartTime
});
let url = data.attributes['web-url'];
let number = data.attributes['build-number'];
percy.client.buildType = (_data$attributes = data.attributes) === null || _data$attributes === void 0 ? void 0 : _data$attributes.type;
Object.assign(build, {
id: data.id,
url,
number
});
// immediately run the queue if not delayed or deferred
if (!percy.delayUploads && !percy.deferUploads) queue.run();
} catch (err) {
// immediately throw the error if not delayed or deferred
if (!percy.delayUploads && !percy.deferUploads) throw err;
Object.assign(build, {
error: 'Failed to create build'
});
percy.log.error(build.error);
percy.log.error(err);
queue.close(true);
}
})
// on end, maybe finalize the build and log about build info
.handle('end', async () => {
var _build, _build2;
if (!percy.readyState) return;
if ((_build = build) !== null && _build !== void 0 && _build.failed) {
percy.log.warn(`Build #${build.number} failed: ${build.url}`, {
build
});
} else if ((_build2 = build) !== null && _build2 !== void 0 && _build2.id) {
await percy.client.finalizeBuild(build.id);
percy.log.info(`Finalized build #${build.number}: ${build.url}`, {
build
});
} else {
percy.log.warn('Build not created', {
build
});
}
})
// snapshots are unique by name and testCase both
.handle('find', ({
name,
testCase,
tag
}, snapshot) => {
return snapshot.testCase === testCase && snapshot.name === name && compareObjectTypes(tag, snapshot.tag);
})
// when pushed, maybe flush old snapshots or possibly merge with existing snapshots
.handle('push', (snapshot, existing) => {
let {
name,
meta
} = snapshot;
// log immediately when not deferred or dry-running
if (!percy.deferUploads) percy.log.info(`Snapshot taken: ${snapshotLogName(name, meta)}`, meta);
if (percy.dryRun) percy.log.info(`Snapshot found: ${snapshotLogName(name, meta)}`, meta);
// immediately flush when uploads are delayed but not skipped
if (percy.delayUploads && !percy.deferUploads) queue.flush();
// overwrite any existing snapshot when not deferred or when resources is a function
if (!percy.deferUploads || typeof snapshot.resources === 'function') return snapshot;
// merge snapshot options when uploads are deferred
return mergeSnapshotOptions(existing, snapshot);
})
// send snapshots to be uploaded to the build
.handle('task', async function* ({
resources,
...snapshot
}) {
let {
name,
meta
} = snapshot;
if (percy.client.screenshotFlow === 'automate' && percy.client.buildType !== 'automate') {
throw new Error(`Cannot run automate screenshots in ${percy.client.buildType} project. Please use automate project token`);
} else if (percy.client.screenshotFlow === 'app' && percy.client.buildType !== 'app') {
throw new Error(`Cannot run App Percy screenshots in ${percy.client.buildType} project. Please use App Percy project token`);
}
// yield to evaluated snapshot resources
snapshot.resources = typeof resources === 'function' ? yield* yieldTo(resources()) : resources;
// upload the snapshot and log when deferred
let send = 'tag' in snapshot ? 'sendComparison' : 'sendSnapshot';
let response = yield percy.client[send](build.id, snapshot);
if (percy.deferUploads) percy.log.info(`Snapshot uploaded: ${name}`, meta);
// Pushing to syncQueue, that will check for
// snapshot processing status, and will resolve once done
if (snapshot.sync) {
percy.log.info(`Waiting for snapshot '${name}' to be completed`, meta);
const data = new JobData(response.data.id, null, snapshot.resolve, snapshot.reject);
percy.syncQueue.push(data);
}
return {
...snapshot,
response
};
})
// handle possible build errors returned by the API
.handle('error', async (snapshot, error) => {
var _error$response, _error$response2;
let result = {
...snapshot,
error
};
let {
name,
meta
} = snapshot;
if (error.name === 'QueueClosedError') return result;
if (error.name === 'AbortError') return result;
let failed = ((_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.statusCode) === 422 && error.response.body.errors.find(e => {
var _e$source;
return ((_e$source = e.source) === null || _e$source === void 0 ? void 0 : _e$source.pointer) === '/data/attributes/build';
});
if (failed) {
build.error = error.message = failed.detail;
build.failed = true;
queue.close(true);
}
let errors = (_error$response2 = error.response) === null || _error$response2 === void 0 || (_error$response2 = _error$response2.body) === null || _error$response2 === void 0 ? void 0 : _error$response2.errors;
let duplicate = (errors === null || errors === void 0 ? void 0 : errors.length) > 1 && errors[1].detail.includes('must be unique');
if (duplicate) {
if (process.env.PERCY_IGNORE_DUPLICATES !== 'true') {
let errMsg = `Ignored duplicate snapshot. ${errors[1].detail}`;
percy.log.warn(errMsg, meta);
await percy.suggestionsForFix(errMsg, {
snapshotLevel: true,
snapshotName: name
});
}
return result;
}
let errMsg = `Encountered an error uploading snapshot: ${name}`;
percy.log.error(errMsg, meta);
percy.log.error(error, meta);
let snapshotErrors = [{
message: errMsg,
meta
}, {
message: error === null || error === void 0 ? void 0 : error.message,
meta
}];
await percy.suggestionsForFix(snapshotErrors, {
snapshotLevel: true,
snapshotName: name
});
if (snapshot.sync) snapshot.reject(error);
return result;
});
}