From 6539aa2370c82123dcf6903d14e3be5da50931fe Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Mon, 19 Oct 2026 14:48:56 +0530 Subject: [PATCH 1/3] PPLT-6233: export PERCY_CLI_API and CYPRESS_PERCY_SERVER_ADDRESS from percy exec The Python and .NET SDKs read PERCY_CLI_API and the Cypress browser only sees CYPRESS_-prefixed vars, so with a non-default --port they fell back to 5338 and silently disabled snapshots. Export both with the server address, keeping any value the user already set. Co-Authored-By: Claude Opus 5.5 --- packages/cli-exec/src/exec.js | 11 +++++++++- packages/cli-exec/test/exec.test.js | 31 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 879be7647..9c72a1154 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -108,7 +108,16 @@ export const exec = command('exec', { } // provide SDKs with useful env vars - env.PERCY_SERVER_ADDRESS = percy?.address(); + let address = percy?.address(); + env.PERCY_SERVER_ADDRESS = address; + + if (address) { + // the Python and .NET SDKs read PERCY_CLI_API, and Cypress only forwards CYPRESS_-prefixed + // vars to the browser; without these a non-default --port leaves those SDKs on 5338 + env.PERCY_CLI_API ||= address; + env.CYPRESS_PERCY_SERVER_ADDRESS ||= address; + } + env.PERCY_BUILD_ID = percy?.build?.id; env.PERCY_BUILD_URL = percy?.build?.url; env.PERCY_LOGLEVEL = log.loglevel(); diff --git a/packages/cli-exec/test/exec.test.js b/packages/cli-exec/test/exec.test.js index 92d341998..3834e9698 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.PERCY_CLI_API; + delete process.env.CYPRESS_PERCY_SERVER_ADDRESS; 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 the child process with the percy server address in every form SDKs read', async () => { + await expectAsync(exec(['--port=4567', '--', 'node', '--eval', [ + 'let e = process.env, a = "http://localhost:4567";', + '(e.PERCY_SERVER_ADDRESS === a && e.PERCY_CLI_API === a &&', + ' e.CYPRESS_PERCY_SERVER_ADDRESS === a) || process.exit(2)' + ].join('')])).toBeResolved(); + }); + + it('does not override server address env vars the user already set', async () => { + process.env.PERCY_CLI_API = 'http://custom:1111'; + process.env.CYPRESS_PERCY_SERVER_ADDRESS = 'http://custom:2222'; + + await expectAsync(exec(['--port=4567', '--', 'node', '--eval', [ + 'let e = process.env;', + '(e.PERCY_SERVER_ADDRESS === "http://localhost:4567" &&', + ' e.PERCY_CLI_API === "http://custom:1111" &&', + ' e.CYPRESS_PERCY_SERVER_ADDRESS === "http://custom:2222") || process.exit(2)' + ].join('')])).toBeResolved(); + }); + + it('does not export the alternate server address env vars when percy is disabled', async () => { + process.env.PERCY_ENABLE = '0'; + + await expectAsync(exec(['--', 'node', '--eval', [ + '(process.env.PERCY_CLI_API || process.env.CYPRESS_PERCY_SERVER_ADDRESS) && process.exit(2)' + ].join('')])).toBeResolved(); + }); + 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)' From 1764052b322e05093b7ff29fc3457e3296a0cdbb Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 27 Oct 2026 19:04:52 +0530 Subject: [PATCH 2/3] PPLT-6233: drop alternate address exports, add snapshot.exactResponsiveWidth Per review, the CLI keeps PERCY_SERVER_ADDRESS as the only address export; SDKs gain PERCY_SERVER_ADDRESS support instead of the CLI exporting PERCY_CLI_API / CYPRESS_PERCY_SERVER_ADDRESS. - percy exec only sets PERCY_SERVER_ADDRESS / PERCY_BUILD_ID / PERCY_BUILD_URL when they have a value, so a disabled run no longer exports the string "undefined". - add snapshot.exactResponsiveWidth (boolean, default true), also accepted per snapshot; SDKs read it from the healthcheck config. - log once per Percy instance on the first responsive DOM snapshot that exact widths may shift some baselines once. Co-Authored-By: Claude Opus 5.5 --- packages/cli-exec/src/exec.js | 21 ++++++------ packages/cli-exec/test/exec.test.js | 34 ++++++------------- packages/core/src/api.js | 22 +++++++++++++ packages/core/src/config.js | 5 +++ packages/core/test/api.test.js | 51 +++++++++++++++++++++++++++++ packages/core/test/percy.test.js | 1 + packages/core/test/snapshot.test.js | 18 ++++++++++ packages/core/types/index.d.ts | 1 + 8 files changed, 118 insertions(+), 35 deletions(-) diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 9c72a1154..76595b702 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -107,19 +107,18 @@ export const exec = command('exec', { } } - // provide SDKs with useful env vars - let address = percy?.address(); - env.PERCY_SERVER_ADDRESS = address; - - if (address) { - // the Python and .NET SDKs read PERCY_CLI_API, and Cypress only forwards CYPRESS_-prefixed - // vars to the browser; without these a non-default --port leaves those SDKs on 5338 - env.PERCY_CLI_API ||= address; - env.CYPRESS_PERCY_SERVER_ADDRESS ||= address; + // 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 + }; + + for (let [key, value] of Object.entries(sdkEnv)) { + if (value != null) env[key] = value; } - env.PERCY_BUILD_ID = percy?.build?.id; - env.PERCY_BUILD_URL = percy?.build?.url; 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 3834e9698..fc5fb6779 100644 --- a/packages/cli-exec/test/exec.test.js +++ b/packages/cli-exec/test/exec.test.js @@ -27,8 +27,7 @@ describe('percy exec', () => { delete process.env.PERCY_ENABLE; delete process.env.PERCY_BUILD_ID; delete process.env.PERCY_SERVER_ADDRESS; - delete process.env.PERCY_CLI_API; - 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; @@ -430,32 +429,19 @@ describe('percy exec', () => { ])); }); - it('provides the child process with the percy server address in every form SDKs read', async () => { - await expectAsync(exec(['--port=4567', '--', 'node', '--eval', [ - 'let e = process.env, a = "http://localhost:4567";', - '(e.PERCY_SERVER_ADDRESS === a && e.PERCY_CLI_API === a &&', - ' e.CYPRESS_PERCY_SERVER_ADDRESS === a) || process.exit(2)' - ].join('')])).toBeResolved(); - }); - - it('does not override server address env vars the user already set', async () => { - process.env.PERCY_CLI_API = 'http://custom:1111'; - process.env.CYPRESS_PERCY_SERVER_ADDRESS = 'http://custom:2222'; - - await expectAsync(exec(['--port=4567', '--', 'node', '--eval', [ - 'let e = process.env;', - '(e.PERCY_SERVER_ADDRESS === "http://localhost:4567" &&', - ' e.PERCY_CLI_API === "http://custom:1111" &&', - ' e.CYPRESS_PERCY_SERVER_ADDRESS === "http://custom:2222") || process.exit(2)' - ].join('')])).toBeResolved(); - }); - - it('does not export the alternate server address env vars when percy is disabled', async () => { + it('does not set percy env vars to "undefined" when percy is disabled', async () => { process.env.PERCY_ENABLE = '0'; await expectAsync(exec(['--', 'node', '--eval', [ - '(process.env.PERCY_CLI_API || process.env.CYPRESS_PERCY_SERVER_ADDRESS) && process.exit(2)' + 'let e = process.env;', + '[e.PERCY_SERVER_ADDRESS, e.PERCY_BUILD_ID, e.PERCY_BUILD_URL]', + '.includes("undefined") && process.exit(2)' ].join('')])).toBeResolved(); + + expect(logger.stderr).toEqual(['[percy] Percy is disabled']); + 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 () => { 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; From 0616ec5287a3bd4c2a144c28cf5774584abef2ef Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 27 Oct 2026 20:59:50 +0530 Subject: [PATCH 3/3] PPLT-6233: Export CYPRESS_PERCY_SERVER_ADDRESS for the Cypress SDK only Cypress specs run in the browser and only see CYPRESS_-prefixed OS vars, and Cypress 16 removed cy.exec, so the SDK has no zero-config way to read PERCY_SERVER_ADDRESS. Every other SDK reads PERCY_SERVER_ADDRESS directly. Co-Authored-By: Claude Opus 5.5 --- packages/cli-exec/src/exec.js | 8 ++++++++ packages/cli-exec/test/exec.test.js | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 76595b702..f0279f59d 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -115,6 +115,14 @@ export const exec = command('exec', { 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; } diff --git a/packages/cli-exec/test/exec.test.js b/packages/cli-exec/test/exec.test.js index fc5fb6779..45a9d858a 100644 --- a/packages/cli-exec/test/exec.test.js +++ b/packages/cli-exec/test/exec.test.js @@ -27,6 +27,7 @@ describe('percy exec', () => { 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; @@ -429,16 +430,29 @@ 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.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();