diff --git a/lib/assert.js b/lib/assert.js index 627040d2d..8f3a92e47 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -1,9 +1,10 @@ import {isNativeError} from 'node:util/types'; -import concordance from 'concordance'; import isPromise from 'is-promise'; +import {AssertionError, getAssertionStack} from './assertion-error.js'; import concordanceOptions from './concordance-options.js'; +import concordance from './concordance.js'; import {CIRCULAR_SELECTOR, isLikeSelector, selectComparable} from './like-selector.js'; import {SnapshotError, VersionMismatchError} from './snapshot-manager.js'; @@ -13,19 +14,19 @@ function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) { const {insertLine, deleteLine} = options.theme.string.diff; return { label: `Difference (${diffGutters.actual}${deleteLine.open}actual${deleteLine.close}, ${diffGutters.expected}${insertLine.open}expected${insertLine.close}):`, - formatted: concordance.diffDescriptors(actualDescriptor, expectedDescriptor, options), + formatted: concordance().diffDescriptors(actualDescriptor, expectedDescriptor, options), }; } function formatDescriptorWithLabel(label, descriptor) { return { label, - formatted: concordance.formatDescriptor(descriptor, concordanceOptions), + formatted: concordance().formatDescriptor(descriptor, concordanceOptions), }; } function formatWithLabel(label, value) { - return formatDescriptorWithLabel(label, concordance.describe(value, concordanceOptions)); + return formatDescriptorWithLabel(label, concordance().describe(value, concordanceOptions)); } const noop = () => {}; @@ -34,24 +35,6 @@ const notImplemented = () => { throw new Error('not implemented'); }; -export class AssertionError extends Error { - constructor(message = '', { - assertion, - assertionStack = getAssertionStack(AssertionError), - formattedDetails = [], - improperUsage = null, - cause, - } = {}) { - super(message, {cause}); - this.name = 'AssertionError'; - - this.assertion = assertion; - this.assertionStack = assertionStack; - this.improperUsage = improperUsage; - this.formattedDetails = formattedDetails; - } -} - export function checkAssertionMessage(message, assertion) { if (message === undefined || typeof message === 'string') { return true; @@ -63,15 +46,6 @@ export function checkAssertionMessage(message, assertion) { }); } -export function getAssertionStack(constructorOpt = getAssertionStack) { - const {stackTraceLimit: limitBefore} = Error; - Error.stackTraceLimit = Number.POSITIVE_INFINITY; - const temporary = {}; - Error.captureStackTrace(temporary, constructorOpt); - Error.stackTraceLimit = limitBefore; - return temporary.stack; -} - function validateExpectations(assertion, expectations, numberArgs) { // eslint-disable-line complexity if (numberArgs === 1 || expectations === null || expectations === undefined) { if (expectations === null) { @@ -297,9 +271,9 @@ export class Assertions { return pass(); } - const result = concordance.compare(actual, expected, concordanceOptions); - const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions); - const expectedDescriptor = result.expected ?? concordance.describe(expected, concordanceOptions); + const result = concordance().compare(actual, expected, concordanceOptions); + const actualDescriptor = result.actual ?? concordance().describe(actual, concordanceOptions); + const expectedDescriptor = result.expected ?? concordance().describe(expected, concordanceOptions); if (result.pass) { throw fail(new AssertionError(message, { @@ -330,13 +304,13 @@ export class Assertions { this.deepEqual = withSkip((actual, expected, message) => { assertMessage(message, 't.deepEqual()'); - const result = concordance.compare(actual, expected, concordanceOptions); + const result = concordance().compare(actual, expected, concordanceOptions); if (result.pass) { return pass(); } - const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions); - const expectedDescriptor = result.expected ?? concordance.describe(expected, concordanceOptions); + const actualDescriptor = result.actual ?? concordance().describe(actual, concordanceOptions); + const expectedDescriptor = result.expected ?? concordance().describe(expected, concordanceOptions); throw fail(new AssertionError(message, { assertion: 't.deepEqual()', formattedDetails: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)], @@ -346,9 +320,9 @@ export class Assertions { this.notDeepEqual = withSkip((actual, expected, message) => { assertMessage(message, 't.notDeepEqual()'); - const result = concordance.compare(actual, expected, concordanceOptions); + const result = concordance().compare(actual, expected, concordanceOptions); if (result.pass) { - const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions); + const actualDescriptor = result.actual ?? concordance().describe(actual, concordanceOptions); throw fail(new AssertionError(message, { assertion: 't.notDeepEqual()', formattedDetails: [formatDescriptorWithLabel('Value is deeply equal:', actualDescriptor)], @@ -382,13 +356,13 @@ export class Assertions { throw error; } - const result = concordance.compare(comparable, selector, concordanceOptions); + const result = concordance().compare(comparable, selector, concordanceOptions); if (result.pass) { return pass(); } - const actualDescriptor = result.actual ?? concordance.describe(comparable, concordanceOptions); - const expectedDescriptor = result.expected ?? concordance.describe(selector, concordanceOptions); + const actualDescriptor = result.actual ?? concordance().describe(comparable, concordanceOptions); + const expectedDescriptor = result.expected ?? concordance().describe(selector, concordanceOptions); throw fail(new AssertionError(message, { assertion: 't.like()', formattedDetails: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)], @@ -810,3 +784,5 @@ export class Assertions { }); } } + +export {AssertionError, getAssertionStack} from './assertion-error.js'; diff --git a/lib/assertion-error.js b/lib/assertion-error.js new file mode 100644 index 000000000..4b0cbfc25 --- /dev/null +++ b/lib/assertion-error.js @@ -0,0 +1,26 @@ +export function getAssertionStack(constructorOpt = getAssertionStack) { + const {stackTraceLimit: limitBefore} = Error; + Error.stackTraceLimit = Number.POSITIVE_INFINITY; + const temporary = {}; + Error.captureStackTrace(temporary, constructorOpt); + Error.stackTraceLimit = limitBefore; + return temporary.stack; +} + +export class AssertionError extends Error { + constructor(message = '', { + assertion, + assertionStack = getAssertionStack(AssertionError), + formattedDetails = [], + improperUsage = null, + cause, + } = {}) { + super(message, {cause}); + this.name = 'AssertionError'; + + this.assertion = assertion; + this.assertionStack = assertionStack; + this.improperUsage = improperUsage; + this.formattedDetails = formattedDetails; + } +} diff --git a/lib/concordance.js b/lib/concordance.js new file mode 100644 index 000000000..3297a3f62 --- /dev/null +++ b/lib/concordance.js @@ -0,0 +1,12 @@ +import {createRequire} from 'node:module'; + +// Concordance (and its lodash dependency) is expensive to evaluate, so it's loaded lazily on first use. +// Workers running passing simple assertions, plain `t.log` calls and snapshot-free files never touch it, +// and the main process only needs it to format the occasional non-native error. +const require = createRequire(import.meta.url); + +let concordance; +export default function loadConcordance() { + concordance ??= require('concordance'); + return concordance; +} diff --git a/lib/fork.js b/lib/fork.js index bab356bc8..be2e280a2 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -64,9 +64,15 @@ export default function loadFork(file, options, execArgv = process.execArgv) { let finished = false; const emitter = new Emittery(); + // `stateChange` is high-frequency and consumed synchronously, so it bypasses Emittery's per-emit async overhead. + // `connectSharedWorker` is rare and stays on Emittery. + const stateChangeListeners = new Set(); const emitStateChange = evt => { if (!finished) { - emitter.emit('stateChange', Object.assign(evt, {testFile: file})); + const data = Object.assign(evt, {testFile: file}); + for (const listener of stateChangeListeners) { + listener(data); + } } }; @@ -172,7 +178,8 @@ export default function loadFork(file, options, execArgv = process.execArgv) { }, onStateChange(listener) { - return emitter.on('stateChange', ({data}) => listener(data)); + stateChangeListeners.add(listener); + return () => stateChangeListeners.delete(listener); }, }; } diff --git a/lib/provider-manager.js b/lib/provider-manager.js index 762b224be..3dbf544a6 100644 --- a/lib/provider-manager.js +++ b/lib/provider-manager.js @@ -1,4 +1,3 @@ -import * as globs from './globs.js'; import pkg from './pkg.js'; // Provides an integer representation of the protocol level. This is internal to a particular AVA installation, and @@ -14,6 +13,8 @@ const levelsByProtocol = Object.assign(Object.create(null), { async function load(providerModule, projectDir, selectProtocol = () => true) { const ava = {version: pkg.version}; + // Loaded lazily so that workers running plain JavaScript don't pay for the glob stack they never use. + const globs = await import('./globs.js'); const {default: makeProvider} = await import(providerModule); let fatal; diff --git a/lib/run-status.js b/lib/run-status.js index ae6b68c4e..b0db265bd 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -1,8 +1,12 @@ -import v8 from 'node:v8'; - import Emittery from 'emittery'; -const copyStats = stats => v8.deserialize(v8.serialize(stats)); +// A purpose-built deep copy. The stats object is flat counters plus a `byFile` map of flat counters, +// so this is far cheaper than a generic structured clone and runs on every state change. +const copyStats = stats => ({ + ...stats, + byFile: new Map(Array.from(stats.byFile, ([file, fileStats]) => [file, {...fileStats}])), + parallelRuns: stats.parallelRuns && {...stats.parallelRuns}, +}); export default class RunStatus extends Emittery { constructor(files, parallelRuns, selectionInsights) { diff --git a/lib/serialize-error.js b/lib/serialize-error.js index ca47a6350..d439d0a62 100644 --- a/lib/serialize-error.js +++ b/lib/serialize-error.js @@ -2,11 +2,11 @@ import path from 'node:path'; import {pathToFileURL} from 'node:url'; import {isNativeError} from 'node:util/types'; -import concordance from 'concordance'; import StackUtils from 'stack-utils'; -import {AssertionError} from './assert.js'; +import {AssertionError} from './assertion-error.js'; import concordanceOptions from './concordance-options.js'; +import concordance from './concordance.js'; function isAvaAssertionError(source) { return source instanceof AssertionError; @@ -57,7 +57,7 @@ export default function serializeError(error, {testFile = null} = {}) { return { type: 'unknown', originalError: error, // Note that the main process receives a structured clone. - formattedError: concordance.formatDescriptor(concordance.describe(error, concordanceOptions), concordanceOptions), + formattedError: concordance().formatDescriptor(concordance().describe(error, concordanceOptions), concordanceOptions), }; } @@ -90,7 +90,7 @@ export default function serializeError(error, {testFile = null} = {}) { type: 'ava', assertion: error.assertion, improperUsage: error.improperUsage, - formattedCause: error.cause ? concordance.formatDescriptor(concordance.describe(error.cause, concordanceOptions), concordanceOptions) : null, + formattedCause: error.cause ? concordance().formatDescriptor(concordance().describe(error.cause, concordanceOptions), concordanceOptions) : null, formattedDetails: error.formattedDetails, source: extractSource(error.assertionStack, testFile), stack: isNativeError(error.cause) ? error.cause.stack : error.assertionStack, diff --git a/lib/snapshot-manager.js b/lib/snapshot-manager.js index 07e2eff32..75bbe330e 100644 --- a/lib/snapshot-manager.js +++ b/lib/snapshot-manager.js @@ -9,13 +9,13 @@ import zlib from 'node:zlib'; import {decode as decodeCbor, encode as encodeCbor, TypeEncoderMap} from 'cbor2'; import {writeArray, writeUint8Array} from 'cbor2/encoder'; import {sortLengthFirstDeterministic} from 'cbor2/sorts'; -import concordance from 'concordance'; import indentString from 'indent-string'; import memoize from 'memoize'; import slash from 'slash'; import writeFileAtomic from 'write-file-atomic'; import {snapshotManager as concordanceOptions} from './concordance-options.js'; +import concordance from './concordance.js'; // Increment if encoding layout or Concordance serialization versions change. Previous AVA versions will not be able to // decode buffers generated by a newer version, so changing this value will require a major version bump of AVA itself. @@ -94,7 +94,7 @@ function formatEntry(snapshot, index) { } = snapshot; const description = data - ? concordance.formatDescriptor(concordance.deserialize(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), concordanceOptions) + ? concordance().formatDescriptor(concordance().deserialize(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), concordanceOptions) : ''; const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n'); @@ -312,9 +312,9 @@ class Manager { return {pass: true}; } - const actual = concordance.deserialize(Buffer.from(data.buffer, data.byteOffset, data.byteLength), concordanceOptions); - const expected = concordance.describe(options.expected, concordanceOptions); - const pass = concordance.compareDescriptors(actual, expected); + const actual = concordance().deserialize(Buffer.from(data.buffer, data.byteOffset, data.byteLength), concordanceOptions); + const expected = concordance().describe(options.expected, concordanceOptions); + const pass = concordance().compareDescriptors(actual, expected); return {actual, expected, pass}; } @@ -340,8 +340,8 @@ class Manager { deferRecord(options) { const {expected, belongsTo, label, index} = options; - const descriptor = concordance.describe(expected, concordanceOptions); - const buffer = concordance.serialize(descriptor); + const descriptor = concordance().describe(expected, concordanceOptions); + const buffer = concordance().serialize(descriptor); const data = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); return () => { // Must be called in order! diff --git a/lib/test.js b/lib/test.js index 5a598bbd1..b89bd05ac 100644 --- a/lib/test.js +++ b/lib/test.js @@ -1,4 +1,3 @@ -import concordance from 'concordance'; import isPromise from 'is-promise'; import plur from 'plur'; @@ -6,6 +5,7 @@ import { AssertionError, Assertions, checkAssertionMessage, getAssertionStack, } from './assert.js'; import concordanceOptions from './concordance-options.js'; +import concordance from './concordance.js'; import * as nowAndTimers from './now-and-timers.js'; import parseTestArgs from './parse-test-args.js'; @@ -24,7 +24,7 @@ function isExternalAssertError(error) { } function formatErrorValue(label, error) { - const formatted = concordance.format(error, concordanceOptions); + const formatted = concordance().format(error, concordanceOptions); return {label, formatted}; } @@ -68,7 +68,7 @@ class ExecutionContext extends Assertions { this.log = (...inputArgs) => { const args = inputArgs.map(value => typeof value === 'string' ? value - : concordance.format(value, concordanceOptions)); + : concordance().format(value, concordanceOptions)); if (args.length > 0) { test.addLog(args.join(' ')); } diff --git a/lib/worker/base.js b/lib/worker/base.js index 7a5de284a..7337028fc 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -5,7 +5,6 @@ import {pathToFileURL} from 'node:url'; import {workerData} from 'node:worker_threads'; import setUpCurrentlyUnhandled from 'currently-unhandled'; -import writeFileAtomic from 'write-file-atomic'; import {set as setChalk} from '../chalk.js'; import {setImmediate} from '../now-and-timers.js'; @@ -15,7 +14,6 @@ import serializeError from '../serialize-error.js'; import * as channel from './channel.js'; import {runCompletionHandlers} from './completion-handlers.js'; -import lineNumberSelection from './line-numbers.js'; import {set as setOptions} from './options.js'; import {flags, refs, sharedWorkerTeardowns} from './state.js'; import {isRunningInThread, isRunningInChildProcess} from './utils.js'; @@ -61,14 +59,18 @@ const run = async options => { } let checkSelectedByLineNumbers; - try { - checkSelectedByLineNumbers = lineNumberSelection({ - file: options.file, - lineNumbers: options.lineNumbers, - }); - } catch (error) { - channel.send({type: 'line-number-selection-error', err: serializeError(error)}); - checkSelectedByLineNumbers = () => false; + if (options.lineNumbers?.length > 0) { + try { + // Loaded lazily, along with its parser, so that workers without line-number filters don't pay for it. + const {default: lineNumberSelection} = await import('./line-numbers.js'); + checkSelectedByLineNumbers = lineNumberSelection({ + file: options.file, + lineNumbers: options.lineNumbers, + }); + } catch (error) { + channel.send({type: 'line-number-selection-error', err: serializeError(error)}); + checkSelectedByLineNumbers = () => false; + } } runner = new Runner({ @@ -199,6 +201,8 @@ const run = async options => { const avaCacheDir = path.join(projectDir, 'node_modules', '.cache', 'ava'); await mkdir(avaCacheDir, {recursive: true}); const stubPath = path.join(avaCacheDir, 'import-from-project.mjs'); + // Loaded lazily as this fallback is only reached for dependencies that can't be imported directly. + const {default: writeFileAtomic} = await import('write-file-atomic'); await writeFileAtomic(stubPath, 'export const importFromProject = ref => import(ref);\n'); ({importFromProject} = await import(pathToFileURL(stubPath))); return importFromProject(ref); diff --git a/test-tap/run-status.js b/test-tap/run-status.js new file mode 100644 index 000000000..eec4b055a --- /dev/null +++ b/test-tap/run-status.js @@ -0,0 +1,50 @@ +import {setTimeout as delay} from 'node:timers/promises'; + +import {test} from 'tap'; + +import RunStatus from '../lib/run-status.js'; + +const collectStats = runStatus => { + const snapshots = []; + runStatus.on('stateChange', ({data: event}) => { + if (event.type === 'stats') { + snapshots.push(event.stats); + } + }); + return snapshots; +}; + +test('each emitted stats snapshot is an independent copy', async t => { + const runStatus = new RunStatus(1, null, {}); + runStatus.observeWorker({onStateChange() {}}, 'test.js', {}); + + const snapshots = collectStats(runStatus); + + runStatus.emitStateChange({type: 'declared-test', testFile: 'test.js'}); + runStatus.emitStateChange({type: 'declared-test', testFile: 'test.js'}); + await delay(10); + + t.equal(snapshots.length, 2); + + // The first snapshot must not be mutated by the second state change. This guards the + // purpose-built copy in run-status.js against regressing to a shallow copy. + t.equal(snapshots[0].declaredTests, 1); + t.equal(snapshots[1].declaredTests, 2); + t.equal(snapshots[0].byFile.get('test.js').declaredTests, 1); + t.equal(snapshots[1].byFile.get('test.js').declaredTests, 2); + t.not(snapshots[0].byFile, snapshots[1].byFile); + t.not(snapshots[0].byFile.get('test.js'), snapshots[1].byFile.get('test.js')); +}); + +test('parallelRuns is copied, not shared', async t => { + const runStatus = new RunStatus(1, {currentFileCount: 1, currentIndex: 0, totalRuns: 2}, {}); + runStatus.observeWorker({onStateChange() {}}, 'test.js', {}); + + const snapshots = collectStats(runStatus); + + runStatus.emitStateChange({type: 'declared-test', testFile: 'test.js'}); + await delay(10); + + t.same(snapshots[0].parallelRuns, {currentFileCount: 1, currentIndex: 0, totalRuns: 2}); + t.not(snapshots[0].parallelRuns, runStatus.stats.parallelRuns); +});