diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 879be7647..f0279f59d 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -107,10 +107,26 @@ export const exec = command('exec', { } } - // provide SDKs with useful env vars - env.PERCY_SERVER_ADDRESS = percy?.address(); - env.PERCY_BUILD_ID = percy?.build?.id; - env.PERCY_BUILD_URL = percy?.build?.url; + // provide SDKs with useful env vars; env is process.env, so assigning undefined would + // store the string "undefined" when percy is disabled + let sdkEnv = { + PERCY_SERVER_ADDRESS: percy?.address(), + PERCY_BUILD_ID: percy?.build?.id, + PERCY_BUILD_URL: percy?.build?.url + }; + + // Cypress is the one SDK that cannot read PERCY_SERVER_ADDRESS itself: its specs run in the + // browser, which only receives CYPRESS_-prefixed OS vars (via cy.env/Cypress.env), and + // Cypress 16 removed cy.exec, the last zero-config way to read the runner's env. Without + // this, `percy exec --port N -- cypress run` silently snapshots against :5338. Every other + // SDK reads PERCY_SERVER_ADDRESS directly, so no other alias is exported. Always overwrite + // so a stale inherited value can't point Cypress at a different server than the live one. + if (sdkEnv.PERCY_SERVER_ADDRESS) sdkEnv.CYPRESS_PERCY_SERVER_ADDRESS = sdkEnv.PERCY_SERVER_ADDRESS; + + for (let [key, value] of Object.entries(sdkEnv)) { + if (value != null) env[key] = value; + } + env.PERCY_LOGLEVEL = log.loglevel(); // run the provided command diff --git a/packages/cli-exec/test/exec.test.js b/packages/cli-exec/test/exec.test.js index 92d341998..45a9d858a 100644 --- a/packages/cli-exec/test/exec.test.js +++ b/packages/cli-exec/test/exec.test.js @@ -26,6 +26,9 @@ describe('percy exec', () => { delete process.env.PERCY_FORCE_PKG_VALUE; delete process.env.PERCY_ENABLE; delete process.env.PERCY_BUILD_ID; + delete process.env.PERCY_SERVER_ADDRESS; + delete process.env.CYPRESS_PERCY_SERVER_ADDRESS; + delete process.env.PERCY_BUILD_URL; delete process.env.PERCY_PARALLEL_TOTAL; delete process.env.PERCY_PARTIAL_BUILD; delete process.env.PERCY_CLIENT_ERROR_LOGS; @@ -427,6 +430,34 @@ describe('percy exec', () => { ])); }); + it('provides cypress with the live server address, overriding a stale value', async () => { + process.env.CYPRESS_PERCY_SERVER_ADDRESS = 'http://localhost:5338'; + + await expectAsync(exec(['--port=4567', '--', 'node', '--eval', ( + 'process.env.CYPRESS_PERCY_SERVER_ADDRESS === "http://localhost:4567" || process.exit(2)' + )])).toBeResolved(); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Percy has started!' + ])); + }); + + it('does not set percy env vars to "undefined" when percy is disabled', async () => { + process.env.PERCY_ENABLE = '0'; + + await expectAsync(exec(['--', 'node', '--eval', [ + 'let e = process.env;', + '[e.PERCY_SERVER_ADDRESS, e.PERCY_BUILD_ID, e.PERCY_BUILD_URL, e.CYPRESS_PERCY_SERVER_ADDRESS]', + '.includes("undefined") && process.exit(2)' + ].join('')])).toBeResolved(); + + expect(logger.stderr).toEqual(['[percy] Percy is disabled']); + expect(process.env.CYPRESS_PERCY_SERVER_ADDRESS).toBeUndefined(); + expect(process.env.PERCY_SERVER_ADDRESS).toBeUndefined(); + expect(process.env.PERCY_BUILD_ID).toBeUndefined(); + expect(process.env.PERCY_BUILD_URL).toBeUndefined(); + }); + it('provides the child process with a percy build id env var', async () => { await exec(['--', 'node', '--eval', ( 'process.env.PERCY_BUILD_ID === "123" || process.exit(2)' diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 3d9e92a5e..717371572 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -102,6 +102,27 @@ function assertNotCrossOrigin(req) { const SDK_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']); // Create a Percy CLI API server instance +const exactWidthNoticeLogged = new WeakSet(); + +// SDKs that size responsive captures from the healthcheck's snapshot.exactResponsiveWidth +// produce exact viewport widths, which can shift some existing baselines once +function logExactResponsiveWidthNotice(percy, body) { + if (exactWidthNoticeLogged.has(percy)) return; + + let snapshot = [].concat(body).find(s => Array.isArray(s?.domSnapshot) && s.domSnapshot.length > 1); + if (!snapshot) return; + + let exact = snapshot.exactResponsiveWidth ?? snapshot.exact_responsive_width ?? + percy.config.snapshot.exactResponsiveWidth; + if (exact === false) return; + + exactWidthNoticeLogged.add(percy); + logger('core:server').info([ + 'Responsive snapshots now use exact viewport widths; Edge, Firefox and remote-Chrome', + 'baselines may shift once. Set snapshot.exactResponsiveWidth: false to keep the previous behaviour.' + ].join(' ')); +} + export function createPercyServer(percy, port) { let pkg = getPackageJSON(import.meta.url); @@ -234,6 +255,7 @@ export function createPercyServer(percy, port) { .route('post', '/percy/snapshot', async (req, res) => { let data; const snapshotPromise = {}; + logExactResponsiveWidthNotice(percy, req.body); const snapshot = percy.snapshot(req.body, snapshotPromise); if (!req.url.searchParams.has('async')) await snapshot; diff --git a/packages/core/src/config.js b/packages/core/src/config.js index e46b5711a..39232c7a3 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -181,6 +181,10 @@ export const configSchema = { type: 'boolean', default: false }, + exactResponsiveWidth: { + type: 'boolean', + default: true + }, testCase: { type: 'string' }, @@ -564,6 +568,7 @@ export const snapshotSchema = { sync: { $ref: '/config/snapshot#/properties/sync' }, readiness: { $ref: '/config/snapshot#/properties/readiness' }, responsiveSnapshotCapture: { $ref: '/config/snapshot#/properties/responsiveSnapshotCapture' }, + exactResponsiveWidth: { $ref: '/config/snapshot#/properties/exactResponsiveWidth' }, testCase: { $ref: '/config/snapshot#/properties/testCase' }, labels: { $ref: '/config/snapshot#/properties/labels' }, thTestCaseExecutionId: { $ref: '/config/snapshot#/properties/thTestCaseExecutionId' }, diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index 40e7e20f0..c8ded92ff 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -335,6 +335,57 @@ describe('API Server', () => { ); }); + describe('exact responsive width notice', () => { + const notice = '[percy] Responsive snapshots now use exact viewport widths; ' + + 'Edge, Firefox and remote-Chrome baselines may shift once. ' + + 'Set snapshot.exactResponsiveWidth: false to keep the previous behaviour.'; + const responsive = (extra = {}) => ({ + name: 'responsive', + url: 'http://localhost:8000', + domSnapshot: [{ html: '', width: 375 }, { html: '', width: 1280 }], + ...extra + }); + + function post(body) { + return request('/percy/snapshot', { method: 'POST', body }); + } + + beforeEach(async () => { + spyOn(percy, 'snapshot').and.resolveTo(); + }); + + it('exposes the exactResponsiveWidth default through the healthcheck config', async () => { + await percy.start(); + let { config } = await request('/percy/healthcheck'); + expect(config.snapshot.exactResponsiveWidth).toBe(true); + }); + + it('logs once per percy instance on the first responsive DOM snapshot', async () => { + await percy.start(); + await post({ name: 'single', url: 'http://localhost:8000', domSnapshot: { html: '' } }); + await post({ name: 'one', url: 'http://localhost:8000', domSnapshot: [{ html: '', width: 375 }] }); + expect(logger.stdout).not.toContain(notice); + + await post([responsive()]); + await post(responsive({ name: 'again' })); + expect(logger.stdout.filter(l => l === notice).length).toBe(1); + }); + + it('does not log when exactResponsiveWidth is disabled in the config', async () => { + percy.config.snapshot.exactResponsiveWidth = false; + await percy.start(); + await post(responsive()); + expect(logger.stdout).not.toContain(notice); + }); + + it('does not log when a snapshot opts out of exactResponsiveWidth', async () => { + await percy.start(); + await post(responsive({ exactResponsiveWidth: false })); + await post(responsive({ exact_responsive_width: false })); + expect(logger.stdout).not.toContain(notice); + }); + }); + it('has a /snapshot endpoint that calls #snapshot() with provided options in sync mode', async () => { spyOn(percy.client, 'getSnapshotDetails').and.returnValue(getSnapshotDetailsResponse); spyOn(percy, 'snapshot').and.callFake((_, promise) => promise.test = new Promise((resolve, reject) => setTimeout(() => resolve(), 100))); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 262d323c3..4af15891b 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -86,6 +86,7 @@ describe('Percy', () => { disableShadowDOM: false, cliEnableJavaScript: true, responsiveSnapshotCapture: false, + exactResponsiveWidth: true, ignoreCanvasSerializationErrors: false, ignoreStyleSheetSerializationErrors: false, forceShadowAsLightDOM: false, diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index 4937bc291..bab56e8b3 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -197,6 +197,24 @@ describe('Snapshot', () => { ]); }); + it('accepts exactResponsiveWidth as a per-snapshot option', async () => { + await percy.snapshot([ + { name: 'exact camel', url: 'http://localhost:8000', exactResponsiveWidth: false }, + { name: 'exact snake', url: 'http://localhost:8000', exact_responsive_width: false } + ]); + + expect(logger.stderr).toEqual([]); + }); + + it('warns if exactResponsiveWidth is not a boolean', async () => { + await percy.snapshot({ name: 'exact invalid', url: 'http://localhost:8000', exactResponsiveWidth: 'no' }); + + expect(logger.stderr).toEqual([ + '[percy] Invalid snapshot options:', + '[percy] - exactResponsiveWidth: must be a boolean, received a string' + ]); + }); + it('warns on deprecated options', async () => { await percy.snapshot([ { url: 'http://localhost:8000/a', devicePixelRatio: 2 } diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 69ad69e06..d525f32c6 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -60,6 +60,7 @@ interface CommonSnapshotOptions { enableLayout?: boolean; sync?: boolean; responsiveSnapshotCapture?: boolean; + exactResponsiveWidth?: boolean; testCase?: string; labels?: string; reshuffleInvalidTags?: boolean;