Skip to content

Commit

Permalink
Percy on Automate: Adds specific configs as global config (#1369)
Browse files Browse the repository at this point in the history
* initial commit

* combine percyCSS similar to web projects

* raise warning when using params that are invalid with non automate projects

* remove defaults, perform validation only when token passed

* lint fix

* increase coverage

* improve coverage

* refactor for improved coverage

* refactor: remove return
  • Loading branch information
itsjwala authored Sep 14, 2023
1 parent 4564deb commit 05d99c5
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 108 deletions.
3 changes: 2 additions & 1 deletion packages/config/src/utils/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getSchema } from '../validate.js';
const CAMELCASE_MAP = new Map([
['css', 'CSS'],
['javascript', 'JavaScript'],
['dom', 'DOM']
['dom', 'DOM'],
['xpaths', 'XPaths']
]);

// Regular expression that matches words from boundaries or consecutive casing
Expand Down
12 changes: 12 additions & 0 deletions packages/config/src/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const ajv = new AJV({
getDefaultSchema()
],
keywords: [{
keyword: 'onlyAutomate',
error: {
message: 'property only valid with Automate integration.'
},
code: cxt => {
let isAutomateProjectToken = (process.env.PERCY_TOKEN || '').split('_')[0] === 'auto';
// we do validation only when token is passed
if (!!process.env.PERCY_TOKEN && !isAutomateProjectToken) {
cxt.error();
}
}
}, {
// custom instanceof schema validation
keyword: 'instanceof',
metaSchema: {
Expand Down
116 changes: 110 additions & 6 deletions packages/config/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,104 @@ describe('PercyConfig', () => {
expect(conf).toEqual({ foo: { bar: 'baz', qux: 'xyzzy' } });
});

describe('validates automate integration specific properties', () => {
beforeEach(() => {
delete process.env.PERCY_TOKEN;

PercyConfig.addSchema({
test: {
type: 'object',
additionalProperties: false,
properties: {
foo: {
type: 'number',
onlyAutomate: true
},
bar: {
type: 'number'
}
}
}
});
});

it('passes when no token present', () => {
expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toBeUndefined();
});

it('passes when token is of automate project', () => {
process.env.PERCY_TOKEN = 'auto_PERCY_TOKEN';

expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toBeUndefined();
});

it('warns when token is of legacy web project', () => {
process.env.PERCY_TOKEN = 'PERCY_TOKEN';

expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toEqual([{
path: 'test.foo',
message: 'property only valid with Automate integration.'
}]);
});

it('warns when token is of web project', () => {
process.env.PERCY_TOKEN = 'web_PERCY_TOKEN';

expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toEqual([{
path: 'test.foo',
message: 'property only valid with Automate integration.'
}]);
});

it('warns when token is of app project', () => {
process.env.PERCY_TOKEN = 'app_PERCY_TOKEN';

expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toEqual([{
path: 'test.foo',
message: 'property only valid with Automate integration.'
}]);
});

it('warns when token is of self serve project', () => {
process.env.PERCY_TOKEN = 'ss_PERCY_TOKEN';

expect(PercyConfig.validate({
test: {
foo: 1,
bar: 2
}
})).toEqual([{
path: 'test.foo',
message: 'property only valid with Automate integration.'
}]);
});
});

it('can validate functions and regular expressions', () => {
PercyConfig.addSchema({
func: { instanceof: 'Function' },
Expand Down Expand Up @@ -1102,7 +1200,8 @@ describe('PercyConfig', () => {
'percy-css': '',
'enable-javascript': false,
'disable-shadow-dom': true,
'cli-enable-javascript': true
'cli-enable-javascript': true,
'ignore-region-xpaths': ['']
})).toEqual({
fooBar: 'baz',
foo: { barBaz: 'qux' },
Expand All @@ -1111,7 +1210,8 @@ describe('PercyConfig', () => {
percyCSS: '',
enableJavaScript: false,
disableShadowDOM: true,
cliEnableJavaScript: true
cliEnableJavaScript: true,
ignoreRegionXPaths: ['']
});
});

Expand All @@ -1123,15 +1223,17 @@ describe('PercyConfig', () => {
percyCSS: '',
enableJavaScript: false,
disableShadowDOM: true,
cliEnableJavaScript: true
cliEnableJavaScript: true,
ignoreRegionXPaths: ['']
}, { kebab: true })).toEqual({
'foo-bar': 'baz',
foo: { 'bar-baz': 'qux' },
'foo-bar-baz': ['qux'],
'percy-css': '',
'enable-javascript': false,
'disable-shadow-dom': true,
'cli-enable-javascript': true
'cli-enable-javascript': true,
'ignore-region-xpaths': ['']
});
});

Expand All @@ -1143,15 +1245,17 @@ describe('PercyConfig', () => {
percyCSS: '',
enableJavaScript: false,
disableShadowDOM: true,
cliEnableJavaScript: true
cliEnableJavaScript: true,
ignoreRegionXPaths: ['']
}, { snake: true })).toEqual({
foo_bar: 'baz',
foo: { bar_baz: 'qux' },
foo_bar_baz: ['qux'],
percy_css: '',
enable_javascript: false,
disable_shadow_dom: true,
cli_enable_javascript: true
cli_enable_javascript: true,
ignore_region_xpaths: ['']
});
});

Expand Down
7 changes: 3 additions & 4 deletions packages/core/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,9 @@ export function createPercyServer(percy, port) {
success: await percy.flush(req.body).then(() => true)
}))
.route('post', '/percy/automateScreenshot', async (req, res) => {
req = percyAutomateRequestHandler(req, percy.build);
res.json(200, {
success: await (percy.upload(await new WebdriverUtils(req.body).automateScreenshot())).then(() => true)
});
percyAutomateRequestHandler(req, percy);
percy.upload(await WebdriverUtils.automateScreenshot(req.body));
res.json(200, { success: true });
})
// stops percy at the end of the current event loop
.route('/percy/stop', (req, res) => {
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,48 @@ export const configSchema = {
},
scope: {
type: 'string'
},
freezeAnimation: {
type: 'boolean',
onlyAutomate: true
},
ignoreRegions: {
type: 'object',
additionalProperties: false,
onlyAutomate: true,
properties: {
ignoreRegionSelectors: {
type: 'array',
items: {
type: 'string'
}
},
ignoreRegionXpaths: {
type: 'array',
items: {
type: 'string'
}
}
}
},
considerRegions: {
type: 'object',
additionalProperties: false,
onlyAutomate: true,
properties: {
considerRegionSelectors: {
type: 'array',
items: {
type: 'string'
}
},
considerRegionXPaths: {
type: 'array',
items: {
type: 'string'
}
}
}
}
}
},
Expand Down
32 changes: 26 additions & 6 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import EventEmitter from 'events';
import { sha256hash } from '@percy/client/utils';
import { camelcase, merge } from '@percy/config/utils';

