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
131 changes: 130 additions & 1 deletion packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,133 @@ function makeRegions(regions, algorithm, algorithmConfiguration) {
}));
}

const VISUAL_CONFIG_TOP_LEVEL_KEYS = new Set([
'enableLayout',
'percyCssValue',
'compareWithPreviousRun',
'diffIgnoreEnabled',
'diffIgnorePercentage',
'diffSensitivity',
'browsers',
'intelliIgnore'
]);

const VISUAL_CONFIG_INTELLI_IGNORE_KEYS = new Set([
'enabled',
'dynamic',
'ignoreAds',
'ignoreBanners',
'ignoreCarousels',
'ignoreCustomElementsEnabled',
'ignoreCustomElementsClasses',
'ignoreImages',
'diffIgnorePercentage'
]);

function validateBoolean(value, path) {
if (value != null && typeof value !== 'boolean') {
throw new Error(`Invalid PERCY_VISUAL_CONFIG: '${path}' must be a boolean`);
}
}

function validateNumberInRange(value, path) {
if (value == null) return;
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 1) {
throw new Error(`Invalid PERCY_VISUAL_CONFIG: '${path}' must be a number between 0 and 1`);
}
}

function validateIntegerRange(value, path, min, max) {
if (value == null) return;
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(
`Invalid PERCY_VISUAL_CONFIG: '${path}' must be an integer between ${min} and ${max}`
);
}
}

function parseVisualConfigFromEnv(log) {
let rawVisualConfig = process.env.PERCY_VISUAL_CONFIG;
if (!rawVisualConfig) return;

let parsed;
try {
parsed = JSON.parse(rawVisualConfig);
} catch {
throw new Error('Invalid PERCY_VISUAL_CONFIG: value must be valid JSON');
}

if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Invalid PERCY_VISUAL_CONFIG: value must be a JSON object');
}

let visualConfig = {};
for (let key of Object.keys(parsed)) {
if (!VISUAL_CONFIG_TOP_LEVEL_KEYS.has(key)) {
log.warn(`Ignoring unknown PERCY_VISUAL_CONFIG key: '${key}'`);
continue;
}
visualConfig[key] = parsed[key];
}

validateBoolean(visualConfig.enableLayout, 'enableLayout');
if (visualConfig.percyCssValue != null && typeof visualConfig.percyCssValue !== 'string') {
throw new Error("Invalid PERCY_VISUAL_CONFIG: 'percyCssValue' must be a string");
}
validateBoolean(visualConfig.compareWithPreviousRun, 'compareWithPreviousRun');
validateBoolean(visualConfig.diffIgnoreEnabled, 'diffIgnoreEnabled');
validateNumberInRange(visualConfig.diffIgnorePercentage, 'diffIgnorePercentage');
validateIntegerRange(visualConfig.diffSensitivity, 'diffSensitivity', 1, 5);

if (visualConfig.browsers != null) {
if (!Array.isArray(visualConfig.browsers) || !visualConfig.browsers.every(b => typeof b === 'string')) {
throw new Error("Invalid PERCY_VISUAL_CONFIG: 'browsers' must be an array of strings");
}
visualConfig.browsers = normalizeBrowsers(visualConfig.browsers);
}

if (visualConfig.intelliIgnore != null) {
if (!visualConfig.intelliIgnore || typeof visualConfig.intelliIgnore !== 'object' ||
Array.isArray(visualConfig.intelliIgnore)) {
throw new Error("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore' must be an object");
}

let sanitizedIntelliIgnore = {};
for (let key of Object.keys(visualConfig.intelliIgnore)) {
if (!VISUAL_CONFIG_INTELLI_IGNORE_KEYS.has(key)) {
log.warn(`Ignoring unknown PERCY_VISUAL_CONFIG intelliIgnore key: '${key}'`);
continue;
}
sanitizedIntelliIgnore[key] = visualConfig.intelliIgnore[key];
}

validateBoolean(sanitizedIntelliIgnore.enabled, 'intelliIgnore.enabled');
validateBoolean(sanitizedIntelliIgnore.dynamic, 'intelliIgnore.dynamic');
validateBoolean(sanitizedIntelliIgnore.ignoreAds, 'intelliIgnore.ignoreAds');
validateBoolean(sanitizedIntelliIgnore.ignoreBanners, 'intelliIgnore.ignoreBanners');
validateBoolean(sanitizedIntelliIgnore.ignoreCarousels, 'intelliIgnore.ignoreCarousels');
validateBoolean(
sanitizedIntelliIgnore.ignoreCustomElementsEnabled,
'intelliIgnore.ignoreCustomElementsEnabled'
);
if (sanitizedIntelliIgnore.ignoreCustomElementsClasses != null &&
typeof sanitizedIntelliIgnore.ignoreCustomElementsClasses !== 'string') {
throw new Error(
"Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore.ignoreCustomElementsClasses' must be a string"
);
}
validateBoolean(sanitizedIntelliIgnore.ignoreImages, 'intelliIgnore.ignoreImages');
validateNumberInRange(
sanitizedIntelliIgnore.diffIgnorePercentage,
'intelliIgnore.diffIgnorePercentage'
);

visualConfig.intelliIgnore = sanitizedIntelliIgnore;
}

return visualConfig;
}

