Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Run tests in a single process #1645

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ exports.run = () => {
'verbose',
'watch',
'update-snapshots',
'color'
'color',
'single'
],
default: {
cache: conf.cache,
Expand Down Expand Up @@ -150,7 +151,8 @@ exports.run = () => {
concurrency: conf.concurrency ? parseInt(conf.concurrency, 10) : 0,
updateSnapshots: conf.updateSnapshots,
snapshotDir: conf.snapshotDir ? path.resolve(projectDir, conf.snapshotDir) : null,
color: conf.color
color: conf.color,
fork: !conf.single
});

let reporter;
Expand Down
42 changes: 42 additions & 0 deletions lib/fork-test-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict';

// Check if the test is being run without AVA CLI
{
const path = require('path');
const chalk = require('chalk');

const isForked = typeof process.send === 'function';
if (!isForked) {
const fp = path.relative('.', process.argv[1]);

console.log();
console.error('Test files must be run with the AVA CLI:\n\n ' + chalk.grey.dim('$') + ' ' + chalk.cyan('ava ' + fp) + '\n');

process.exit(1); // eslint-disable-line unicorn/no-process-exit
}
}

const run = require('./test-worker');

const opts = JSON.parse(process.argv[2]);

// Adapter for simplified communication between AVA and worker
const ipcMain = {
send: (name, data) => {
process.send({
name: `ava-${name}`,
data,
ava: true
});
},
on: (name, listener) => process.on(name, listener),
// `process.channel` was added in Node.js 7.1.0, but the channel was available
// through an undocumented API as `process._channel`.
ipcChannel: process.channel || process._channel
};

run({
ipcMain,
opts,
isForked: true
});
27 changes: 20 additions & 7 deletions lib/fork.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const fs = require('fs');
const Promise = require('bluebird');
const debug = require('debug')('ava');
const AvaError = require('./ava-error');
const single = require('./single-test-worker');

