Skip to content

Commit e23c97e

Browse files
committed
feat(plugin-lighthouse): log runner steps (incl. categories per url, config loading)
1 parent b868bc0 commit e23c97e

File tree

5 files changed

+170
-85
lines changed

5 files changed

+170
-85
lines changed
Lines changed: 104 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import type { Config, RunnerResult } from 'lighthouse';
1+
import ansis from 'ansis';
2+
import type { Config, Result, RunnerResult } from 'lighthouse';
23
import { runLighthouse } from 'lighthouse/cli/run.js';
34
import path from 'node:path';
4-
import type { AuditOutputs, RunnerFunction } from '@code-pushup/models';
5+
import type {
6+
AuditOutputs,
7+
RunnerFunction,
8+
TableColumnObject,
9+
} from '@code-pushup/models';
510
import {
611
addIndex,
12+
asyncSequential,
713
ensureDirectoryExists,
8-
formatAsciiLink,
14+
formatAsciiTable,
15+
formatReportScore,
916
logger,
1017
shouldExpandForUrls,
1118
stringifyError,
@@ -15,8 +22,8 @@ import { DEFAULT_CLI_FLAGS } from './constants.js';
1522
import type { LighthouseCliFlags } from './types.js';
1623
import {
1724
enrichFlags,
25+
filterAuditOutputs,
1826
getConfig,
19-
normalizeAuditOutputs,
2027
toAuditOutputs,
2128
withLocalTmpDir,
2229
} from './utils.js';
@@ -28,64 +35,118 @@ export function createRunnerFunction(
2835
return withLocalTmpDir(async (): Promise<AuditOutputs> => {
2936
const config = await getConfig(flags);
3037
const normalizationFlags = enrichFlags(flags);
31-
const isSingleUrl = !shouldExpandForUrls(urls.length);
38+
const urlsCount = urls.length;
39+
const isSingleUrl = !shouldExpandForUrls(urlsCount);
3240

33-
const allResults = await urls.reduce(async (prev, url, index) => {
34-
const acc = await prev;
35-
try {
36-
const enrichedFlags = isSingleUrl
37-
? normalizationFlags
38-
: enrichFlags(flags, index + 1);
41+
const allResults = await asyncSequential(urls, (url, urlIndex) => {
42+
const enrichedFlags = isSingleUrl
43+
? normalizationFlags
44+
: enrichFlags(flags, urlIndex + 1);
45+
const step = { urlIndex, urlsCount };
46+
return runLighthouseForUrl(url, enrichedFlags, config, step);
47+
});
3948

40-
const auditOutputs = await runLighthouseForUrl(
41-
url,
42-
enrichedFlags,
43-
config,
44-
);
45-
46-
const processedOutputs = isSingleUrl
47-
? auditOutputs
48-
: auditOutputs.map(audit => ({
49-
...audit,
50-
slug: addIndex(audit.slug, index),
51-
}));
52-
53-
return [...acc, ...processedOutputs];
54-
} catch (error) {
55-
logger.warn(stringifyError(error));
56-
return acc;
57-
}
58-
}, Promise.resolve<AuditOutputs>([]));
59-
60-
if (allResults.length === 0) {
49+
const collectedResults = allResults.filter(res => res != null);
50+
if (collectedResults.length === 0) {
6151
throw new Error(
6252
isSingleUrl
6353
? 'Lighthouse did not produce a result.'
6454
: 'Lighthouse failed to produce results for all URLs.',
6555
);
6656
}
67-
return normalizeAuditOutputs(allResults, normalizationFlags);
57+
58+
logResultsForAllUrls(collectedResults);
59+
60+
const auditOutputs: AuditOutputs = collectedResults.flatMap(
61+
res => res.auditOutputs,
62+
);
63+
return filterAuditOutputs(auditOutputs, normalizationFlags);
6864
});
6965
}
7066

67+
type ResultForUrl = {
68+
url: string;
69+
lhr: Result;
70+
auditOutputs: AuditOutputs;
71+
};
72+
7173
async function runLighthouseForUrl(
7274
url: string,
7375
flags: LighthouseOptions,
7476
config: Config | undefined,
75-
): Promise<AuditOutputs> {
76-
if (flags.outputPath) {
77-
await ensureDirectoryExists(path.dirname(flags.outputPath));
78-
}
77+
step: { urlIndex: number; urlsCount: number },
78+
): Promise<ResultForUrl | null> {
79+
const { urlIndex, urlsCount } = step;
7980

80-
const runnerResult: unknown = await runLighthouse(url, flags, config);
81+
const prefix = ansis.gray(`[${step.urlIndex + 1}/${step.urlsCount}]`);
8182

82-
if (runnerResult == null) {
83-
throw new Error(
84-
`Lighthouse did not produce a result for URL: ${formatAsciiLink(url)}`,
83+
try {
84+
if (flags.outputPath) {
85+
await ensureDirectoryExists(path.dirname(flags.outputPath));
86+
}
87+
88+
const lhr: Result = await logger.task(
89+
`${prefix} Running lighthouse on ${url}`,
90+
async () => {
91+
const runnerResult: RunnerResult | undefined = await runLighthouse(
92+
url,
93+
flags,
94+
config,
95+
);
96+
97+
if (runnerResult == null) {
98+
throw new Error('Lighthouse did not produce a result');
99+
}
100+
101+
return {
102+
message: `${prefix} Completed lighthouse run on ${url}`,
103+
result: runnerResult.lhr,
104+
};
105+
},
85106
);
107+
108+
const auditOutputs = toAuditOutputs(Object.values(lhr.audits), flags);
109+
if (shouldExpandForUrls(urlsCount)) {
110+
return {
111+
url,
112+
lhr,
113+
auditOutputs: auditOutputs.map(audit => ({
114+
...audit,
115+
slug: addIndex(audit.slug, urlIndex),
116+
})),
117+
};
118+
}
119+
return { url, lhr, auditOutputs };
120+
} catch (error) {
121+
logger.warn(`Lighthouse run failed for ${url} - ${stringifyError(error)}`);
122+
return null;
86123
}
124+
}
87125

88-
const { lhr } = runnerResult as RunnerResult;
126+
function logResultsForAllUrls(results: ResultForUrl[]): void {
127+
const categoryNames = Object.fromEntries(
128+
results
129+
.flatMap(res => Object.values(res.lhr.categories))
130+
.map(category => [category.id, category.title]),
131+
);
89132

90-
return toAuditOutputs(Object.values(lhr.audits), flags);
133+
logger.info(
134+
formatAsciiTable({
135+
columns: [
136+
{ key: 'url', label: 'URL', align: 'left' },
137+
...Object.entries(categoryNames).map(
138+
([key, label]): TableColumnObject => ({ key, label, align: 'right' }),
139+
),
140+
],
141+
rows: results.map(({ url, lhr }) => ({
142+
url,
143+
...Object.fromEntries(
144+
Object.values(lhr.categories).map(category => [
145+
category.id,
146+
category.score == null ? '-' : formatReportScore(category.score),
147+
]),
148+
),
149+
})),
150+
}),
151+
);
91152
}

packages/plugin-lighthouse/src/lib/runner/runner.unit.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import ansis from 'ansis';
21
import type { Config } from 'lighthouse';
32
import { runLighthouse } from 'lighthouse/cli/run.js';
43
import type { Result } from 'lighthouse/types/lhr/audit-result';
@@ -52,6 +51,7 @@ vi.mock('lighthouse/cli/run.js', async () => {
5251
score: 0.9,
5352
} satisfies Result,
5453
},
54+
categories: {},
5555
},
5656
},
5757
);
@@ -177,7 +177,7 @@ describe('createRunnerFunction', () => {
177177
it('should continue with other URLs when one fails in multiple URL scenario', async () => {
178178
const runner = createRunnerFunction([
179179
'https://localhost:8080',
180-
'fail',
180+
'http://fail.com',
181181
'https://localhost:8082',
182182
]);
183183

@@ -199,7 +199,7 @@ describe('createRunnerFunction', () => {
199199
);
200200

201201
expect(logger.warn).toHaveBeenCalledWith(
202-
`Lighthouse did not produce a result for URL: ${ansis.blueBright('fail')}`,
202+
'Lighthouse run failed for http://fail.com - Lighthouse did not produce a result',
203203
);
204204
});
205205

packages/plugin-lighthouse/src/lib/runner/utils.ts

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { LighthouseOptions } from '../types.js';
2222
import { logUnsupportedDetails, toAuditDetails } from './details/details.js';
2323
import type { LighthouseCliFlags } from './types.js';
2424

25-
export function normalizeAuditOutputs(
25+
export function filterAuditOutputs(
2626
auditOutputs: AuditOutputs,
2727
flags: LighthouseOptions = { skipAudits: [] },
2828
): AuditOutputs {
@@ -33,7 +33,7 @@ export function normalizeAuditOutputs(
3333
export class LighthouseAuditParsingError extends Error {
3434
constructor(slug: string, error: unknown) {
3535
super(
36-
`\nAudit ${ansis.bold(slug)} failed parsing details: \n${stringifyError(error)}`,
36+
`Failed to parse ${ansis.bold(slug)} audit's details - ${stringifyError(error)}`,
3737
);
3838
}
3939
}
@@ -99,6 +99,7 @@ export type LighthouseLogLevel =
9999
| 'silent'
100100
| 'warn'
101101
| undefined;
102+
102103
export function determineAndSetLogLevel({
103104
verbose,
104105
quiet,
@@ -127,31 +128,52 @@ export type ConfigOptions = Partial<
127128
export async function getConfig(
128129
options: ConfigOptions = {},
129130
): Promise<Config | undefined> {
130-
const { configPath: filepath, preset } = options;
131-
132-
if (filepath != null) {
133-
if (filepath.endsWith('.json')) {
134-
// Resolve the config file path relative to where cli was called.
135-
return readJsonFile<Config>(filepath);
136-
} else if (/\.(ts|js|mjs)$/.test(filepath)) {
137-
return importModule<Config>({ filepath, format: 'esm' });
131+
const { configPath, preset } = options;
132+
133+
if (configPath != null) {
134+
// Resolve the config file path relative to where cli was called.
135+
return logger.task(
136+
`Loading lighthouse config from ${configPath}`,
137+
async () => {
138+
const message = `Loaded lighthouse config from ${configPath}`;
139+
if (configPath.endsWith('.json')) {
140+
return { message, result: await readJsonFile<Config>(configPath) };
141+
}
142+
if (/\.(ts|js|mjs)$/.test(configPath)) {
143+
return {
144+
message,
145+
result: await importModule<Config>({
146+
filepath: configPath,
147+
format: 'esm',
148+
}),
149+
};
150+
}
151+
throw new Error(
152+
`Unknown Lighthouse config file extension in ${configPath}`,
153+
);
154+
},
155+
);
156+
}
157+
158+
if (preset != null) {
159+
const supportedPresets: Record<
160+
NonNullable<LighthouseCliFlags['preset']>,
161+
Config
162+
> = {
163+
desktop: desktopConfig,
164+
perf: perfConfig,
165+
experimental: experimentalConfig,
166+
};
167+
// in reality, the preset could be a string not included in the type definition
168+
const config: Config | undefined = supportedPresets[preset];
169+
if (config) {
170+
logger.info(`Loaded config from ${ansis.bold(preset)} preset`);
171+
return config;
138172
} else {
139-
logger.warn(`Format of file ${filepath} not supported`);
140-
}
141-
} else if (preset != null) {
142-
switch (preset) {
143-
case 'desktop':
144-
return desktopConfig;
145-
case 'perf':
146-
return perfConfig as Config;
147-
case 'experimental':
148-
return experimentalConfig as Config;
149-
default:
150-
// as preset is a string literal the default case here is normally caught by TS and not possible to happen. Now in reality it can happen and preset could be a string not included in the literal.
151-
// Therefore, we have to use `as string`. Otherwise, it will consider preset as type never
152-
logger.warn(`Preset "${preset as string}" is not supported`);
173+
logger.warn(`Preset "${preset}" is not supported`);
153174
}
154175
}
176+
155177
return undefined;
156178
}
157179

@@ -190,17 +212,22 @@ export function withLocalTmpDir<T>(fn: () => Promise<T>): () => Promise<T> {
190212

191213
return async () => {
192214
const originalTmpDir = process.env['TEMP'];
215+
const localPath = path.join(pluginWorkDir(LIGHTHOUSE_PLUGIN_SLUG), 'tmp');
216+
193217
// eslint-disable-next-line functional/immutable-data
194-
process.env['TEMP'] = path.join(
195-
pluginWorkDir(LIGHTHOUSE_PLUGIN_SLUG),
196-
'tmp',
218+
process.env['TEMP'] = localPath;
219+
logger.debug(
220+
`Temporarily overwriting TEMP environment variable with ${localPath} to prevent permissions error on cleanup`,
197221
);
198222

199223
try {
200224
return await fn();
201225
} finally {
202226
// eslint-disable-next-line functional/immutable-data
203227
process.env['TEMP'] = originalTmpDir;
228+
logger.debug(
229+
`Restored TEMP environment variable to original value ${originalTmpDir}`,
230+
);
204231
}
205232
};
206233
}

0 commit comments

Comments
 (0)