diff --git a/packages/cli-doctor/src/checks/config.js b/packages/cli-doctor/src/checks/config.js index 91a69b0ab..a8c73f45a 100644 --- a/packages/cli-doctor/src/checks/config.js +++ b/packages/cli-doctor/src/checks/config.js @@ -80,7 +80,7 @@ export async function checkConfig(options = {}) { // Keys that only work with automate tokens const automateOnlyKeys = ['fullPage', 'freezeAnimation', 'freezeAnimatedImage', - 'freezeAnimatedImageOptions', 'ignoreRegions', 'considerRegions']; + 'freezeAnimatedImageOptions', 'ignoreRegions', 'considerRegions', 'scaleToFit']; // Keys that only work with web tokens (not automate, not app) const webOnlyKeys = ['waitForTimeout', 'waitForSelector']; diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 0fcf5abf9..dfd94ed68 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -202,6 +202,12 @@ export const configSchema = { type: 'boolean', onlyAutomate: true }, + // Downscales each tile by 1/dpr so the stitched image fits the 50,000px ceiling; + // without it the DOM walk is capped at 50000/dpr CSS px. fullPage path only. + scaleToFit: { + type: 'boolean', + onlyAutomate: true + }, freezeAnimation: { // for backward compatibility type: 'boolean', onlyAutomate: true @@ -927,7 +933,11 @@ export const comparisonSchema = { }, cliScreenshotStartTime: { type: 'integer', default: 0 }, cliScreenshotEndTime: { type: 'integer', default: 0 }, - screenshotType: { type: 'string', default: 'singlepage' } + screenshotType: { type: 'string', default: 'singlepage' }, + // additionalProperties is false here and PercyConfig.validate DELETES unknown keys + // from the object it validates, so an undeclared key is silently dropped pre-upload. + scaleToFit: { type: 'boolean' }, + appliedScaleFactor: { type: 'number', exclusiveMinimum: 0, maximum: 1 } } }, tag: { diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index a28ea02ca..740ece3c4 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -412,6 +412,9 @@ export function percyAutomateRequestHandler(req, percy) { req.body.options = merge([{ fullPage: percy.config.snapshot.fullPage, + // Must be listed here or a .percy.yml `snapshot.scaleToFit` validates and is then + // silently dropped -- the page truncates as before while the build stays green. + scaleToFit: percy.config.snapshot.scaleToFit, percyCSS: percy.config.snapshot.percyCSS, freezeAnimatedImage: percy.config.snapshot.freezeAnimatedImage || percy.config.snapshot.freezeAnimation, freezeImageBySelectors: percy.config.snapshot.freezeAnimatedImageOptions?.freezeImageBySelectors, diff --git a/packages/core/test/unit/config.test.js b/packages/core/test/unit/config.test.js index 539184bd1..4e5d169cb 100644 --- a/packages/core/test/unit/config.test.js +++ b/packages/core/test/unit/config.test.js @@ -116,6 +116,66 @@ describe('SnapshotSchema', () => { expect(errors[0].path).toBe('scope'); expect(errors[0].message).toBe('must have property scope when property scopeOptions is present'); }); + + // Structural, not a validate() round-trip: onlyAutomate is evaluated when AJV COMPILES + // the schema, so flipping PERCY_TOKEN inside a spec cannot change the outcome. + it('declares scaleToFit as an automate-only boolean', () => { + // The config schema is the one entry with no $id; index into that rather than [0]. + const configSchema = CoreConfig.schemas.find(s => !s.$id); + expect(configSchema.snapshot.properties.scaleToFit) + .toEqual({ type: 'boolean', onlyAutomate: true }); + }); + + // ...and this proves it is wired into validation, not inert. Asserted RELATIVE to + // fullPage: onlyAutomate is compiled in from PERCY_TOKEN, which differs between a local + // run and CI, so an absolute expectation here passes locally and breaks in CI. + it('gates scaleToFit exactly like fullPage', () => { + PercyConfig.addSchema(CoreConfig.schemas); + const errors = PercyConfig.validate({ fullPage: true, scaleToFit: true }, '/config/snapshot') || []; + const messagesFor = (path) => errors.filter(e => e.path === path).map(e => e.message); + + expect(messagesFor('scaleToFit')).toEqual(messagesFor('fullPage')); + // An undeclared key would draw 'unknown property' here while fullPage drew none, so + // this still fails if the schema entry goes missing. + expect(messagesFor('scaleToFit')).not.toContain('unknown property'); + }); +}); + +describe('ComparisonSchema - scaleToFit metadata', () => { + beforeEach(() => { + PercyConfig.addSchema(CoreConfig.schemas); + }); + + // metadata sets additionalProperties:false and PercyConfig.validate DELETES unknown keys + // from the object it is handed, so an undeclared key is dropped before upload -- silently, + // with the build still green. A structural assertion would not catch that; this asserts + // the value survives the round trip. + it('keeps scaleToFit and appliedScaleFactor on the validated object', () => { + const options = { + name: 'snap', + tag: { name: 'Pixel 10' }, + tiles: [], + metadata: { screenshotType: 'fullpage', scaleToFit: true, appliedScaleFactor: 0.380952 } + }; + + expect(PercyConfig.validate(options, '/comparison')).toBe(undefined); + expect(options.metadata).toEqual({ + screenshotType: 'fullpage', scaleToFit: true, appliedScaleFactor: 0.380952 + }); + }); + + it('rejects a factor outside (0, 1]', () => { + const build = (appliedScaleFactor) => PercyConfig.validate({ + name: 'snap', + tag: { name: 'Pixel 10' }, + tiles: [], + metadata: { scaleToFit: true, appliedScaleFactor } + }, '/comparison') || []; + + expect(build(2).map(e => e.path)).toContain('metadata.appliedScaleFactor'); + expect(build(0).map(e => e.path)).toContain('metadata.appliedScaleFactor'); + expect(build(0.380952)).toEqual([]); + }); }); describe('ComparisonSchema - elementSelectorsData', () => { diff --git a/packages/core/test/utils.test.js b/packages/core/test/utils.test.js index e2e33340e..45b32c151 100644 --- a/packages/core/test/utils.test.js +++ b/packages/core/test/utils.test.js @@ -81,6 +81,31 @@ describe('utils', () => { expect(req.body.buildInfo).toEqual({ id: 'b1' }); }); + // scaleToFit is settable at BOTH levels: globally via .percy.yml `snapshot:` (which + // only reaches the provider because it is enumerated in the merge above) and + // per-screenshot (which overrides it). Both paths are load-bearing. + it('carries scaleToFit from global config and lets a per-screenshot value override it', () => { + const base = () => ({ + build: { id: 'b1' }, + config: { percy: { platforms: [] }, snapshot: { percyCSS: '', scaleToFit: true } } + }); + + // global only + let req = { body: { options: {} } }; + percyAutomateRequestHandler(req, base()); + expect(req.body.options.scaleToFit).toBeTrue(); + + // per-screenshot snake_case wins over global + req = { body: { options: { scale_to_fit: false } } }; + percyAutomateRequestHandler(req, base()); + expect(req.body.options.scaleToFit).toBeFalse(); + + // per-screenshot can opt in when global is unset + req = { body: { options: { scaleToFit: true } } }; + percyAutomateRequestHandler(req, { build: {}, config: { percy: {}, snapshot: { percyCSS: '' } } }); + expect(req.body.options.scaleToFit).toBeTrue(); + }); + it('handles missing client/environment and empty options', () => { const req = { body: { options: { } } }; const percy = { build: { id: 'b' }, config: { percy: { platforms: [] }, snapshot: { percyCSS: '', freezeAnimation: false } } }; diff --git a/packages/webdriver-utils/src/providers/automateProvider.js b/packages/webdriver-utils/src/providers/automateProvider.js index eab956812..10f980624 100644 --- a/packages/webdriver-utils/src/providers/automateProvider.js +++ b/packages/webdriver-utils/src/providers/automateProvider.js @@ -96,6 +96,14 @@ export default class AutomateProvider extends GenericProvider { const metadata = { screenshotType: screenshotType }; + // percy-api needs the factor to relax its tile-count limit. Added only when it really + // scaled -- these land in the largest table on the platform. + const scaleFactor = tileResponse.applied_scale_factor; + if (tileResponse.scale_to_fit === true && + Number.isFinite(scaleFactor) && scaleFactor > 0 && scaleFactor <= 1) { + metadata.scaleToFit = true; + metadata.appliedScaleFactor = scaleFactor; + } return { tiles: tiles, domInfoSha: tileResponse.dom_sha, diff --git a/packages/webdriver-utils/src/providers/genericProvider.js b/packages/webdriver-utils/src/providers/genericProvider.js index 01a1a5dc1..e82662ff5 100644 --- a/packages/webdriver-utils/src/providers/genericProvider.js +++ b/packages/webdriver-utils/src/providers/genericProvider.js @@ -48,6 +48,13 @@ export default class GenericProvider { addDefaultOptions() { this.options.freezeAnimation = this.options.freezeAnimatedImage || this.options.freezeAnimation || false; + // PERCY_SCALE_TO_FIT opts a whole run in without per-snapshot config. Coerced to a + // real boolean: mobile-common compares with `== true`, so a truthy string would no-op. + // fullPage-only: the host shrinks tiles solely in its full-page loop, so outside it the + // flag would report a scale factor for tiles that were never scaled. + this.options.scaleToFit = this.options.fullPage === true && + (this.options.scaleToFit === true || + (this.options.scaleToFit !== false && process.env.PERCY_SCALE_TO_FIT === 'true')); } async createDriver() { diff --git a/packages/webdriver-utils/src/providers/playwrightProvider.js b/packages/webdriver-utils/src/providers/playwrightProvider.js index 5c5b50149..7384b2a9c 100644 --- a/packages/webdriver-utils/src/providers/playwrightProvider.js +++ b/packages/webdriver-utils/src/providers/playwrightProvider.js @@ -42,6 +42,10 @@ export default class PlaywrightProvider extends GenericProvider { async screenshot(name, options) { let response = null; let error; + // This override does not call super.screenshot(), which is where GenericProvider applies + // addDefaultOptions -- so without this the scaleToFit fullPage gate, its boolean + // coercion and PERCY_SCALE_TO_FIT were all skipped on the playwright path. + this.addDefaultOptions(); log.debug(`[${name}] : Preparing to capture screenshots on playwright with automate ...`); try { log.debug(`[${name}] : Marking automate session as percy ...`); @@ -134,6 +138,14 @@ export default class PlaywrightProvider extends GenericProvider { const metadata = { screenshotType: screenshotType }; + // Same pair as automateProvider: the host shrinks tiles, so percy-api needs the factor + // to relax its tile-count limit. Without this a playwright capture reports none. + const scaleFactor = tileResponse.applied_scale_factor; + if (tileResponse.scale_to_fit === true && + Number.isFinite(scaleFactor) && scaleFactor > 0 && scaleFactor <= 1) { + metadata.scaleToFit = true; + metadata.appliedScaleFactor = scaleFactor; + } return { tiles: tiles, domInfoSha: tileResponse.dom_sha, diff --git a/packages/webdriver-utils/test/providers/automateProvider.test.js b/packages/webdriver-utils/test/providers/automateProvider.test.js index ad7573d74..ba25977d3 100644 --- a/packages/webdriver-utils/test/providers/automateProvider.test.js +++ b/packages/webdriver-utils/test/providers/automateProvider.test.js @@ -331,6 +331,47 @@ describe('AutomateProvider', () => { expect(res).toEqual(expectedOutput); }); + // percy-api relaxes its tile-count limit by this factor, so it must reach the payload. + it('forwards scaleToFit and the applied factor into metadata when scaling happened', async () => { + const response = { + success: true, + result: JSON.stringify({ + tiles: [{ sha: 'abc', index: 0 }], + dom_sha: 'def', + scale_to_fit: true, + applied_scale_factor: 0.38095238095 + }) + }; + spyOn(AutomateProvider.prototype, 'browserstackExecutor') + .and.returnValue(Promise.resolve({ value: JSON.stringify(response) })); + await automateProvider.createDriver(); + const res = await automateProvider.getTiles(false); + + expect(res.metadata).toEqual({ + screenshotType: 'fullpage', + scaleToFit: true, + appliedScaleFactor: 0.38095238095 + }); + }); + + // These land in the largest table on the platform; don't grow every row. + it('omits the scaleToFit metadata keys when mobile-common did not scale', async () => { + const response = { + success: true, + result: JSON.stringify({ + tiles: [{ sha: 'abc', index: 0 }], + dom_sha: 'def', + scale_to_fit: false + }) + }; + spyOn(AutomateProvider.prototype, 'browserstackExecutor') + .and.returnValue(Promise.resolve({ value: JSON.stringify(response) })); + await automateProvider.createDriver(); + const res = await automateProvider.getTiles(false); + + expect(res.metadata).toEqual({ screenshotType: 'fullpage' }); + }); + it('should return default values of header and footer if not in response', async () => { const response = { success: true, diff --git a/packages/webdriver-utils/test/providers/genericProvider.test.js b/packages/webdriver-utils/test/providers/genericProvider.test.js index 8e283cf8f..882ccd048 100644 --- a/packages/webdriver-utils/test/providers/genericProvider.test.js +++ b/packages/webdriver-utils/test/providers/genericProvider.test.js @@ -51,6 +51,52 @@ describe('GenericProvider', () => { provider.addDefaultOptions(); expect(provider.options.freezeAnimation).toBeFalse(); }); + + describe('scaleToFit', () => { + const build = (options) => { + const provider = new GenericProvider({ options }); + provider.addDefaultOptions(); + return provider.options.scaleToFit; + }; + + beforeEach(() => { delete process.env.PERCY_SCALE_TO_FIT; }); + afterEach(() => { delete process.env.PERCY_SCALE_TO_FIT; }); + + it('enables from the option or PERCY_SCALE_TO_FIT', () => { + expect(build({ fullPage: true, scaleToFit: true })).toBeTrue(); + + process.env.PERCY_SCALE_TO_FIT = 'true'; + expect(build({ fullPage: true })).toBeTrue(); + + delete process.env.PERCY_SCALE_TO_FIT; + expect(build({ fullPage: true })).toBeFalse(); + }); + + // The host only shrinks tiles in its full-page loop, so outside it the flag would + // report a scale factor for tiles that were never scaled. + it('is ignored without fullPage', () => { + expect(build({ scaleToFit: true })).toBeFalse(); + expect(build({ fullPage: false, scaleToFit: true })).toBeFalse(); + + process.env.PERCY_SCALE_TO_FIT = 'true'; + expect(build({})).toBeFalse(); + }); + + // The env var is a run-wide default, not an override -- otherwise a single snapshot + // could never opt out once it is set. + it('lets an explicit false beat the env var', () => { + process.env.PERCY_SCALE_TO_FIT = 'true'; + expect(build({ fullPage: true, scaleToFit: false })).toBeFalse(); + }); + + // mobile-common compares with `== true`, so a merely-truthy value would silently no-op. + it('coerces to a real boolean', () => { + expect(build({ fullPage: true, scaleToFit: 'true' })).toBeFalse(); + + process.env.PERCY_SCALE_TO_FIT = '1'; + expect(build({ fullPage: true })).toBeFalse(); + }); + }); }); describe('supports', () => { diff --git a/packages/webdriver-utils/test/providers/playwrightProvider.test.js b/packages/webdriver-utils/test/providers/playwrightProvider.test.js index 78328c38d..f5390ebb5 100644 --- a/packages/webdriver-utils/test/providers/playwrightProvider.test.js +++ b/packages/webdriver-utils/test/providers/playwrightProvider.test.js @@ -13,7 +13,7 @@ describe('PlaywrightProvider', () => { 'pageGuid', 'clientInfo', 'environmentInfo', - 'options', + {}, { id: 1 } ); }); @@ -25,7 +25,7 @@ describe('PlaywrightProvider', () => { expect(provider.pageGuid).toBe('pageGuid'); expect(provider.clientInfo).toBe('clientInfo'); expect(provider.environmentInfo).toBe('environmentInfo'); - expect(provider.options).toBe('options'); + expect(provider.options).toEqual({}); expect(provider.buildInfo).toEqual({ id: 1 }); }); }); @@ -206,7 +206,7 @@ describe('PlaywrightProvider', () => { percyBuildId: 1, screenshotType: 'singlepage', scaleFactor: 1, - options: 'options', + options: {}, frameworkData: { frameGuid: 'frameGuid', pageGuid: 'pageGuid' }, framework: 'playwright' } @@ -260,7 +260,7 @@ describe('PlaywrightProvider', () => { percyBuildId: 1, screenshotType: 'singlepage', scaleFactor: 1, - options: 'options', + options: {}, frameworkData: { frameGuid: 'frameGuid', pageGuid: 'pageGuid' }, framework: 'playwright' } @@ -368,6 +368,58 @@ describe('PlaywrightProvider', () => { }); }); + it('reports the scale factor when the host shrank the tiles', async () => { + const provider = new PlaywrightProvider( + 'sessionId', 'frameGuid', 'pageGuid', 'clientInfo', 'environmentInfo', + { fullPage: true, scaleToFit: true }, { id: 1 } + ); + provider.browserstackExecutor = jasmine + .createSpy('browserstackExecutor') + .and.resolveTo({ + value: JSON.stringify({ + success: true, + result: JSON.stringify({ + tiles: [{ status_bar: 0, nav_bar: 0, header_height: 0, footer_height: 0, sha: 't1' }], + comparison_tag_data: { width: 411, height: 858, resolution: '1080x2251' }, + dom_sha: 'domSHA', + scale_to_fit: true, + applied_scale_factor: 0.380952 + }) + }) + }); + + const response = await provider.getTiles(true); + + expect(response.metadata).toEqual({ + screenshotType: 'fullpage', scaleToFit: true, appliedScaleFactor: 0.380952 + }); + }); + + // A half-pair would ask percy-api to relax its tile limit by nothing. + it('omits the pair when the factor is missing', async () => { + const provider = new PlaywrightProvider( + 'sessionId', 'frameGuid', 'pageGuid', 'clientInfo', 'environmentInfo', + { fullPage: true, scaleToFit: true }, { id: 1 } + ); + provider.browserstackExecutor = jasmine + .createSpy('browserstackExecutor') + .and.resolveTo({ + value: JSON.stringify({ + success: true, + result: JSON.stringify({ + tiles: [{ status_bar: 0, nav_bar: 0, header_height: 0, footer_height: 0, sha: 't1' }], + comparison_tag_data: { width: 411, height: 858, resolution: '1080x2251' }, + dom_sha: 'domSHA', + scale_to_fit: true + }) + }) + }); + + const response = await provider.getTiles(true); + + expect(response.metadata).toEqual({ screenshotType: 'fullpage' }); + }); + it('should handle errors during tile capture', async () => { const error = new Error('Failed to capture tiles'); provider.browserstackExecutor = jasmine