Skip to content

Commit

Permalink
feat: add transform to persist config (#229)
Browse files Browse the repository at this point in the history
  • Loading branch information
BioPhoton committed Nov 14, 2023
1 parent 10d2e5f commit ce4d975
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 110 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export {
} from './lib/implementation/persist';
export {
executePlugins,
PluginOutputError,
PluginOutputMissingAuditError,
} from './lib/implementation/execute-plugin';
export { collect, CollectOptions } from './lib/implementation/collect';
export { upload, UploadOptions } from './lib/upload';
Expand Down
74 changes: 53 additions & 21 deletions packages/core/src/lib/implementation/execute-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import {
AuditReport,
AuditOutput,
AuditOutputs,
PluginConfig,
auditOutputsSchema,
} from '@code-pushup/models';
import {
auditReport,
echoRunnerConfig,
pluginConfig,
} from '@code-pushup/models/testing';
import { auditReport, pluginConfig } from '@code-pushup/models/testing';
import { DEFAULT_TESTING_CLI_OPTIONS } from '../../../test/constants';
import { executePlugin, executePlugins } from './execute-plugin';
import {
PluginOutputMissingAuditError,
executePlugin,
executePlugins,
} from './execute-plugin';

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

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

it('should throw if invalid runnerOutput is produced', async () => {
const invalidAuditOutputs: AuditReport[] = [
{ p: 42 } as unknown as AuditReport,
];
const pluginCfg = pluginConfig([auditReport()]);
pluginCfg.runner = echoRunnerConfig(
invalidAuditOutputs,
join('tmp', 'out.json'),
);
it('should throw if invalid runnerOutput is produced with transform', async () => {
const pluginCfg: PluginConfig = {
...validPluginCfg,
runner: {
...validPluginCfg.runner,
outputTransform: (d: unknown) =>
Array.from(d as Record<string, unknown>[]).map((d, idx) => ({
...d,
slug: '-invalid-slug-' + idx,
})) as unknown as AuditOutputs,
},
};

await expect(() => executePlugin(pluginCfg)).rejects.toThrow(
/Plugin output of plugin .* is invalid./,
'The slug has to follow the pattern',
);
});
});
Expand All @@ -79,6 +83,34 @@ describe('executePlugins', () => {
const plugins: PluginConfig[] = [validPluginCfg, invalidSlugPluginCfg];
await expect(() =>
executePlugins(plugins, DEFAULT_OPTIONS),
).rejects.toThrow(/Plugin output of plugin .* is invalid./);
).rejects.toThrow('Audit metadata not found for slug mock-audit-slug');
});

