From d36806a1d0bb2f19fe274293b326a54eddd32832 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 4 Oct 2021 11:46:31 -0700 Subject: [PATCH] Replace Lodash with built-in syntax, libraries, and some code Co-authored-by: Mark Wubben --- docs/01-writing-tests.md | 2 +- lib/api.js | 58 +++++++++++++++++++++++++++----------- lib/concordance-options.js | 7 +---- lib/context-ref.js | 5 ++-- lib/line-numbers.js | 13 ++++----- lib/run-status.js | 7 +++-- lib/watcher.js | 18 ++++++------ package-lock.json | 1 - package.json | 1 - test/helpers/exec.js | 7 +++-- 10 files changed, 70 insertions(+), 49 deletions(-) diff --git a/docs/01-writing-tests.md b/docs/01-writing-tests.md index fdbad8145..7d1a90698 100644 --- a/docs/01-writing-tests.md +++ b/docs/01-writing-tests.md @@ -255,7 +255,7 @@ test('context data is foo', t => { }); ``` -Context created in `.before()` hooks is [cloned](https://www.npmjs.com/package/lodash.clone) before it is passed to `.beforeEach()` hooks and / or tests. The `.after()` and `.after.always()` hooks receive the original context value. +If `.before()` hooks treat `t.context` as an object, a shallow copy is made and passed to `.beforeEach()` hooks and / or tests. Other types of values are passed as-is. The `.after()` and `.after.always()` hooks receive the original context value. For `.beforeEach()`, `.afterEach()` and `.afterEach.always()` hooks the context is *not* shared between different tests, allowing you to set up data such that it will not leak to other tests. diff --git a/lib/api.js b/lib/api.js index daa198523..b4afc9f22 100644 --- a/lib/api.js +++ b/lib/api.js @@ -7,7 +7,6 @@ import arrify from 'arrify'; import chunkd from 'chunkd'; import commonPathPrefix from 'common-path-prefix'; import Emittery from 'emittery'; -import debounce from 'lodash/debounce.js'; import ms from 'ms'; import pMap from 'p-map'; import resolveCwd from 'resolve-cwd'; @@ -43,6 +42,39 @@ function getFilePathPrefix(files) { return commonPathPrefix(files); } +class TimeoutTrigger { + constructor(fn, waitMs = 0) { + this.fn = fn.bind(null); + this.ignoreUntil = 0; + this.waitMs = waitMs; + this.timer = undefined; + } + + debounce() { + if (this.timer === undefined) { + this.timer = setTimeout(() => this.trigger(), this.waitMs); + } else { + this.timer.refresh(); + } + } + + discard() { + // N.B. this.timer is not cleared so if debounce() is called after it will + // not run again. + clearTimeout(this.timer); + } + + ignoreFor(periodMs) { + this.ignoreUntil = Math.max(this.ignoreUntil, Date.now() + periodMs); + } + + trigger() { + if (Date.now() >= this.ignoreUntil) { + this.fn(); + } + } +} + export default class Api extends Emittery { constructor(options) { super(); @@ -73,17 +105,11 @@ export default class Api extends Emittery { let bailed = false; const pendingWorkers = new Set(); const timedOutWorkerFiles = new Set(); - let restartTimer; - let ignoreTimeoutsUntil = 0; + let timeoutTrigger; if (apiOptions.timeout && !apiOptions.debug) { const timeout = ms(apiOptions.timeout); - restartTimer = debounce(() => { - if (Date.now() < ignoreTimeoutsUntil) { - restartTimer(); - return; - } - + timeoutTrigger = new TimeoutTrigger(() => { // If failFast is active, prevent new test files from running after // the current ones are exited. if (failFast) { @@ -98,7 +124,7 @@ export default class Api extends Emittery { } }, timeout); } else { - restartTimer = Object.assign(() => {}, {cancel() {}}); + timeoutTrigger = new TimeoutTrigger(() => {}); } this._interruptHandler = () => { @@ -111,7 +137,7 @@ export default class Api extends Emittery { bailed = true; // Make sure we don't run the timeout handler - restartTimer.cancel(); + timeoutTrigger.cancel(); runStatus.emitStateChange({type: 'interrupt'}); @@ -180,9 +206,9 @@ export default class Api extends Emittery { runStatus.on('stateChange', record => { if (record.testFile && !timedOutWorkerFiles.has(record.testFile)) { - // Restart the timer whenever there is activity from workers that + // Debounce the timer whenever there is activity from workers that // haven't already timed out. - restartTimer(); + timeoutTrigger.debounce(); } if (failFast && (record.type === 'hook-failed' || record.type === 'test-failed' || record.type === 'worker-failed')) { @@ -242,7 +268,7 @@ export default class Api extends Emittery { const worker = fork(file, options, apiOptions.nodeArguments); worker.onStateChange(data => { if (data.type === 'test-timeout-configured' && !apiOptions.debug) { - ignoreTimeoutsUntil = Math.max(ignoreTimeoutsUntil, Date.now() + data.period); + timeoutTrigger.ignoreFor(data.period); } }); runStatus.observeWorker(worker, file, {selectingLines: lineNumbers.length > 0}); @@ -252,7 +278,7 @@ export default class Api extends Emittery { worker.promise.then(() => { pendingWorkers.delete(worker); }); - restartTimer(); + timeoutTrigger.debounce(); await worker.promise; }, {concurrency, stopOnError: false}); @@ -270,7 +296,7 @@ export default class Api extends Emittery { } } - restartTimer.cancel(); + timeoutTrigger.discard(); return runStatus; } diff --git a/lib/concordance-options.js b/lib/concordance-options.js index 1d0ff7eeb..5da1b691c 100644 --- a/lib/concordance-options.js +++ b/lib/concordance-options.js @@ -1,7 +1,6 @@ import {inspect} from 'node:util'; import ansiStyles from 'ansi-styles'; -import cloneDeepWith from 'lodash/cloneDeepWith.js'; import stripAnsi from 'strip-ansi'; import {chalk} from './chalk.js'; @@ -85,11 +84,7 @@ const colorTheme = { undefined: ansiStyles.yellow, }; -const plainTheme = cloneDeepWith(colorTheme, value => { - if (typeof value === 'string') { - return stripAnsi(value); - } -}); +const plainTheme = JSON.parse(JSON.stringify(colorTheme), value => typeof value === 'string' ? stripAnsi(value) : value); const theme = chalk.level > 0 ? colorTheme : plainTheme; diff --git a/lib/context-ref.js b/lib/context-ref.js index 0504066a3..e440ca738 100644 --- a/lib/context-ref.js +++ b/lib/context-ref.js @@ -1,5 +1,3 @@ -import clone from 'lodash/clone.js'; - export default class ContextRef { constructor() { this.value = {}; @@ -27,7 +25,8 @@ class LateBinding extends ContextRef { get() { if (!this.bound) { - this.set(clone(this.ref.get())); + const value = this.ref.get(); + this.set(value !== null && typeof value === 'object' ? {...value} : value); } return super.get(); diff --git a/lib/line-numbers.js b/lib/line-numbers.js index e3779298f..24a6e1f36 100644 --- a/lib/line-numbers.js +++ b/lib/line-numbers.js @@ -1,4 +1,3 @@ -import flatten from 'lodash/flatten.js'; import picomatch from 'picomatch'; const NUMBER_REGEX = /^\d+$/; @@ -17,8 +16,8 @@ const parseNumber = string => Number.parseInt(string, 10); const removeAllWhitespace = string => string.replace(/\s/g, ''); const range = (start, end) => Array.from({length: end - start + 1}).fill(start).map((element, index) => element + index); -const parseLineNumbers = suffix => sortNumbersAscending(distinctArray(flatten( - suffix.split(',').map(part => { +const parseLineNumbers = suffix => sortNumbersAscending(distinctArray( + suffix.split(',').flatMap(part => { if (NUMBER_REGEX.test(part)) { return parseNumber(part); } @@ -33,7 +32,7 @@ const parseLineNumbers = suffix => sortNumbersAscending(distinctArray(flatten( return range(start, end); }), -))); +)); export function splitPatternAndLineNumbers(pattern) { const parts = pattern.split(DELIMITER); @@ -50,9 +49,9 @@ export function splitPatternAndLineNumbers(pattern) { } export function getApplicableLineNumbers(normalizedFilePath, filter) { - return sortNumbersAscending(distinctArray(flatten( + return sortNumbersAscending(distinctArray( filter .filter(({pattern, lineNumbers}) => lineNumbers && picomatch.isMatch(normalizedFilePath, pattern)) - .map(({lineNumbers}) => lineNumbers), - ))); + .flatMap(({lineNumbers}) => lineNumbers), + )); } diff --git a/lib/run-status.js b/lib/run-status.js index 3a09176c4..473d1046e 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -1,5 +1,8 @@ +import v8 from 'node:v8'; + import Emittery from 'emittery'; -import cloneDeep from 'lodash/cloneDeep.js'; + +const copyStats = stats => v8.deserialize(v8.serialize(stats)); export default class RunStatus extends Emittery { constructor(files, parallelRuns) { @@ -146,7 +149,7 @@ export default class RunStatus extends Emittery { } if (changedStats) { - this.emit('stateChange', {type: 'stats', stats: cloneDeep(stats)}); + this.emit('stateChange', {type: 'stats', stats: copyStats(stats)}); } this.emit('stateChange', event); diff --git a/lib/watcher.js b/lib/watcher.js index 14507de9c..ed514f6b7 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -2,8 +2,6 @@ import nodePath from 'node:path'; import chokidar_ from 'chokidar'; import createDebug from 'debug'; -import diff from 'lodash/difference.js'; -import flatten from 'lodash/flatten.js'; import {chalk} from './chalk.js'; import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js'; @@ -114,7 +112,7 @@ export default class Watcher { if (runOnlyExclusive) { // The test files that previously contained exclusive tests are always // run, together with the remaining specific files. - const remainingFiles = diff(specificFiles, exclusiveFiles); + const remainingFiles = specificFiles.filter(file => !exclusiveFiles.includes(file)); specificFiles = [...this.filesWithExclusiveTests, ...remainingFiles]; } @@ -404,21 +402,23 @@ export default class Watcher { } const dirtyHelpersAndSources = []; - const dirtyTests = []; + const addedOrChangedTests = []; + const unlinkedTests = []; for (const filePath of dirtyPaths) { const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs); if (!isIgnoredByWatcher) { if (isTest) { - dirtyTests.push(filePath); + if (dirtyStates[filePath] === 'unlink') { + unlinkedTests.push(filePath); + } else { + addedOrChangedTests.push(filePath); + } } else { dirtyHelpersAndSources.push(filePath); } } } - const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink'); - const unlinkedTests = diff(dirtyTests, addedOrChangedTests); - this.cleanUnlinkedTests(unlinkedTests); // No need to rerun tests if the only change is that tests were deleted @@ -448,6 +448,6 @@ export default class Watcher { } // Run all affected tests - this.run([...new Set([...addedOrChangedTests, ...flatten(testsByHelpersOrSource)])]); + this.run([...new Set([addedOrChangedTests, testsByHelpersOrSource].flat(2))]); } } diff --git a/package-lock.json b/package-lock.json index 164daf0dc..6b21a88fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", - "lodash": "^4.17.21", "matcher": "^4.0.0", "mem": "^9.0.1", "ms": "^2.1.3", diff --git a/package.json b/package.json index 848edf529..e16d4d440 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", - "lodash": "^4.17.21", "matcher": "^4.0.0", "mem": "^9.0.1", "ms": "^2.1.3", diff --git a/test/helpers/exec.js b/test/helpers/exec.js index fe1822521..8532ea5d0 100644 --- a/test/helpers/exec.js +++ b/test/helpers/exec.js @@ -3,7 +3,6 @@ import {fileURLToPath} from 'node:url'; import test from '@ava/test'; import execa from 'execa'; -import defaultsDeep from 'lodash/defaultsDeep.js'; import replaceString from 'replace-string'; const cliPath = fileURLToPath(new URL('../../entrypoints/cli.mjs', import.meta.url)); @@ -45,15 +44,17 @@ const forwardErrorOutput = async from => { export const fixture = async (args, options = {}) => { const workingDir = options.cwd || cwd(); - const running = execa.node(cliPath, args, defaultsDeep({ + const running = execa.node(cliPath, args, { + ...options, env: { + ...options.env, AVA_EMIT_RUN_STATUS_OVER_IPC: 'I\'ll find a payphone baby / Take some time to talk to you', TEST_AVA_IMPORT_FROM, }, cwd: workingDir, serialization: 'advanced', nodeOptions: ['--require', ttySimulator], - }, options)); + }); // Besides buffering stderr, if this environment variable is set, also pipe // to stderr. This can be useful when debugging the tests.