// Validate project path arguments
function validateProjectPath(path) {
if (!path) throw new Error('Missing project path');
Expand Down Expand Up @@ -188,6 +315,7 @@ export class PercyClient {
// done more seamlessly without manually tracking build ids
async createBuild({ resources = [], projectType, cliStartTime = null } = {}) {
this.log.debug('Creating a new build...');
let visualConfig = parseVisualConfigFromEnv(this.log);
let source = 'user_created';

if (process.env.PERCY_ORIGINATED_SOURCE) {
Expand Down Expand Up @@ -222,7 +350,8 @@ export class PercyClient {
source: source,
'skip-base-build': this.config.percy?.skipBaseBuild,
'testhub-build-uuid': this.env.testhubBuildUuid,
'testhub-build-run-id': this.env.testhubBuildRunId
'testhub-build-run-id': this.env.testhubBuildRunId,
...(visualConfig ? { 'visual-config': visualConfig } : {})
},
relationships: {
resources: {
Expand Down
135 changes: 135 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ describe('PercyClient', () => {
beforeEach(() => {
delete process.env.PERCY_AUTO_ENABLED_GROUP_BUILD;
delete process.env.PERCY_ORIGINATED_SOURCE;
delete process.env.PERCY_VISUAL_CONFIG;
});

it('creates a new build', async () => {
Expand Down Expand Up @@ -604,6 +605,140 @@ describe('PercyClient', () => {
}
}));
});

it('creates a new build with visual-config from PERCY_VISUAL_CONFIG', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({
diffSensitivity: 3,
compareWithPreviousRun: false,
intelliIgnore: {
enabled: true,
dynamic: true,
ignoreCustomElementsClasses: '.ad;.promo'
}
});

await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved();

expect(api.requests['/builds'][0].body.data.attributes['visual-config'])
.toEqual({
diffSensitivity: 3,
compareWithPreviousRun: false,
intelliIgnore: {
enabled: true,
dynamic: true,
ignoreCustomElementsClasses: '.ad;.promo'
}
});
});

it('warns and strips unknown visual-config keys before build creation', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({
diffSensitivity: 2,
unknownTopLevel: true,
intelliIgnore: {
enabled: true,
unknownNested: true
}
});

await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
"[percy:client] Ignoring unknown PERCY_VISUAL_CONFIG key: 'unknownTopLevel'",
"[percy:client] Ignoring unknown PERCY_VISUAL_CONFIG intelliIgnore key: 'unknownNested'"
]));
expect(api.requests['/builds'][0].body.data.attributes['visual-config'])
.toEqual({
diffSensitivity: 2,
intelliIgnore: {
enabled: true
}
});
});

it('throws when PERCY_VISUAL_CONFIG is invalid JSON', async () => {
process.env.PERCY_VISUAL_CONFIG = '{ invalid json }';

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError('Invalid PERCY_VISUAL_CONFIG: value must be valid JSON');
});

it('throws when PERCY_VISUAL_CONFIG contains invalid types', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({
diffSensitivity: 'high'
});

await expectAsync(client.createBuild({ projectType: 'web' })).toBeRejectedWithError(
"Invalid PERCY_VISUAL_CONFIG: 'diffSensitivity' must be an integer between 1 and 5"
);
});

it('throws when PERCY_VISUAL_CONFIG is not a JSON object', async () => {
process.env.PERCY_VISUAL_CONFIG = '"just a string"';

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError('Invalid PERCY_VISUAL_CONFIG: value must be a JSON object');
});

it('throws when PERCY_VISUAL_CONFIG boolean field has non-boolean value', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ enableLayout: 'yes' });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'enableLayout' must be a boolean");
});

it('throws when PERCY_VISUAL_CONFIG percyCssValue is not a string', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ percyCssValue: 123 });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'percyCssValue' must be a string");
});

it('throws when PERCY_VISUAL_CONFIG diffIgnorePercentage is out of range', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ diffIgnorePercentage: 5 });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'diffIgnorePercentage' must be a number between 0 and 1");
});

it('creates a new build with valid diffIgnorePercentage', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ diffIgnorePercentage: 0.5 });

await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved();

expect(api.requests['/builds'][0].body.data.attributes['visual-config'])
.toEqual(jasmine.objectContaining({ diffIgnorePercentage: 0.5 }));
});

it('throws when PERCY_VISUAL_CONFIG browsers is not an array of strings', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ browsers: 'chrome' });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'browsers' must be an array of strings");
});

it('creates a new build with visual-config browsers array', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ browsers: ['chrome', 'firefox'] });

await expectAsync(client.createBuild({ projectType: 'web' })).toBeResolved();

expect(api.requests['/builds'][0].body.data.attributes['visual-config'])
.toEqual(jasmine.objectContaining({ browsers: jasmine.any(Array) }));
});

it('throws when PERCY_VISUAL_CONFIG intelliIgnore is not an object', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ intelliIgnore: [] });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore' must be an object");
});

it('throws when PERCY_VISUAL_CONFIG intelliIgnore.ignoreCustomElementsClasses is not a string', async () => {
process.env.PERCY_VISUAL_CONFIG = JSON.stringify({ intelliIgnore: { ignoreCustomElementsClasses: 123 } });

await expectAsync(client.createBuild({ projectType: 'web' }))
.toBeRejectedWithError("Invalid PERCY_VISUAL_CONFIG: 'intelliIgnore.ignoreCustomElementsClasses' must be a string");
});
});

describe('#getBuild()', () => {
Expand Down
Loading