Skip to content

Commit a2244c7

Browse files
authored
feat(cli): add user-timing assertion support (#280)
1 parent e17cc01 commit a2244c7

File tree

5 files changed

+162
-3
lines changed

5 files changed

+162
-3
lines changed

docs/assertions.md

+17
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ The below example warns when FCP is above 2 seconds on _all_ pages and warns whe
101101
}
102102
```
103103

104+
### User Timings
105+
106+
Your custom user timings using [`performance.mark`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark) and [`performance.measure`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure) can be asserted against as well.
107+
108+
The general format for asserting against a user timing value is `"user-timings:<kebab-cased-name>": ["<level>", {maxNumericValue: <value in milliseconds>}]`. For example, if you wanted to assert that a mark with name `My Custom Mark` started within the first 2s of page load and that a measure `my:custom-@-Measure` lasted fewer than 50 ms you would use the following assertions config.
109+
110+
Note that only the first matching entry with the name will be used from each run and the rest will be ignored.
111+
112+
```json
113+
{
114+
"assertions": {
115+
"user-timings:my-custom-mark": ["warn", {"maxNumericValue": 2000}],
116+
"user-timings:my-custom-measure": ["error", {"maxNumericValue": 50}]
117+
}
118+
}
119+
```
120+
104121
## Presets
105122

106123
There are three presets available to provide a good starting point. Presets can be extended with manual assertions.

packages/utils/src/assertions.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,22 @@ function getAssertionResultsForAudit(auditId, auditProperty, auditResults, asser
311311
...result,
312312
auditProperty: auditProperty.join('.'),
313313
}));
314+
} else if (auditId === 'user-timings' && auditProperty) {
315+
const userTimingName = _.kebabCase(auditProperty.join('-'), {alphanumericOnly: true});
316+
const psuedoAuditResults = auditResults.map(result => {
317+
if (!result || !result.details || !result.details.items) return;
318+
const item = result.details.items.find(
319+
item => _.kebabCase(item.name, {alphanumericOnly: true}) === userTimingName
320+
);
321+
if (!item) return;
322+
const numericValue = Number.isFinite(item.duration) ? item.duration : item.startTime;
323+
return {...result, numericValue};
324+
});
325+
326+
return getStandardAssertionResults(psuedoAuditResults, assertionOptions).map(result => ({
327+
...result,
328+
auditProperty: userTimingName,
329+
}));
314330
} else {
315331
return getStandardAssertionResults(auditResults, assertionOptions);
316332
}
@@ -342,7 +358,7 @@ function resolveAssertionOptionsAndLhrs(baseOptions, unfilteredLhrs) {
342358
lhrs.map(lhr => /** @type {[LH.Result, LH.Result]} */ ([lhr, lhr])),
343359
]);
344360

345-
const auditsToAssert = [...new Set(Object.keys(assertions).map(_.kebabCase))].map(
361+
const auditsToAssert = [...new Set(Object.keys(assertions).map(key => _.kebabCase(key)))].map(
346362
assertionKey => {
347363
const [auditId, ...rest] = assertionKey.split(/\.|:/g).filter(Boolean);
348364
const auditInstances = lhrs.map(lhr => lhr.audits[auditId]).filter(Boolean);

packages/utils/src/lodash.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ function merge(v1, v2) {
4444
/**
4545
* Converts a string from camelCase to kebab-case.
4646
* @param {string} s
47+
* @param {{alphanumericOnly?: boolean}} [opts]
4748
*/
48-
function kebabCase(s) {
49-
return s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
49+
function kebabCase(s, opts) {
50+
let kebabed = s.replace(/([a-z])([A-Z])/g, '$1-$2');
51+
if (opts && opts.alphanumericOnly) {
52+
kebabed = kebabed
53+
.replace(/[^a-z0-9]+/gi, '-')
54+
.replace(/-+/g, '-')
55+
.replace(/(^-|-$)/g, '');
56+
}
57+
return kebabed.toLowerCase();
5058
}
5159

5260
/**

packages/utils/test/assertions.test.js

+99
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,105 @@ describe('getAllAssertionResults', () => {
701701
});
702702
});
703703

704+
describe('user timings', () => {
705+
let lhrWithUserTimings;
706+
707+
beforeEach(() => {
708+
lhrWithUserTimings = {
709+
finalUrl: 'http://example.com',
710+
audits: {
711+
'user-timings': {
712+
details: {
713+
items: [
714+
{name: 'Core initializer', startTime: 757, duration: 123, timingType: 'Measure'},
715+
{name: 'super_Cool_Measure', startTime: 999, duration: 52, timingType: 'Measure'},
716+
{name: 'ultraCoolMark', startTime: 5231, timingType: 'Mark'},
717+
{name: 'other:%Cool.Mark', startTime: 12052, timingType: 'Mark'},
718+
// Duplicates will be ignored
719+
{name: 'super_Cool_Measure', startTime: 0, duration: 4252, timingType: 'Measure'},
720+
],
721+
},
722+
},
723+
},
724+
};
725+
});
726+
727+
it('should assert user timing keys', () => {
728+
const assertions = {
729+
'user-timings.core-initializer': ['error', {maxNumericValue: 100}],
730+
'user-timings.super-cool-measure': ['warn', {maxNumericValue: 100}],
731+
'user-timings:ultra-cool-mark': ['warn', {maxNumericValue: 5000}],
732+
'user-timings:other-cool-mark': ['error', {maxNumericValue: 10000}],
733+
'user-timings:missing-timing': ['error', {maxNumericValue: 10000}],
734+
};
735+
736+
const lhrs = [lhrWithUserTimings, lhrWithUserTimings];
737+
const results = getAllAssertionResults({assertions, includePassedAssertions: true}, lhrs);
738+
expect(results).toEqual([
739+
{
740+
actual: 123,
741+
auditId: 'user-timings',
742+
auditProperty: 'core-initializer',
743+
expected: 100,
744+
level: 'error',
745+
name: 'maxNumericValue',
746+
operator: '<=',
747+
passed: false,
748+
url: 'http://example.com',
749+
values: [123, 123],
750+
},
751+
{
752+
actual: 52,
753+
auditId: 'user-timings',
754+
auditProperty: 'super-cool-measure',
755+
expected: 100,
756+
level: 'warn',
757+
name: 'maxNumericValue',
758+
operator: '<=',
759+
passed: true,
760+
url: 'http://example.com',
761+
values: [52, 52],
762+
},
763+
{
764+
actual: 5231,
765+
auditId: 'user-timings',
766+
auditProperty: 'ultra-cool-mark',
767+
expected: 5000,
768+
level: 'warn',
769+
name: 'maxNumericValue',
770+
operator: '<=',
771+
passed: false,
772+
url: 'http://example.com',
773+
values: [5231, 5231],
774+
},
775+
{
776+
actual: 12052,
777+
auditId: 'user-timings',
778+
auditProperty: 'other-cool-mark',
779+
expected: 10000,
780+
level: 'error',
781+
name: 'maxNumericValue',
782+
operator: '<=',
783+
passed: false,
784+
url: 'http://example.com',
785+
values: [12052, 12052],
786+
},
787+
{
788+
actual: 0,
789+
auditId: 'user-timings',
790+
auditProperty: 'missing-timing',
791+
expected: 1,
792+
level: 'error',
793+
name: 'auditRan',
794+
operator: '>=',
795+
passed: false,
796+
url: 'http://example.com',
797+
values: [0, 0],
798+
},
799+
]);
800+
});
801+
});
802+
704803
describe('URL-grouping', () => {
705804
beforeEach(() => {
706805
for (const lhr of [...lhrs]) {

packages/utils/test/lodash.test.js

+19
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,31 @@ describe('lodash.js', () => {
135135
// WAI
136136
expect(_.kebabCase('camelCase')).toEqual('camel-case');
137137
expect(_.kebabCase('kebab-case')).toEqual('kebab-case');
138+
expect(_.kebabCase('exampleURL')).toEqual('example-url');
138139

139140
// Not implemented but should probably work consistently at some point.
140141
// Tests just for documentation.
142+
expect(_.kebabCase('kebab-case:document.type')).toEqual('kebab-case:document.type');
143+
expect(_.kebabCase('mixed-Case3_Issues+')).toEqual('mixed-case3_issues+');
141144
expect(_.kebabCase('ALL CAPS')).toEqual('all caps');
142145
expect(_.kebabCase('snake_case')).toEqual('snake_case');
143146
});
147+
148+
it('should convert strings to kebab-case alphanumericOnly', () => {
149+
// WAI
150+
expect(_.kebabCase('camelCase', {alphanumericOnly: true})).toEqual('camel-case');
151+
expect(_.kebabCase('kebab-case', {alphanumericOnly: true})).toEqual('kebab-case');
152+
expect(_.kebabCase('kebab-case:document.type', {alphanumericOnly: true})).toEqual(
153+
'kebab-case-document-type'
154+
);
155+
expect(_.kebabCase('exampleURL', {alphanumericOnly: true})).toEqual('example-url');
156+
expect(_.kebabCase('!exampleURL', {alphanumericOnly: true})).toEqual('example-url');
157+
expect(_.kebabCase('mixed-Case3_Issues+', {alphanumericOnly: true})).toEqual(
158+
'mixed-case3-issues'
159+
);
160+
expect(_.kebabCase('ALL CAPS', {alphanumericOnly: true})).toEqual('all-caps');
161+
expect(_.kebabCase('snake_case', {alphanumericOnly: true})).toEqual('snake-case');
162+
});
144163
});
145164

146165
describe('#startCase', () => {

0 commit comments

Comments
 (0)