Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add esm plugin logic #248

Merged
merged 21 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

.env

.nx

# compiled output
dist
tmp
Expand Down Expand Up @@ -42,4 +44,4 @@ testem.log
Thumbs.db

# generated Code PushUp reports
/.code-pushup
/.code-pushup
Empty file removed packages/cli/src/lib/model.ts
Empty file.
67 changes: 55 additions & 12 deletions packages/core/src/lib/implementation/execute-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
AuditOutput,
AuditOutputs,
OnProgress,
PluginConfig,
RunnerConfig,
RunnerFunction,
auditOutputsSchema,
} from '@code-pushup/models';
import { auditReport, pluginConfig } from '@code-pushup/models/testing';
Expand Down Expand Up @@ -42,16 +44,57 @@ describe('executePlugin', () => {
);
});

it('should throw if invalid runnerOutput is produced with transform', async () => {
it('should work with valid runner config', async () => {
const runnerConfig = validPluginCfg.runner as RunnerConfig;
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,
...runnerConfig,
outputTransform: (audits: unknown) =>
Promise.resolve(audits as AuditOutputs),
},
};
const pluginResult = await executePlugin(pluginCfg);
expect(pluginResult.audits[0]?.slug).toBe('mock-audit-slug');
expect(() => auditOutputsSchema.parse(pluginResult.audits)).not.toThrow();
});

it('should work with valid runner function', async () => {
const runnerFunction = (onProgress?: OnProgress) => {
onProgress?.('update');
return Promise.resolve([
{ slug: 'mock-audit-slug', score: 0, value: 0 },
] satisfies AuditOutputs);
};

const pluginCfg: PluginConfig = {
...validPluginCfg,
runner: runnerFunction,
};
const pluginResult = await executePlugin(pluginCfg);
expect(pluginResult.audits[0]?.slug).toBe('mock-audit-slug');
expect(() => auditOutputsSchema.parse(pluginResult.audits)).not.toThrow();
});

it('should throw with invalid runner config', async () => {
const pluginCfg: PluginConfig = {
...validPluginCfg,
runner: '' as unknown as RunnerFunction,
};
await expect(executePlugin(pluginCfg)).rejects.toThrow(
'runner is not a function',
);
});