it('should use outputTransform if provided', async () => {
const plugins: PluginConfig[] = [
{
...validPluginCfg,
runner: {
...validPluginCfg.runner,
outputTransform: (outputs: unknown): Promise<AuditOutputs> => {
const arr = Array.from(outputs as Record<string, unknown>[]);
return Promise.resolve(
arr.map(output => {
return {
...output,
displayValue:
'transformed slug description - ' +
(output as { slug: string }).slug,
} as unknown as AuditOutput;
}),
);
},
},
},
];
const pluginResult = await executePlugins(plugins, DEFAULT_OPTIONS);
expect(pluginResult[0]?.audits[0]?.displayValue).toBe(
'transformed slug description - mock-audit-slug',
);
});
});
123 changes: 63 additions & 60 deletions packages/core/src/lib/implementation/execute-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import chalk from 'chalk';
import { readFile } from 'fs/promises';
import { join } from 'path';
import {
Audit,
AuditOutput,
AuditOutputs,
AuditReport,
PluginConfig,
PluginReport,
auditOutputsSchema,
Expand All @@ -10,20 +13,15 @@ import {
ProcessObserver,
executeProcess,
getProgressBar,
readJsonFile,
} from '@code-pushup/utils';

/**
* Error thrown when plugin output is invalid.
*/
export class PluginOutputError extends Error {
constructor(pluginSlug: string, error?: Error) {
super(
`Plugin output of plugin with slug ${pluginSlug} is invalid. \n Error: ${error?.message}`,
);
if (error) {
this.name = error.name;
this.stack = error.stack;
}
export class PluginOutputMissingAuditError extends Error {
constructor(auditSlug: string) {
super(`Audit metadata not found for slug ${auditSlug}`);
}
}

Expand All @@ -34,7 +32,7 @@ export class PluginOutputError extends Error {
* @param pluginConfig - {@link ProcessConfig} object with runner and meta
* @param observer - process {@link ProcessObserver}
* @returns {Promise<AuditOutput[]>} - audit outputs from plugin runner
* @throws {PluginOutputError} - if plugin runner output is invalid
* @throws {PluginOutputMissingAuditError} - if plugin runner output is invalid
*
* @example
* // plugin execution
Expand All @@ -54,70 +52,61 @@ export async function executePlugin(
observer?: ProcessObserver,
): Promise<PluginReport> {
const {
slug,
title,
icon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
runner: onlyUsedForRestingPluginMeta,
audits: pluginConfigAudits,
description,
docsUrl,
version,
packageName,
groups,
...pluginMeta
} = pluginConfig;
const { args, command } = pluginConfig.runner;

const { duration, date } = await executeProcess({
const { date, duration } = await executeProcess({
command,
args,
observer,
});
const executionMeta = { date, duration };

try {
const processOutputPath = join(
process.cwd(),
pluginConfig.runner.outputFile,
);
const processOutputPath = join(process.cwd(), pluginConfig.runner.outputFile);

// read process output from file system and parse it
let unknownAuditOutputs = await readJsonFile<Record<string, unknown>[]>(
processOutputPath,
);

// read process output from file system and parse it
const auditOutputs = auditOutputsSchema.parse(
JSON.parse((await readFile(processOutputPath)).toString()),
// parse transform unknownAuditOutputs to auditOutputs
if (pluginConfig.runner?.outputTransform) {
unknownAuditOutputs = await pluginConfig.runner.outputTransform(
unknownAuditOutputs,
);
}

// validate audit outputs
const auditOutputs = auditOutputsSchema.parse(unknownAuditOutputs);

// validate auditOutputs
auditOutputsCorrelateWithPluginOutput(auditOutputs, pluginConfigAudits);

const audits = auditOutputs.map(auditOutput => {
const auditMetadata = pluginConfig.audits.find(
// enrich `AuditOutputs` to `AuditReport`
const audits: AuditReport[] = auditOutputs.map(
(auditOutput: AuditOutput) => ({
...auditOutput,
...(pluginConfigAudits.find(
audit => audit.slug === auditOutput.slug,
);
if (!auditMetadata) {
throw new PluginOutputError(
slug,
new Error(
`Audit metadata not found for slug ${auditOutput.slug} from runner output`,
),
);
}
return {
...auditOutput,
...auditMetadata,
};
});

// @TODO consider just resting/spreading the values
return {
version,
packageName,
slug,
title,
icon,
date,
duration,
audits,
...(description && { description }),
...(docsUrl && { docsUrl }),
...(groups && { groups }),
} satisfies PluginReport;
} catch (error) {
const e = error as Error;
throw new PluginOutputError(slug, e);
}
) as Audit),
}),
);

return {
...pluginMeta,
...executionMeta,
audits,
...(description && { description }),
...(docsUrl && { docsUrl }),
...(groups && { groups }),
} satisfies PluginReport;
}

/**
Expand Down Expand Up @@ -165,3 +154,17 @@ export async function executePlugins(

return pluginsResult;
}

function auditOutputsCorrelateWithPluginOutput(
auditOutputs: AuditOutputs,
pluginConfigAudits: PluginConfig['audits'],
) {
auditOutputs.forEach(auditOutput => {
const auditMetadata = pluginConfigAudits.find(
audit => audit.slug === auditOutput.slug,
);
if (!auditMetadata) {
throw new PluginOutputMissingAuditError(auditOutput.slug);
}
});
}
7 changes: 6 additions & 1 deletion packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ export {
AuditGroup,
auditGroupSchema,
} from './lib/plugin-config-groups';
export { AuditOutput, auditOutputsSchema } from './lib/plugin-process-output';
export {
AuditOutput,
AuditOutputs,
auditOutputsSchema,
} from './lib/plugin-process-output';
export { Issue, IssueSeverity } from './lib/plugin-process-output-audit-issue';
export {
AuditReport,
auditReportSchema,
PluginReport,
Report,
pluginReportSchema,
Expand Down
9 changes: 9 additions & 0 deletions packages/models/src/lib/plugin-config-runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { z } from 'zod';
import { filePathSchema } from './implementation/schemas';
import { auditOutputsSchema } from './plugin-process-output';

export const outputTransformSchema = z
.function()
.args(z.unknown())
.returns(z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]));

export type OutputTransform = z.infer<typeof outputTransformSchema>;

export const runnerConfigSchema = z.object(
{
Expand All @@ -8,6 +16,7 @@ export const runnerConfigSchema = z.object(
}),
args: z.array(z.string({ description: 'Command arguments' })).optional(),
outputFile: filePathSchema('Output path'),
outputTransform: outputTransformSchema.optional(),
},
{
description: 'How to execute runner',
Expand Down
36 changes: 32 additions & 4 deletions packages/models/src/lib/plugin-config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { config } from '../../test';
import { config, pluginConfig } from '../../test';
import { pluginConfigSchema } from './plugin-config';
import { AuditOutputs } from './plugin-process-output';

describe('pluginConfigSchema', () => {
it('should parse if plugin configuration is valid', () => {
Expand Down Expand Up @@ -40,7 +41,7 @@ describe('pluginConfigSchema', () => {
it('should throw if plugin groups contain invalid slugs', () => {
const invalidGroupSlug = '-invalid-group-slug';
const pluginConfig = config().plugins[1];
const groups = pluginConfig.groups;
const groups = pluginConfig.groups!;
groups[0].slug = invalidGroupSlug;
pluginConfig.groups = groups;

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

it('should throw if plugin groups have duplicate slugs', () => {
const pluginConfig = config().plugins[1];
const groups = pluginConfig.groups;
const groups = pluginConfig.groups!;
pluginConfig.groups = [...groups, groups[0]];
expect(() => pluginConfigSchema.parse(pluginConfig)).toThrow(
'In groups the slugs are not unique',
Expand All @@ -61,7 +62,7 @@ describe('pluginConfigSchema', () => {
it('should throw if plugin groups refs contain invalid slugs', () => {
const invalidAuditRef = '-invalid-audit-ref';
const pluginConfig = config().plugins[1];
const groups = pluginConfig.groups;
const groups = pluginConfig.groups!;

groups[0].refs[0].slug = invalidAuditRef;
pluginConfig.groups = groups;
Expand All @@ -70,4 +71,31 @@ describe('pluginConfigSchema', () => {
`slug has to follow the pattern`,
);
});

it('should take a outputTransform function', () => {
const undefinedPluginOutput = [
{ slug: 'audit-1', errors: 0 },
{ slug: 'audit-2', errors: 5 },
];
const pluginCfg = pluginConfig([]);
pluginCfg.runner.outputTransform = (data: unknown): AuditOutputs => {
return (data as typeof undefinedPluginOutput).map(data => ({
slug: data.slug,
score: Number(data.errors === 0),
value: data.errors,
}));
};

expect(
pluginConfigSchema.parse(pluginCfg).runner.outputTransform,
).toBeDefined();
expect(
pluginConfigSchema.parse(pluginCfg).runner.outputTransform!(
undefinedPluginOutput,
),
).toEqual([
{ slug: 'audit-1', score: 1, value: 0 },
{ slug: 'audit-2', score: 0, value: 5 },
]);
});
});
Loading

0 comments on commit ce4d975

Please sign in to comment.