Skip to content

Commit 5db68e2

Browse files
authored
feat: implement scoreTargets for audits
1 parent 6a961c2 commit 5db68e2

File tree

16 files changed

+340
-36
lines changed

16 files changed

+340
-36
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
groupByStatus,
1515
logMultipleResults,
1616
pluralizeToken,
17+
scoreAuditsWithTarget,
1718
} from '@code-pushup/utils';
1819
import {
1920
executePluginRunner,
@@ -57,6 +58,7 @@ export async function executePlugin(
5758
description,
5859
docsUrl,
5960
groups,
61+
scoreTargets,
6062
...pluginMeta
6163
} = pluginConfig;
6264
const { write: cacheWrite = false, read: cacheRead = false } = cache;
@@ -76,8 +78,13 @@ export async function executePlugin(
7678
});
7779
}
7880

81+
// transform audit scores to 1 when they meet/exceed their targets
82+
const scoredAuditsWithTarget = scoreTargets
83+
? scoreAuditsWithTarget(audits, scoreTargets)
84+
: audits;
85+
7986
// enrich `AuditOutputs` to `AuditReport`
80-
const auditReports: AuditReport[] = audits.map(
87+
const auditReports: AuditReport[] = scoredAuditsWithTarget.map(
8188
(auditOutput: AuditOutput) => ({
8289
...auditOutput,
8390
...(pluginConfigAudits.find(

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,67 @@ describe('executePlugin', () => {
124124
MINIMAL_PLUGIN_CONFIG_MOCK,
125125
);
126126
});
127+
128+
it('should apply a single score target to all audits', async () => {
129+
const pluginConfig: PluginConfig = {
130+
...MINIMAL_PLUGIN_CONFIG_MOCK,
131+
scoreTargets: 0.8,
132+
audits: [
133+
{
134+
slug: 'speed-index',
135+
title: 'Speed Index',
136+
},
137+
{
138+
slug: 'total-blocking-time',
139+
title: 'Total Blocking Time',
140+
},
141+
],
142+
runner: () => [
143+
{ slug: 'speed-index', score: 0.9, value: 1300 },
144+
{ slug: 'total-blocking-time', score: 0.3, value: 600 },
145+
],
146+
};
147+
148+
const result = await executePlugin(pluginConfig, {
149+
persist: { outputDir: '' },
150+
cache: { read: false, write: false },
151+
});
152+
153+
expect(result.audits).toEqual(
154+
expect.arrayContaining([
155+
expect.objectContaining({
156+
slug: 'speed-index',
157+
score: 1,
158+
scoreTarget: 0.8,
159+
}),
160+
expect.objectContaining({
161+
slug: 'total-blocking-time',
162+
score: 0.3,
163+
scoreTarget: 0.8,
164+
}),
165+
]),
166+
);
167+
});
168+
169+
it('should apply per-audit score targets', async () => {
170+
const pluginConfig: PluginConfig = {
171+
...MINIMAL_PLUGIN_CONFIG_MOCK, // returns node-version audit with score 0.3
172+
scoreTargets: {
173+
'node-version': 0.2,
174+
},
175+
};
176+
177+
const result = await executePlugin(pluginConfig, {
178+
persist: { outputDir: '' },
179+
cache: { read: false, write: false },
180+
});
181+
182+
expect(result.audits[0]).toMatchObject({
183+
slug: 'node-version',
184+
score: 1,
185+
scoreTarget: 0.2,
186+
});
187+
});
127188
});
128189

129190
describe('executePlugins', () => {

packages/models/docs/models-reference.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ _Object containing the following properties:_
4949
| `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` |
5050
| **`value`** (\*) | Raw numeric value | `number` (_≥0_) |
5151
| **`score`** (\*) | Value between 0 and 1 | `number` (_≥0, ≤1_) |
52+
| `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) |
5253
| `details` | Detailed information | [AuditDetails](#auditdetails) |
5354

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

7880
_(\*) Required._
@@ -1282,20 +1284,21 @@ _(\*) Required._
12821284

12831285
_Object containing the following properties:_
12841286

1285-
| Property | Description | Type |
1286-
| :---------------- | :---------------------------------------- | :------------------------------------------------------------------- |
1287-
| `packageName` | NPM package name | `string` |
1288-
| `version` | NPM version of the package | `string` |
1289-
| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) |
1290-
| `description` | Description (markdown) | `string` (_max length: 65536_) |
1291-
| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` |
1292-
| `isSkipped` | | `boolean` |
1293-
| **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) |
1294-
| **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) |
1295-
| **`runner`** (\*) | | [RunnerConfig](#runnerconfig) _or_ [RunnerFunction](#runnerfunction) |
1296-
| **`audits`** (\*) | List of audits maintained in a plugin | _Array of at least 1 [Audit](#audit) items_ |
1297-
| `groups` | List of groups | _Array of [Group](#group) items_ |
1298-
| `context` | Plugin-specific context data for helpers | [PluginContext](#plugincontext) |
1287+
| Property | Description | Type |
1288+
| :---------------- | :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
1289+
| `packageName` | NPM package name | `string` |
1290+
| `version` | NPM version of the package | `string` |
1291+
| **`title`** (\*) | Descriptive name | `string` (_max length: 256_) |
1292+
| `description` | Description (markdown) | `string` (_max length: 65536_) |
1293+
| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` |
1294+
| `isSkipped` | | `boolean` |
1295+
| **`slug`** (\*) | Unique plugin slug within core config | `string` (_regex: `/^[a-z\d]+(?:-[a-z\d]+)*$/`, max length: 128_) |
1296+
| **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) |
1297+
| **`runner`** (\*) | | [RunnerConfig](#runnerconfig) _or_ [RunnerFunction](#runnerfunction) |
1298+
| **`audits`** (\*) | List of audits maintained in a plugin | _Array of at least 1 [Audit](#audit) items_ |
1299+
| `groups` | List of groups | _Array of [Group](#group) items_ |
1300+
| `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_) |
1301+
| `context` | Plugin-specific context data for helpers | [PluginContext](#plugincontext) |
12991302

13001303
_(\*) Required._
13011304

packages/models/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export {
8080
type PluginConfig,
8181
type PluginContext,
8282
type PluginMeta,
83+
type PluginScoreTargets,
8384
} from './lib/plugin-config.js';
8485
export {
8586
auditReportSchema,

packages/models/src/lib/audit-output.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createDuplicateSlugsCheck } from './implementation/checks.js';
33
import {
44
nonnegativeNumberSchema,
55
scoreSchema,
6+
scoreTargetSchema,
67
slugSchema,
78
} from './implementation/schemas.js';
89
import { issueSchema } from './issue.js';
@@ -34,6 +35,7 @@ export const auditOutputSchema = z
3435
displayValue: auditDisplayValueSchema,
3536
value: auditValueSchema,
3637
score: scoreSchema,
38+
scoreTarget: scoreTargetSchema,
3739
details: auditDetailsSchema.optional(),
3840
})
3941
.describe('Audit information');

packages/models/src/lib/audit-output.unit.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ describe('auditOutputSchema', () => {
8787
).not.toThrow();
8888
});
8989

90+
it('should accept a valid audit output with a score target', () => {
91+
expect(() =>
92+
auditOutputSchema.parse({
93+
slug: 'total-blocking-time',
94+
score: 0.91,
95+
scoreTarget: 0.9,
96+
value: 183.5,
97+
displayValue: '180 ms',
98+
} satisfies AuditOutput),
99+
).not.toThrow();
100+
});
101+
90102
it('should accept a decimal value', () => {
91103
expect(() =>
92104
auditOutputSchema.parse({

packages/models/src/lib/audit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ export const auditSchema = z
66
.object({
77
slug: slugSchema.describe('ID (unique within plugin)'),
88
})
9-
.merge(
9+
.extend(
1010
metaSchema({
1111
titleDescription: 'Descriptive name',
1212
descriptionDescription: 'Description (markdown)',
1313
docsUrlDescription: 'Link to documentation (rationale)',
1414
description: 'List of scorable metrics for the given plugin',
1515
isSkippedDescription: 'Indicates whether the audit is skipped',
16-
}),
16+
}).shape,
1717
);
1818

1919
export type Audit = z.infer<typeof auditSchema>;

packages/models/src/lib/category-config.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
} from './implementation/checks.js';
66
import {
77
metaSchema,
8-
nonnegativeNumberSchema,
98
scorableSchema,
9+
scoreTargetSchema,
1010
slugSchema,
1111
weightedRefSchema,
1212
} from './implementation/schemas.js';
@@ -44,12 +44,7 @@ export const categoryConfigSchema = scorableSchema(
4444
description: 'Meta info for category',
4545
}).shape,
4646
)
47-
.extend({
48-
scoreTarget: nonnegativeNumberSchema
49-
.max(1)
50-
.describe('Pass/fail score threshold (0-1)')
51-
.optional(),
52-
});
47+
.extend({ scoreTarget: scoreTargetSchema });
5348

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

packages/models/src/lib/category-config.unit.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,30 @@ describe('categoryConfigSchema', () => {
121121
).not.toThrow();
122122
});
123123

124+
it('should accept a valid category configuration with a score target', () => {
125+
expect(() =>
126+
categoryConfigSchema.parse({
127+
slug: 'core-web-vitals',
128+
title: 'Core Web Vitals',
129+
scoreTarget: 0.9,
130+
refs: [
131+
{
132+
plugin: 'lighthouse',
133+
slug: 'largest-contentful-paint',
134+
type: 'audit',
135+
weight: 3,
136+
},
137+
{
138+
plugin: 'lighthouse',
139+
slug: 'first-input-delay',
140+
type: 'audit',
141+
weight: 2,
142+
},
143+
],
144+
} satisfies CategoryConfig),
145+
).not.toThrow();
146+
});
147+
124148
it('should throw for an empty category', () => {
125149
expect(() =>
126150
categoryConfigSchema.parse({

packages/models/src/lib/core-config.unit.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,13 @@ describe('coreConfigSchema', () => {
170170
slug: 'lighthouse',
171171
title: 'Lighthouse',
172172
icon: 'lighthouse',
173-
runner: { command: 'npm run lint', outputFile: 'output.json' },
173+
runner: async () => [
174+
{
175+
slug: 'csp-xss',
176+
score: 1,
177+
value: 1,
178+
},
179+
],
174180
audits: [
175181
{
176182
slug: 'csp-xss',

0 commit comments

Comments
 (0)