it('should throw if invalid runnerOutput', async () => {
const pluginCfg: PluginConfig = {
...validPluginCfg,
runner: (onProgress?: OnProgress) => {
onProgress?.('update');

return Promise.resolve([
{ slug: '-mock-audit-slug', score: 0, value: 0 },
] satisfies AuditOutputs);
},
};

Expand Down Expand Up @@ -87,21 +130,21 @@ describe('executePlugins', () => {
});

it('should use outputTransform if provided', async () => {
const processRunner = validPluginCfg.runner as RunnerConfig;
const plugins: PluginConfig[] = [
{
...validPluginCfg,
runner: {
...validPluginCfg.runner,
...processRunner,
outputTransform: (outputs: unknown): Promise<AuditOutputs> => {
const arr = Array.from(outputs as Record<string, unknown>[]);
return Promise.resolve(
arr.map(output => {
(outputs as AuditOutputs).map(output => {
return {
...output,
displayValue:
'transformed slug description - ' +
(output as { slug: string }).slug,
} as unknown as AuditOutput;
};
}),
);
},
Expand Down
53 changes: 16 additions & 37 deletions packages/core/src/lib/implementation/execute-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import chalk from 'chalk';
import { join } from 'path';
import {
Audit,
AuditOutput,
AuditOutputs,
AuditReport,
OnProgress,
PluginConfig,
PluginReport,
auditOutputsSchema,
} from '@code-pushup/models';
import {
ProcessObserver,
executeProcess,
getProgressBar,
readJsonFile,
} from '@code-pushup/utils';
import { getProgressBar } from '@code-pushup/utils';
import { executeRunnerConfig, executeRunnerFunction } from './runner';

/**
* Error thrown when plugin output is invalid.
Expand All @@ -30,7 +26,7 @@ export class PluginOutputMissingAuditError extends Error {
*
* @public
* @param pluginConfig - {@link ProcessConfig} object with runner and meta
* @param observer - process {@link ProcessObserver}
* @param onProgress - progress handler {@link OnProgress}
* @returns {Promise<AuditOutput[]>} - audit outputs from plugin runner
* @throws {PluginOutputMissingAuditError} - if plugin runner output is invalid
*
Expand All @@ -49,48 +45,30 @@ export class PluginOutputMissingAuditError extends Error {
*/
export async function executePlugin(
pluginConfig: PluginConfig,
observer?: ProcessObserver,
onProgress?: OnProgress,
): Promise<PluginReport> {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
runner: onlyUsedForRestingPluginMeta,
runner,
audits: pluginConfigAudits,
description,
docsUrl,
groups,
...pluginMeta
} = pluginConfig;
const { args, command } = pluginConfig.runner;

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

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,
);

// parse transform unknownAuditOutputs to auditOutputs
if (pluginConfig.runner?.outputTransform) {
unknownAuditOutputs = await pluginConfig.runner.outputTransform(
unknownAuditOutputs,
);
}

// validate audit outputs
const auditOutputs = auditOutputsSchema.parse(unknownAuditOutputs);
// execute plugin runner
const runnerResult =
typeof runner === 'object'
? await executeRunnerConfig(runner, onProgress)
: await executeRunnerFunction(runner, onProgress);
const { audits: unvalidatedAuditOutputs, ...executionMeta } = runnerResult;

// validate auditOutputs
const auditOutputs = auditOutputsSchema.parse(unvalidatedAuditOutputs);
auditOutputsCorrelateWithPluginOutput(auditOutputs, pluginConfigAudits);

// enrich `AuditOutputs` to `AuditReport`
const audits: AuditReport[] = auditOutputs.map(
const auditReports: AuditReport[] = auditOutputs.map(
(auditOutput: AuditOutput) => ({
...auditOutput,
...(pluginConfigAudits.find(
Expand All @@ -99,10 +77,11 @@ export async function executePlugin(
}),
);

// create plugin report
return {
...pluginMeta,
...executionMeta,
audits,
audits: auditReports,
...(description && { description }),
...(docsUrl && { docsUrl }),
...(groups && { groups }),
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/lib/implementation/runner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
AuditOutputs,
OnProgress,
auditOutputsSchema,
} from '@code-pushup/models';
import { auditReport, echoRunnerConfig } from '@code-pushup/models/testing';
import {
RunnerResult,
executeRunnerConfig,
executeRunnerFunction,
} from './runner';

const validRunnerCfg = echoRunnerConfig([auditReport()], 'output.json');

describe('executeRunnerConfig', () => {
it('should work with valid plugins', async () => {
const runnerResult = await executeRunnerConfig(validRunnerCfg);

// data sanity
expect(runnerResult.date.endsWith('Z')).toBeTruthy();
expect(runnerResult.duration).toBeTruthy();
expect(runnerResult.audits[0]?.slug).toBe('mock-audit-slug');

// schema validation
expect(() => auditOutputsSchema.parse(runnerResult.audits)).not.toThrow();
});

it('should use transform if provided', async () => {
const runnerCfgWithTransform = {
...validRunnerCfg,
outputTransform: (audits: unknown) =>
(audits as AuditOutputs).map(a => ({
...a,
displayValue: `transformed - ${a.slug}`,
})),
};

const runnerResult = await executeRunnerConfig(runnerCfgWithTransform);

expect(runnerResult.audits[0]?.displayValue).toBe(
'transformed - mock-audit-slug',
);
});

it('should throw if transform throws', async () => {
const runnerCfgWithErrorTransform = {
...validRunnerCfg,
outputTransform: () => {
return Promise.reject(new Error('transform mock error'));
},
};

await expect(
executeRunnerConfig(runnerCfgWithErrorTransform),
).rejects.toThrow('transform mock error');
});
});

describe('executeRunnerFunction', () => {
it('should execute valid plugin config', async () => {
const nextSpy = vi.fn();
const runnerResult: RunnerResult = await executeRunnerFunction(
(observer?: OnProgress) => {
observer?.('update');

return Promise.resolve([
{ slug: 'mock-audit-slug', score: 0, value: 0 },
] satisfies AuditOutputs);
},
nextSpy,
);
expect(nextSpy).toHaveBeenCalledWith('update');
expect(runnerResult.audits[0]?.slug).toBe('mock-audit-slug');
});

it('should throw if plugin throws', async () => {
const nextSpy = vi.fn();
await expect(
executeRunnerFunction(
() => Promise.reject(new Error('plugin exec mock error')),
nextSpy,
),
).rejects.toThrow('plugin exec mock error');
expect(nextSpy).not.toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions packages/core/src/lib/implementation/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { join } from 'path';
import {
AuditOutputs,
OnProgress,
RunnerConfig,
RunnerFunction,
} from '@code-pushup/models';
import { calcDuration, executeProcess, readJsonFile } from '@code-pushup/utils';

export type RunnerResult = {
date: string;
duration: number;
audits: AuditOutputs;
};

export async function executeRunnerConfig(
cfg: RunnerConfig,
onProgress?: OnProgress,
): Promise<RunnerResult> {
const { args, command, outputFile, outputTransform } = cfg;

// execute process
const { duration, date } = await executeProcess({
command,
args,
observer: { onStdout: onProgress },
});

// read process output from file system and parse it
let audits = await readJsonFile<AuditOutputs>(
join(process.cwd(), outputFile),
);

// transform unknownAuditOutputs to auditOutputs
if (outputTransform) {
audits = await outputTransform(audits);
}

// create runner result
return {
duration,
date,
audits,
};
}

export async function executeRunnerFunction(
runner: RunnerFunction,
onProgress?: OnProgress,
): Promise<RunnerResult> {
const date = new Date().toISOString();
const start = performance.now();

// execute plugin runner
const audits = await runner(onProgress);

// create runner result
return {
date,
duration: calcDuration(start),
audits,
};
}
8 changes: 7 additions & 1 deletion packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export {
persistConfigSchema,
} from './lib/persist-config';
export { PluginConfig, pluginConfigSchema } from './lib/plugin-config';
export { RunnerConfig } from './lib/plugin-config-runner';
export {
auditSchema,
Audit,
Expand All @@ -45,3 +44,10 @@ export {
} from './lib/report';
export { UploadConfig, uploadConfigSchema } from './lib/upload-config';
export { materialIconSchema } from './lib/implementation/schemas';
export {
onProgressSchema,
OnProgress,
RunnerFunction,
runnerConfigSchema,
RunnerConfig,
} from './lib/plugin-config-runner';
Loading