export {
request,
Expand All @@ -23,19 +24,38 @@ export function normalizeURL(url) {
return `${protocol}//${host}${pathname}${search}`;
}

/* istanbul ignore next: tested, but coverage is stripped */
// Returns the body for automateScreenshot in structure
export function percyAutomateRequestHandler(req, buildInfo) {
export function percyAutomateRequestHandler(req, percy) {
if (req.body.client_info) {
req.body.clientInfo = req.body.client_info;
}
if (req.body.environment_info) {
req.body.environmentInfo = req.body.environment_info;
}
if (!req.body.options) {
req.body.options = {};
}
req.body.buildInfo = buildInfo;
return req;

// combines array and overrides global config with per-screenshot config
let camelCasedOptions = {};
Object.entries(req.body.options || {}).forEach(([key, value]) => {
camelCasedOptions[camelcase(key)] = value;
});

req.body.options = merge([{
percyCSS: percy.config.snapshot.percyCSS,
freezeAnimation: percy.config.snapshot.freezeAnimation,
ignoreRegionSelectors: percy.config.snapshot.ignoreRegions?.ignoreRegionSelectors,
ignoreRegionXpaths: percy.config.snapshot.ignoreRegions?.ignoreRegionXpaths,
considerRegionSelectors: percy.config.snapshot.considerRegions?.considerRegionSelectors,
considerRegionXPaths: percy.config.snapshot.considerRegions?.considerRegionXPaths
},
camelCasedOptions
], (path, prev, next) => {
switch (path.map(k => k.toString()).join('.')) {
case 'percyCSS': // concatenate percy css
return [path, [prev, next].filter(Boolean).join('\n')];
}
});
req.body.buildInfo = percy.build;
}

// Creates a local resource object containing the resource URL, mimetype, content, sha, and any
Expand Down
59 changes: 47 additions & 12 deletions packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import PercyConfig from '@percy/config';
import { logger, setupTest, fs } from './helpers/index.js';
import Percy from '@percy/core';
import WebdriverUtils from '@percy/webdriver-utils'; // eslint-disable-line import/no-extraneous-dependencies
import WebdriverUtils from '@percy/webdriver-utils';

describe('API Server', () => {
let percy;
Expand Down Expand Up @@ -131,17 +131,6 @@ describe('API Server', () => {
});
});

it('has a /automateScreenshot endpoint that calls #upload()', async () => {
spyOn(percy, 'upload').and.resolveTo();
spyOn(WebdriverUtils.prototype, 'automateScreenshot').and.resolveTo(true);
await percy.start();

await expectAsync(request('/percy/automateScreenshot', {
body: { name: 'Snapshot name' },
method: 'post'
})).toBeResolvedTo({ success: true });
});

it('has a /stop endpoint that calls #stop()', async () => {
spyOn(percy, 'stop').and.resolveTo();
await percy.start();
Expand Down Expand Up @@ -277,6 +266,52 @@ describe('API Server', () => {
await expectAsync(pending).toBeResolved();
});

it('has a /automateScreenshot endpoint that calls #upload() async with provided options', async () => {
let resolve, test = new Promise(r => (resolve = r));
spyOn(percy, 'upload').and.returnValue(test);
let mockWebdriverUtilResponse = 'TODO: mocked response';
let automateScreenshotSpy = spyOn(WebdriverUtils, 'automateScreenshot').and.resolveTo(mockWebdriverUtilResponse);

await percy.start();

percy.config.snapshot.percyCSS = '.global { color: blue }';
percy.config.snapshot.freezeAnimation = false;
percy.config.snapshot.ignoreRegions = { ignoreRegionSelectors: ['.selector-global'] };
percy.config.snapshot.considerRegions = { considerRegionXPaths: ['/xpath-global'] };

await expectAsync(request('/percy/automateScreenshot', {
body: {
name: 'Snapshot name',
client_info: 'client',
environment_info: 'environment',
options: {
percyCSS: '.percy-screenshot: { color: red }',
freeze_animation: true,
ignore_region_xpaths: ['/xpath-per-screenshot'],
consider_region_xpaths: ['/xpath-per-screenshot']
}
},
method: 'post'
})).toBeResolvedTo({ success: true });

expect(automateScreenshotSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({
clientInfo: 'client',
environmentInfo: 'environment',
buildInfo: { id: '123', url: 'https://percy.io/test/test/123', number: 1 },
options: {
freezeAnimation: true,
percyCSS: '.global { color: blue }\n.percy-screenshot: { color: red }',
ignoreRegionSelectors: ['.selector-global'],
ignoreRegionXPaths: ['/xpath-per-screenshot'],
considerRegionXPaths: ['/xpath-global', '/xpath-per-screenshot']
}
}));

expect(percy.upload).toHaveBeenCalledOnceWith(mockWebdriverUtilResponse);
await expectAsync(test).toBePending();
resolve(); // no hanging promises
});

it('returns a 500 error when an endpoint throws', async () => {
spyOn(percy, 'snapshot').and.rejectWith(new Error('test error'));
await percy.start();
Expand Down
Loading

0 comments on commit 05d99c5

Please sign in to comment.