-
Notifications
You must be signed in to change notification settings - Fork 9.4k
/
config-helpers.js
619 lines (549 loc) · 20.9 KB
/
config-helpers.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'path';
import {createRequire} from 'module';
import url from 'url';
import {isEqual} from 'lodash-es';
import * as constants from './constants.js';
import ConfigPlugin from './config-plugin.js';
import {Runner} from '../runner.js';
import * as i18n from '../lib/i18n/i18n.js';
import * as validation from './validation.js';
import {getModuleDirectory} from '../../shared/esm-utils.js';
const require = createRequire(import.meta.url);
/** @typedef {typeof import('../gather/base-gatherer.js').default} GathererConstructor */
/** @typedef {typeof import('../audits/audit.js')['Audit']} Audit */
/** @typedef {InstanceType<GathererConstructor>} Gatherer */
function isBundledEnvironment() {
// If we're in DevTools or LightRider, we are definitely bundled.
// TODO: refactor and delete `global.isDevtools`.
if (global.isDevtools || global.isLightrider) return true;
try {
// Not foolproof, but `lighthouse-logger` is a dependency of lighthouse that should always be resolvable.
// `require.resolve` will only throw in atypical/bundled environments.
require.resolve('lighthouse-logger');
return false;
} catch (err) {
return true;
}
}
/**
* If any items with identical `path` properties are found in the input array,
* merge their `options` properties into the first instance and then discard any
* other instances.
* @template {{path?: string, options: Record<string, unknown>}} T
* @param {T[]} items
* @return T[]
*/
const mergeOptionsOfItems = function(items) {
/** @type {T[]} */
const mergedItems = [];
for (const item of items) {
const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path);
if (!existingItem) {
mergedItems.push(item);
continue;
}
existingItem.options = Object.assign({}, existingItem.options, item.options);
}
return mergedItems;
};
/**
* Recursively merges config fragment objects in a somewhat Lighthouse-specific way.
*
* - `null` is treated similarly to `undefined` for whether a value should be overridden.
* - `overwriteArrays` controls array extension behavior:
* - true: Arrays are overwritten without any merging or concatenation.
* - false: Arrays are concatenated and de-duped by isEqual.
* - Objects are recursively merged.
* - If the `settings` key is encountered while traversing an object, its arrays are *always*
* overridden, not concatenated. (`overwriteArrays` is flipped to `true`)
*
* More widely typed than exposed merge() function, below.
* @param {Object<string, any>|Array<any>|undefined|null} base
* @param {Object<string, any>|Array<any>} extension
* @param {boolean=} overwriteArrays
*/
function _mergeConfigFragment(base, extension, overwriteArrays = false) {
// If the default value doesn't exist or is explicitly null, defer to the extending value
if (typeof base === 'undefined' || base === null) {
return extension;
} else if (typeof extension === 'undefined') {
return base;
} else if (Array.isArray(extension)) {
if (overwriteArrays) return extension;
if (!Array.isArray(base)) throw new TypeError(`Expected array but got ${typeof base}`);
const merged = base.slice();
extension.forEach(item => {
if (!merged.some(candidate => isEqual(candidate, item))) merged.push(item);
});
return merged;
} else if (typeof extension === 'object') {
if (typeof base !== 'object') throw new TypeError(`Expected object but got ${typeof base}`);
if (Array.isArray(base)) throw new TypeError('Expected object but got Array');
Object.keys(extension).forEach(key => {
const localOverwriteArrays = overwriteArrays ||
(key === 'settings' && typeof base[key] === 'object');
base[key] = _mergeConfigFragment(base[key], extension[key], localOverwriteArrays);
});
return base;
}
return extension;
}
/**
* Until support of jsdoc templates with constraints, type in config.d.ts.
* See https://github.com/Microsoft/TypeScript/issues/24283
* @type {LH.Config.Merge}
*/
const mergeConfigFragment = _mergeConfigFragment;
/**
* Merge an array of items by a caller-defined key. `mergeConfigFragment` is used to merge any items
* with a matching key.
*
* @template {Record<string, any>} T
* @param {Array<T>|null|undefined} baseArray
* @param {Array<T>|null|undefined} extensionArray
* @param {(item: T) => string} keyFn
* @return {Array<T>}
*/
function mergeConfigFragmentArrayByKey(baseArray, extensionArray, keyFn) {
/** @type {Map<string, {index: number, item: T}>} */
const itemsByKey = new Map();
const mergedArray = baseArray || [];
for (let i = 0; i < mergedArray.length; i++) {
const item = mergedArray[i];
itemsByKey.set(keyFn(item), {index: i, item});
}
for (const item of extensionArray || []) {
const baseItemEntry = itemsByKey.get(keyFn(item));
if (baseItemEntry) {
const baseItem = baseItemEntry.item;
const merged = typeof item === 'object' && typeof baseItem === 'object' ?
mergeConfigFragment(baseItem, item, true) :
item;
mergedArray[baseItemEntry.index] = merged;
} else {
mergedArray.push(item);
}
}
return mergedArray;
}
/**
* Expands a gatherer from user-specified to an internal gatherer definition format.
*
* Input Examples:
* - 'my-gatherer'
* - class MyGatherer extends Gatherer { }
* - {instance: myGathererInstance}
*
* @param {LH.Config.GathererJson} gatherer
* @return {{instance?: Gatherer, implementation?: GathererConstructor, path?: string}}
*/
function expandGathererShorthand(gatherer) {
if (typeof gatherer === 'string') {
// just 'path/to/gatherer'
return {path: gatherer};
} else if ('implementation' in gatherer || 'instance' in gatherer) {
// {implementation: GathererConstructor, ...} or {instance: GathererInstance, ...}
return gatherer;
} else if ('path' in gatherer) {
// {path: 'path/to/gatherer', ...}
if (typeof gatherer.path !== 'string') {
throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer));
}
return gatherer;
} else if (typeof gatherer === 'function') {
// just GathererConstructor
return {implementation: gatherer};
} else if (gatherer && typeof gatherer.getArtifact === 'function') {
// just GathererInstance
return {instance: gatherer};
} else {
throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer));
}
}
/**
* Expands the audits from user-specified JSON to an internal audit definition format.
* @param {LH.Config.AuditJson} audit
* @return {{id?: string, path: string, options?: {}} | {id?: string, implementation: Audit, path?: string, options?: {}}}
*/
function expandAuditShorthand(audit) {
if (typeof audit === 'string') {
// just 'path/to/audit'
return {path: audit, options: {}};
} else if ('implementation' in audit && typeof audit.implementation.audit === 'function') {
// {implementation: AuditClass, ...}
return audit;
} else if ('path' in audit && typeof audit.path === 'string') {
// {path: 'path/to/audit', ...}
return audit;
} else if ('audit' in audit && typeof audit.audit === 'function') {
// just AuditClass
return {implementation: audit, options: {}};
} else {
throw new Error('Invalid Audit type ' + JSON.stringify(audit));
}
}
/** @type {Map<string, Promise<any>>} */
const bundledModules = new Map(/* BUILD_REPLACE_BUNDLED_MODULES */);
/**
* Wraps `import`/`require` with an entrypoint for bundled dynamic modules.
* See build-bundle.js
* @param {string} requirePath
*/
async function requireWrapper(requirePath) {
// For windows.
if (path.isAbsolute(requirePath)) {
requirePath = url.pathToFileURL(requirePath).href;
}
/** @type {any} */
let module;
if (bundledModules.has(requirePath)) {
module = await bundledModules.get(requirePath);
} else if (requirePath.match(/\.(js|mjs|cjs)$/)) {
module = await import(requirePath);
} else {
requirePath += '.js';
module = await import(requirePath);
}
if (module.default) return module.default;
// Find a valid named export.
const methods = new Set(['meta']);
const possibleNamedExports = Object.keys(module).filter(key => {
if (!(module[key] && module[key] instanceof Object)) return false;
return Object.getOwnPropertyNames(module[key]).some(method => methods.has(method));
});
if (possibleNamedExports.length === 1) return possibleNamedExports[0];
if (possibleNamedExports.length > 1) {
throw new Error(`module '${requirePath}' has too many possible exports`);
}
throw new Error(`module '${requirePath}' missing default export`);
}
/**
* @param {string} gathererPath
* @param {Array<string>} coreGathererList
* @param {string=} configDir
* @return {Promise<LH.Config.AnyGathererDefn>}
*/
async function requireGatherer(gathererPath, coreGathererList, configDir) {
const coreGatherer = coreGathererList.find(a => a === `${gathererPath}.js`);
let requirePath = `../gather/gatherers/${gathererPath}`;
if (!coreGatherer) {
// Otherwise, attempt to find it elsewhere. This throws if not found.
requirePath = resolveModulePath(gathererPath, configDir, 'gatherer');
}
const GathererClass = /** @type {GathererConstructor} */ (await requireWrapper(requirePath));
return {
instance: new GathererClass(),
implementation: GathererClass,
path: gathererPath,
};
}
/**
* @param {string} auditPath
* @param {Array<string>} coreAuditList
* @param {string=} configDir
* @return {Promise<LH.Config.AuditDefn['implementation']>}
*/
function requireAudit(auditPath, coreAuditList, configDir) {
// See if the audit is a Lighthouse core audit.
const auditPathJs = `${auditPath}.js`;
const coreAudit = coreAuditList.find(a => a === auditPathJs);
let requirePath = `../audits/${auditPath}`;
if (!coreAudit) {
if (isBundledEnvironment()) {
// This is for plugin bundling.
requirePath = auditPath;
} else {
// Otherwise, attempt to find it elsewhere. This throws if not found.
const absolutePath = resolveModulePath(auditPath, configDir, 'audit');
if (isBundledEnvironment()) {
// Use a relative path so bundler can easily expose it.
requirePath = path.relative(getModuleDirectory(import.meta), absolutePath);
} else {
requirePath = absolutePath;
}
}
}
return requireWrapper(requirePath);
}
/**
* Creates a settings object from potential flags object by dropping all the properties
* that don't exist on Config.Settings.
* @param {Partial<LH.Flags>=} flags
* @return {LH.Util.RecursivePartial<LH.Config.Settings>}
*/
function cleanFlagsForSettings(flags = {}) {
/** @type {LH.Util.RecursivePartial<LH.Config.Settings>} */
const settings = {};
for (const key of Object.keys(flags)) {
if (key in constants.defaultSettings) {
// @ts-expect-error tsc can't yet express that key is only a single type in each iteration, not a union of types.
settings[key] = flags[key];
}
}
return settings;
}
/**
* @param {LH.SharedFlagsSettings} settingsJson
* @param {LH.Flags|undefined} overrides
* @return {LH.Config.Settings}
*/
function resolveSettings(settingsJson = {}, overrides = undefined) {
// If a locale is requested in flags or settings, use it. A typical CLI run will not have one,
// however `lookupLocale` will always determine which of our supported locales to use (falling
// back if necessary).
// TODO: could do more work to sniff out the user's locale
const locale = i18n.lookupLocale(overrides?.locale || settingsJson.locale);
// Fill in missing settings with defaults
const {defaultSettings} = constants;
const settingWithDefaults = mergeConfigFragment(deepClone(defaultSettings), settingsJson, true);
// Override any applicable settings with CLI flags
const settingsWithFlags = mergeConfigFragment(
settingWithDefaults,
cleanFlagsForSettings(overrides),
true
);
// Locale is special and comes only from flags/settings/lookupLocale.
settingsWithFlags.locale = locale;
// Default constants uses the mobile UA. Explicitly stating to true asks LH to use the associated UA.
// It's a little awkward, but the alternatives are not allowing `true` or a dedicated `disableUAEmulation` setting.
if (settingsWithFlags.emulatedUserAgent === true) {
settingsWithFlags.emulatedUserAgent = constants.userAgents[settingsWithFlags.formFactor];
}
validation.assertValidSettings(settingsWithFlags);
return settingsWithFlags;
}
/**
* @param {LH.Config} config
* @param {string | undefined} configDir
* @param {{plugins?: string[]} | undefined} flags
* @return {Promise<LH.Config>}
*/
async function mergePlugins(config, configDir, flags) {
const configPlugins = config.plugins || [];
const flagPlugins = flags?.plugins || [];
const pluginNames = new Set([...configPlugins, ...flagPlugins]);
for (const pluginName of pluginNames) {
validation.assertValidPluginName(config, pluginName);
// In bundled contexts, `resolveModulePath` will fail, so use the raw pluginName directly.
const pluginPath = isBundledEnvironment() ?
pluginName :
resolveModulePath(pluginName, configDir, 'plugin');
const rawPluginJson = await requireWrapper(pluginPath);
const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName);
config = mergeConfigFragment(config, pluginJson);
}
return config;
}
/**
* Turns a GathererJson into a GathererDefn which involves a few main steps:
* - Expanding the JSON shorthand the full definition format.
* - `require`ing in the implementation.
* - Creating a gatherer instance from the implementation.
* @param {LH.Config.GathererJson} gathererJson
* @param {Array<string>} coreGathererList
* @param {string=} configDir
* @return {Promise<LH.Config.AnyGathererDefn>}
*/
async function resolveGathererToDefn(gathererJson, coreGathererList, configDir) {
const gathererDefn = expandGathererShorthand(gathererJson);
if (gathererDefn.instance) {
return {
instance: gathererDefn.instance,
implementation: gathererDefn.implementation,
path: gathererDefn.path,
};
} else if (gathererDefn.implementation) {
const GathererClass = gathererDefn.implementation;
return {
instance: new GathererClass(),
implementation: gathererDefn.implementation,
path: gathererDefn.path,
};
} else if (gathererDefn.path) {
const path = gathererDefn.path;
return requireGatherer(path, coreGathererList, configDir);
} else {
throw new Error('Invalid expanded Gatherer: ' + JSON.stringify(gathererDefn));
}
}
/**
* Take an array of audits and audit paths and require any paths (possibly
* relative to the optional `configDir`) using `resolveModule`,
* leaving only an array of AuditDefns.
* @param {LH.Config['audits']} audits
* @param {string=} configDir
* @return {Promise<Array<LH.Config.AuditDefn>|null>}
*/
async function resolveAuditsToDefns(audits, configDir) {
if (!audits) {
return null;
}
const coreList = Runner.getAuditList();
const auditDefnsPromises = audits.map(async (auditJson) => {
const auditDefn = expandAuditShorthand(auditJson);
let implementation;
if ('implementation' in auditDefn) {
implementation = auditDefn.implementation;
} else {
implementation = await requireAudit(auditDefn.path, coreList, configDir);
}
return {
implementation,
path: auditDefn.path,
options: auditDefn.options || {},
};
});
const auditDefns = await Promise.all(auditDefnsPromises);
const mergedAuditDefns = mergeOptionsOfItems(auditDefns);
mergedAuditDefns.forEach(audit => validation.assertValidAudit(audit));
return mergedAuditDefns;
}
/**
* Resolves the location of the specified module and returns an absolute
* string path to the file. Used for loading custom audits and gatherers.
* Throws an error if no module is found.
* @param {string} moduleIdentifier
* @param {string=} configDir The absolute path to the directory of the config file, if there is one.
* @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
* @return {string}
* @throws {Error}
*/
function resolveModulePath(moduleIdentifier, configDir, category) {
// module in a node_modules/ that is...
// | | Lighthouse globally installed | Lighthouse locally installed |
// |--------------------------------|-------------------------------|------------------------------|
// | global | 1. | 1. |
// | in current working directory | 2. | 1. |
// | relative to config.js file | 5. | - |
// module given by a path that is...
// | | Lighthouse globally/locally installed |
// |-------------------------------------------|---------------------------------------|
// | absolute | 1. |
// | relative to the current working directory | 3. |
// | relative to the config.js file | 4. |
// 1.
// First try straight `require()`. Unlikely to be specified relative to this
// file, but adds support for Lighthouse modules from npm since
// `require()` walks up parent directories looking inside any node_modules/
// present. Also handles absolute paths.
try {
return require.resolve(moduleIdentifier);
} catch (e) {}
// 2.
// Lighthouse globally installed, node_modules/ in current working directory.
// ex: lighthouse https://test.com
//
// working directory/
// |-- node_modules/
// |-- package.json
try {
return require.resolve(moduleIdentifier, {paths: [process.cwd()]});
} catch (e) {}
// 3.
// See if the module resolves relative to the current working directory.
// Most useful to handle the case of invoking Lighthouse as a module, since
// then the config is an object and so has no path.
const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
try {
return require.resolve(cwdPath);
} catch (e) {}
const errorString = 'Unable to locate ' + (category ? `${category}: ` : '') +
`\`${moduleIdentifier}\`.
Tried to resolve the module from these locations:
${getModuleDirectory(import.meta)}
${cwdPath}`;
if (!configDir) {
throw new Error(errorString);
}
// 4.
// Try looking up relative to the config file path. Just like the
// relative path passed to `require()` is found relative to the file it's
// in, this allows module paths to be specified relative to the config file.
const relativePath = path.resolve(configDir, moduleIdentifier);
try {
return require.resolve(relativePath);
} catch (requireError) {}
// 5.
// Lighthouse globally installed, node_modules/ in config directory.
// ex: lighthouse https://test.com --config-path=./config/config.js
//
// working directory/
// |-- config/
// |-- node_modules/
// |-- config.js
// |-- package.json
try {
return require.resolve(moduleIdentifier, {paths: [configDir]});
} catch (requireError) {}
throw new Error(errorString + `
${relativePath}`);
}
/**
* Many objects in the config can be an object whose properties are not serializable.
* We use a shallow clone for these objects instead.
* Any value that isn't an object will not be cloned.
*
* @template T
* @param {T} item
* @return {T}
*/
function shallowClone(item) {
if (typeof item === 'object') {
// Return copy of instance and prototype chain (in case item is instantiated class).
return Object.assign(
Object.create(
Object.getPrototypeOf(item)
),
item
);
}
return item;
}
/**
* // TODO(bckenny): could adopt "jsonified" type to ensure T will survive JSON
* round trip: https://github.com/Microsoft/TypeScript/issues/21838
* @template T
* @param {T} json
* @return {T}
*/
function deepClone(json) {
return JSON.parse(JSON.stringify(json));
}
/**
* Deep clone a config, copying over any "live" gatherer or audit that
* wouldn't make the JSON round trip.
* @param {LH.Config} json
* @return {LH.Config}
*/
function deepCloneConfigJson(json) {
const cloned = deepClone(json);
// Copy arrays that could contain non-serializable properties to allow for programmatic
// injection of audit and gatherer implementations.
if (Array.isArray(json.audits)) {
cloned.audits = json.audits.map(audit => shallowClone(audit));
}
if (Array.isArray(json.artifacts)) {
cloned.artifacts = json.artifacts.map(artifact => ({
...artifact,
gatherer: shallowClone(artifact.gatherer),
}));
}
return cloned;
}
export {
deepClone,
deepCloneConfigJson,
mergeConfigFragment,
mergeConfigFragmentArrayByKey,
mergeOptionsOfItems,
mergePlugins,
resolveAuditsToDefns,
resolveGathererToDefn,
resolveModulePath,
resolveSettings,
};