Skip to content

Commit 4945b33

Browse files
authored
Merge pull request #32 from pinanks/config-validation
Add config validation
2 parents cb153ae + 2969ff1 commit 4945b33

11 files changed

+187
-56
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"@types/cross-spawn": "^6.0.4",
2929
"@types/node": "^20.8.9",
3030
"@types/which": "^3.0.2",
31+
"ajv": "^8.12.0",
32+
"ajv-errors": "^3.0.0",
3133
"axios": "^1.6.0",
3234
"chalk": "^4.1.2",
3335
"commander": "^11.1.0",

pnpm-lock.yaml

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commander/capture.ts

+13-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import getGitInfo from '../tasks/getGitInfo.js'
88
import createBuild from '../tasks/createBuild.js'
99
import captureScreenshots from '../tasks/captureScreenshots.js'
1010
import finalizeBuild from '../tasks/finalizeBuild.js'
11+
import { validateWebStaticConfig } from '../lib/schemaValidation.js'
1112

1213
const command = new Command();
1314

@@ -18,6 +19,18 @@ command
1819
.action(async function(file, _, command) {
1920
let ctx: Context = ctxInit(command.optsWithGlobals());
2021

22+
if (!fs.existsSync(file)) {
23+
console.log(`Error: Web Static Config file ${file} not found.`);
24+
return;
25+
}
26+
try {
27+
ctx.webStaticConfig = JSON.parse(fs.readFileSync(file, 'utf8'));
28+
if (!validateWebStaticConfig(ctx.webStaticConfig)) throw new Error(validateWebStaticConfig.errors[0].message);
29+
} catch (error: any) {
30+
console.log(`[smartui] Error: Invalid Web Static Config; ${error.message}`);
31+
return;
32+
}
33+
2134
let tasks = new Listr<Context>(
2235
[
2336
auth(ctx),
@@ -39,12 +52,6 @@ command
3952
)
4053

4154
try {
42-
if (!fs.existsSync(file)) {
43-
console.log(`Error: Config file ${file} not found.`);
44-
return;
45-
}
46-
ctx.staticConfig = JSON.parse(fs.readFileSync(file, 'utf8'));
47-
4855
await tasks.run(ctx);
4956
} catch (error) {
5057
console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/');

src/commander/config.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { Command } from 'commander'
2-
import { createWebConfig, createWebStaticConfig } from '../lib/config.js'
2+
import { createConfig, createWebStaticConfig } from '../lib/config.js'
33

44
export const configWeb = new Command();
55
export const configStatic = new Command();
66

77
configWeb
8-
.name('config:create-web')
9-
.description('Create SmartUI Web config file')
8+
.name('config:create')
9+
.description('Create SmartUI config file')
1010
.argument('[filepath]', 'Optional config filepath')
1111
.action(async function(filepath, options) {
12-
createWebConfig(filepath);
12+
createConfig(filepath);
1313
})
1414

1515
configStatic
16-
.name('config:web-static')
16+
.name('config:create-web-static')
1717
.description('Create Web Static config file')
1818
.argument('[filepath]', 'Optional config filepath')
1919
.action(async function(filepath, options) {

src/lib/config.ts

+11-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import path from 'path'
22
import fs from 'fs'
3-
import { WebConfigSchema, WebStaticConfigSchema } from '../types.js'
3+
import { Config, WebStaticConfig } from '../types.js'
44

5-
export const DEFAULT_WEB_STATIC_CONFIG: WebStaticConfigSchema = [
5+
export const DEFAULT_WEB_STATIC_CONFIG: WebStaticConfig = [
66
{
77
"name": "lambdatest-home-page",
88
"url": "https://www.lambdatest.com",
@@ -14,7 +14,7 @@ export const DEFAULT_WEB_STATIC_CONFIG: WebStaticConfigSchema = [
1414
}
1515
]
1616

17-
export const DEFAULT_WEB_CONFIG: WebConfigSchema = {
17+
export const DEFAULT_CONFIG: Config = {
1818
web: {
1919
browsers: [
2020
'chrome',
@@ -27,13 +27,13 @@ export const DEFAULT_WEB_CONFIG: WebConfigSchema = {
2727
[1366, 768],
2828
[360, 640],
2929
],
30-
waitForTimeout: 0,
30+
waitForTimeout: 1000,
3131
}
3232
};
3333

34-
export function createWebConfig(filepath: string) {
34+
export function createConfig(filepath: string) {
3535
// default filepath
36-
filepath = filepath || 'smartui-web.json';
36+
filepath = filepath || '.smartui.json';
3737
let filetype = path.extname(filepath);
3838
if (filetype != '.json') {
3939
console.log('Error: Config file must have .json extension');
@@ -42,15 +42,15 @@ export function createWebConfig(filepath: string) {
4242

4343
// verify the file does not already exist
4444
if (fs.existsSync(filepath)) {
45-
console.log(`Error: SmartUI Web Config already exists: ${filepath}`);
46-
console.log(`To create a new file, please specify the file name like: 'smartui config:create-web webConfig.json'`);
45+
console.log(`Error: SmartUI Config already exists: ${filepath}`);
46+
console.log(`To create a new file, please specify the file name like: 'smartui config:create .smartui-config.json'`);
4747
return
4848
}
4949

5050
// write stringified default config options to the filepath
5151
fs.mkdirSync(path.dirname(filepath), { recursive: true });
52-
fs.writeFileSync(filepath, JSON.stringify(DEFAULT_WEB_CONFIG, null, 2) + '\n');
53-
console.log(`Created SmartUI Web Config: ${filepath}`);
52+
fs.writeFileSync(filepath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
53+
console.log(`Created SmartUI Config: ${filepath}`);
5454
};
5555

5656
export function createWebStaticConfig(filepath: string) {
@@ -65,7 +65,7 @@ export function createWebStaticConfig(filepath: string) {
6565
// verify the file does not already exist
6666
if (fs.existsSync(filepath)) {
6767
console.log(`Error: web-static config already exists: ${filepath}`);
68-
console.log(`To create a new file, please specify the file name like: 'smartui config:create-web links.json'`);
68+
console.log(`To create a new file, please specify the file name like: 'smartui config:create-web-static links.json'`);
6969
return
7070
}
7171

src/lib/ctx.ts

+21-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Context, Env, WebConfigSchema, WebStaticConfigSchema } from '../types.js'
2-
import { DEFAULT_WEB_CONFIG, DEFAULT_WEB_STATIC_CONFIG } from './config.js'
1+
import { Context, Env, Config } from '../types.js'
2+
import { DEFAULT_CONFIG } from './config.js'
33
import { version } from '../../package.json'
4+
import { validateConfig } from './schemaValidation.js'
45
import logger from '../lib/logger.js'
56
import getEnv from '../lib/env.js'
67
import httpClient from './httpClient.js'
@@ -9,31 +10,37 @@ import fs from 'fs'
910
export default (options: Record<string, string>): Context => {
1011
let env: Env = getEnv();
1112
let viewports: Array<{width: number, height: number}> = []
12-
let webConfig: WebConfigSchema = DEFAULT_WEB_CONFIG;
13+
let config: Config = DEFAULT_CONFIG;
1314

1415
try {
1516
if (options.config) {
16-
webConfig = JSON.parse(fs.readFileSync(options.config, 'utf-8'));
17-
}
18-
for (let viewport of webConfig.web.resolutions || webConfig.web.viewports) {
19-
viewports.push({ width: viewport[0], height: viewport[1]})
17+
config = JSON.parse(fs.readFileSync(options.config, 'utf-8'));
18+
// resolutions supported for backward compatibility
19+
if (config.web.resolutions) {
20+
config.web.viewports = config.web.resolutions;
21+
delete config.web.resolutions;
22+
}
2023
}
24+
25+
// validate config
26+
if (!validateConfig(config)) throw new Error(validateConfig.errors[0].message);
2127
} catch (error: any) {
22-
throw new Error(error.message);
28+
console.log(`[smartui] Error: ${error.message}`);
29+
process.exit();
2330
}
24-
// TODO: validate config
2531

32+
for (let viewport of config.web.viewports) viewports.push({ width: viewport[0], height: viewport[1]});
2633
return {
2734
env: env,
2835
log: logger,
2936
client: new httpClient(env),
30-
config: {
31-
browsers: webConfig.web.browsers,
37+
webConfig: {
38+
browsers: config.web.browsers,
3239
viewports: viewports,
33-
waitForPageRender: webConfig.web.waitForPageRender || 0,
34-
waitForTimeout: webConfig.web.waitForTimeout || 0
40+
waitForPageRender: config.web.waitForPageRender || 0,
41+
waitForTimeout: config.web.waitForTimeout || 0
3542
},
36-
staticConfig: [],
43+
webStaticConfig: [],
3744
git: {
3845
branch: '',
3946
commitId: '',

src/lib/schemaValidation.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { WebStaticConfig } from '../types.js'
2+
import Ajv, { JSONSchemaType } from 'ajv'
3+
import addErrors from 'ajv-errors'
4+
5+
const ajv = new Ajv({ allErrors: true });
6+
ajv.addFormat('web-url', {
7+
type: 'string',
8+
validate: (url: string) => {
9+
const urlPattern = new RegExp('^(https?:\\/\\/)?' + // protocol
10+
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
11+
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
12+
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
13+
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
14+
'(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
15+
return urlPattern.test(url);
16+
}
17+
});
18+
addErrors(ajv);
19+
20+
const ConfigSchema = {
21+
type: "object",
22+
properties: {
23+
web: {
24+
type: "object",
25+
properties: {
26+
browsers: {
27+
type: "array",
28+
items: { type: "string", enum: ["chrome", "firefox", "edge", "safari"] },
29+
uniqueItems: true,
30+
maxItems: 4,
31+
errorMessage: "Invalid config; allowed browsers - chrome, firefox, edge, safari"
32+
},
33+
viewports: {
34+
type: "array",
35+
items: {
36+
type: "array",
37+
items: [
38+
{ type: "number", minimum: 320, maximum: 7680 },
39+
{ type: "number", minimum: 320, maximum: 7680 }
40+
],
41+
additionalItems: false,
42+
minItems: 2,
43+
maxItems: 2
44+
},
45+
uniqueItems: true,
46+
maxItems: 5,
47+
errorMessage: "Invalid config; width/height must be >= 320 and <= 7680; max viewports allowed - 5"
48+
},
49+
waitForPageRender: {
50+
type: "number",
51+
minimum: 0,
52+
maximum: 300000,
53+
errorMessage: "Invalid config; waitForPageRender must be > 0 and <= 300000"
54+
},
55+
waitForTimeout: {
56+
type: "number",
57+
minimum: 0,
58+
maximum: 30000,
59+
errorMessage: "Invalid config; waitForTimeout must be > 0 and <= 30000"
60+
},
61+
},
62+
required: ["browsers", "viewports"],
63+
additionalProperties: false
64+
}
65+
},
66+
required: ["web"],
67+
additionalProperties: false
68+
}
69+
70+
const WebStaticConfigSchema: JSONSchemaType<WebStaticConfig> = {
71+
type: "array",
72+
items: {
73+
type: "object",
74+
properties: {
75+
name: {
76+
type: "string",
77+
minLength: 1,
78+
errorMessage: "name is mandatory and cannot be empty"
79+
},
80+
url: {
81+
type: "string",
82+
format: "web-url",
83+
errorMessage: "url is mandatory and must be a valid web URL"
84+
},
85+
waitForTimeout: {
86+
type: "number",
87+
nullable: true,
88+
minimum: 0,
89+
maximum: 30000,
90+
errorMessage: "waitForTimeout must be > 0 and <= 30000"
91+
},
92+
},
93+
required: ["name", "url"],
94+
additionalProperties: false
95+
},
96+
uniqueItems: true
97+
}
98+
99+
export const validateConfig = ajv.compile(ConfigSchema);
100+
export const validateWebStaticConfig = ajv.compile(WebStaticConfigSchema);

src/lib/screenshot.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { chromium, firefox, webkit, Browser } from "@playwright/test"
2-
import { Context, WebStaticConfigSchema } from "../types.js"
2+
import { Context, WebStaticConfig } from "../types.js"
33
import { delDir } from "./utils.js"
44

55
const BROWSER_CHROME = 'chrome';
@@ -9,16 +9,16 @@ const BROWSER_EDGE = 'edge';
99
const EDGE_CHANNEL = 'msedge';
1010
const PW_WEBKIT = 'webkit';
1111

12-
export async function captureScreenshots(ctx: Context, screenshots: WebStaticConfigSchema): Promise<number> {
12+
export async function captureScreenshots(ctx: Context, screenshots: WebStaticConfig): Promise<number> {
1313
// Clean up directory to store screenshots
1414
delDir('screenshots');
1515

1616
// Capture screenshots for every browser-viewport and upload them
17-
let totalBrowsers: number = ctx.config.browsers.length;
18-
let totalViewports: number = ctx.config.viewports.length;
17+
let totalBrowsers: number = ctx.webConfig.browsers.length;
18+
let totalViewports: number = ctx.webConfig.viewports.length;
1919
let totalScreenshots: number = screenshots.length
2020
for (let i = 0; i < totalBrowsers; i++) {
21-
let browserName = ctx.config.browsers[i]?.toLowerCase();
21+
let browserName = ctx.webConfig.browsers[i]?.toLowerCase();
2222
let browser: Browser;
2323
let launchOptions: Record<string, any> = { headless: true };
2424
let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load' }
@@ -50,7 +50,7 @@ export async function captureScreenshots(ctx: Context, screenshots: WebStaticCon
5050
await page.waitForTimeout(screenshot.waitForTimeout || 0)
5151

5252
for (let k = 0; k < totalViewports; k++) {
53-
let { width, height } = ctx.config.viewports[k];
53+
let { width, height } = ctx.webConfig.viewports[k];
5454
let ssName = `${browserName}-${width}x${height}-${screenshotId}.png`
5555
let ssPath = `screenshots/${screenshotId}/${ssName}.png`
5656
await page.setViewportSize({ width, height})

src/tasks/captureScreenshots.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default (ctx: Context): ListrTask<Context, ListrRendererFactory, ListrRen
88
title: 'Capturing screenshots',
99
task: async (ctx, task): Promise<void> => {
1010
try {
11-
let { staticConfig: screenshots } = ctx;
11+
let { webStaticConfig: screenshots } = ctx;
1212

1313
let totalScreenshots = await captureScreenshots(ctx, screenshots);
1414
task.title = 'Screenshots captured successfully'

src/tasks/createBuild.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default (ctx: Context): ListrTask<Context, ListrRendererFactory, ListrRen
1010
updateLogContext({task: 'createBuild'});
1111

1212
try {
13-
let resp = await ctx.client.createBuild(ctx.git, ctx.config, ctx.log);
13+
let resp = await ctx.client.createBuild(ctx.git, ctx.webConfig, ctx.log);
1414
ctx.build = {
1515
id: resp.data.buildId,
1616
name: resp.data.buildName,

0 commit comments

Comments
 (0)