Skip to content
This repository has been archived by the owner on Dec 13, 2018. It is now read-only.

Commit

Permalink
jest-atom runner
Browse files Browse the repository at this point in the history
Summary:
sorry for a huge diff. it was a spontaneous hack :(

this implements a jest runner that can spawn atom processes and run tests in them.

Video:
https://pxl.cl/d2bF

to run tests you need to run jest and pass the config for atom tests:
- `cd modules/jest-atom-runner/`
- `yarn watch`
- `jest --config ~/nuclide/jest/atom.jest.config.js`

This will pick up any tests that are located under any of the `__atom_tests__` directories within `nuclide/` project.

This is not run on any of the CIs/Sandcastle yet.

next step would be:
1. dogfooding it and making sure it works reliably
2. adding a sandcastle step to our macos build
3. polishing (the IPC part is gnarly :( )

Reviewed By: hansonw

Differential Revision: D7771785

fbshipit-source-id: 68dbce1d95d0f9e90a68718b43b4523489d3973c
  • Loading branch information
aaronabramov authored and facebook-github-bot committed Apr 26, 2018
1 parent e323c69 commit da233f8
Show file tree
Hide file tree
Showing 19 changed files with 1,422 additions and 5 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -13,3 +13,5 @@ npm-debug.log*
# Ignore locked gems because GitHub Pages controls the gems in production,
# not Nuclide. Omit the lock so `bundle install` always installs the latest.
/docs/Gemfile.lock

/modules/jest-atom-runner/build
14 changes: 14 additions & 0 deletions jest/__atom_tests__/sample-1-test.js
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*
* @flow
* @format
*/

test('atom', () => {
expect(1).toBe(1);
});
14 changes: 14 additions & 0 deletions jest/__atom_tests__/sample-2-test.js
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*
* @flow
* @format
*/

test('atom', () => {
expect(2).toBe(2);
});
13 changes: 13 additions & 0 deletions jest/__mocks__/emptyObject.js
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*
* @flow
* @format
*/

// eslint-disable-next-line rulesdir/no-commonjs
module.exports = {};
35 changes: 35 additions & 0 deletions jest/atom.jest.config.js
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*
* @noflow
*/
'use strict';

/* eslint
comma-dangle: [1, always-multiline],
prefer-object-spread/prefer-object-spread: 0,
rulesdir/no-commonjs: 0,
*/

const path = require('path');

module.exports = {
rootDir: path.resolve(__dirname, '..'),
testMatch: ['**/__atom_tests__/**/*.js?(x)'],
transform: {
'\\.js$': '<rootDir>/modules/nuclide-jest/jestTransformer.js',
},
setupFiles: [],
testFailureExitCode: 0,
forceExit: true,
testPathIgnorePatterns: ['/node_modules/'],
runner: path.resolve(__dirname, '../modules/jest-atom-runner/build/index.js'),
moduleNameMapper: {
oniguruma: path.resolve(__dirname, './__mocks__/emptyObject.js'),
},
testEnvironment: '<rootDir>/modules/jest-atom-runner/build/environment.js',
};
7 changes: 7 additions & 0 deletions modules/jest-atom-runner/.babelrc
@@ -0,0 +1,7 @@
{
"presets": ["flow"],
"plugins": [
["transform-es2015-modules-commonjs", {"allowTopLevelThis": true}]
],
"retainLines": true
}
5 changes: 5 additions & 0 deletions modules/jest-atom-runner/.eslintrc
@@ -0,0 +1,5 @@
{
"rules": {
"rulesdir/modules-dependencies": [1, {"allowDevDependencies": true}]
}
}
28 changes: 28 additions & 0 deletions modules/jest-atom-runner/package.json
@@ -0,0 +1,28 @@
{
"name": "jest-atom-runner",
"version": "0.7.1-dev",
"description": "Jest runner that spawns atom/electron workers instead of node",
"author": "dabramov",
"license": "BSD-3-Clause",
"homepage": "https://nuclide.io/",
"repository": "https://github.com/facebook/nuclide/tree/master/modules/jest-atom-runner",
"private": true,
"devDependencies": {
"async-to-generator": "1.1.0",
"babel-cli": "6.26.0",
"babel-plugin-transform-es2015-modules-commonjs": "6.26.0",
"babel-preset-flow": "6.23.0",
"jest-haste-map": "22.4.3",
"jest-message-util": "22.4.3",
"jest-runner": "22.4.3",
"jest-runtime": "22.4.3",
"mkdirp": "0.5.1",
"node-ipc": "9.1.1"
},
"scripts": {
"watch": "babel src --out-dir build --watch"
},
"dependencies": {
"jest-environment-jsdom": "22.4.3"
}
}
191 changes: 191 additions & 0 deletions modules/jest-atom-runner/src/AtomTestWorker.js
@@ -0,0 +1,191 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
* @format
*/

/* This is a Jest worker. An abstraction class that knows how to start up
* an Atom process and communicate with it */

/* eslint-disable rulesdir/prefer-nuclide-uri */

/* eslint-disable-next-line rulesdir/consistent-import-name */
import type {IPCServer, Socket} from './ipc-server';
import type {ServerID, WorkerID, MessageType} from './utils';
import type {Test, GlobalConfig, TestResult} from './types';

// eslint-disable-next-line rulesdir/consistent-import-name
import {spawn} from 'child_process';
import mkdirp from 'mkdirp';
import path from 'path';
import fs from 'fs';
import os from 'os';
import {
makeUniqWorkerId,
mergeIPCIDs,
parseMessage,
makeMessage,
MESSAGE_TYPES,
parseJSON,
} from './utils';

const TMP_DIR = path.resolve(os.tmpdir(), 'jest-atom-runner');

// Atom resolves to its testing framework based on what's specified
// under the "atomTestRunner" key in the package.json in the parent directory
// of the first passed path.
// so if we run `atom -t /some_dir/__tests__/1-test.js`
// it'll look up `/some_dir/package.json` and then require whatever file is
// specified in "atomTestRunner" of this packages.json.
// To work around (or rather make atom execute arbitrary code) we
// will create a dummy `/tmp/packages.json` with `atomTestRunner` pointing
// to the file that we want to inject into atom's runtime.
const createDummyPackageJson = () => {
mkdirp.sync(path.resolve(TMP_DIR));
const packageJsonPath = path.resolve(TMP_DIR, 'package.json');
fs.writeFileSync(
packageJsonPath,
JSON.stringify({atomTestRunner: require.resolve('./atomTestRunner')}),
);
};

type OnMessageCallback = (MessageType, data?: string) => void;
type TestRunResolver = {resolve: TestResult => void, reject: Error => void};

class AtomTestWorker {
_childProcess: child_process$ChildProcess;
_ipcServer: IPCServer;
_serverID: ServerID;
_workerID: WorkerID;
_alive: boolean; // whether the worker is up and running
_socket: ?Socket;
_onMessageCallbacks: Array<OnMessageCallback>;
_globalConfig: GlobalConfig;
_runningTests: Map<string, TestRunResolver>;

constructor({
ipcServer,
serverID,
globalConfig,
}: {
ipcServer: IPCServer,
serverID: ServerID,
globalConfig: GlobalConfig,
}) {
this._ipcServer = ipcServer;
this._serverID = serverID;
this._alive = false;
this._onMessageCallbacks = [];
this._workerID = makeUniqWorkerId();
this._globalConfig = globalConfig;
this._runningTests = new Map();
}

async start() {
const {_serverID: serverID, _ipcServer: ipcServer} = this;
return new Promise(resolve => {
createDummyPackageJson();
const workerID = this._workerID;
const atomPathArg = path.resolve(
TMP_DIR,
mergeIPCIDs({serverID, workerID}),
);

let firstMessage = false;
ipcServer.on(workerID, (message, socket) => {
const {messageType, data} = parseMessage(message);
if (!firstMessage) {
firstMessage = true;
this._alive = true;
this._socket = socket;
resolve();
} else {
this._onMessage((messageType: MessageType), data);
}
});

this._childProcess = spawn('atom', ['-t', atomPathArg], {
stdio: ['inherit', 'inherit', 'inherit'],
});
});
}

async stop() {
this.send(makeMessage({messageType: MESSAGE_TYPES.SHUT_DOWN}));
this._childProcess.kill('SIGTERM');
}

send(message: string) {
if (!this._socket || !this._alive || !this._workerID) {
throw new Error("Can't interact with the worker before it comes alive");
}
this._ipcServer.emit(this._socket, this._workerID, message);
}

_onMessage(messageType: MessageType, data: string) {
switch (messageType) {
case MESSAGE_TYPES.TEST_RESULT: {
const testResult: TestResult = parseJSON(data);
const {testFilePath} = testResult;
const runningTest = this._runningTests.get(testFilePath);
if (!runningTest) {
throw new Error(`
Can't find any references to the test result that returned from the worker.
returned test path: ${testFilePath}
list of tests that we know has been running in the worker:
${Array.from(this._runningTests)
.map(([key, _]) => key)
.join(', ')}
`);
}

runningTest.resolve(testResult);
this._runningTests.delete(testFilePath);
}
}
}

runTest(test: Test): Promise<TestResult> {
if (this._runningTests.has(test.path)) {
throw new Error(
"Can't run the same times in the same worker at the same time",
);
}
return new Promise((resolve, reject) => {
// Ideally we don't want to pass all thing info with every test
// because it never changes. We should try to initialize it
// when the worker starts and keep it there for the whole run
// (if it's a single run and not a watch mode of course, in that case
// it'll be able to change)
const rawModuleMap = test.context.moduleMap.getRawModuleMap();
const config = test.context.config;
const globalConfig = this._globalConfig;

this.send(
makeMessage({
messageType: MESSAGE_TYPES.RUN_TEST,
data: JSON.stringify({
rawModuleMap,
config,
globalConfig,
path: test.path,
}),
}),
);

this._runningTests.set(test.path, {resolve, reject});
});
}

isBusy() {
return this._runningTests.size > 0;
}
}

export default AtomTestWorker;

0 comments on commit da233f8

Please sign in to comment.