Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli-doctor/src/checks/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions packages/core/test/unit/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/core/test/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } };
Expand Down
8 changes: 8 additions & 0 deletions packages/webdriver-utils/src/providers/automateProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/webdriver-utils/src/providers/genericProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 12 additions & 0 deletions packages/webdriver-utils/src/providers/playwrightProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...`);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/webdriver-utils/test/providers/genericProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('PlaywrightProvider', () => {
'pageGuid',
'clientInfo',
'environmentInfo',
'options',
{},
{ id: 1 }
);
});
Expand All @@ -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 });
});
});
Expand Down Expand Up @@ -206,7 +206,7 @@ describe('PlaywrightProvider', () => {
percyBuildId: 1,
screenshotType: 'singlepage',
scaleFactor: 1,
options: 'options',
options: {},
frameworkData: { frameGuid: 'frameGuid', pageGuid: 'pageGuid' },
framework: 'playwright'
}
Expand Down Expand Up @@ -260,7 +260,7 @@ describe('PlaywrightProvider', () => {
percyBuildId: 1,
screenshotType: 'singlepage',
scaleFactor: 1,
options: 'options',
options: {},
frameworkData: { frameGuid: 'frameGuid', pageGuid: 'pageGuid' },
framework: 'playwright'
}
Expand Down Expand Up @@ -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
Expand Down
Loading