Skip to content

Commit 836b242

Browse files
authoredDec 13, 2024··
feat(core): enhance config validation
1 parent 9099514 commit 836b242

14 files changed

+108
-27
lines changed
 

‎package-lock.json

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

‎package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"vscode-material-icons": "^0.1.1",
4444
"yaml": "^2.5.1",
4545
"yargs": "^17.7.2",
46-
"zod": "^3.23.8"
46+
"zod": "^3.23.8",
47+
"zod-validation-error": "^3.4.0"
4748
},
4849
"devDependencies": {
4950
"@beaussan/nx-knip": "^0.0.5-15",

‎packages/core/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"dependencies": {
4242
"@code-pushup/models": "0.56.0",
4343
"@code-pushup/utils": "0.56.0",
44-
"ansis": "^3.3.0"
44+
"ansis": "^3.3.0",
45+
"zod-validation-error": "^3.4.0"
4546
},
4647
"peerDependencies": {
4748
"@code-pushup/portal-client": "^0.9.0"

‎packages/core/src/lib/implementation/read-rc-file.integration.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { dirname, join } from 'node:path';
22
import { fileURLToPath } from 'node:url';
33
import { describe, expect } from 'vitest';
4-
import { readRcByPath } from './read-rc-file.js';
4+
import { ConfigValidationError, readRcByPath } from './read-rc-file.js';
55

66
describe('readRcByPath', () => {
77
const configDirPath = join(
@@ -69,7 +69,7 @@ describe('readRcByPath', () => {
6969
it('should throw if the configuration is empty', async () => {
7070
await expect(
7171
readRcByPath(join(configDirPath, 'code-pushup.empty.config.js')),
72-
).rejects.toThrow(`"code": "invalid_type",`);
72+
).rejects.toThrow(expect.any(ConfigValidationError));
7373
});
7474

7575
it('should throw if the configuration is invalid', async () => {

‎packages/core/src/lib/implementation/read-rc-file.ts

+27-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
1-
import { join } from 'node:path';
1+
import { bold } from 'ansis';
2+
import path, { join } from 'node:path';
3+
import { fromError, isZodErrorLike } from 'zod-validation-error';
24
import {
35
CONFIG_FILE_NAME,
46
type CoreConfig,
57
SUPPORTED_CONFIG_FILE_FORMATS,
68
coreConfigSchema,
79
} from '@code-pushup/models';
8-
import { fileExists, importModule } from '@code-pushup/utils';
10+
import {
11+
fileExists,
12+
importModule,
13+
zodErrorMessageBuilder,
14+
} from '@code-pushup/utils';
915

1016
export class ConfigPathError extends Error {
1117
constructor(configPath: string) {
1218
super(`Provided path '${configPath}' is not valid.`);
1319
}
1420
}
1521

22+
export class ConfigValidationError extends Error {
23+
constructor(configPath: string, message: string) {
24+
const relativePath = path.relative(process.cwd(), configPath);
25+
super(`Failed parsing core config in ${bold(relativePath)}.\n\n${message}`);
26+
}
27+
}
28+
1629
export async function readRcByPath(
1730
filepath: string,
1831
tsconfig?: string,
@@ -27,16 +40,25 @@ export async function readRcByPath(
2740

2841
const cfg = await importModule({ filepath, tsconfig, format: 'esm' });
2942

30-
return coreConfigSchema.parse(cfg);
43+
try {
44+
return coreConfigSchema.parse(cfg);
45+
} catch (error) {
46+
const validationError = fromError(error, {
47+
messageBuilder: zodErrorMessageBuilder,
48+
});
49+
throw isZodErrorLike(error)
50+
? new ConfigValidationError(filepath, validationError.message)
51+
: error;
52+
}
3153
}
3254

3355
export async function autoloadRc(tsconfig?: string): Promise<CoreConfig> {
3456
// eslint-disable-next-line functional/no-let
3557
let ext = '';
3658
// eslint-disable-next-line functional/no-loop-statements
3759
for (const extension of SUPPORTED_CONFIG_FILE_FORMATS) {
38-
const path = `${CONFIG_FILE_NAME}.${extension}`;
39-
const exists = await fileExists(path);
60+
const filePath = `${CONFIG_FILE_NAME}.${extension}`;
61+
const exists = await fileExists(filePath);
4062

4163
if (exists) {
4264
ext = extension;

‎packages/models/src/lib/category-config.unit.test.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('categoryConfigSchema', () => {
129129
title: 'This category is empty for now',
130130
refs: [],
131131
} satisfies CategoryConfig),
132-
).toThrow('In a category there has to be at least one ref');
132+
).toThrow('In a category, there has to be at least one ref');
133133
});
134134

135135
it('should throw for duplicate category references', () => {
@@ -175,7 +175,9 @@ describe('categoryConfigSchema', () => {
175175
},
176176
],
177177
} satisfies CategoryConfig),
178-
).toThrow('In a category there has to be at least one ref with weight > 0');
178+
).toThrow(
179+
'In a category, there has to be at least one ref with weight > 0. Affected refs: functional/immutable-data, lighthouse-experimental',
180+
);
179181
});
180182
});
181183

‎packages/models/src/lib/group.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const groupSchema = scorableSchema(
3232
getDuplicateRefsInGroups,
3333
duplicateRefsInGroupsErrorMsg,
3434
).merge(groupMetaSchema);
35+
3536
export type Group = z.infer<typeof groupSchema>;
3637

3738
export const groupsSchema = z

‎packages/models/src/lib/group.unit.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('groupSchema', () => {
6969
title: 'Empty group',
7070
refs: [],
7171
} satisfies Group),
72-
).toThrow('In a category there has to be at least one ref');
72+
).toThrow('In a category, there has to be at least one ref');
7373
});
7474

