UNPKG

@percy/cypress

Version:

Cypress client library for visual testing with Percy

309 lines (261 loc) 10 kB
const utils = require('@percy/sdk-utils'); const { createRegion } = require('./createRegion'); const { getEnvValue, lazyResolveAddress } = require('./env-utils'); const sdkPkg = require('./package.json'); const CLIENT_INFO = `${sdkPkg.name}/${sdkPkg.version}`; const ENV_INFO = `cypress/${Cypress.version}`; const CY_TIMEOUT = 30 * 1000 * 1.5; utils.percy.address = getEnvValue('PERCY_SERVER_ADDRESS'); utils.request.fetch = async function fetch(url, options) { options = { url, retryOnNetworkFailure: false, ...options }; return Cypress.backend('http:request', options); }; function cylog(message, meta) { Cypress.log({ name: 'percySnapshot', displayName: 'percy', consoleProps: () => meta, message }); } const SKIP_IFRAME_SRCS = [ 'about:blank', 'about:srcdoc', 'javascript:', 'data:', 'vbscript:', 'blob:', 'chrome:', 'chrome-extension:' ]; function processCrossOriginIframes(dom, domSnapshot, options, percyDOMScript) { const log = utils.logger('cypress'); try { const currentUrl = new URL(dom.URL); const iframes = dom.querySelectorAll('iframe'); const processedFrames = []; for (const iframe of iframes) { const src = iframe.getAttribute('src'); const srcdoc = iframe.getAttribute('srcdoc'); const srcLower = src ? src.toLowerCase() : ''; if (!src || srcdoc || SKIP_IFRAME_SRCS.some(p => srcLower === p || srcLower.startsWith(p))) continue; try { const frameUrl = new URL(src, currentUrl.href); if (frameUrl.origin === currentUrl.origin) continue; const percyElementId = iframe.getAttribute('data-percy-element-id'); if (!percyElementId) { log.debug(`Skipping cross-origin iframe ${frameUrl.href}: no data-percy-element-id`); continue; } let iframeSnapshot = null; try { const frameWindow = iframe.contentWindow; const frameDocument = iframe.contentDocument || frameWindow?.document; if (frameDocument) { if (!frameWindow.PercyDOM) { const script = frameDocument.createElement('script'); script.textContent = percyDOMScript; frameDocument.head.appendChild(script); frameDocument.head.removeChild(script); } if (frameWindow.PercyDOM) { iframeSnapshot = frameWindow.PercyDOM.serialize({ ...options, enableJavaScript: true }); } } } catch (accessError) { log.debug(`Cannot access cross-origin iframe directly (expected): ${accessError.message}`); iframeSnapshot = null; } processedFrames.push({ iframeData: { percyElementId }, iframeSnapshot, frameUrl: frameUrl.href }); } catch (e) { log.debug(`Skipping iframe "${src}": ${e.message}`); } } if (processedFrames.length > 0) { domSnapshot.corsIframes = processedFrames; } } catch (e) { log.debug(`Error during cross-origin iframe processing: ${e.message}`); } } function isResponsiveDOMCaptureValid(options) { if (utils.percy?.config?.percy?.deferUploads) { const log = utils.logger('cypress'); log.warn('Responsive capture disabled: deferUploads is enabled'); return false; } return ( options?.responsive_snapshot_capture || options?.responsiveSnapshotCapture || utils.percy?.config?.snapshot?.responsiveSnapshotCapture || false ); } // Shared preconditions: check interactive mode, verify Percy is enabled, and // fetch the PercyDOM serialization script. async function checkPreconditions(log, name) { if (Cypress.config('isInteractive') && !Cypress.config('enablePercyInteractiveMode')) { return { skip: true, reason: 'interactive' }; } if (!await utils.isPercyEnabled()) { return { skip: true, reason: 'disabled' }; } const percyDOMScript = await utils.fetchPercyDOM(); return { skip: false, percyDOMScript }; } function injectPercyDOM(percyDOMScript) { if (!window.PercyDOM) { // eslint-disable-next-line no-eval (0, eval)(percyDOMScript); } } Cypress.Commands.add('percySnapshot', (name, options = {}) => { const log = utils.logger('cypress'); lazyResolveAddress(log); if (typeof name === 'object') { options = name; name = undefined; } name = name || cy.state('runnable').fullTitle(); const meta = { snapshot: { name, testCase: options.testCase } }; const withLog = async (func, context, _throw) => { try { return await func(); } catch (error) { log.error(`Got error while ${context}`, meta); log.error(error, meta); log.error(error.stack, meta); if (_throw) throw error; return error; } }; const withRetry = async (func) => { let num = 1; const maxNum = 3; let error; while (num <= maxNum) { try { return await func(); } catch (e) { error = e; log.error(`Retrying... (${num}/${maxNum})`); await new Promise(res => setTimeout(res, 1000)); } num += 1; } throw error; }; const needsReload = getEnvValue('PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE')?.toString().toLowerCase() === 'true'; const originalWidth = Cypress.config('viewportWidth'); const originalHeight = Cypress.config('viewportHeight'); let _skip = false; let _percyDOMScript = null; let _widthHeights = null; let _snapshots = []; let _isResponsive = false; // Step 1: Preconditions (async — runs in cy.then) cy.then({ timeout: CY_TIMEOUT }, async () => { const preconditions = await checkPreconditions(log, name); if (preconditions.skip) { _skip = true; if (preconditions.reason === 'interactive') { cylog('Disabled in interactive mode', { details: 'use "cypress run" instead of "cypress open"', name }); } else { cylog('Not running', { name }); } return; } _percyDOMScript = preconditions.percyDOMScript; // Check responsive AFTER isPercyEnabled() populates utils.percy.config _isResponsive = isResponsiveDOMCaptureValid(options); try { if (_isResponsive) { _widthHeights = await utils.getResponsiveWidths(options.widths || []); } } catch (e) { log.debug('getResponsiveWidths not available — please upgrade @percy/cli to 1.31.10+. Using fallback widths.'); } }); // Step 2: Capture DOM at each width (flat cy commands — no async nesting) cy.then(() => { if (_skip) return; // Use CLI-provided width/height pairs when available, fallback to raw widths let widthHeights; if (_isResponsive) { widthHeights = _widthHeights || (options.widths || [originalWidth]).map(w => ({ width: w })); } else { widthHeights = [{ width: null }]; // null = don't resize, capture at current viewport } const useMinHeight = _isResponsive && getEnvValue('PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT')?.toString().toLowerCase() === 'true'; const defaultHeight = useMinHeight ? (utils.percy?.config?.snapshot?.minHeight || originalHeight) : originalHeight; const rawSleepTime = _isResponsive ? (getEnvValue('PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME') || getEnvValue('RESPONSIVE_CAPTURE_SLEEP_TIME')) : null; const sleepMs = rawSleepTime ? parseInt(rawSleepTime, 10) * 1000 : 0; let lastWindowWidth = originalWidth; let lastWindowHeight = defaultHeight; for (let { width, height } of widthHeights) { height = height || defaultHeight; // Resize viewport only when dimensions change (skip redundant resizes) if (width !== null && (lastWindowWidth !== width || lastWindowHeight !== height)) { cy.viewport(width, height); lastWindowWidth = width; lastWindowHeight = height; } // Reload page at new viewport (responsive + reload mode only) if (width !== null && needsReload) { cy.reload({ log: false }); cy.document({ log: false }).its('readyState').should('eq', 'complete'); } if (sleepMs > 0) cy.wait(sleepMs, { log: false }); // Serialize DOM and collect snapshot cy.document({ log: false }).then(async doc => { if (_skip || !_percyDOMScript) return; injectPercyDOM(_percyDOMScript); const domSnapshot = window.PercyDOM.serialize({ ...options, dom: doc }); if (width !== null) domSnapshot.width = width; processCrossOriginIframes(doc, domSnapshot, options, _percyDOMScript); _snapshots.push(domSnapshot); }); } }); // Restore viewport after responsive capture cy.then(() => { if (_isResponsive) { cy.viewport(originalWidth, originalHeight); } }); // Step 3: Post snapshot(s) with cookies, retry, and error handling cy.then(() => { if (_skip || _snapshots.length === 0) return; cy.getCookies({ log: false }).then(cookies => { if (cookies && cookies.length > 0) { for (const snap of _snapshots) { snap.cookies = cookies; } } cy.document({ log: false }).then({ timeout: CY_TIMEOUT }, async doc => { const throwConfig = Cypress.config('percyThrowErrorOnFailure'); const _throw = throwConfig === undefined ? false : throwConfig; // Post single snapshot or array depending on mode const domSnapshot = _isResponsive ? _snapshots : _snapshots[0]; try { let response = await withRetry(async () => await withLog(async () => { return await utils.postSnapshot({ ...options, environmentInfo: ENV_INFO, clientInfo: CLIENT_INFO, domSnapshot, url: doc.URL, name }); }, 'posting dom snapshot', _throw)); cylog(name, meta); return response; } catch (err) { log.error(`Failed to post snapshot "${name}"`, err); } finally { _snapshots = []; } }); }); }); }); module.exports = { createRegion };