-
Notifications
You must be signed in to change notification settings - Fork 9.5k
/
Copy pathtest-utils.js
359 lines (333 loc) · 10.3 KB
/
test-utils.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
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import url from 'url';
import {createRequire} from 'module';
import {gunzipSync} from 'zlib';
import * as td from 'testdouble';
import jestMock from 'jest-mock';
import {LH_ROOT} from '../../shared/root.js';
import * as mockCommands from './gather/mock-commands.js';
import {NetworkRecorder} from '../lib/network-recorder.js';
import {timers} from './test-env/fake-timers.js';
import {getModuleDirectory} from '../../shared/esm-utils.js';
const require = createRequire(import.meta.url);
/**
* Some tests use the result of a LHR processed by our proto serialization.
* Proto is an annoying dependency to setup, so we allows tests that use it
* to be skipped when run locally. This makes external contributions simpler.
*
* Along with the sample LHR, this function returns jest `it` and `describe`
* functions that will skip if the sample LHR could not be loaded.
*/
function getProtoRoundTrip() {
let sampleResultsRoundtripStr;
let describeIfProtoExists;
let itIfProtoExists;
try {
sampleResultsRoundtripStr =
fs.readFileSync(LH_ROOT + '/.tmp/sample_v2_round_trip.json', 'utf-8');
describeIfProtoExists = describe;
itIfProtoExists = it;
} catch (err) {
if (process.env.GITHUB_ACTIONS) {
throw new Error('sample_v2_round_trip must be generated for CI proto test');
}
// Otherwise no proto roundtrip for tests, so skip them.
// This is fine for running the tests locally.
itIfProtoExists = it.skip;
describeIfProtoExists = describe.skip;
}
return {
itIfProtoExists,
describeIfProtoExists,
sampleResultsRoundtripStr,
};
}
/**
* @param {string} name
* @return {{map: LH.Artifacts.RawSourceMap, content: string}}
*/
function loadSourceMapFixture(name) {
const dir = `${LH_ROOT}/core/test/fixtures/source-maps`;
const mapJson = fs.readFileSync(`${dir}/${name}.js.map`, 'utf-8');
const content = fs.readFileSync(`${dir}/${name}.js`, 'utf-8');
return {
map: JSON.parse(mapJson),
content,
};
}
/**
* @param {string} name
* @return {{map: LH.Artifacts.RawSourceMap, content: string, usage: LH.Crdp.Profiler.ScriptCoverage}}
*/
function loadSourceMapAndUsageFixture(name) {
const dir = `${LH_ROOT}/core/test/fixtures/source-maps`;
const usagePath = `${dir}/${name}.usage.json`;
const usageJson = fs.readFileSync(usagePath, 'utf-8');
// Usage is exported from DevTools, which simplifies the real format of the
// usage protocol.
/** @type {{url: string, ranges: Array<{start: number, end: number, count: number}>}} */
const exportedUsage = JSON.parse(usageJson);
const usage = {
scriptId: name,
url: exportedUsage.url,
functions: [
{
functionName: 'FakeFunctionName', // Not used.
isBlockCoverage: false, // Not used.
ranges: exportedUsage.ranges.map((range, i) => {
return {
startOffset: range.start,
endOffset: range.end,
count: i % 2 === 0 ? 0 : 1,
};
}),
},
],
};
return {
...loadSourceMapFixture(name),
usage,
};
}
/**
* @param {string} name
* @return {{devtoolsLog: LH.DevtoolsLog, trace: LH.Trace}}
*/
function loadTraceFixture(name) {
const dir = `${LH_ROOT}/core/test/fixtures/artifacts/${name}`;
return {
devtoolsLog: JSON.parse(fs.readFileSync(`${dir}/devtoolslog.json`, 'utf-8')),
trace: JSON.parse(fs.readFileSync(`${dir}/trace.json`, 'utf-8')),
};
}
/**
* @template {unknown[]} TParams
* @template TReturn
* @param {(...args: TParams) => TReturn} fn
*/
function makeParamsOptional(fn) {
return /** @type {(...args: LH.Util.RecursivePartial<TParams>) => TReturn} */ (fn);
}
/**
* Transparently augments the promise with inspectable functions to query its state.
*
* @template T
* @param {Promise<T>} promise
*/
function makePromiseInspectable(promise) {
let isResolved = false;
let isRejected = false;
/** @type {T=} */
let resolvedValue = undefined;
/** @type {any=} */
let rejectionError = undefined;
const inspectablePromise = promise.then(value => {
isResolved = true;
resolvedValue = value;
return value;
}).catch(err => {
isRejected = true;
rejectionError = err;
throw err;
});
return Object.assign(inspectablePromise, {
isDone() {
return isResolved || isRejected;
},
isResolved() {
return isResolved;
},
isRejected() {
return isRejected;
},
getDebugValues() {
return {resolvedValue, rejectionError};
},
});
}
function createDecomposedPromise() {
/** @type {Function} */
let resolve;
/** @type {Function} */
let reject;
const promise = new Promise((r1, r2) => {
resolve = r1;
reject = r2;
});
// @ts-expect-error: Ignore 'unused' error.
return {promise, resolve, reject};
}
/**
* In some functions we have lots of promise follow ups that get queued by protocol messages.
* This is a convenience method to easily advance all timers and flush all the queued microtasks.
*/
async function flushAllTimersAndMicrotasks(ms = 1000) {
for (let i = 0; i < ms; i++) {
timers.advanceTimersByTime(1);
await Promise.resolve();
}
}
/**
* Mocks gatherers for BaseArtifacts that tests for components using GatherRunner
* shouldn't concern themselves about.
*/
async function makeMocksForGatherRunner() {
await td.replaceEsm('../gather/driver/environment.js', {
getBenchmarkIndex: () => Promise.resolve(150),
getBrowserVersion: async () => ({userAgent: 'Chrome', milestone: 80}),
getEnvironmentWarnings: () => [],
});
await td.replaceEsm('../lib/emulation.js', {
emulate: jestMock.fn(),
throttle: jestMock.fn(),
clearThrottling: jestMock.fn(),
});
await td.replaceEsm('../gather/driver/prepare.js', {
prepareTargetForNavigationMode: jestMock.fn(),
prepareTargetForIndividualNavigation: fnAny().mockResolvedValue({warnings: []}),
enableAsyncStacks: jestMock.fn().mockReturnValue(jestMock.fn()),
});
await td.replaceEsm('../gather/driver/storage.js', {
clearDataForOrigin: jestMock.fn(),
cleanBrowserCaches: jestMock.fn(),
getImportantStorageWarning: jestMock.fn(),
});
await td.replaceEsm('../gather/driver/navigation.js', {
gotoURL: fnAny().mockResolvedValue({
mainDocumentUrl: 'http://example.com',
warnings: [],
}),
});
}
/**
* Same as jestMock.fn(), but uses `any` instead of `unknown`.
* This makes it simpler to override existing properties in test files that are
* typechecked.
*/
const fnAny = () => {
return /** @type {Mock<any>} */ (jestMock.fn());
};
/**
* @param {Partial<LH.Artifacts.Script>} script
* @return {LH.Artifacts.Script} script
*/
function createScript(script) {
if (!script.scriptId) throw new Error('Must include a scriptId');
// @ts-expect-error For testing purposes we assume the test set all valid properties.
return {
...script,
length: script.content?.length ?? script.length,
name: script.name ?? script.url ?? '<no name>',
scriptLanguage: 'JavaScript',
};
}
/**
* This has a slightly different, less strict implementation than `PageDependencyGraph`.
* It's a convenience function so we don't have to dig through the log and determine the URL artifact manually.
*
* @param {LH.DevtoolsLog} devtoolsLog
* @return {LH.Artifacts['URL']}
*/
function getURLArtifactFromDevtoolsLog(devtoolsLog) {
/** @type {string|undefined} */
let requestedUrl;
/** @type {string|undefined} */
let mainDocumentUrl;
for (const event of devtoolsLog) {
if (event.method === 'Page.frameNavigated' && !event.params.frame.parentId) {
const {url} = event.params.frame;
// Only set requestedUrl on the first main frame navigation.
if (!requestedUrl) requestedUrl = url;
mainDocumentUrl = url;
}
}
const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
let initialRequest = networkRecords.find(r => r.url === requestedUrl);
while (initialRequest?.redirectSource) {
initialRequest = initialRequest.redirectSource;
requestedUrl = initialRequest.url;
}
if (!requestedUrl || !mainDocumentUrl) throw new Error('No main frame navigations found');
return {
requestedUrl,
mainDocumentUrl,
finalDisplayedUrl: mainDocumentUrl,
};
}
/**
* Use to import a module in tests with Mock types.
* Asserts that the module is actually a mock.
* Resolves module path relative to importMeta.url.
*
* @param {string} modulePath
* @param {ImportMeta} importMeta
* @return {Promise<Record<string, Mock<any>>>}
*/
async function importMock(modulePath, importMeta) {
const mock = await import(new URL(modulePath, importMeta.url).href);
if (!Object.keys(mock).some(key => mock[key]?.mock)) {
throw new Error(`${modulePath} was not mocked!`);
}
return mock;
}
/**
* Same as importMock, but uses require instead to avoid
* an unnecessary `.default` or Promise return value.
*
* @param {string} modulePath
* @param {ImportMeta} importMeta
* @return {Record<string, Mock<any>>}
*/
function requireMock(modulePath, importMeta) {
const dir = path.dirname(url.fileURLToPath(importMeta.url));
modulePath = path.resolve(dir, modulePath);
const mock = require(modulePath);
if (!Object.keys(mock).some(key => mock[key]?.mock)) {
throw new Error(`${modulePath} was not mocked!`);
}
return mock;
}
/**
* Return parsed json object.
* Resolves path relative to importMeta.url (if provided) or LH_ROOT (if not provided).
* Supports `.json.gz`
*
* Note: Do not use this in core/ outside tests or scripts, as it
* will not be inlined when bundled. Instead, use `fs.readFileSync`.
*
* @param {string} filePath Can be an absolute or relative path.
* @param {ImportMeta=} importMeta
*/
function readJson(filePath, importMeta) {
const dir = importMeta ? getModuleDirectory(importMeta) : LH_ROOT;
filePath = path.resolve(dir, filePath);
if (filePath.endsWith('.gz')) {
return JSON.parse(gunzipSync(fs.readFileSync(filePath)).toString('utf-8'));
}
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
export {
timers,
getProtoRoundTrip,
loadSourceMapFixture,
loadSourceMapAndUsageFixture,
makeParamsOptional,
makePromiseInspectable,
createDecomposedPromise,
flushAllTimersAndMicrotasks,
makeMocksForGatherRunner,
loadTraceFixture,
fnAny,
mockCommands,
createScript,
getURLArtifactFromDevtoolsLog,
importMock,
requireMock,
readJson,
};