@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
576 lines (542 loc) • 22 kB
JavaScript
import logger from '@percy/logger';
import Queue from './queue.js';
import Page from './page.js';
import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser, isGzipped, maybeScrollToBottom } from './utils.js';
import { sha256hash } from '@percy/client/utils';
import Pako from 'pako';
// Logs verbose debug logs detailing various snapshot options.
function debugSnapshotOptions(snapshot) {
let log = logger('core:snapshot');
// log snapshot info
log.debug('---------', snapshot.meta);
log.debug(`Received 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 === null || o === void 0 ? void 0 : 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, 'scope');
debugProp(snapshot, 'widths', v => `${v}px`);
debugProp(snapshot, 'minHeight', v => `${v}px`);
debugProp(snapshot, 'enableJavaScript');
debugProp(snapshot, 'cliEnableJavaScript');
debugProp(snapshot, 'disableShadowDOM');
debugProp(snapshot, 'enableLayout');
debugProp(snapshot, 'domTransformation');
debugProp(snapshot, 'reshuffleInvalidTags');
debugProp(snapshot, 'deviceScaleFactor');
debugProp(snapshot, 'waitForTimeout');
debugProp(snapshot, 'waitForSelector');
debugProp(snapshot, 'scopeOptions.scroll');
debugProp(snapshot, 'browsers');
debugProp(snapshot, 'execute.afterNavigation');
debugProp(snapshot, 'execute.beforeResize');
debugProp(snapshot, 'execute.afterResize');
debugProp(snapshot, 'execute.beforeSnapshot');
debugProp(snapshot, 'discovery.allowedHostnames');
debugProp(snapshot, 'discovery.disallowedHostnames');
debugProp(snapshot, 'discovery.devicePixelRatio');
debugProp(snapshot, 'discovery.requestHeaders', JSON.stringify);
debugProp(snapshot, 'discovery.authorization', JSON.stringify);
debugProp(snapshot, 'discovery.disableCache');
debugProp(snapshot, 'discovery.captureMockedServiceWorker');
debugProp(snapshot, 'discovery.captureSrcset');
debugProp(snapshot, 'discovery.userAgent');
debugProp(snapshot, 'clientInfo');
debugProp(snapshot, 'environmentInfo');
debugProp(snapshot, 'domSnapshot', Boolean);
debugProp(snapshot, 'discovery.scrollToBottom');
if (Array.isArray(snapshot.domSnapshot)) {
debugProp(snapshot, 'domSnapshot.0.userAgent');
} else {
debugProp(snapshot, 'domSnapshot.userAgent');
}
for (let added of snapshot.additionalSnapshots || []) {
log.debug(`Additional snapshot: ${added.name}`, snapshot.meta);
debugProp(added, 'waitForTimeout');
debugProp(added, 'waitForSelector');
debugProp(added, 'execute');
}
}
// parse browser cookies in correct format if flag is enabled
function parseCookies(cookies) {
if (process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES === 'true') return null;
// If cookies is collected via SDK
if (Array.isArray(cookies) && cookies.every(item => typeof item === 'object' && 'name' in item && 'value' in item)) {
// omit other fields reason sometimes expiry comes as actual date where we expect it to be double
return cookies.map(c => ({
name: c.name,
value: c.value,
secure: c.secure,
domain: c.domain
}));
}
if (!(typeof cookies === 'string' && cookies !== '')) return null;
// it assumes that cookiesStr is string returned by document.cookie
const cookiesStr = cookies;
return cookiesStr.split('; ').map(c => {
const eqIdx = c.indexOf('=');
const name = c.substring(0, eqIdx);
const value = c.substring(eqIdx + 1);
const cookieObj = {
name,
value
};
if (name.startsWith('__Secure')) {
cookieObj.secure = true;
}
return cookieObj;
});
}
// Wait for a page's asset discovery network to idle
function waitForDiscoveryNetworkIdle(page, options) {
let {
allowedHostnames,
networkIdleTimeout,
captureResponsiveAssetsEnabled
} = options;
let filter = r => hostnameMatches(allowedHostnames, r.url);
return page.network.idle(filter, networkIdleTimeout, captureResponsiveAssetsEnabled);
}
async function waitForFontLoading(page) {
return await logger.measure('core:discovery', 'waitForFontLoading', undefined, async () => {
return await Promise.race([page.eval('await document.fonts.ready;'), new Promise(res => setTimeout(res, 5000))]);
});
}
// Creates an initial resource map for a snapshot containing serialized DOM
function parseDomResources({
url,
domSnapshot
}) {
const map = new Map();
if (!domSnapshot) return map;
let allRootResources = new Set();
let allResources = new Set();
if (!Array.isArray(domSnapshot)) {
domSnapshot = [domSnapshot];
}
for (let dom of domSnapshot) {
let isHTML = typeof dom === 'string';
let {
html,
resources = []
} = isHTML ? {
html: dom
} : dom;
resources.forEach(r => allResources.add(r));
const attrs = dom.width ? {
widths: [dom.width]
} : {};
let rootResource = createRootResource(url, html, attrs);
allRootResources.add(rootResource);
}
allRootResources = Array.from(allRootResources);
map.set(allRootResources[0].url, allRootResources);
allResources = Array.from(allResources);
// reduce the array of resources into a keyed map
return allResources.reduce((map, {
url,
content,
mimetype
}) => {
// serialized resource contents are base64 encoded
content = Buffer.from(content, mimetype.includes('text') ? 'utf8' : 'base64');
// specify the resource as provided to prevent overwriting during asset discovery
let resource = createResource(url, content, mimetype, {
provided: true
});
// key the resource by its url and return the map
return map.set(resource.url, resource);
// the initial map is created with at least a root resource
}, map);
}
function createAndApplyPercyCSS({
percyCSS,
roots
}) {
let css = createPercyCSSResource(roots[0].url, percyCSS);
// replace root contents and associated properties
roots.forEach(root => {
Object.assign(root, createRootResource(root.url, root.content.replace(/(<\/body>)(?!.*\1)/is, `<link data-percy-specific-css rel="stylesheet" href="${css.pathname}"/>` + '$&')));
});
return css;
}
// Calls the provided callback with additional resources
function processSnapshotResources({
domSnapshot,
resources,
...snapshot
}) {
var _resources;
let log = logger('core:snapshot');
resources = [...(((_resources = resources) === null || _resources === void 0 ? void 0 : _resources.values()) ?? [])];
// find any root resource matching the provided dom snapshot
// since root resources are stored as array
let roots = resources.find(r => Array.isArray(r));
// initialize root resources if needed
if (!roots) {
let domResources = parseDomResources({
...snapshot,
domSnapshot
});
resources = [...domResources.values(), ...resources];
roots = resources.find(r => Array.isArray(r));
}
// inject Percy CSS
if (snapshot.percyCSS) {
// check @percy/dom/serialize-dom.js
let domSnapshotHints = (domSnapshot === null || domSnapshot === void 0 ? void 0 : domSnapshot.hints) ?? [];
if (domSnapshotHints.includes('DOM elements found outside </body>')) {
log.warn('DOM elements found outside </body>, percyCSS might not work');
}
const percyCSSReource = createAndApplyPercyCSS({
percyCSS: snapshot.percyCSS,
roots
});
resources.push(percyCSSReource);
}
// For multi dom root resources are stored as array
resources = resources.flat();
// include associated snapshot logs matched by meta information
resources.push(createLogResource(logger.query(log => {
var _log$meta$snapshot, _log$meta$snapshot2;
return ((_log$meta$snapshot = log.meta.snapshot) === null || _log$meta$snapshot === void 0 ? void 0 : _log$meta$snapshot.testCase) === snapshot.meta.snapshot.testCase && ((_log$meta$snapshot2 = log.meta.snapshot) === null || _log$meta$snapshot2 === void 0 ? void 0 : _log$meta$snapshot2.name) === snapshot.meta.snapshot.name;
})));
if (process.env.PERCY_GZIP) {
for (let index = 0; index < resources.length; index++) {
const alreadyZipped = isGzipped(resources[index].content);
/* istanbul ignore next: very hard to mock true */
if (!alreadyZipped) {
resources[index].content = Pako.gzip(resources[index].content);
resources[index].sha = sha256hash(resources[index].content);
}
}
}
return {
...snapshot,
resources
};
}
// Triggers the capture of resource requests for a page by iterating over snapshot widths to resize
// the page and calling any provided execute options.
async function* captureSnapshotResources(page, snapshot, options) {
var _snapshot$domSnapshot, _snapshot$domSnapshot2;
const log = logger('core:discovery');
let {
discovery,
additionalSnapshots = [],
...baseSnapshot
} = snapshot;
let {
capture,
captureWidths,
deviceScaleFactor,
mobile,
captureForDevices
} = options;
let cookies = ((_snapshot$domSnapshot = snapshot.domSnapshot) === null || _snapshot$domSnapshot === void 0 ? void 0 : _snapshot$domSnapshot.cookies) || ((_snapshot$domSnapshot2 = snapshot.domSnapshot) === null || _snapshot$domSnapshot2 === void 0 || (_snapshot$domSnapshot2 = _snapshot$domSnapshot2[0]) === null || _snapshot$domSnapshot2 === void 0 ? void 0 : _snapshot$domSnapshot2.cookies);
cookies = parseCookies(cookies);
// iterate over device to trigger reqeusts and capture other dpr width
async function* captureResponsiveAssets() {
for (const device of captureForDevices) {
discovery = {
...discovery,
captureResponsiveAssetsEnabled: true
};
// We are not adding these widths and pixels ratios in loop below because we want to explicitly reload the page after resize which we dont do below
yield* captureSnapshotResources(page, {
...snapshot,
discovery,
widths: [device.width]
}, {
deviceScaleFactor: device.deviceScaleFactor,
mobile: true
});
yield waitForFontLoading(page);
yield waitForDiscoveryNetworkIdle(page, discovery);
}
}
// used to take snapshots and remove any discovered root resource
async function* takeSnapshot(options, width) {
if (captureWidths) options = {
...options,
width
};
let captured = await page.snapshot(options);
yield* captureResponsiveAssets();
captured.resources.delete(normalizeURL(captured.url));
capture(processSnapshotResources(captured));
return captured;
}
;
// used to resize the using capture options
let resizePage = width => {
page.network.intercept.currentWidth = width;
return page.resize({
height: snapshot.minHeight,
deviceScaleFactor,
mobile,
width
});
};
// navigate to the url
yield resizePage(snapshot.widths[0]);
yield page.goto(snapshot.url, {
cookies,
forceReload: discovery.captureResponsiveAssetsEnabled
});
// wait for any specified timeout
if (snapshot.discovery.waitForTimeout && page.enableJavaScript) {
log.debug(`Wait for ${snapshot.discovery.waitForTimeout}ms timeout`);
await waitForTimeout(snapshot.discovery.waitForTimeout);
}
// wait for any specified selector
if (snapshot.discovery.waitForSelector && page.enableJavaScript) {
log.debug(`Wait for selector: ${snapshot.discovery.waitForSelector}`);
await waitForSelectorInsideBrowser(page, snapshot.discovery.waitForSelector, Page.TIMEOUT);
}
if (snapshot.execute) {
// when any execute options are provided, inject snapshot options
/* istanbul ignore next: cannot detect coverage of injected code */
yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
yield page.evaluate(snapshot.execute.afterNavigation);
}
yield* maybeScrollToBottom(page, discovery);
// Running before page idle since this will trigger many network calls
// so need to run as early as possible. plus it is just reading urls from dom srcset
// which will be already loaded after navigation complete
// Don't run incase of responsiveSnapshotCapture since we are running discovery for all widths so images will get captured in all required widths
if (!snapshot.responsiveSnapshotCapture && discovery.captureSrcset) {
await page.insertPercyDom();
yield page.eval('window.PercyDOM.loadAllSrcsetLinks()');
}
// iterate over additional snapshots for proper DOM capturing
for (let additionalSnapshot of [baseSnapshot, ...additionalSnapshots]) {
let isBaseSnapshot = additionalSnapshot === baseSnapshot;
let snap = {
...baseSnapshot,
...additionalSnapshot
};
let {
widths,
execute
} = snap;
let [width] = widths;
// iterate over widths to trigger reqeusts and capture other widths
if (isBaseSnapshot || captureWidths) {
for (let i = 0; i < widths.length - 1; i++) {
if (captureWidths) yield* takeSnapshot(snap, width);
yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.beforeResize);
yield waitForFontLoading(page);
yield waitForDiscoveryNetworkIdle(page, discovery);
yield resizePage(width = widths[i + 1]);
if (snapshot.responsiveSnapshotCapture) {
yield page.goto(snapshot.url, {
cookies,
forceReload: true
});
}
yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.afterResize);
yield* maybeScrollToBottom(page, discovery);
}
}
if (capture && !snapshot.domSnapshot) {
// capture this snapshot and update the base snapshot after capture
let captured = yield* takeSnapshot(snap, width);
if (isBaseSnapshot) baseSnapshot = captured;
// resize back to the initial width when capturing additional snapshot widths
if (captureWidths && additionalSnapshots.length) {
let l = additionalSnapshots.indexOf(additionalSnapshot) + 1;
if (l < additionalSnapshots.length) yield resizePage(snapshot.widths[0]);
}
}
}
// recursively trigger resource requests for any alternate device pixel ratio
if (discovery.devicePixelRatio) {
log.deprecated('discovery.devicePixelRatio is deprecated percy will now auto capture resource in all devicePixelRatio, Ignoring configuration');
}
// wait for final network idle when not capturing DOM
if (capture && snapshot.domSnapshot) {
yield waitForFontLoading(page);
yield waitForDiscoveryNetworkIdle(page, discovery);
yield* captureResponsiveAssets();
capture(processSnapshotResources(snapshot));
}
}
// Pushes all provided snapshots to a discovery queue with the provided callback, yielding to each
// one concurrently. When skipping asset discovery, the callback is called immediately for each
// snapshot, also processing snapshot resources when not dry-running.
export async function* discoverSnapshotResources(queue, options, callback) {
let {
snapshots,
skipDiscovery,
dryRun,
checkAndUpdateConcurrency
} = options;
yield* yieldAll(snapshots.reduce((all, snapshot) => {
debugSnapshotOptions(snapshot);
if (skipDiscovery) {
let {
additionalSnapshots,
...baseSnapshot
} = snapshot;
additionalSnapshots = dryRun && additionalSnapshots || [];
for (let snap of [baseSnapshot, ...additionalSnapshots]) {
callback(dryRun ? snap : processSnapshotResources(snap));
}
} else {
// update concurrency before pushing new job in discovery queue
// if case of monitoring is stopped due to in-activity,
// it can take upto 1 sec to execute this fun
checkAndUpdateConcurrency();
all.push(queue.push(snapshot, callback));
}
return all;
}, []));
}
// Used to cache resources across core instances
export const RESOURCE_CACHE_KEY = Symbol('resource-cache');
// Creates an asset discovery queue that uses the percy browser instance to create a page for each
// snapshot which is used to intercept and capture snapshot resource requests.
export function createDiscoveryQueue(percy) {
let {
concurrency
} = percy.config.discovery;
let queue = new Queue('discovery');
let cache;
return queue.set({
concurrency
})
// on start, launch the browser and run the queue
.handle('start', async () => {
cache = percy[RESOURCE_CACHE_KEY] = new Map();
// If browser.launch() fails it will get captured in
// *percy.start()
await percy.browser.launch();
queue.run();
})
// on end, close the browser
.handle('end', async () => {
await percy.browser.close();
})
// snapshots are unique by name and testCase; when deferred also by widths
.handle('find', ({
name,
testCase,
widths
}, snapshot) => snapshot.testCase === testCase && snapshot.name === name && (!percy.deferUploads || !widths || widths.join() === snapshot.widths.join()))
// initialize the resources for DOM snapshots
.handle('push', snapshot => {
let resources = parseDomResources(snapshot);
return {
...snapshot,
resources
};
})
// discovery resources for snapshots and call the callback for each discovered snapshot
.handle('task', async function* (snapshot, callback) {
await logger.measure('asset-discovery', snapshot.name, snapshot.meta, async () => {
percy.log.debug(`Discovering resources: ${snapshot.name}`, snapshot.meta);
// expectation explained in tests
/* istanbul ignore next: tested, but coverage is stripped */
let assetDiscoveryPageEnableJS = snapshot.cliEnableJavaScript && !snapshot.domSnapshot || (snapshot.enableJavaScript ?? !snapshot.domSnapshot);
percy.log.debug(`Asset discovery Browser Page enable JS: ${assetDiscoveryPageEnableJS}`, snapshot.meta);
await withRetries(async function* () {
// create a new browser page
let page = yield percy.browser.page({
enableJavaScript: assetDiscoveryPageEnableJS,
networkIdleTimeout: snapshot.discovery.networkIdleTimeout,
requestHeaders: snapshot.discovery.requestHeaders,
authorization: snapshot.discovery.authorization,
userAgent: snapshot.discovery.userAgent,
captureMockedServiceWorker: snapshot.discovery.captureMockedServiceWorker,
meta: {
...snapshot.meta,
snapshotURL: snapshot.url
},
// enable network inteception
intercept: {
enableJavaScript: snapshot.enableJavaScript,
disableCache: snapshot.discovery.disableCache,
allowedHostnames: snapshot.discovery.allowedHostnames,
disallowedHostnames: snapshot.discovery.disallowedHostnames,
getResource: (u, width = null) => {
let resource = snapshot.resources.get(u) || cache.get(u);
if (resource && Array.isArray(resource) && resource[0].root) {
const rootResource = resource.find(r => {
var _r$widths;
return (_r$widths = r.widths) === null || _r$widths === void 0 ? void 0 : _r$widths.includes(width);
});
resource = rootResource || resource[0];
}
return resource;
},
saveResource: r => {
const limitResources = process.env.LIMIT_SNAPSHOT_RESOURCES || false;
const MAX_RESOURCES = Number(process.env.MAX_SNAPSHOT_RESOURCES) || 749;
if (limitResources && snapshot.resources.size >= MAX_RESOURCES) {
percy.log.debug(`Skipping resource ${r.url} — resource limit reached`);
return;
}
snapshot.resources.set(r.url, r);
if (!snapshot.discovery.disableCache) {
cache.set(r.url, r);
}
}
}
});
try {
yield* captureSnapshotResources(page, snapshot, {
captureWidths: !snapshot.domSnapshot && percy.deferUploads,
capture: callback,
captureForDevices: percy.deviceDetails || []
});
} finally {
// always close the page when done
await page.close();
}
}, {
count: snapshot.discovery.retry ? 3 : 1,
onRetry: () => {
percy.log.info(`Retrying snapshot: ${snapshotLogName(snapshot.name, snapshot.meta)}`, snapshot.meta);
},
signal: snapshot._ctrl.signal,
throwOn: ['AbortError']
});
});
}).handle('error', async ({
name,
meta
}, error) => {
if (error.name === 'AbortError' && queue.readyState < 3) {
// only error about aborted snapshots when not closed
let errMsg = 'Received a duplicate snapshot, ' + `the previous snapshot was aborted: ${snapshotLogName(name, meta)}`;
percy.log.error(errMsg, {
snapshotLevel: true,
snapshotName: name
});
await percy.suggestionsForFix(errMsg, meta);
} else {
// log all other encountered errors
let errMsg = `Encountered an error taking snapshot: ${name}`;
percy.log.error(errMsg, meta);
percy.log.error(error, meta);
let assetDiscoveryErrors = [{
message: errMsg,
meta
}, {
message: error === null || error === void 0 ? void 0 : error.message,
meta
}];
await percy.suggestionsForFix(assetDiscoveryErrors, {
snapshotLevel: true,
snapshotName: name
});
}
});
}