if (fs.realpathSync(__filename) !== __filename) {
console.warn('WARNING: `npm link ava` and the `--preserve-symlink` flag are incompatible. We have detected that AVA is linked via `npm link`, and that you are using either an early version of Node 6, or the `--preserve-symlink` flag. This breaks AVA. You should upgrade to Node 6.2.0+, avoid the `--preserve-symlink` flag, or avoid using `npm link ava`.');
Expand All @@ -24,6 +25,19 @@ if (env.NODE_PATH) {
// the presence of this variable allows it to require this one instead
env.AVA_PATH = path.resolve(__dirname, '..');

const fork = (opts, env, execArgv) => {
const args = [JSON.stringify(opts), opts.color ? '--color' : '--no-color'];

const ps = childProcess.fork(path.join(__dirname, 'fork-test-worker.js'), args, {
cwd: opts.projectDir,
silent: true,
env,
execArgv: execArgv || process.execArgv
});

return ps;
};

module.exports = (file, opts, execArgv) => {
opts = Object.assign({
file,
Expand All @@ -34,14 +48,13 @@ module.exports = (file, opts, execArgv) => {
} : false
}, opts);

const args = [JSON.stringify(opts), opts.color ? '--color' : '--no-color'];
let ps;

const ps = childProcess.fork(path.join(__dirname, 'test-worker.js'), args, {
cwd: opts.projectDir,
silent: true,
env,
execArgv: execArgv || process.execArgv
});
if (opts.fork) {
ps = fork(opts, env, execArgv || process.execArgv);
} else {
ps = single(opts);
}

const relFile = path.relative('.', file);

Expand Down
36 changes: 19 additions & 17 deletions lib/main.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
'use strict';
const worker = require('./test-worker');
const adapter = require('./process-adapter');
const serializeError = require('./serialize-error');
const globals = require('./globals');
const Runner = require('./runner');

const opts = globals.options;
const testPath = module.parent.parent.filename;
const opts = global.__shared.options;

const runner = new Runner({
bail: opts.failFast,
failWithoutAssertions: opts.failWithoutAssertions,
file: opts.file,
file: testPath,
match: opts.match,
projectDir: opts.projectDir,
serial: opts.serial,
updateSnapshots: opts.updateSnapshots,
snapshotDir: opts.snapshotDir
});

worker.setRunner(runner);
const ipcMain = global.__shared.ipcMain[testPath];
const ipcWorker = global.__shared.ipcWorker[testPath];
global.__shared.runner[testPath] = runner;

// If fail-fast is enabled, use this variable to detect
// that no more tests should be logged
Expand All @@ -43,7 +45,7 @@ function test(props) {
props.error = null;
}

adapter.send('test', props);
ipcMain.send('test', props);

if (hasError && opts.failFast) {
isFailed = true;
Expand All @@ -53,33 +55,28 @@ function test(props) {

function exit() {
// Reference the IPC channel now that tests have finished running.
adapter.ipcChannel.ref();
ipcMain.ipcChannel.ref();

const stats = runner.buildStats();
adapter.send('results', {stats});
ipcMain.send('results', {stats});
}

globals.setImmediate(() => {
const hasExclusive = runner.tests.hasExclusive;
const numberOfTests = runner.tests.testCount;

if (numberOfTests === 0) {
adapter.send('no-tests', {avaRequired: true});
ipcMain.send('no-tests', {avaRequired: true});
return;
}

adapter.send('stats', {
testCount: numberOfTests,
hasExclusive
});

runner.on('test', test);

process.on('ava-run', options => {
ipcWorker.on('ava-run', options => {
// Unreference the IPC channel. This stops it from keeping the event loop
// busy, which means the `beforeExit` event can be used to detect when tests
// stall.
adapter.ipcChannel.unref();
ipcMain.ipcChannel.unref();

runner.run(options)
.then(() => {
Expand All @@ -92,9 +89,14 @@ globals.setImmediate(() => {
});
});

process.on('ava-init-exit', () => {
ipcWorker.on('ava-init-exit', () => {
exit();
});

ipcMain.send('stats', {
testCount: numberOfTests,
hasExclusive
});
});

module.exports = runner.chain;
Expand Down
85 changes: 33 additions & 52 deletions lib/process-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,43 @@ const path = require('path');
const debug = require('debug')('ava');
const sourceMapSupport = require('source-map-support');
const installPrecompiler = require('require-precompiled');

// Parse and re-emit AVA messages
process.on('message', message => {
if (!message.ava) {
return;
}

process.emit(message.name, message.data);
});

exports.send = (name, data) => {
process.send({
name: `ava-${name}`,
data,
ava: true
});
};

// `process.channel` was added in Node.js 7.1.0, but the channel was available
// through an undocumented API as `process._channel`.
exports.ipcChannel = process.channel || process._channel;

const opts = JSON.parse(process.argv[2]);
exports.opts = opts;
const mem = require('mem');

// Fake TTY support
if (opts.tty) {
process.stdout.isTTY = true;
process.stdout.columns = opts.tty.columns || 80;
process.stdout.rows = opts.tty.rows;
exports.setupFakeTTY = mem(opts => {
if (opts.tty) {
process.stdout.isTTY = true;
process.stdout.columns = opts.tty.columns || 80;
process.stdout.rows = opts.tty.rows;

const tty = require('tty');
const isatty = tty.isatty;

tty.isatty = function (fd) {
if (fd === 1 || fd === process.stdout) {
return true;
}

const tty = require('tty');
const isatty = tty.isatty;
return isatty(fd);
};
}
});

tty.isatty = function (fd) {
if (fd === 1 || fd === process.stdout) {
return true;
exports.setupTimeRequire = mem(opts => {
if (debug.enabled) {
// Forward the `@ladjs/time-require` `--sorted` flag.
// Intended for internal optimization tests only.
if (opts._sorted) {
process.argv.push('--sorted');
}

return isatty(fd);
};
}

if (debug.enabled) {
// Forward the `@ladjs/time-require` `--sorted` flag.
// Intended for internal optimization tests only.
if (opts._sorted) {
process.argv.push('--sorted');
require('@ladjs/time-require'); // eslint-disable-line import/no-unassigned-import
}

require('@ladjs/time-require'); // eslint-disable-line import/no-unassigned-import
}
});

const sourceMapCache = new Map();
const cacheDir = opts.cacheDir;

exports.installSourceMapSupport = () => {
exports.installSourceMapSupport = mem(() => {
sourceMapSupport.install({
environment: 'node',
handleUncaughtExceptions: false,
Expand All @@ -73,21 +53,22 @@ exports.installSourceMapSupport = () => {
}
}
});
};
});

exports.installPrecompilerHook = () => {
exports.installPrecompilerHook = mem(opts => {
installPrecompiler(filename => {
const precompiled = opts.precompiled[filename];

if (precompiled) {
sourceMapCache.set(filename, path.join(cacheDir, `${precompiled}.js.map`));
return fs.readFileSync(path.join(cacheDir, `${precompiled}.js`), 'utf8');
sourceMapCache.set(filename, path.join(opts.cacheDir, `${precompiled}.js.map`));
return fs.readFileSync(path.join(opts.cacheDir, `${precompiled}.js`), 'utf8');
}

return null;
});
};
});

// TODO: Detect which dependencies belong to other test files in single mode
exports.installDependencyTracking = (dependencies, testPath) => {
Object.keys(require.extensions).forEach(ext => {
const wrappedHandler = require.extensions[ext];
Expand Down
53 changes: 53 additions & 0 deletions lib/single-test-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';
const EventEmitter = require('events');
const stream = require('stream');
const run = require('./test-worker');

// Required to prevent warnings from Node.js, because in single mode each test file
// attaches its own `uncaughtException` and `unhandledRejection` listeners.
process.setMaxListeners(Infinity);

module.exports = opts => {
// Fake child process
const ps = new EventEmitter();
ps.stdout = new stream.PassThrough();
ps.stderr = new stream.PassThrough();

// Adapter for simplified communication between AVA and worker
const ipcMain = new EventEmitter();

// Incoming message from AVA to worker
ps.send = data => {
ipcMain.emit('message', data);
};

// Fake IPC channel
ipcMain.ipcChannel = {
ref: () => {},
unref: () => {}
};

// Outgoing message from worker to AVA
ipcMain.send = (name, data) => {
ps.emit('message', {
name: `ava-${name}`,
data,
ava: true
});
};

// Fake `process.exit()`
ipcMain.exit = code => {
ps.emit('exit', code);
};

setImmediate(() => {
run({
ipcMain,
opts,
isForked: false
});
});

return ps;
};
Loading