ava
Version:
285 lines (240 loc) • 8.8 kB
JavaScript
import {mkdir} from 'node:fs/promises';
import {createRequire} from 'node:module';
import path from 'node:path';
import process from 'node:process';
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 nowAndTimers from '../now-and-timers.cjs';
import providerManager from '../provider-manager.js';
import Runner from '../runner.js';
import serializeError from '../serialize-error.js';
import channel from './channel.cjs';
import {runCompletionHandlers} from './completion-handlers.js';
import lineNumberSelection from './line-numbers.js';
import {set as setOptions} from './options.cjs';
import {flags, refs, sharedWorkerTeardowns} from './state.cjs';
import {isRunningInThread, isRunningInChildProcess} from './utils.cjs';
const currentlyUnhandled = setUpCurrentlyUnhandled();
let runner;
let expectingExit = false;
const forceExit = () => {
expectingExit = true;
process.exit(1);
};
const avaIsDone = () => {
expectingExit = true;
runCompletionHandlers();
};
// Override process.exit with an undetectable replacement
// to report when it is called from a test (which it should never be).
const handleProcessExit = (target, thisArg, args) => {
if (!expectingExit) {
const error = new Error('Unexpected process.exit()');
Error.captureStackTrace(error, handleProcessExit);
channel.send({type: 'process-exit', stack: error.stack});
}
target.apply(thisArg, args);
};
process.exit = new Proxy(process.exit, {
apply: handleProcessExit,
});
const run = async options => {
setOptions(options);
setChalk(options.chalkOptions);
if (options.chalkOptions.level > 0) {
const {stdout, stderr} = process;
globalThis.console = Object.assign(globalThis.console, new console.Console({stdout, stderr, colorMode: true}));
}
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;
}
runner = new Runner({
checkSelectedByLineNumbers,
experiments: options.experiments,
failFast: options.failFast,
failWithoutAssertions: options.failWithoutAssertions,
file: options.file,
match: options.match,
projectDir: options.projectDir,
recordNewSnapshots: options.recordNewSnapshots,
runOnlyExclusive: options.runOnlyExclusive,
serial: options.serial,
snapshotDir: options.snapshotDir,
updateSnapshots: options.updateSnapshots,
});
refs.runnerChain = runner.chain;
channel.peerFailed.then(() => { // eslint-disable-line promise/prefer-await-to-then
runner.interrupt();
});
runner.on('accessed-snapshots', filename => channel.send({type: 'accessed-snapshots', filename}));
runner.on('stateChange', state => channel.send(state));
runner.on('error', error => {
channel.send({type: 'internal-error', err: serializeError(error)});
forceExit();
});
runner.on('finish', async () => {
try {
const {touchedFiles} = await runner.saveSnapshotState();
if (touchedFiles) {
channel.send({type: 'touched-files', files: touchedFiles});
}
} catch (error) {
channel.send({type: 'internal-error', err: serializeError(error)});
forceExit();
return;
}
try {
await Promise.all(sharedWorkerTeardowns.map(fn => fn()));
} catch (error) {
channel.send({type: 'uncaught-exception', err: serializeError(error)});
forceExit();
return;
}
nowAndTimers.setImmediate(() => {
const unhandled = currentlyUnhandled();
if (unhandled.length === 0) {
return avaIsDone();
}
for (const rejection of unhandled) {
channel.send({type: 'unhandled-rejection', err: serializeError(rejection.reason, {testFile: options.file})});
}
forceExit();
});
});
process.on('uncaughtException', error => {
channel.send({type: 'uncaught-exception', err: serializeError(error, {testFile: options.file})});
forceExit();
});
// Store value to prevent required modules from modifying it.
const testPath = options.file;
const extensionsToLoadAsModules = Object.entries(options.moduleTypes)
.filter(([, type]) => type === 'module')
.map(([extension]) => extension);
// Install before processing options.require, so if helpers are added to the
// require configuration the *compiled* helper will be loaded.
const {projectDir, providerStates = []} = options;
const providers = [];
await Promise.all(providerStates.map(async ({type, state, protocol}) => {
if (type === 'typescript') {
const provider = await providerManager.typescript(projectDir, {protocol});
providers.push(provider.worker({extensionsToLoadAsModules, state}));
}
}));
const require = createRequire(import.meta.url);
const load = async ref => {
for (const provider of providers) {
if (provider.canLoad(ref)) {
return provider.load(ref, {requireFn: require});
}
}
for (const extension of extensionsToLoadAsModules) {
if (ref.endsWith(`.${extension}`)) {
return import(pathToFileURL(ref));
}
}
// We still support require() since it's more easily monkey-patched.
return require(ref);
};
const loadRequiredModule = async ref => {
// If the provider can load the module, assume it's a local file and not a
// dependency.
for (const provider of providers) {
if (provider.canLoad(ref)) {
return provider.load(ref, {requireFn: require});
}
}
// Try to load the module as a file, relative to the project directory.
// Match load() behavior.
const fullPath = path.resolve(projectDir, ref);
try {
for (const extension of extensionsToLoadAsModules) {
if (fullPath.endsWith(`.${extension}`)) {
return await import(pathToFileURL(fullPath)); // eslint-disable-line no-await-in-loop
}
}
return require(fullPath);
} catch (error) {
// If the module could not be found, assume it's not a file but a dependency.
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
return importFromProject(ref);
}
throw error;
}
};
let importFromProject = async ref => {
// Do not use the cacheDir since it's not guaranteed to be inside node_modules.
const avaCacheDir = path.join(projectDir, 'node_modules', '.cache', 'ava');
await mkdir(avaCacheDir, {recursive: true});
const stubPath = path.join(avaCacheDir, 'import-from-project.mjs');
await writeFileAtomic(stubPath, 'export const importFromProject = ref => import(ref);\n');
({importFromProject} = await import(pathToFileURL(stubPath)));
return importFromProject(ref);
};
try {
for await (const [ref, ...args] of (options.require ?? [])) {
const loadedModule = await loadRequiredModule(ref);
if (typeof loadedModule === 'function') { // CJS module
await loadedModule(...args);
} else if (typeof loadedModule.default === 'function') { // ES module, or exports.default from CJS
const {default: fn} = loadedModule;
await fn(...args);
}
}
if (options.debug?.port !== undefined && options.debug?.host !== undefined) {
// If an inspector was active when the main process started, and is
// already active for the worker process, do not open a new one.
const {default: inspector} = await import('node:inspector');
if (!options.debug.active || inspector.url() === undefined) {
inspector.open(options.debug.port, options.debug.host, true);
}
if (options.debug.break) {
debugger; // eslint-disable-line no-debugger
}
}
await load(testPath);
if (flags.loadedMain) {
// Unreference the channel if the test file required AVA. This stops it
// from keeping the event loop busy, which means the `beforeExit` event can be
// used to detect when tests stall.
channel.unref();
} else {
channel.send({type: 'missing-ava-import'});
forceExit();
}
} catch (error) {
channel.send({type: 'uncaught-exception', err: serializeError(error, {testFile: options.file})});
forceExit();
}
};
const onError = error => {
// There shouldn't be any errors, but if there are we may not have managed
// to bootstrap enough code to serialize them. Re-throw and let the process
// crash.
setImmediate(() => {
throw error;
});
};
let options;
if (isRunningInThread) {
channel.send({type: 'starting'}); // AVA won't terminate the worker thread until it's seen this message.
({options} = workerData);
delete workerData.options; // Don't allow user code access.
} else if (isRunningInChildProcess) {
channel.send({type: 'ready-for-options'});
options = await channel.options;
}
try {
await run(options);
} catch (error) {
onError(error);
}