Skip to content

Commit ce4d975

Browse files
authored
feat: add transform to persist config (#229)
1 parent 10d2e5f commit ce4d975

File tree

8 files changed

+166
-110
lines changed

8 files changed

+166
-110
lines changed

packages/core/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export {
55
} from './lib/implementation/persist';
66
export {
77
executePlugins,
8-
PluginOutputError,
8+
PluginOutputMissingAuditError,
99
} from './lib/implementation/execute-plugin';
1010
export { collect, CollectOptions } from './lib/implementation/collect';
1111
export { upload, UploadOptions } from './lib/upload';

packages/core/src/lib/implementation/execute-plugin.spec.ts

+53-21
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { join } from 'path';
21
import { describe, expect, it } from 'vitest';
32
import {
4-
AuditReport,
3+
AuditOutput,
4+
AuditOutputs,
55
PluginConfig,
66
auditOutputsSchema,
77
} from '@code-pushup/models';
8-
import {
9-
auditReport,
10-
echoRunnerConfig,
11-
pluginConfig,
12-
} from '@code-pushup/models/testing';
8+
import { auditReport, pluginConfig } from '@code-pushup/models/testing';
139
import { DEFAULT_TESTING_CLI_OPTIONS } from '../../../test/constants';
14-
import { executePlugin, executePlugins } from './execute-plugin';
10+
import {
11+
PluginOutputMissingAuditError,
12+
executePlugin,
13+
executePlugins,
14+
} from './execute-plugin';
1515

1616
const validPluginCfg = pluginConfig([auditReport()]);
1717
const validPluginCfg2 = pluginConfig([auditReport()], {
@@ -35,24 +35,28 @@ describe('executePlugin', () => {
3535
expect(() => auditOutputsSchema.parse(pluginResult.audits)).not.toThrow();
3636
});
3737

38-
it('should throws with invalid plugin audits slug', async () => {
38+
it('should throw with missing plugin audit', async () => {
3939
const pluginCfg = invalidSlugPluginCfg;
4040
await expect(() => executePlugin(pluginCfg)).rejects.toThrow(
41-
/Plugin output of plugin .* is invalid./,
41+
new PluginOutputMissingAuditError('mock-audit-slug'),
4242
);
4343
});
4444

45-
it('should throw if invalid runnerOutput is produced', async () => {
46-
const invalidAuditOutputs: AuditReport[] = [
47-
{ p: 42 } as unknown as AuditReport,
48-
];
49-
const pluginCfg = pluginConfig([auditReport()]);
50-
pluginCfg.runner = echoRunnerConfig(
51-
invalidAuditOutputs,
52-
join('tmp', 'out.json'),
53-
);
45+
it('should throw if invalid runnerOutput is produced with transform', async () => {
46+
const pluginCfg: PluginConfig = {
47+
...validPluginCfg,
48+
runner: {
49+
...validPluginCfg.runner,
50+
outputTransform: (d: unknown) =>
51+
Array.from(d as Record<string, unknown>[]).map((d, idx) => ({
52+
...d,
53+
slug: '-invalid-slug-' + idx,
54+
})) as unknown as AuditOutputs,
55+
},
56+
};
57+
5458
await expect(() => executePlugin(pluginCfg)).rejects.toThrow(
55-
/Plugin output of plugin .* is invalid./,
59+
'The slug has to follow the pattern',
5660
);
5761
});
5862
});
@@ -79,6 +83,34 @@ describe('executePlugins', () => {
7983
const plugins: PluginConfig[] = [validPluginCfg, invalidSlugPluginCfg];
8084
await expect(() =>
8185
executePlugins(plugins, DEFAULT_OPTIONS),
82-
).rejects.toThrow(/Plugin output of plugin .* is invalid./);
86+
).rejects.toThrow('Audit metadata not found for slug mock-audit-slug');
87+
});
88+
89+
it('should use outputTransform if provided', async () => {
90+
const plugins: PluginConfig[] = [
91+
{
92+
...validPluginCfg,
93+
runner: {
94+
...validPluginCfg.runner,
95+
outputTransform: (outputs: unknown): Promise<AuditOutputs> => {
96+
const arr = Array.from(outputs as Record<string, unknown>[]);
97+
return Promise.resolve(
98+
arr.map(output => {
99+
return {
100+
...output,
101+
displayValue:
102+
'transformed slug description - ' +
103+
(output as { slug: string }).slug,
104+
} as unknown as AuditOutput;
105+
}),
106+
);
107+
},
108+
},
109+
},
110+
];
111+
const pluginResult = await executePlugins(plugins, DEFAULT_OPTIONS);
112+
expect(pluginResult[0]?.audits[0]?.displayValue).toBe(
113+
'transformed slug description - mock-audit-slug',
114+
);
83115
});
84116
});
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import chalk from 'chalk';
2-
import { readFile } from 'fs/promises';
32
import { join } from 'path';
43
import {
4+
Audit,
5+
AuditOutput,
6+
AuditOutputs,
7+
AuditReport,
58
PluginConfig,
69
PluginReport,
710
auditOutputsSchema,
@@ -10,20 +13,15 @@ import {
1013
ProcessObserver,
1114
executeProcess,
1215
getProgressBar,
16+
readJsonFile,
1317
} from '@code-pushup/utils';
1418

1519
/**
1620
* Error thrown when plugin output is invalid.
1721
*/
18-
export class PluginOutputError extends Error {
19-
constructor(pluginSlug: string, error?: Error) {
20-
super(
21-
`Plugin output of plugin with slug ${pluginSlug} is invalid. \n Error: ${error?.message}`,
22-
);
23-
if (error) {
24-
this.name = error.name;
25-
this.stack = error.stack;
26-
}
22+
export class PluginOutputMissingAuditError extends Error {
23+
constructor(auditSlug: string) {
24+
super(`Audit metadata not found for slug ${auditSlug}`);
2725
}
2826
}
2927

@@ -34,7 +32,7 @@ export class PluginOutputError extends Error {
3432
* @param pluginConfig - {@link ProcessConfig} object with runner and meta
3533
* @param observer - process {@link ProcessObserver}
3634
* @returns {Promise<AuditOutput[]>} - audit outputs from plugin runner
37-
* @throws {PluginOutputError} - if plugin runner output is invalid
35+
* @throws {PluginOutputMissingAuditError} - if plugin runner output is invalid
3836
*
3937
* @example
4038
* // plugin execution
@@ -54,70 +52,61 @@ export async function executePlugin(
5452
observer?: ProcessObserver,
5553
): Promise<PluginReport> {
5654
const {
57-
slug,
58-
title,
59-
icon,
55+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
56+
runner: onlyUsedForRestingPluginMeta,
57+
audits: pluginConfigAudits,
6058
description,
6159
docsUrl,
62-
version,
63-
packageName,
6460
groups,
61+
...pluginMeta
6562
} = pluginConfig;
6663
const { args, command } = pluginConfig.runner;
6764

68-
const { duration, date } = await executeProcess({
65+
const { date, duration } = await executeProcess({
6966
command,
7067
args,
7168
observer,
7269
});
70+
const executionMeta = { date, duration };
7371

74-
try {
75-
const processOutputPath = join(
76-
process.cwd(),
77-
pluginConfig.runner.outputFile,
78-
);
72+
const processOutputPath = join(process.cwd(), pluginConfig.runner.outputFile);
73+
74+
// read process output from file system and parse it
75+
let unknownAuditOutputs = await readJsonFile<Record<string, unknown>[]>(
76+
processOutputPath,
77+
);
7978

80-
// read process output from file system and parse it
81-
const auditOutputs = auditOutputsSchema.parse(
82-
JSON.parse((await readFile(processOutputPath)).toString()),
79+
// parse transform unknownAuditOutputs to auditOutputs
80+
if (pluginConfig.runner?.outputTransform) {
81+
unknownAuditOutputs = await pluginConfig.runner.outputTransform(
82+
unknownAuditOutputs,
8383
);
84+
}
85+
86+
// validate audit outputs
87+
const auditOutputs = auditOutputsSchema.parse(unknownAuditOutputs);
88+
89+
// validate auditOutputs
90+
auditOutputsCorrelateWithPluginOutput(auditOutputs, pluginConfigAudits);
8491

85-
const audits = auditOutputs.map(auditOutput => {
86-
const auditMetadata = pluginConfig.audits.find(
92+
// enrich `AuditOutputs` to `AuditReport`
93+
const audits: AuditReport[] = auditOutputs.map(
94+
(auditOutput: AuditOutput) => ({
95+
...auditOutput,
96+
...(pluginConfigAudits.find(
8797
audit => audit.slug === auditOutput.slug,
88-
);
89-
if (!auditMetadata) {
90-
throw new PluginOutputError(
91-
slug,
92-
new Error(
93-
`Audit metadata not found for slug ${auditOutput.slug} from runner output`,
94-
),
95-
);
96-
}
97-
return {
98-
...auditOutput,
99-
...auditMetadata,
100-
};
101-
});
102-
103-
// @TODO consider just resting/spreading the values
104-
return {
105-
version,
106-
packageName,
107-
slug,
108-
title,
109-
icon,
110-
date,
111-
duration,
112-
audits,
113-
...(description && { description }),
114-
...(docsUrl && { docsUrl }),
115-
...(groups && { groups }),
116-
} satisfies PluginReport;
117-
} catch (error) {
118-
const e = error as Error;
119-
throw new PluginOutputError(slug, e);
120-
}
98+
) as Audit),
99+
}),
100+
);
101+
102+
return {
103+
...pluginMeta,
104+
...executionMeta,
105+
audits,
106+
...(description && { description }),
107+
...(docsUrl && { docsUrl }),
108+
...(groups && { groups }),
109+
} satisfies PluginReport;
121110
}
122111

123112
/**
@@ -165,3 +154,17 @@ export async function executePlugins(
165154

166155
return pluginsResult;
167156
}
157+
158+
function auditOutputsCorrelateWithPluginOutput(
159+
auditOutputs: AuditOutputs,
160+
pluginConfigAudits: PluginConfig['audits'],
161+
) {
162+
auditOutputs.forEach(auditOutput => {
163+
const auditMetadata = pluginConfigAudits.find(
164+
audit => audit.slug === auditOutput.slug,
165+
);
166+
if (!auditMetadata) {
167+
throw new PluginOutputMissingAuditError(auditOutput.slug);
168+
}
169+
});
170+
}

packages/models/src/index.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ export {
2929
AuditGroup,
3030
auditGroupSchema,
3131
} from './lib/plugin-config-groups';
32-
export { AuditOutput, auditOutputsSchema } from './lib/plugin-process-output';
32+
export {
33+
AuditOutput,
34+
AuditOutputs,
35+
auditOutputsSchema,
36+
} from './lib/plugin-process-output';
3337
export { Issue, IssueSeverity } from './lib/plugin-process-output-audit-issue';
3438
export {
3539
AuditReport,
40+
auditReportSchema,
3641
PluginReport,
3742
Report,
3843
pluginReportSchema,

packages/models/src/lib/plugin-config-runner.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { z } from 'zod';
22
import { filePathSchema } from './implementation/schemas';
3+
import { auditOutputsSchema } from './plugin-process-output';
4+
5+
export const outputTransformSchema = z
6+
.function()
7+
.args(z.unknown())
8+
.returns(z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]));
9+
10+
export type OutputTransform = z.infer<typeof outputTransformSchema>;
311

412
export const runnerConfigSchema = z.object(
513
{
@@ -8,6 +16,7 @@ export const runnerConfigSchema = z.object(
816
}),
917
args: z.array(z.string({ description: 'Command arguments' })).optional(),
1018
outputFile: filePathSchema('Output path'),
19+
outputTransform: outputTransformSchema.optional(),
1120
},
1221
{
1322
description: 'How to execute runner',

packages/models/src/lib/plugin-config.spec.ts

+32-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
2-
import { config } from '../../test';
2+
import { config, pluginConfig } from '../../test';
33
import { pluginConfigSchema } from './plugin-config';
4+
import { AuditOutputs } from './plugin-process-output';
45

56
describe('pluginConfigSchema', () => {
67
it('should parse if plugin configuration is valid', () => {
@@ -40,7 +41,7 @@ describe('pluginConfigSchema', () => {
4041
it('should throw if plugin groups contain invalid slugs', () => {
4142
const invalidGroupSlug = '-invalid-group-slug';
4243
const pluginConfig = config().plugins[1];
43-
const groups = pluginConfig.groups;
44+
const groups = pluginConfig.groups!;
4445
groups[0].slug = invalidGroupSlug;
4546
pluginConfig.groups = groups;
4647

@@ -51,7 +52,7 @@ describe('pluginConfigSchema', () => {
5152

5253
it('should throw if plugin groups have duplicate slugs', () => {
5354
const pluginConfig = config().plugins[1];
54-
const groups = pluginConfig.groups;
55+
const groups = pluginConfig.groups!;
5556
pluginConfig.groups = [...groups, groups[0]];
5657
expect(() => pluginConfigSchema.parse(pluginConfig)).toThrow(
5758
'In groups the slugs are not unique',
@@ -61,7 +62,7 @@ describe('pluginConfigSchema', () => {
6162
it('should throw if plugin groups refs contain invalid slugs', () => {
6263
const invalidAuditRef = '-invalid-audit-ref';
6364
const pluginConfig = config().plugins[1];
64-
const groups = pluginConfig.groups;
65+
const groups = pluginConfig.groups!;
6566

6667
groups[0].refs[0].slug = invalidAuditRef;
6768
pluginConfig.groups = groups;
@@ -70,4 +71,31 @@ describe('pluginConfigSchema', () => {
7071
`slug has to follow the pattern`,
7172
);
7273
});
74+
75+
it('should take a outputTransform function', () => {
76+
const undefinedPluginOutput = [
77+
{ slug: 'audit-1', errors: 0 },
78+
{ slug: 'audit-2', errors: 5 },
79+
];
80+
const pluginCfg = pluginConfig([]);
81+
pluginCfg.runner.outputTransform = (data: unknown): AuditOutputs => {
82+
return (data as typeof undefinedPluginOutput).map(data => ({
83+
slug: data.slug,
84+
score: Number(data.errors === 0),
85+
value: data.errors,
86+
}));
87+
};
88+
89+
expect(
90+
pluginConfigSchema.parse(pluginCfg).runner.outputTransform,
91+
).toBeDefined();
92+
expect(
93+
pluginConfigSchema.parse(pluginCfg).runner.outputTransform!(
94+
undefinedPluginOutput,
95+
),
96+
).toEqual([
97+
{ slug: 'audit-1', score: 1, value: 0 },
98+
{ slug: 'audit-2', score: 0, value: 5 },
99+
]);
100+
});
73101
});

0 commit comments

Comments
 (0)