UNPKG

@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

327 lines (313 loc) 13.3 kB
import fs from 'fs'; import path, { dirname, resolve } from 'path'; import logger from '@percy/logger'; import { normalize } from '@percy/config/utils'; import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler } from './utils.js'; import WebdriverUtils from '@percy/webdriver-utils'; import { handleSyncJob } from './snapshot.js'; // Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module. // This approach relied on `createRequire`, which is Node.js-specific and less compatible with modern ESM (ECMAScript Module) standards. // This was leading to hard coded paths when CLI is used as a dependency in another project. // Now, we use `fileURLToPath` and `path.resolve` to determine the absolute path in a way that's more aligned with ESM conventions. // This change ensures better compatibility and avoids relying on Node.js-specific APIs that might cause issues in ESM environments. import { fileURLToPath } from 'url'; import { createRequire } from 'module'; export const getPercyDomPath = url => { try { return createRequire(url).resolve('@percy/dom'); } catch (error) { logger('core:server').warn(['Failed to resolve @percy/dom path using createRequire.', 'Falling back to using fileURLToPath and path.resolve.'].join(' ')); } const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); return resolve(__dirname, 'node_modules/@percy/dom'); }; // Resolved path for PERCY_DOM export const PERCY_DOM = getPercyDomPath(import.meta.url); // Returns a URL encoded string of nested query params function encodeURLSearchParams(subj, prefix) { return typeof subj === 'object' ? Object.entries(subj).map(([key, value]) => encodeURLSearchParams(value, prefix ? `${prefix}[${key}]` : key)).join('&') : `${prefix}=${encodeURIComponent(subj)}`; } // Create a Percy CLI API server instance export function createPercyServer(percy, port) { let pkg = getPackageJSON(import.meta.url); let server = Server.createServer({ port }) // general middleware .route((req, res, next) => { var _percy$testing, _percy$testing4, _percy$testing5; // treat all request bodies as json if (req.body) try { req.body = JSON.parse(req.body); } catch {} // add version header res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version'); // skip or change api version header in testing mode if (((_percy$testing = percy.testing) === null || _percy$testing === void 0 ? void 0 : _percy$testing.version) !== false) { var _percy$testing2; res.setHeader('X-Percy-Core-Version', ((_percy$testing2 = percy.testing) === null || _percy$testing2 === void 0 ? void 0 : _percy$testing2.version) ?? pkg.version); } // track all api reqeusts in testing mode if (percy.testing && !req.url.pathname.startsWith('/test/')) { var _percy$testing3; ((_percy$testing3 = percy.testing).requests || (_percy$testing3.requests = [])).push({ url: `${req.url.pathname}${req.url.search}`, method: req.method, body: req.body }); } // support sabotaging requests in testing mode if (((_percy$testing4 = percy.testing) === null || _percy$testing4 === void 0 || (_percy$testing4 = _percy$testing4.api) === null || _percy$testing4 === void 0 ? void 0 : _percy$testing4[req.url.pathname]) === 'error') { next = () => { var _percy$testing$build; return Promise.reject(new Error(((_percy$testing$build = percy.testing.build) === null || _percy$testing$build === void 0 ? void 0 : _percy$testing$build.error) || 'testing')); }; } else if (((_percy$testing5 = percy.testing) === null || _percy$testing5 === void 0 || (_percy$testing5 = _percy$testing5.api) === null || _percy$testing5 === void 0 ? void 0 : _percy$testing5[req.url.pathname]) === 'disconnect') { next = () => req.connection.destroy(); } // return json errors return next().catch(e => { var _percy$testing6; return res.json(e.status ?? 500, { build: ((_percy$testing6 = percy.testing) === null || _percy$testing6 === void 0 ? void 0 : _percy$testing6.build) || percy.build, error: e.message, success: false }); }); }) // healthcheck returns basic information .route('get', '/percy/healthcheck', (req, res) => { var _percy$testing7; return res.json(200, { build: ((_percy$testing7 = percy.testing) === null || _percy$testing7 === void 0 ? void 0 : _percy$testing7.build) ?? percy.build, loglevel: percy.loglevel(), config: percy.config, widths: { // This is always needed even if width is passed mobile: percy.deviceDetails ? percy.deviceDetails.map(d => d.width) : [], // This will only be used if width is not passed in options config: percy.config.snapshot.widths }, success: true, type: percy.client.tokenType() }); }) // get or set config options .route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, { config: req.body ? percy.set(req.body) : percy.config, success: true })) // responds once idle (may take a long time) .route('get', '/percy/idle', async (req, res) => res.json(200, { success: await percy.idle().then(() => true) })) // convenient @percy/dom bundle .route('get', '/percy/dom.js', (req, res) => { return res.file(200, PERCY_DOM); }) // legacy agent wrapper for @percy/dom .route('get', '/percy-agent.js', async (req, res) => { logger('core:server').deprecated(['It looks like you’re using @percy/cli with an older SDK.', 'Please upgrade to the latest version to fix this warning.', 'See these docs for more info: https://www.browserstack.com/docs/percy/migration/migrate-to-cli'].join(' ')); let content = await fs.promises.readFile(PERCY_DOM, 'utf-8'); let wrapper = '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });'; return res.send(200, 'applicaton/javascript', content.concat(wrapper)); }) // post one or more snapshots, optionally async .route('post', '/percy/snapshot', async (req, res) => { let data; const snapshotPromise = {}; const snapshot = percy.snapshot(req.body, snapshotPromise); if (!req.url.searchParams.has('async')) await snapshot; if (percy.syncMode(req.body)) data = await handleSyncJob(snapshotPromise[req.body.name], percy, 'snapshot'); return res.json(200, { success: true, data: data }); }) // post one or more comparisons, optionally waiting .route('post', '/percy/comparison', async (req, res) => { let data; if (percy.syncMode(req.body)) { const snapshotPromise = new Promise((resolve, reject) => percy.upload(req.body, { resolve, reject }, 'app')); data = await handleSyncJob(snapshotPromise, percy, 'comparison'); } else { let upload = percy.upload(req.body, null, 'app'); if (req.url.searchParams.has('await')) await upload; } // generate and include one or more redirect links to comparisons let link = ({ name, tag }) => { var _percy$build; return [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({ buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id, snapshot: { name }, tag }, { snake: true }))].join(''); }; const response = { success: true, data: data }; if (req.body) { if (Array.isArray(req.body)) { response.links = req.body.map(link); } else { response.link = link(req.body); } } return res.json(200, response); }) // flushes one or more snapshots from the internal queue .route('post', '/percy/flush', async (req, res) => res.json(200, { success: await percy.flush(req.body).then(() => true) })).route('post', '/percy/automateScreenshot', async (req, res) => { let data; percyAutomateRequestHandler(req, percy); let comparisonData = await WebdriverUtils.captureScreenshot(req.body); if (percy.syncMode(comparisonData)) { const snapshotPromise = new Promise((resolve, reject) => percy.upload(comparisonData, { resolve, reject }, 'automate')); data = await handleSyncJob(snapshotPromise, percy, 'comparison'); } else { percy.upload(comparisonData, null, 'automate'); } res.json(200, { success: true, data: data }); }) // Receives events from sdk's. .route('post', '/percy/events', async (req, res) => { var _percy$build2; const body = percyBuildEventHandler(req, pkg.version); await percy.client.sendBuildEvents((_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id, body); res.json(200, { success: true }); }).route('post', '/percy/log', async (req, res) => { const log = logger('sdk'); if (!req.body) { log.error('No request body for /percy/log endpoint'); return res.json(400, { error: 'No body passed' }); } const level = req.body.level; const message = req.body.message; const meta = req.body.meta || {}; log[level](message, meta); res.json(200, { success: true }); }) // stops percy at the end of the current event loop .route('/percy/stop', (req, res) => { setImmediate(() => percy.stop()); return res.json(200, { success: true }); }); // add test endpoints only in testing mode return !percy.testing ? server : server // manipulates testing mode configuration to trigger specific scenarios .route('/test/api/:cmd', ({ body, params: { cmd } }, res) => { body = Buffer.isBuffer(body) ? body.toString() : body; if (cmd === 'reset') { // the reset command will reset testing mode and clear any logs percy.testing = {}; logger.instance.messages.clear(); } else if (cmd === 'version') { // the version command will update the api version header for testing percy.testing.version = body; } else if (cmd === 'config') { var _body$mobile; percy.config.snapshot.widths = body.config; percy.deviceDetails = (_body$mobile = body.mobile) === null || _body$mobile === void 0 ? void 0 : _body$mobile.map(w => { return { width: w }; }); percy.config.snapshot.responsiveSnapshotCapture = !!body.responsive; percy.config.percy.deferUploads = !!body.deferUploads; } else if (cmd === 'error' || cmd === 'disconnect') { var _percy$testing8; // the error or disconnect commands will cause specific endpoints to fail ((_percy$testing8 = percy.testing).api || (_percy$testing8.api = {}))[body] = cmd; } else if (cmd === 'build-failure') { // the build-failure command will cause api errors to include a failed build percy.testing.build = { failed: true, error: 'Build failed' }; } else if (cmd === 'build-created') { // the build-failure command will cause api errors to include a failed build percy.testing.build = { id: '123', url: 'https://percy.io/test/test/123' }; } else { // 404 for unknown commands return res.send(404); } return res.json(200, { success: true }); }) // returns an array of raw requests made to the api .route('get', '/test/requests', (req, res) => res.json(200, { requests: percy.testing.requests })) // returns an array of raw logs from the logger .route('get', '/test/logs', (req, res) => res.json(200, { logs: Array.from(logger.instance.messages) })) // serves a very basic html page for testing snapshots .route('get', '/test/snapshot', (req, res) => { return res.send(200, 'text/html', '<p>Snapshot Me!</p>'); }); } // Create a static server instance with an automatic sitemap export function createStaticServer(options) { let { serve: dir, baseUrl = '' } = options; let server = Server.createServer(options); // remove trailing slashes so the base snapshot name matches other snapshots baseUrl = baseUrl.replace(/\/$/, ''); // used when generating an automatic sitemap let toURL = Server.createRewriter( // reverse rewrites' src, dest, & order Object.entries((options === null || options === void 0 ? void 0 : options.rewrites) ?? {}).reduce((acc, rw) => [rw.reverse(), ...acc], []), (filename, rewrite) => new URL(path.posix.join('/', baseUrl, // cleanUrls will trim trailing .html/index.html from paths !options.cleanUrls ? rewrite(filename) : rewrite(filename).replace(/(\/index)?\.html$/, '')), server.address())); // include automatic sitemap route server.route('get', `${baseUrl}/sitemap.xml`, async (req, res) => { let { default: glob } = await import('fast-glob'); let files = await glob('**/*.html', { cwd: dir, fs }); return res.send(200, 'application/xml', ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ...files.map(name => ` <url><loc>${toURL(name)}</loc></url>`), '</urlset>'].join('\n')); }); return server; }