Skip to content

Commit 18d4e3a

Browse files
authored
feat(core): add esm plugin logic (#248)
1 parent 58b0aec commit 18d4e3a

16 files changed

+302
-96
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
.env
44

5+
.nx
6+
57
# compiled output
68
dist
79
tmp
@@ -42,4 +44,4 @@ testem.log
4244
Thumbs.db
4345

4446
# generated Code PushUp reports
45-
/.code-pushup
47+
/.code-pushup

packages/cli/src/lib/model.ts

Whitespace-only changes.

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

+55-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3-
AuditOutput,
43
AuditOutputs,
4+
OnProgress,
55
PluginConfig,
6+
RunnerConfig,
7+
RunnerFunction,
68
auditOutputsSchema,
79
} from '@code-pushup/models';
810
import { auditReport, pluginConfig } from '@code-pushup/models/testing';
@@ -42,16 +44,57 @@ describe('executePlugin', () => {
4244
);
4345
});
4446

45-
it('should throw if invalid runnerOutput is produced with transform', async () => {
47+
it('should work with valid runner config', async () => {
48+
const runnerConfig = validPluginCfg.runner as RunnerConfig;
4649
const pluginCfg: PluginConfig = {
4750
...validPluginCfg,
4851
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,
52+
...runnerConfig,
53+
outputTransform: (audits: unknown) =>
54+
Promise.resolve(audits as AuditOutputs),
55+
},
56+
};
57+
const pluginResult = await executePlugin(pluginCfg);
58+
expect(pluginResult.audits[0]?.slug).toBe('mock-audit-slug');
59+
expect(() => auditOutputsSchema.parse(pluginResult.audits)).not.toThrow();
60+
});
61+
62+
it('should work with valid runner function', async () => {
63+
const runnerFunction = (onProgress?: OnProgress) => {
64+
onProgress?.('update');
65+
return Promise.resolve([
66+
{ slug: 'mock-audit-slug', score: 0, value: 0 },
67+
] satisfies AuditOutputs);
68+
};
69+
70+
const pluginCfg: PluginConfig = {
71+
...validPluginCfg,
72+
runner: runnerFunction,
73+
};
74+
const pluginResult = await executePlugin(pluginCfg);
75+
expect(pluginResult.audits[0]?.slug).toBe('mock-audit-slug');
76+
expect(() => auditOutputsSchema.parse(pluginResult.audits)).not.toThrow();
77+
});
78+
79+
it('should throw with invalid runner config', async () => {
80+
const pluginCfg: PluginConfig = {
81+
...validPluginCfg,
82+
runner: '' as unknown as RunnerFunction,
83+
};
84+
await expect(executePlugin(pluginCfg)).rejects.toThrow(
85+
'runner is not a function',
86+
);
87+
});
88+
89+
it('should throw if invalid runnerOutput', async () => {
90+
const pluginCfg: PluginConfig = {
91+
...validPluginCfg,
92+
runner: (onProgress?: OnProgress) => {
93+
onProgress?.('update');
94+
95+
return Promise.resolve([
96+
{ slug: '-mock-audit-slug', score: 0, value: 0 },
97+
] satisfies AuditOutputs);
5598
},
5699
};
57100

@@ -87,21 +130,21 @@ describe('executePlugins', () => {
87130
});
88131

89132
it('should use outputTransform if provided', async () => {
133+
const processRunner = validPluginCfg.runner as RunnerConfig;
90134
const plugins: PluginConfig[] = [
91135
{
92136
...validPluginCfg,
93137
runner: {
94-
...validPluginCfg.runner,
138+
...processRunner,
95139
outputTransform: (outputs: unknown): Promise<AuditOutputs> => {
96-
const arr = Array.from(outputs as Record<string, unknown>[]);
97140
return Promise.resolve(
98-
arr.map(output => {
141+
(outputs as AuditOutputs).map(output => {
99142
return {
100143
...output,
101144
displayValue:
102145
'transformed slug description - ' +
103146
(output as { slug: string }).slug,
104-
} as unknown as AuditOutput;
147+
};
105148
}),
106149
);
107150
},

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

+16-37
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
import chalk from 'chalk';
2-
import { join } from 'path';
32
import {
43
Audit,
54
AuditOutput,
65
AuditOutputs,
76
AuditReport,
7+
OnProgress,
88
PluginConfig,
99
PluginReport,
1010
auditOutputsSchema,
1111
} from '@code-pushup/models';
12-
import {
13-
ProcessObserver,
14-
executeProcess,
15-
getProgressBar,
16-
readJsonFile,
17-
} from '@code-pushup/utils';
12+
import { getProgressBar } from '@code-pushup/utils';
13+
import { executeRunnerConfig, executeRunnerFunction } from './runner';
1814

1915
/**
2016
* Error thrown when plugin output is invalid.
@@ -30,7 +26,7 @@ export class PluginOutputMissingAuditError extends Error {
3026
*
3127
* @public
3228
* @param pluginConfig - {@link ProcessConfig} object with runner and meta
33-
* @param observer - process {@link ProcessObserver}
29+
* @param onProgress - progress handler {@link OnProgress}
3430
* @returns {Promise<AuditOutput[]>} - audit outputs from plugin runner
3531
* @throws {PluginOutputMissingAuditError} - if plugin runner output is invalid
3632
*
@@ -49,48 +45,30 @@ export class PluginOutputMissingAuditError extends Error {
4945
*/
5046
export async function executePlugin(
5147
pluginConfig: PluginConfig,
52-
observer?: ProcessObserver,
48+
onProgress?: OnProgress,
5349
): Promise<PluginReport> {
5450
const {
55-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
56-
runner: onlyUsedForRestingPluginMeta,
51+
runner,
5752
audits: pluginConfigAudits,
5853
description,
5954
docsUrl,
6055
groups,
6156
...pluginMeta
6257
} = pluginConfig;
63-
const { args, command } = pluginConfig.runner;
64-
65-
const { date, duration } = await executeProcess({
66-
command,
67-
args,
68-
observer,
69-
});
70-
const executionMeta = { date, duration };
71-
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-
);
78-
79-
// parse transform unknownAuditOutputs to auditOutputs
80-
if (pluginConfig.runner?.outputTransform) {
81-
unknownAuditOutputs = await pluginConfig.runner.outputTransform(
82-
unknownAuditOutputs,
83-
);
84-
}
8558

86-
// validate audit outputs
87-
const auditOutputs = auditOutputsSchema.parse(unknownAuditOutputs);
59+
// execute plugin runner
60+
const runnerResult =
61+
typeof runner === 'object'
62+
? await executeRunnerConfig(runner, onProgress)
63+
: await executeRunnerFunction(runner, onProgress);
64+
const { audits: unvalidatedAuditOutputs, ...executionMeta } = runnerResult;
8865

8966
// validate auditOutputs
67+
const auditOutputs = auditOutputsSchema.parse(unvalidatedAuditOutputs);
9068
auditOutputsCorrelateWithPluginOutput(auditOutputs, pluginConfigAudits);
9169

9270
// enrich `AuditOutputs` to `AuditReport`
93-
const audits: AuditReport[] = auditOutputs.map(
71+
const auditReports: AuditReport[] = auditOutputs.map(
9472
(auditOutput: AuditOutput) => ({
9573
...auditOutput,
9674
...(pluginConfigAudits.find(
@@ -99,10 +77,11 @@ export async function executePlugin(
9977
}),
10078
);
10179

80+
// create plugin report
10281
return {
10382
...pluginMeta,
10483
...executionMeta,
105-
audits,
84+
audits: auditReports,
10685
...(description && { description }),
10786
...(docsUrl && { docsUrl }),
10887
...(groups && { groups }),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
AuditOutputs,
4+
OnProgress,
5+
auditOutputsSchema,
6+
} from '@code-pushup/models';
7+
import { auditReport, echoRunnerConfig } from '@code-pushup/models/testing';
8+
import {
9+
RunnerResult,
10+
executeRunnerConfig,
11+
executeRunnerFunction,
12+
} from './runner';
13+
14+
const validRunnerCfg = echoRunnerConfig([auditReport()], 'output.json');
15+
16+
describe('executeRunnerConfig', () => {
17+
it('should work with valid plugins', async () => {
18+
const runnerResult = await executeRunnerConfig(validRunnerCfg);
19+
20+
// data sanity
21+
expect(runnerResult.date.endsWith('Z')).toBeTruthy();
22+
expect(runnerResult.duration).toBeTruthy();
23+
expect(runnerResult.audits[0]?.slug).toBe('mock-audit-slug');
24+
25+
// schema validation
26+
expect(() => auditOutputsSchema.parse(runnerResult.audits)).not.toThrow();
27+
});
28+
29+
it('should use transform if provided', async () => {
30+
const runnerCfgWithTransform = {
31+
...validRunnerCfg,
32+
outputTransform: (audits: unknown) =>
33+
(audits as AuditOutputs).map(a => ({
34+
...a,
35+
displayValue: `transformed - ${a.slug}`,
36+
})),
37+
};
38+
39+
const runnerResult = await executeRunnerConfig(runnerCfgWithTransform);
40+
41+
expect(runnerResult.audits[0]?.displayValue).toBe(
42+
'transformed - mock-audit-slug',
43+
);
44+
});
45+
46+
it('should throw if transform throws', async () => {
47+
const runnerCfgWithErrorTransform = {
48+
...validRunnerCfg,
49+
outputTransform: () => {
50+
return Promise.reject(new Error('transform mock error'));
51+
},
52+
};
53+
54+
await expect(
55+
executeRunnerConfig(runnerCfgWithErrorTransform),
56+
).rejects.toThrow('transform mock error');
57+
});
58+
});
59+
60+
describe('executeRunnerFunction', () => {
61+
it('should execute valid plugin config', async () => {
62+
const nextSpy = vi.fn();
63+
const runnerResult: RunnerResult = await executeRunnerFunction(
64+
(observer?: OnProgress) => {
65+
observer?.('update');
66+
67+
return Promise.resolve([
68+
{ slug: 'mock-audit-slug', score: 0, value: 0 },
69+
] satisfies AuditOutputs);
70+
},
71+
nextSpy,
72+
);
73+
expect(nextSpy).toHaveBeenCalledWith('update');
74+
expect(runnerResult.audits[0]?.slug).toBe('mock-audit-slug');
75+
});
76+
77+
it('should throw if plugin throws', async () => {
78+
const nextSpy = vi.fn();
79+
await expect(
80+
executeRunnerFunction(
81+
() => Promise.reject(new Error('plugin exec mock error')),
82+
nextSpy,
83+
),
84+
).rejects.toThrow('plugin exec mock error');
85+
expect(nextSpy).not.toHaveBeenCalled();
86+
});
87+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { join } from 'path';
2+
import {
3+
AuditOutputs,
4+
OnProgress,
5+
RunnerConfig,
6+
RunnerFunction,
7+
} from '@code-pushup/models';
8+
import { calcDuration, executeProcess, readJsonFile } from '@code-pushup/utils';
9+
10+
export type RunnerResult = {
11+
date: string;
12+
duration: number;
13+
audits: AuditOutputs;
14+
};
15+
16+
export async function executeRunnerConfig(
17+
cfg: RunnerConfig,
18+
onProgress?: OnProgress,
19+
): Promise<RunnerResult> {
20+
const { args, command, outputFile, outputTransform } = cfg;
21+
22+
// execute process
23+
const { duration, date } = await executeProcess({
24+
command,
25+
args,
26+
observer: { onStdout: onProgress },
27+
});
28+
29+
// read process output from file system and parse it
30+
let audits = await readJsonFile<AuditOutputs>(
31+
join(process.cwd(), outputFile),
32+
);
33+
34+
// transform unknownAuditOutputs to auditOutputs
35+
if (outputTransform) {
36+
audits = await outputTransform(audits);
37+
}
38+
39+
// create runner result
40+
return {
41+
duration,
42+
date,
43+
audits,
44+
};
45+
}
46+
47+
export async function executeRunnerFunction(
48+
runner: RunnerFunction,
49+
onProgress?: OnProgress,
50+
): Promise<RunnerResult> {
51+
const date = new Date().toISOString();
52+
const start = performance.now();
53+
54+
// execute plugin runner
55+
const audits = await runner(onProgress);
56+
57+
// create runner result
58+
return {
59+
date,
60+
duration: calcDuration(start),
61+
audits,
62+
};
63+
}

packages/models/src/index.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export {
1818
persistConfigSchema,
1919
} from './lib/persist-config';
2020
export { PluginConfig, pluginConfigSchema } from './lib/plugin-config';
21-
export { RunnerConfig } from './lib/plugin-config-runner';
2221
export {
2322
auditSchema,
2423
Audit,
@@ -45,3 +44,10 @@ export {
4544
} from './lib/report';
4645
export { UploadConfig, uploadConfigSchema } from './lib/upload-config';
4746
export { materialIconSchema } from './lib/implementation/schemas';
47+
export {
48+
onProgressSchema,
49+
OnProgress,
50+
RunnerFunction,
51+
runnerConfigSchema,
52+
RunnerConfig,
53+
} from './lib/plugin-config-runner';

0 commit comments

Comments
 (0)