UNPKG

26.2 kBJavaScriptView Raw
1/**
2 * Testing framework with support for generating tests.
3 *
4 * ```js
5 * // test.js template for creating a test executable
6 * import { runTests } from 'lib0/testing'
7 * import * as log from 'lib0/logging'
8 * import * as mod1 from './mod1.test.js'
9 * import * as mod2 from './mod2.test.js'
10
11 * import { isBrowser, isNode } from 'lib0/environment.js'
12 *
13 * if (isBrowser) {
14 * // optional: if this is ran in the browser, attach a virtual console to the dom
15 * log.createVConsole(document.body)
16 * }
17 *
18 * runTests({
19 * mod1,
20 * mod2,
21 * }).then(success => {
22 * if (isNode) {
23 * process.exit(success ? 0 : 1)
24 * }
25 * })
26 * ```
27 *
28 * ```js
29 * // mod1.test.js
30 * /**
31 * * runTests automatically tests all exported functions that start with "test".
32 * * The name of the function should be in camelCase and is used for the logging output.
33 * *
34 * * @param {t.TestCase} tc
35 * *\/
36 * export const testMyFirstTest = tc => {
37 * t.compare({ a: 4 }, { a: 4 }, 'objects are equal')
38 * }
39 * ```
40 *
41 * Now you can simply run `node test.js` to run your test or run test.js in the browser.
42 *
43 * @module testing
44 */
45
46import * as log from './logging.js'
47import { simpleDiffString } from './diff.js'
48import * as object from './object.js'
49import * as string from './string.js'
50import * as math from './math.js'
51import * as random from './random.js'
52import * as prng from './prng.js'
53import * as statistics from './statistics.js'
54import * as array from './array.js'
55import * as env from './environment.js'
56import * as json from './json.js'
57import * as time from './time.js'
58import * as promise from './promise.js'
59
60import { performance } from './isomorphic.js'
61
62export { production } from './environment.js'
63
64export const extensive = env.hasConf('extensive')
65
66/* istanbul ignore next */
67export const envSeed = env.hasParam('--seed') ? Number.parseInt(env.getParam('--seed', '0')) : null
68
69export class TestCase {
70 /**
71 * @param {string} moduleName
72 * @param {string} testName
73 */
74 constructor (moduleName, testName) {
75 /**
76 * @type {string}
77 */
78 this.moduleName = moduleName
79 /**
80 * @type {string}
81 */
82 this.testName = testName
83 this._seed = null
84 this._prng = null
85 }
86
87 resetSeed () {
88 this._seed = null
89 this._prng = null
90 }
91
92 /**
93 * @type {number}
94 */
95 /* istanbul ignore next */
96 get seed () {
97 /* istanbul ignore else */
98 if (this._seed === null) {
99 /* istanbul ignore next */
100 this._seed = envSeed === null ? random.uint32() : envSeed
101 }
102 return this._seed
103 }
104
105 /**
106 * A PRNG for this test case. Use only this PRNG for randomness to make the test case reproducible.
107 *
108 * @type {prng.PRNG}
109 */
110 get prng () {
111 /* istanbul ignore else */
112 if (this._prng === null) {
113 this._prng = prng.create(this.seed)
114 }
115 return this._prng
116 }
117}
118
119export const repetitionTime = Number(env.getParam('--repetition-time', '50'))
120/* istanbul ignore next */
121const testFilter = env.hasParam('--filter') ? env.getParam('--filter', '') : null
122
123/* istanbul ignore next */
124const testFilterRegExp = testFilter !== null ? new RegExp(testFilter) : new RegExp('.*')
125
126const repeatTestRegex = /^(repeat|repeating)\s/
127
128/**
129 * @param {string} moduleName
130 * @param {string} name
131 * @param {function(TestCase):void|Promise<any>} f
132 * @param {number} i
133 * @param {number} numberOfTests
134 */
135export const run = async (moduleName, name, f, i, numberOfTests) => {
136 const uncamelized = string.fromCamelCase(name.slice(4), ' ')
137 const filtered = !testFilterRegExp.test(`[${i + 1}/${numberOfTests}] ${moduleName}: ${uncamelized}`)
138 /* istanbul ignore if */
139 if (filtered) {
140 return true
141 }
142 const tc = new TestCase(moduleName, name)
143 const repeat = repeatTestRegex.test(uncamelized)
144 const groupArgs = [log.GREY, `[${i + 1}/${numberOfTests}] `, log.PURPLE, `${moduleName}: `, log.BLUE, uncamelized]
145 /* istanbul ignore next */
146 if (testFilter === null) {
147 log.groupCollapsed(...groupArgs)
148 } else {
149 log.group(...groupArgs)
150 }
151 const times = []
152 const start = performance.now()
153 let lastTime = start
154 /**
155 * @type {any}
156 */
157 let err = null
158 performance.mark(`${name}-start`)
159 do {
160 try {
161 const p = f(tc)
162 if (promise.isPromise(p)) {
163 await p
164 }
165 } catch (_err) {
166 err = _err
167 }
168 const currTime = performance.now()
169 times.push(currTime - lastTime)
170 lastTime = currTime
171 if (repeat && err === null && (lastTime - start) < repetitionTime) {
172 tc.resetSeed()
173 } else {
174 break
175 }
176 } while (err === null && (lastTime - start) < repetitionTime)
177 performance.mark(`${name}-end`)
178 /* istanbul ignore if */
179 if (err !== null && err.constructor !== SkipError) {
180 log.printError(err)
181 }
182 performance.measure(name, `${name}-start`, `${name}-end`)
183 log.groupEnd()
184 const duration = lastTime - start
185 let success = true
186 times.sort((a, b) => a - b)
187 /* istanbul ignore next */
188 const againMessage = env.isBrowser
189 ? ` - ${window.location.host + window.location.pathname}?filter=\\[${i + 1}/${tc._seed === null ? '' : `&seed=${tc._seed}`}`
190 : `\nrepeat: npm run test -- --filter "\\[${i + 1}/" ${tc._seed === null ? '' : `--seed ${tc._seed}`}`
191 const timeInfo = (repeat && err === null)
192 ? ` - ${times.length} repetitions in ${time.humanizeDuration(duration)} (best: ${time.humanizeDuration(times[0])}, worst: ${time.humanizeDuration(array.last(times))}, median: ${time.humanizeDuration(statistics.median(times))}, average: ${time.humanizeDuration(statistics.average(times))})`
193 : ` in ${time.humanizeDuration(duration)}`
194 if (err !== null) {
195 /* istanbul ignore else */
196 if (err.constructor === SkipError) {
197 log.print(log.GREY, log.BOLD, 'Skipped: ', log.UNBOLD, uncamelized)
198 } else {
199 success = false
200 log.print(log.RED, log.BOLD, 'Failure: ', log.UNBOLD, log.UNCOLOR, uncamelized, log.GREY, timeInfo, againMessage)
201 }
202 } else {
203 log.print(log.GREEN, log.BOLD, 'Success: ', log.UNBOLD, log.UNCOLOR, uncamelized, log.GREY, timeInfo, againMessage)
204 }
205 return success
206}
207
208/**
209 * Describe what you are currently testing. The message will be logged.
210 *
211 * ```js
212 * export const testMyFirstTest = tc => {
213 * t.describe('crunching numbers', 'already crunched 4 numbers!') // the optional second argument can describe the state.
214 * }
215 * ```
216 *
217 * @param {string} description
218 * @param {string} info
219 */
220export const describe = (description, info = '') => log.print(log.BLUE, description, ' ', log.GREY, info)
221
222/**
223 * Describe the state of the current computation.
224 * ```js
225 * export const testMyFirstTest = tc => {
226 * t.info(already crunched 4 numbers!') // the optional second argument can describe the state.
227 * }
228 * ```
229 *
230 * @param {string} info
231 */
232export const info = info => describe('', info)
233
234export const printDom = log.printDom
235
236export const printCanvas = log.printCanvas
237
238/**
239 * Group outputs in a collapsible category.
240 *
241 * ```js
242 * export const testMyFirstTest = tc => {
243 * t.group('subtest 1', () => {
244 * t.describe('this message is part of a collapsible section')
245 * })
246 * await t.groupAsync('subtest async 2', async () => {
247 * await someaction()
248 * t.describe('this message is part of a collapsible section')
249 * })
250 * }
251 * ```
252 *
253 * @param {string} description
254 * @param {function(void):void} f
255 */
256export const group = (description, f) => {
257 log.group(log.BLUE, description)
258 try {
259 f()
260 } finally {
261 log.groupEnd()
262 }
263}
264
265/**
266 * Group outputs in a collapsible category.
267 *
268 * ```js
269 * export const testMyFirstTest = async tc => {
270 * t.group('subtest 1', () => {
271 * t.describe('this message is part of a collapsible section')
272 * })
273 * await t.groupAsync('subtest async 2', async () => {
274 * await someaction()
275 * t.describe('this message is part of a collapsible section')
276 * })
277 * }
278 * ```
279 *
280 * @param {string} description
281 * @param {function(void):Promise<any>} f
282 */
283export const groupAsync = async (description, f) => {
284 log.group(log.BLUE, description)
285 try {
286 await f()
287 } finally {
288 log.groupEnd()
289 }
290}
291
292/**
293 * Measure the time that it takes to calculate something.
294 *
295 * ```js
296 * export const testMyFirstTest = async tc => {
297 * t.measureTime('measurement', () => {
298 * heavyCalculation()
299 * })
300 * await t.groupAsync('async measurement', async () => {
301 * await heavyAsyncCalculation()
302 * })
303 * }
304 * ```
305 *
306 * @param {string} message
307 * @param {function():void} f
308 * @return {number} Returns a promise that resolves the measured duration to apply f
309 */
310export const measureTime = (message, f) => {
311 let duration
312 const start = performance.now()
313 try {
314 f()
315 } finally {
316 duration = performance.now() - start
317 log.print(log.PURPLE, message, log.GREY, ` ${time.humanizeDuration(duration)}`)
318 }
319 return duration
320}
321
322/**
323 * Measure the time that it takes to calculate something.
324 *
325 * ```js
326 * export const testMyFirstTest = async tc => {
327 * t.measureTimeAsync('measurement', async () => {
328 * await heavyCalculation()
329 * })
330 * await t.groupAsync('async measurement', async () => {
331 * await heavyAsyncCalculation()
332 * })
333 * }
334 * ```
335 *
336 * @param {string} message
337 * @param {function():Promise<any>} f
338 * @return {Promise<number>} Returns a promise that resolves the measured duration to apply f
339 */
340export const measureTimeAsync = async (message, f) => {
341 let duration
342 const start = performance.now()
343 try {
344 await f()
345 } finally {
346 duration = performance.now() - start
347 log.print(log.PURPLE, message, log.GREY, ` ${time.humanizeDuration(duration)}`)
348 }
349 return duration
350}
351
352/**
353 * @template T
354 * @param {Array<T>} as
355 * @param {Array<T>} bs
356 * @param {string} [m]
357 * @return {boolean}
358 */
359export const compareArrays = (as, bs, m = 'Arrays match') => {
360 if (as.length !== bs.length) {
361 fail(m)
362 }
363 for (let i = 0; i < as.length; i++) {
364 if (as[i] !== bs[i]) {
365 fail(m)
366 }
367 }
368 return true
369}
370
371/**
372 * @param {string} a
373 * @param {string} b
374 * @param {string} [m]
375 * @throws {TestError} Throws if tests fails
376 */
377export const compareStrings = (a, b, m = 'Strings match') => {
378 if (a !== b) {
379 const diff = simpleDiffString(a, b)
380 log.print(log.GREY, a.slice(0, diff.index), log.RED, a.slice(diff.index, diff.remove), log.GREEN, diff.insert, log.GREY, a.slice(diff.index + diff.remove))
381 fail(m)
382 }
383}
384
385/**
386 * @template K,V
387 * @param {Object<K,V>} a
388 * @param {Object<K,V>} b
389 * @param {string} [m]
390 * @throws {TestError} Throws if test fails
391 */
392export const compareObjects = (a, b, m = 'Objects match') => { object.equalFlat(a, b) || fail(m) }
393
394/**
395 * @param {any} constructor
396 * @param {any} a
397 * @param {any} b
398 * @param {string} path
399 * @throws {TestError}
400 */
401const compareValues = (constructor, a, b, path) => {
402 if (a !== b) {
403 fail(`Values ${json.stringify(a)} and ${json.stringify(b)} don't match (${path})`)
404 }
405 return true
406}
407
408/**
409 * @param {string?} message
410 * @param {string} reason
411 * @param {string} path
412 * @throws {TestError}
413 */
414const _failMessage = (message, reason, path) => fail(
415 message === null
416 ? `${reason} ${path}`
417 : `${message} (${reason}) ${path}`
418)
419
420/**
421 * @param {any} a
422 * @param {any} b
423 * @param {string} path
424 * @param {string?} message
425 * @param {function(any,any,any,string,any):boolean} customCompare
426 */
427const _compare = (a, b, path, message, customCompare) => {
428 // we don't use assert here because we want to test all branches (istanbul errors if one branch is not tested)
429 if (a == null || b == null) {
430 return compareValues(null, a, b, path)
431 }
432 if (a.constructor !== b.constructor) {
433 _failMessage(message, 'Constructors don\'t match', path)
434 }
435 let success = true
436 switch (a.constructor) {
437 case ArrayBuffer:
438 a = new Uint8Array(a)
439 b = new Uint8Array(b)
440 // eslint-disable-next-line no-fallthrough
441 case Uint8Array: {
442 if (a.byteLength !== b.byteLength) {
443 _failMessage(message, 'ArrayBuffer lengths match', path)
444 }
445 for (let i = 0; success && i < a.length; i++) {
446 success = success && a[i] === b[i]
447 }
448 break
449 }
450 case Set: {
451 if (a.size !== b.size) {
452 _failMessage(message, 'Sets have different number of attributes', path)
453 }
454 // @ts-ignore
455 a.forEach(value => {
456 if (!b.has(value)) {
457 _failMessage(message, `b.${path} does have ${value}`, path)
458 }
459 })
460 break
461 }
462 case Map: {
463 if (a.size !== b.size) {
464 _failMessage(message, 'Maps have different number of attributes', path)
465 }
466 // @ts-ignore
467 a.forEach((value, key) => {
468 if (!b.has(key)) {
469 _failMessage(message, `Property ${path}["${key}"] does not exist on second argument`, path)
470 }
471 _compare(value, b.get(key), `${path}["${key}"]`, message, customCompare)
472 })
473 break
474 }
475 case Object:
476 if (object.length(a) !== object.length(b)) {
477 _failMessage(message, 'Objects have a different number of attributes', path)
478 }
479 object.forEach(a, (value, key) => {
480 if (!object.hasProperty(b, key)) {
481 _failMessage(message, `Property ${path} does not exist on second argument`, path)
482 }
483 _compare(value, b[key], `${path}["${key}"]`, message, customCompare)
484 })
485 break
486 case Array:
487 if (a.length !== b.length) {
488 _failMessage(message, 'Arrays have a different number of attributes', path)
489 }
490 // @ts-ignore
491 a.forEach((value, i) => _compare(value, b[i], `${path}[${i}]`, message, customCompare))
492 break
493 /* istanbul ignore next */
494 default:
495 if (!customCompare(a.constructor, a, b, path, compareValues)) {
496 _failMessage(message, `Values ${json.stringify(a)} and ${json.stringify(b)} don't match`, path)
497 }
498 }
499 assert(success, message)
500 return true
501}
502
503/**
504 * @template T
505 * @param {T} a
506 * @param {T} b
507 * @param {string?} [message]
508 * @param {function(any,T,T,string,any):boolean} [customCompare]
509 */
510export const compare = (a, b, message = null, customCompare = compareValues) => _compare(a, b, 'obj', message, customCompare)
511
512/* istanbul ignore next */
513/**
514 * @param {boolean} condition
515 * @param {string?} [message]
516 * @throws {TestError}
517 */
518export const assert = (condition, message = null) => condition || fail(`Assertion failed${message !== null ? `: ${message}` : ''}`)
519
520/**
521 * @param {function():void} f
522 * @throws {TestError}
523 */
524export const fails = f => {
525 let err = null
526 try {
527 f()
528 } catch (_err) {
529 err = _err
530 log.print(log.GREEN, '⇖ This Error was expected')
531 }
532 /* istanbul ignore if */
533 if (err === null) {
534 fail('Expected this to fail')
535 }
536}
537
538/**
539 * @param {Object<string, Object<string, function(TestCase):void|Promise<any>>>} tests
540 */
541export const runTests = async tests => {
542 const numberOfTests = object.map(tests, mod => object.map(mod, f => /* istanbul ignore next */ f ? 1 : 0).reduce(math.add, 0)).reduce(math.add, 0)
543 let successfulTests = 0
544 let testnumber = 0
545 const start = performance.now()
546 for (const modName in tests) {
547 const mod = tests[modName]
548 for (const fname in mod) {
549 const f = mod[fname]
550 /* istanbul ignore else */
551 if (f) {
552 const repeatEachTest = 1
553 let success = true
554 for (let i = 0; success && i < repeatEachTest; i++) {
555 success = await run(modName, fname, f, testnumber, numberOfTests)
556 }
557 testnumber++
558 /* istanbul ignore else */
559 if (success) {
560 successfulTests++
561 }
562 }
563 }
564 }
565 const end = performance.now()
566 log.print('')
567 const success = successfulTests === numberOfTests
568 /* istanbul ignore next */
569 if (success) {
570 /* istanbul ignore next */
571 log.print(log.GREEN, log.BOLD, 'All tests successful!', log.GREY, log.UNBOLD, ` in ${time.humanizeDuration(end - start)}`)
572 /* istanbul ignore next */
573 log.printImgBase64(nyanCatImage, 50)
574 } else {
575 const failedTests = numberOfTests - successfulTests
576 log.print(log.RED, log.BOLD, `> ${failedTests} test${failedTests > 1 ? 's' : ''} failed`)
577 }
578 return success
579}
580
581class TestError extends Error {}
582
583/**
584 * @param {string} reason
585 * @throws {TestError}
586 */
587export const fail = reason => {
588 log.print(log.RED, log.BOLD, 'X ', log.UNBOLD, reason)
589 throw new TestError('Test Failed')
590}
591
592class SkipError extends Error {}
593
594/**
595 * @param {boolean} cond If true, this tests will be skipped
596 * @throws {SkipError}
597 */
598export const skip = (cond = true) => {
599 if (cond) {
600 throw new SkipError('skipping..')
601 }
602}
603
604// eslint-disable-next-line
605const nyanCatImage = 'R0lGODlhjABMAPcAAMiSE0xMTEzMzUKJzjQ0NFsoKPc7//FM/9mH/z9x0HIiIoKCgmBHN+frGSkZLdDQ0LCwsDk71g0KCUzDdrQQEOFz/8yYdelmBdTiHFxcXDU2erR/mLrTHCgoKK5szBQUFNgSCTk6ymfpCB9VZS2Bl+cGBt2N8kWm0uDcGXhZRUvGq94NCFPhDiwsLGVlZTgqIPMDA1g3aEzS5D6xAURERDtG9JmBjJsZGWs2AD1W6Hp6eswyDeJ4CFNTU1LcEoJRmTMzSd14CTg5ser2GmDzBd17/xkZGUzMvoSMDiEhIfKruCwNAJaWlvRzA8kNDXDrCfi0pe1U/+GS6SZrAB4eHpZwVhoabsx9oiYmJt/TGHFxcYyMjOid0+Zl/0rF6j09PeRr/0zU9DxO6j+z0lXtBtp8qJhMAEssLGhoaPL/GVn/AAsWJ/9/AE3Z/zs9/3cAAOlf/+aa2RIyADo85uhh/0i84WtrazQ0UyMlmDMzPwUFBe16BTMmHau0E03X+g8pMEAoS1MBAf++kkzO8pBaqSZoe9uB/zE0BUQ3Sv///4WFheuiyzo880gzNDIyNissBNqF/8RiAOF2qG5ubj0vL1z6Avl5ASsgGkgUSy8vL/8n/z4zJy8lOv96uEssV1csAN5ZCDQ0Wz1a3tbEGHLeDdYKCg4PATE7PiMVFSoqU83eHEi43gUPAOZ8reGogeKU5dBBC8faHEez2lHYF4bQFMukFtl4CzY3kkzBVJfMGZkAAMfSFf27mP0t//g4/9R6Dfsy/1DRIUnSAPRD/0fMAFQ0Q+l7rnbaD0vEntCDD6rSGtO8GNpUCU/MK07LPNEfC7RaABUWWkgtOst+71v9AfD7GfDw8P19ATtA/NJpAONgB9yL+fm6jzIxMdnNGJxht1/2A9x//9jHGOSX3+5tBP27l35+fk5OTvZ9AhYgTjo0PUhGSDs9+LZjCFf2Aw0IDwcVAA8PD5lwg9+Q7YaChC0kJP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/wtYTVAgRGF0YVhNUDw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpGNEM2MUEyMzE0QTRFMTExOUQzRkE3QTBCRDNBMjdBQyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpERjQ0NEY0QkI2MTcxMUUxOUJEQkUzNUNGQTkwRTU2MiIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpERjQ0NEY0QUI2MTcxMUUxOUJEQkUzNUNGQTkwRTU2MiIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1IFdpbmRvd3MiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1OEE3RTIwRjcyQTlFMTExOTQ1QkY2QTU5QzVCQjJBOSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpGNEM2MUEyMzE0QTRFMTExOUQzRkE3QTBCRDNBMjdBQyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PgH//v38+/r5+Pf29fTz8vHw7+7t7Ovq6ejn5uXk4+Lh4N/e3dzb2tnY19bV1NPS0dDPzs3My8rJyMfGxcTDwsHAv769vLu6ubi3trW0s7KxsK+urayrqqmop6alpKOioaCfnp2cm5qZmJeWlZSTkpGQj46NjIuKiYiHhoWEg4KBgH9+fXx7enl4d3Z1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'