Skip to content

Commit b1ed60b

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

File tree

2 files changed

+163
-72
lines changed

2 files changed

+163
-72
lines changed
Lines changed: 106 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
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,
814
formatAsciiLink,
15+
formatAsciiTable,
16+
formatReportScore,
917
logger,
1018
shouldExpandForUrls,
1119
stringifyError,
@@ -15,8 +23,8 @@ import { DEFAULT_CLI_FLAGS } from './constants.js';
1523
import type { LighthouseCliFlags } from './types.js';
1624
import {
1725
enrichFlags,
26+
filterAuditOutputs,
1827
getConfig,
19-
normalizeAuditOutputs,
2028
toAuditOutputs,
2129
withLocalTmpDir,
2230
} from './utils.js';
@@ -28,64 +36,120 @@ export function createRunnerFunction(
2836
return withLocalTmpDir(async (): Promise<AuditOutputs> => {
2937
const config = await getConfig(flags);
3038
const normalizationFlags = enrichFlags(flags);
31-
const isSingleUrl = !shouldExpandForUrls(urls.length);
39+
const urlsCount = urls.length;
40+
const isSingleUrl = !shouldExpandForUrls(urlsCount);
3241

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);
42+
const allResults = await asyncSequential(urls, (url, urlIndex) => {
43+
const enrichedFlags = isSingleUrl
44+
? normalizationFlags
45+
: enrichFlags(flags, urlIndex + 1);
46+
const step = { urlIndex, urlsCount };
47+
return runLighthouseForUrl(url, enrichedFlags, config, step);
48+
});
3949

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) {
50+
const collectedResults = allResults.filter(res => res != null);
51+
if (collectedResults.length === 0) {
6152
throw new Error(
6253
isSingleUrl
6354
? 'Lighthouse did not produce a result.'
6455
: 'Lighthouse failed to produce results for all URLs.',
6556
);
6657
}
67-
return normalizeAuditOutputs(allResults, normalizationFlags);
58+
59+
logResultsForAllUrls(collectedResults);
60+
61+
const auditOutputs: AuditOutputs = collectedResults.flatMap(
62+
res => res.auditOutputs,
63+
);
64+
return filterAuditOutputs(auditOutputs, normalizationFlags);
6865
});
6966
}
7067

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

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

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

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

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

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

Lines changed: 57 additions & 30 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

@@ -184,23 +206,28 @@ export function enrichFlags(
184206
* @returns Wrapped function which overrides `TEMP` environment variable, before cleaning up afterwards.
185207
*/
186208
export function withLocalTmpDir<T>(fn: () => Promise<T>): () => Promise<T> {
187-
if (os.platform() !== 'win32') {
188-
return fn;
189-
}
209+
// if (os.platform() !== 'win32') {
210+
// return fn;
211+
// }
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)