Skip to content

Commit 77ccef3

Browse files
authored
fix: Surface runtime errors in Deploy Log and Deploy Summary (#505)
1 parent 56e3248 commit 77ccef3

File tree

4 files changed

+374
-109
lines changed

4 files changed

+374
-109
lines changed

src/format.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const chalk = require('chalk');
2+
const { minify } = require('html-minifier');
3+
const { makeReplacements } = require('./replacements');
4+
5+
const belowThreshold = (id, expected, categories) => {
6+
const category = categories.find((c) => c.id === id);
7+
if (!category) {
8+
console.warn(`Could not find category ${chalk.yellow(id)}`);
9+
}
10+
const actual = category ? category.score : Number.MAX_SAFE_INTEGER;
11+
return actual < expected;
12+
};
13+
14+
const getError = (id, expected, categories, audits) => {
15+
const category = categories.find((c) => c.id === id);
16+
17+
const categoryError = `Expected category ${chalk.cyan(
18+
category.title,
19+
)} to be greater or equal to ${chalk.green(expected)} but got ${chalk.red(
20+
category.score !== null ? category.score : 'unknown',
21+
)}`;
22+
23+
const categoryAudits = category.auditRefs
24+
.filter(({ weight, id }) => weight > 0 && audits[id].score < 1)
25+
.map((ref) => {
26+
const audit = audits[ref.id];
27+
return ` '${chalk.cyan(
28+
audit.title,
29+
)}' received a score of ${chalk.yellow(audit.score)}`;
30+
})
31+
.join('\n');
32+
33+
return { message: categoryError, details: categoryAudits };
34+
};
35+
36+
const formatShortSummary = (categories) => {
37+
return categories
38+
.map(({ title, score }) => `${title}: ${Math.round(score * 100)}`)
39+
.join(', ');
40+
};
41+
42+
const formatResults = ({ results, thresholds }) => {
43+
const runtimeError = results.lhr.runtimeError;
44+
45+
const categories = Object.values(results.lhr.categories).map(
46+
({ title, score, id, auditRefs }) => ({ title, score, id, auditRefs }),
47+
);
48+
49+
const categoriesBelowThreshold = Object.entries(thresholds).filter(
50+
([id, expected]) => belowThreshold(id, expected, categories),
51+
);
52+
53+
const errors = categoriesBelowThreshold.map(([id, expected]) =>
54+
getError(id, expected, categories, results.lhr.audits),
55+
);
56+
57+
const summary = categories.map(({ title, score, id }) => ({
58+
title,
59+
score,
60+
id,
61+
...(thresholds[id] ? { threshold: thresholds[id] } : {}),
62+
}));
63+
64+
const shortSummary = formatShortSummary(categories);
65+
66+
const formattedReport = makeReplacements(results.report);
67+
68+
// Pull some additional details to pass to App
69+
const { formFactor, locale } = results.lhr.configSettings;
70+
const installable = results.lhr.audits['installable-manifest']?.score === 1;
71+
const details = { installable, formFactor, locale };
72+
73+
const report = minify(formattedReport, {
74+
removeAttributeQuotes: true,
75+
collapseWhitespace: true,
76+
removeRedundantAttributes: true,
77+
removeOptionalTags: true,
78+
removeEmptyElements: true,
79+
minifyCSS: true,
80+
minifyJS: true,
81+
});
82+
83+
return { summary, shortSummary, details, report, errors, runtimeError };
84+
};
85+
86+
module.exports = {
87+
belowThreshold,
88+
getError,
89+
formatShortSummary,
90+
formatResults,
91+
};

src/format.test.js

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
const {
2+
belowThreshold,
3+
getError,
4+
formatShortSummary,
5+
formatResults,
6+
} = require('./format');
7+
8+
// Strip ANSI color codes from strings, as they make CI sad.
9+
const stripAnsiCodes = (str) =>
10+
str.replace(
11+
// eslint-disable-next-line no-control-regex
12+
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
13+
'',
14+
);
15+
16+
describe('format', () => {
17+
const getCategories = ({ score }) => [
18+
{
19+
title: 'Performance',
20+
score,
21+
id: 'performance',
22+
auditRefs: [
23+
{ weight: 1, id: 'is-crawlable' },
24+
{ weight: 1, id: 'robots-txt' },
25+
{ weight: 1, id: 'tap-targets' },
26+
],
27+
},
28+
];
29+
const audits = {
30+
'is-crawlable': {
31+
id: 'is-crawlable',
32+
title: 'Page isn’t blocked from indexing',
33+
description:
34+
"Search engines are unable to include your pages in search results if they don't have permission to crawl them. [Learn more](https://web.dev/is-crawable/).",
35+
score: 1,
36+
},
37+
'robots-txt': {
38+
id: 'robots-txt',
39+
title: 'robots.txt is valid',
40+
description:
41+
'If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed. [Learn more](https://web.dev/robots-txt/).',
42+
score: 0,
43+
},
44+
'tap-targets': {
45+
id: 'tap-targets',
46+
title: 'Tap targets are sized appropriately',
47+
description:
48+
'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://web.dev/tap-targets/).',
49+
score: 0.5,
50+
},
51+
};
52+
53+
const formattedError = {
54+
details:
55+
" 'robots.txt is valid' received a score of 0\n" +
56+
" 'Tap targets are sized appropriately' received a score of 0.5",
57+
message:
58+
'Expected category Performance to be greater or equal to 1 but got 0.5',
59+
};
60+
61+
describe('belowThreshold', () => {
62+
const categories = [
63+
{ title: 'Performance', score: 0.9, id: 'performance' },
64+
{ title: 'Accessibility', score: 0.8, id: 'accessibility' },
65+
];
66+
67+
it('returns false when the score is above the threshold', () => {
68+
expect(belowThreshold('performance', 0.8, categories)).toBe(false);
69+
});
70+
71+
it('returns false when the category is not found', () => {
72+
console.warn = jest.fn();
73+
const result = belowThreshold('seo', 0.8, categories);
74+
expect(console.warn).toHaveBeenCalled();
75+
expect(result).toBe(false);
76+
});
77+
78+
it('returns true when the score is below the threshold', () => {
79+
expect(belowThreshold('performance', 1, categories)).toBe(true);
80+
});
81+
});
82+
83+
describe('getError', () => {
84+
it('returns an expected error message and list of details with valid score', () => {
85+
const errorMessage = getError(
86+
'performance',
87+
1,
88+
getCategories({ score: 0.5 }),
89+
audits,
90+
);
91+
expect(stripAnsiCodes(errorMessage.details)).toEqual(
92+
formattedError.details,
93+
);
94+
expect(stripAnsiCodes(errorMessage.message)).toEqual(
95+
formattedError.message,
96+
);
97+
});
98+
99+
it('returns an expected error message and list of details without valid score', () => {
100+
const errorMessage = getError(
101+
'performance',
102+
1,
103+
getCategories({ score: null }),
104+
audits,
105+
);
106+
expect(stripAnsiCodes(errorMessage.message)).toContain(
107+
'to be greater or equal to 1 but got unknown',
108+
);
109+
});
110+
});
111+
112+
describe('formatShortSummary', () => {
113+
const categories = [
114+
{ title: 'Performance', score: 1, id: 'performance' },
115+
{ title: 'Accessibility', score: 0.9, id: 'accessibility' },
116+
{ title: 'Best Practices', score: 0.8, id: 'best-practices' },
117+
{ title: 'SEO', score: 0.7, id: 'seo' },
118+
{ title: 'PWA', score: 0.6, id: 'pwa' },
119+
];
120+
121+
it('should return a shortSummary containing scores if available', () => {
122+
const shortSummary = formatShortSummary(categories);
123+
expect(shortSummary).toEqual(
124+
'Performance: 100, Accessibility: 90, Best Practices: 80, SEO: 70, PWA: 60',
125+
);
126+
});
127+
});
128+
129+
describe('formatResults', () => {
130+
const getResults = () => ({
131+
lhr: {
132+
lighthouseVersion: '9.6.3',
133+
requestedUrl: 'http://localhost:5100/404.html',
134+
finalUrl: 'http://localhost:5100/404.html',
135+
audits,
136+
configSettings: {},
137+
categories: getCategories({ score: 0.5 }),
138+
},
139+
artifacts: {},
140+
report: '<!doctype html>\n' + '<html lang="en">Hi</html>\n',
141+
});
142+
143+
it('should return formatted results', () => {
144+
expect(formatResults({ results: getResults(), thresholds: {} })).toEqual({
145+
details: {
146+
formFactor: undefined,
147+
installable: false,
148+
locale: undefined,
149+
},
150+
errors: [],
151+
report: '<!doctype html><html lang=en>Hi',
152+
shortSummary: 'Performance: 50',
153+
summary: [{ id: 'performance', score: 0.5, title: 'Performance' }],
154+
});
155+
});
156+
157+
it('should return formatted results with passing thresholds', () => {
158+
const thresholds = {
159+
performance: 0.1,
160+
};
161+
const formattedResults = formatResults({
162+
results: getResults(),
163+
thresholds,
164+
});
165+
expect(formattedResults.errors).toEqual([]);
166+
expect(formattedResults.summary).toEqual([
167+
{
168+
id: 'performance',
169+
score: 0.5,
170+
title: 'Performance',
171+
threshold: 0.1,
172+
},
173+
]);
174+
});
175+
176+
it('should return formatted results with failing thresholds', () => {
177+
const thresholds = {
178+
performance: 1,
179+
};
180+
const formattedResults = formatResults({
181+
results: getResults(),
182+
thresholds,
183+
});
184+
expect(stripAnsiCodes(formattedResults.errors[0].message)).toEqual(
185+
formattedError.message,
186+
);
187+
expect(stripAnsiCodes(formattedResults.errors[0].details)).toEqual(
188+
formattedError.details,
189+
);
190+
expect(formattedResults.summary).toEqual([
191+
{
192+
id: 'performance',
193+
score: 0.5,
194+
title: 'Performance',
195+
threshold: 1,
196+
},
197+
]);
198+
});
199+
200+
it('should use supplied config settings and data to populate `details`', () => {
201+
const results = getResults();
202+
results.lhr.configSettings = {
203+
locale: 'es',
204+
formFactor: 'desktop',
205+
};
206+
results.lhr.audits['installable-manifest'] = {
207+
id: 'installable-manifest',
208+
score: 1,
209+
};
210+
211+
const formattedResults = formatResults({ results, thresholds: {} });
212+
expect(formattedResults.details).toEqual({
213+
formFactor: 'desktop',
214+
installable: true,
215+
locale: 'es',
216+
});
217+
});
218+
});
219+
});

0 commit comments

Comments
 (0)