7575
it('should throw for duplicate group references', () => {

‎packages/models/src/lib/implementation/schemas.ts

+11-9
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const slugSchema = z
3838
'The slug has to follow the pattern [0-9a-z] followed by multiple optional groups of -[0-9a-z]. e.g. my-slug',
3939
})
4040
.max(MAX_SLUG_LENGTH, {
41-
message: `slug can be max ${MAX_SLUG_LENGTH} characters long`,
41+
message: `The slug can be max ${MAX_SLUG_LENGTH} characters long`,
4242
});
4343

4444
/** Schema for a general description property */
@@ -105,7 +105,7 @@ export function metaSchema(options?: {
105105
export const filePathSchema = z
106106
.string()
107107
.trim()
108-
.min(1, { message: 'path is invalid' });
108+
.min(1, { message: 'The path is invalid' });
109109

110110
/** Schema for a fileNameSchema */
111111
export const fileNameSchema = z
@@ -114,7 +114,7 @@ export const fileNameSchema = z
114114
.regex(filenameRegex, {
115115
message: `The filename has to be valid`,
116116
})
117-
.min(1, { message: 'file name is invalid' });
117+
.min(1, { message: 'The file name is invalid' });
118118

119119
/** Schema for a positiveInt */
120120
export const positiveIntSchema = z.number().int().positive();
@@ -172,19 +172,21 @@ export function scorableSchema<T extends ReturnType<typeof weightedRefSchema>>(
172172
slug: slugSchema.describe('Human-readable unique ID, e.g. "performance"'),
173173
refs: z
174174
.array(refSchema)
175-
.min(1)
175+
.min(1, { message: 'In a category, there has to be at least one ref' })
176176
// refs are unique
177177
.refine(
178178
refs => !duplicateCheckFn(refs),
179179
refs => ({
180180
message: duplicateMessageFn(refs),
181181
}),
182182
)
183-
// categories weights are correct
184-
.refine(hasNonZeroWeightedRef, () => ({
185-
message:
186-
'In a category there has to be at least one ref with weight > 0',
187-
})),
183+
// category weights are correct
184+
.refine(hasNonZeroWeightedRef, refs => {
185+
const affectedRefs = refs.map(ref => ref.slug).join(', ');
186+
return {
187+
message: `In a category, there has to be at least one ref with weight > 0. Affected refs: ${affectedRefs}`,
188+
};
189+
}),
188190
},
189191
{ description },
190192
);

‎packages/plugin-lighthouse/src/lib/lighthouse-plugin.unit.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,14 @@ describe('lighthousePlugin-config-object', () => {
5050
]),
5151
);
5252
});
53+
54+
it('should throw when filtering groups by zero-weight onlyAudits', () => {
55+
const pluginConfig = lighthousePlugin('https://code-pushup-portal.com', {
56+
onlyAudits: ['csp-xss'],
57+
});
58+
59+
expect(() => pluginConfigSchema.parse(pluginConfig)).toThrow(
60+
'In a category, there has to be at least one ref with weight > 0. Affected refs: csp-xss',
61+
);
62+
});
5363
});

‎packages/utils/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"esbuild": "^0.19.2",
3434
"multi-progress-bars": "^5.0.3",
3535
"semver": "^7.6.0",
36-
"simple-git": "^3.20.0"
36+
"simple-git": "^3.20.0",
37+
"zod-validation-error": "^3.4.0"
3738
}
3839
}

‎packages/utils/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,4 @@ export type {
122122
WithRequired,
123123
} from './lib/types.js';
124124
export { verboseUtils } from './lib/verbose-utils.js';
125+
export { zodErrorMessageBuilder } from './lib/zod-validation.js';
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { bold, red } from 'ansis';
2+
import type { MessageBuilder } from 'zod-validation-error';
3+
4+
export function formatErrorPath(errorPath: (string | number)[]): string {
5+
return errorPath
6+
.map((key, index) => {
7+
if (typeof key === 'number') {
8+
return `[${key}]`;
9+
}
10+
return index > 0 ? `.${key}` : key;
11+
})
12+
.join('');
13+
}
14+
15+
export const zodErrorMessageBuilder: MessageBuilder = issues =>
16+
issues
17+
.map(issue => {
18+
const formattedMessage = red(`${bold(issue.code)}: ${issue.message}`);
19+
const formattedPath = formatErrorPath(issue.path);
20+
if (formattedPath) {
21+
return `Validation error at ${bold(formattedPath)}\n${formattedMessage}\n`;
22+
}
23+
return `${formattedMessage}\n`;
24+
})
25+
.join('\n');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { formatErrorPath } from './zod-validation';
2+
3+
describe('formatErrorPath', () => {
4+
it.each([
5+
[['categories', 1, 'slug'], 'categories[1].slug'],
6+
[['plugins', 2, 'groups', 0, 'refs'], 'plugins[2].groups[0].refs'],
7+
[['refs', 0, 'slug'], 'refs[0].slug'],
8+
[['categories'], 'categories'],
9+
[[], ''],
10+
[['path', 5], 'path[5]'],
11+
])('should format error path %j as %j', (input, expected) => {
12+
expect(formatErrorPath(input)).toBe(expected);
13+
});
14+
});

0 commit comments

Comments
 (0)
Please sign in to comment.