Skip to content
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
9 changes: 8 additions & 1 deletion packages/core/src/lib/implementation/execute-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
groupByStatus,
logMultipleResults,
pluralizeToken,
scoreAuditsWithTarget,
} from '@code-pushup/utils';
import {
executePluginRunner,
Expand Down Expand Up @@ -57,6 +58,7 @@ export async function executePlugin(
description,
docsUrl,
groups,
scoreTargets,
...pluginMeta
} = pluginConfig;
const { write: cacheWrite = false, read: cacheRead = false } = cache;
Expand All @@ -76,8 +78,13 @@ export async function executePlugin(
});
}

// transform audit scores to 1 when they meet/exceed their targets
const scoredAuditsWithTarget = scoreTargets
? scoreAuditsWithTarget(audits, scoreTargets)
: audits;

// enrich `AuditOutputs` to `AuditReport`
const auditReports: AuditReport[] = audits.map(
const auditReports: AuditReport[] = scoredAuditsWithTarget.map(
(auditOutput: AuditOutput) => ({
...auditOutput,
...(pluginConfigAudits.find(
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/lib/implementation/execute-plugin.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,67 @@ describe('executePlugin', () => {
MINIMAL_PLUGIN_CONFIG_MOCK,
);
});

it('should apply a single score target to all audits', async () => {
const pluginConfig: PluginConfig = {
...MINIMAL_PLUGIN_CONFIG_MOCK,
scoreTargets: 0.8,
audits: [
{
slug: 'speed-index',
title: 'Speed Index',
},
{
slug: 'total-blocking-time',
title: 'Total Blocking Time',
},
],
runner: () => [
{ slug: 'speed-index', score: 0.9, value: 1300 },
{ slug: 'total-blocking-time', score: 0.3, value: 600 },
],
};

const result = await executePlugin(pluginConfig, {
persist: { outputDir: '' },
cache: { read: false, write: false },
});

expect(result.audits).toEqual(
expect.arrayContaining([
expect.objectContaining({
slug: 'speed-index',
score: 1,
scoreTarget: 0.8,
}),
expect.objectContaining({
slug: 'total-blocking-time',
score: 0.3,
scoreTarget: 0.8,
}),
]),
);
});

it('should apply per-audit score targets', async () => {
const pluginConfig: PluginConfig = {
...MINIMAL_PLUGIN_CONFIG_MOCK, // returns node-version audit with score 0.3
scoreTargets: {
'node-version': 0.2,
},
};

const result = await executePlugin(pluginConfig, {
persist: { outputDir: '' },
cache: { read: false, write: false },
});

expect(result.audits[0]).toMatchObject({
slug: 'node-version',
score: 1,
scoreTarget: 0.2,
});
});
});

describe('executePlugins', () => {
Expand Down
31 changes: 17 additions & 14 deletions packages/models/docs/models-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ _Object containing the following properties:_
| `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` |
| **`value`** (\*) | Raw numeric value | `number` (_≥0_) |
| **`score`** (\*) | Value between 0 and 1 | `number` (_≥0, ≤1_) |
| `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) |
| `details` | Detailed information | [AuditDetails](#auditdetails) |

_(\*) Required._
Expand All @@ -73,6 +74,7 @@ _Object containing the following properties:_
| `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` |
| **`value`** (\*) | Raw numeric value | `number` (_≥0_) |
| **`score`** (\*) | Value between 0 and 1 | `number` (_≥0, ≤1_) |
| `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) |
| `details` | Detailed information | [AuditDetails](#auditdetails) |

_(\*) Required._
Expand Down Expand Up @@ -1282,20 +1284,21 @@ _(\*) Required._

_Object containing the following properties:_

| Property | Description | Type |
| :---------------- | :---------------------------------------- | :------------------------------------------------------------------- |
| `packageName` | NPM package name | `string` |
| `version` | NPM version of the package | `string` |
| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) |
| `description` | Description (markdown) | `string` (_max length: 65536_) |
| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` |
| `isSkipped` | | `boolean` |
| **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) |
| **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) |
| **`runner`** (\*) | | [RunnerConfig](#runnerconfig) _or_ [RunnerFunction](#runnerfunction) |
| **`audits`** (\*) | List of audits maintained in a plugin | _Array of at least 1 [Audit](#audit) items_ |
| `groups` | List of groups | _Array of [Group](#group) items_ |
| `context` | Plugin-specific context data for helpers | [PluginContext](#plugincontext) |
| Property | Description | Type |
| :---------------- | :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| `packageName` | NPM package name | `string` |
| `version` | NPM version of the package | `string` |
| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) |
| `description` | Description (markdown) | `string` (_max length: 65536_) |
| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` |
| `isSkipped` | | `boolean` |
| **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) |
| **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) |
| **`runner`** (\*) | | [RunnerConfig](#runnerconfig) _or_ [RunnerFunction](#runnerfunction) |
| **`audits`** (\*) | List of audits maintained in a plugin | _Array of at least 1 [Audit](#audit) items_ |
| `groups` | List of groups | _Array of [Group](#group) items_ |
| `scoreTargets` | Score targets that trigger a perfect score. Number for all audits or record { slug: target } for specific audits | `number` (_≥0, ≤1_) (_optional_) _or_ _Object with dynamic keys of type_ `string` _and values of type_ `number` (_≥0, ≤1_) |
| `context` | Plugin-specific context data for helpers | [PluginContext](#plugincontext) |

_(\*) Required._

Expand Down
1 change: 1 addition & 0 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export {
type PluginConfig,
type PluginContext,
type PluginMeta,
type PluginScoreTargets,
} from './lib/plugin-config.js';
export {
auditReportSchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/models/src/lib/audit-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createDuplicateSlugsCheck } from './implementation/checks.js';
import {
nonnegativeNumberSchema,
scoreSchema,
scoreTargetSchema,
slugSchema,
} from './implementation/schemas.js';
import { issueSchema } from './issue.js';
Expand Down Expand Up @@ -34,6 +35,7 @@ export const auditOutputSchema = z
displayValue: auditDisplayValueSchema,
value: auditValueSchema,
score: scoreSchema,
scoreTarget: scoreTargetSchema,
details: auditDetailsSchema.optional(),
})
.describe('Audit information');
Expand Down
12 changes: 12 additions & 0 deletions packages/models/src/lib/audit-output.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ describe('auditOutputSchema', () => {
).not.toThrow();
});

it('should accept a valid audit output with a score target', () => {
expect(() =>
auditOutputSchema.parse({
slug: 'total-blocking-time',
score: 0.91,
scoreTarget: 0.9,
value: 183.5,
displayValue: '180 ms',
} satisfies AuditOutput),
).not.toThrow();
});

it('should accept a decimal value', () => {
expect(() =>
auditOutputSchema.parse({
Expand Down
4 changes: 2 additions & 2 deletions packages/models/src/lib/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export const auditSchema = z
.object({
slug: slugSchema.describe('ID (unique within plugin)'),
})
.merge(
.extend(
metaSchema({
titleDescription: 'Descriptive name',
descriptionDescription: 'Description (markdown)',
docsUrlDescription: 'Link to documentation (rationale)',
description: 'List of scorable metrics for the given plugin',
isSkippedDescription: 'Indicates whether the audit is skipped',
}),
}).shape,
);

export type Audit = z.infer<typeof auditSchema>;
Expand Down
9 changes: 2 additions & 7 deletions packages/models/src/lib/category-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
} from './implementation/checks.js';
import {
metaSchema,
nonnegativeNumberSchema,
scorableSchema,
scoreTargetSchema,
slugSchema,
weightedRefSchema,
} from './implementation/schemas.js';
Expand Down Expand Up @@ -44,12 +44,7 @@ export const categoryConfigSchema = scorableSchema(
description: 'Meta info for category',
}).shape,
)
.extend({
scoreTarget: nonnegativeNumberSchema
.max(1)
.describe('Pass/fail score threshold (0-1)')
.optional(),
});
.extend({ scoreTarget: scoreTargetSchema });

export type CategoryConfig = z.infer<typeof categoryConfigSchema>;

Expand Down
24 changes: 24 additions & 0 deletions packages/models/src/lib/category-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ describe('categoryConfigSchema', () => {
).not.toThrow();
});

it('should accept a valid category configuration with a score target', () => {
expect(() =>
categoryConfigSchema.parse({
slug: 'core-web-vitals',
title: 'Core Web Vitals',
scoreTarget: 0.9,
refs: [
{
plugin: 'lighthouse',
slug: 'largest-contentful-paint',
type: 'audit',
weight: 3,
},
{
plugin: 'lighthouse',
slug: 'first-input-delay',
type: 'audit',
weight: 2,
},
],
} satisfies CategoryConfig),
).not.toThrow();
});

it('should throw for an empty category', () => {
expect(() =>
categoryConfigSchema.parse({
Expand Down
8 changes: 7 additions & 1 deletion packages/models/src/lib/core-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ describe('coreConfigSchema', () => {
slug: 'lighthouse',
title: 'Lighthouse',
icon: 'lighthouse',
runner: { command: 'npm run lint', outputFile: 'output.json' },
runner: async () => [
{
slug: 'csp-xss',
score: 1,
value: 1,
},
],
audits: [
{
slug: 'csp-xss',
Expand Down
6 changes: 6 additions & 0 deletions packages/models/src/lib/implementation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ export function packageVersionSchema<
}>;
}

/** Schema for a binary score threshold */
export const scoreTargetSchema = nonnegativeNumberSchema
.max(1)
.describe('Pass/fail score threshold (0-1)')
.optional();

/** Schema for a weight */
export const weightSchema = nonnegativeNumberSchema.describe(
'Coefficient for the given score (use weight 0 if only for display)',
Expand Down
30 changes: 21 additions & 9 deletions packages/models/src/lib/plugin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
materialIconSchema,
metaSchema,
packageVersionSchema,
scoreTargetSchema,
slugSchema,
} from './implementation/schemas.js';
import { formatSlugsList, hasMissingStrings } from './implementation/utils.js';
Expand All @@ -18,31 +19,42 @@ export const pluginContextSchema = z
export type PluginContext = z.infer<typeof pluginContextSchema>;

export const pluginMetaSchema = packageVersionSchema()
.merge(
.extend(
metaSchema({
titleDescription: 'Descriptive name',
descriptionDescription: 'Description (markdown)',
docsUrlDescription: 'Plugin documentation site',
description: 'Plugin metadata',
}),
}).shape,
)
.merge(
z.object({
slug: slugSchema.describe('Unique plugin slug within core config'),
icon: materialIconSchema,
}),
);
.extend({
slug: slugSchema.describe('Unique plugin slug within core config'),
icon: materialIconSchema,
});
export type PluginMeta = z.infer<typeof pluginMetaSchema>;

export const pluginScoreTargetsSchema = z
.union([
scoreTargetSchema,
z.record(z.string(), scoreTargetSchema.nonoptional()),
])
.describe(
'Score targets that trigger a perfect score. Number for all audits or record { slug: target } for specific audits',
)
.optional();

export type PluginScoreTargets = z.infer<typeof pluginScoreTargetsSchema>;

export const pluginDataSchema = z.object({
runner: z.union([runnerConfigSchema, runnerFunctionSchema]),
audits: pluginAuditsSchema,
groups: groupsSchema,
scoreTargets: pluginScoreTargetsSchema,
context: pluginContextSchema,
});

export const pluginConfigSchema = pluginMetaSchema
.merge(pluginDataSchema)
.extend(pluginDataSchema.shape)
.check(createCheck(findMissingSlugsInGroupRefs));

export type PluginConfig = z.infer<typeof pluginConfigSchema>;
Expand Down
29 changes: 29 additions & 0 deletions packages/models/src/lib/plugin-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,35 @@ describe('pluginConfigSchema', () => {
).not.toThrow();
});

it('should accept a valid plugin configuration with a score target', () => {
expect(() =>
pluginConfigSchema.parse({
slug: 'lighthouse',
title: 'Lighthouse',
icon: 'lighthouse',
runner: async () => [
{
slug: 'first-contentful-paint',
score: 0.28,
value: 3752,
displayValue: '3.8 s',
},
{
slug: 'total-blocking-time',
score: 0.91,
value: 183.5,
displayValue: '180 ms',
},
],
scoreTarget: { 'total-blocking-time': 0.9 },
audits: [
{ slug: 'first-contentful-paint', title: 'First Contentful Paint' },
{ slug: 'total-blocking-time', title: 'Total Blocking Time' },
],
} satisfies PluginConfig),
).not.toThrow();
});

it('should throw for a plugin configuration without audits', () => {
expect(() =>
pluginConfigSchema.parse({
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export {
} from './lib/reports/generate-md-reports-diff.js';
export { loadReport } from './lib/reports/load-report.js';
export { logStdoutSummary } from './lib/reports/log-stdout-summary.js';
export { scoreReport } from './lib/reports/scoring.js';
export { scoreReport, scoreAuditsWithTarget } from './lib/reports/scoring.js';
export { sortReport } from './lib/reports/sorting.js';
export type {
ScoredCategoryConfig,
Expand Down
Loading
Loading