react-component-isolator
Version:
A simple module that spins up a react app where the only component is the react component you pass in.
335 lines (303 loc) • 9.61 kB
JavaScript
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf').sync;
const { execSync } = require('child_process');
const run = require('./run');
const projectToTestPath = process.env.PWD;
const reactProjectSubdirectory = process.env.REACT_SUBDIR || 'client';
const reactProjectDirectory = path.join(
projectToTestPath,
reactProjectSubdirectory
);
const injectedFilesFolder = path.join(
reactProjectDirectory,
'src',
'_isolator_generated_files_'
);
/*------------------------------------------------------------------------*/
/* Hot Reloading Isolator */
/*------------------------------------------------------------------------*/
// Add config-overrides.js
const configOverridesPath = path.join(
reactProjectDirectory,
'config-overrides.js'
);
let prevConfigOverridesBody;
if (fs.existsSync(configOverridesPath)) {
prevConfigOverridesBody = fs.readFileSync(configOverridesPath, 'utf-8');
}
const configOverridesContents = fs.readFileSync(
path.join(__dirname, 'config-overrides.js'),
'utf-8'
);
fs.writeFileSync(configOverridesPath, configOverridesContents);
// Prepare isolator
let onHandlerOutput;
const isolatorReady = new Promise((resolve, reject) => {
// Kill other processes listening to :3030
execSync('lsof -n -i4TCP:3030 | grep LISTEN | awk \'{ print $2 }\' | xargs kill');
// Start the app
let done;
const killFunction = run({
command: 'npm',
args: ['run', 'isolate'],
cwd: reactProjectDirectory,
env: {
BROWSER: 'none',
PORT: 3030,
},
handlers: {
onStderr: console.log,
onStdout: (text) => {
if (onHandlerOutput) {
onHandlerOutput(text);
}
if (!done) {
if (
text.includes('Failed to compile')
|| text.includes('You can now view client in the browser')
|| text.includes('Compiled with')
) {
done = true;
return resolve();
}
if (text.includes('Something is already running')) {
done = true;
return reject(new Error('Could not create a shell React app to isolate a component because another React app is already running on port 3030.'));
}
}
},
onClose: (code) => {
if (code !== 0) {
reject(new Error('An error occurred while starting the React component isolator.'));
}
},
},
});
// Kill on exit
process.on('exit', () => {
killFunction();
});
});
// Kill on exit
process.on('exit', () => {
// Reset config-overrides.js
if (!prevConfigOverridesBody) {
rimraf(configOverridesPath);
} else {
fs.writeFileSync(configOverridesPath, prevConfigOverridesBody);
}
// Kill React subprocesses listening on 3030
execSync('lsof -n -i4TCP:3030 | grep LISTEN | awk \'{ print $2 }\' | xargs kill');
});
// Function that waits for isolator to be ready
const waitForIsolatorReady = async () => {
let compiling = true;
const waitPromise = new Promise((resolve, reject) => {
// Start listening for output
let collectingError;
onHandlerOutput = (text) => {
// React client loaded
if (text.includes('Compiled successfully!') || text.includes('Compiled with warnings')) {
compiling = false;
// Wait a moment to make sure we aren't about to compile again
setTimeout(() => {
if (!compiling) {
// No longer compiling! Resolve.
return resolve();
}
}, 250);
}
// React client reloading
if (text.includes('Compiling...')) {
compiling = true;
}
// Compiler failed
if (text.includes('Failed to compile')) {
collectingError = true;
return;
}
// Collect this error
if (collectingError) {
// Done collecting error
return reject(new Error(`The component we wanted to test could not be compiled. Error:\n${text}`));
}
};
});
return waitPromise.then(() => {
// Stop listening for output
onHandlerOutput = null;
});
};
/**
* Starts a shell app that only displays the component
* @param {string} componentPath - the relative path to the component with
* respect to the src folder of the React app
* @param {object} [props] - mapping { propName => propValue } of the props to
* pass into the component. The value must either be a function or a
* JSONifiable value/object
* @return {object} information in the form { kill, url } where kill is a
* function you can call to kill the app and url is the url of the app
*/
const startShellApp = async (componentPath, props = {}) => {
await isolatorReady;
// Wipe and re-add injected files folder
rimraf(injectedFilesFolder);
fs.mkdirSync(injectedFilesFolder);
// Save Object.watch polyfills
const polyfillsBody = fs.readFileSync(
path.join(__dirname, 'polyfills.js'),
'utf-8'
);
fs.writeFileSync(
path.join(injectedFilesFolder, 'polyfills.js'),
polyfillsBody
);
// Save each prop to file
const propsFolder = path.join(injectedFilesFolder, 'props');
fs.mkdirSync(propsFolder);
const isFunction = {}; // propName => true if is a function
Object.keys(props).forEach((propName) => {
if (typeof props[propName] === 'function') {
fs.writeFileSync(
path.join(propsFolder, `${propName}.js`),
`module.exports = function () {\nreturn ${String(props[propName])}\n}`
);
isFunction[propName] = true;
} else {
fs.writeFileSync(
path.join(propsFolder, `${propName}.json`),
JSON.stringify(props[propName])
);
}
});
// Create a props index
// > Add polyfills
let propsIndexBody = 'require(\'../polyfills\');\n\n';
// > Add props
Object.keys(props).forEach((propName, i) => {
if (isFunction[propName]) {
propsIndexBody += `const prop_${i} = require('./${propName}');\n`;
} else {
propsIndexBody += `const prop_${i} = require('./${propName}.json');\n`;
}
});
propsIndexBody += '\n';
// > Build exports
propsIndexBody += 'module.exports = (newEmbeddedMetadata) => {\n';
// > Add embeddedMetadata object
propsIndexBody += 'const embeddedMetadata = Object.observe({}, () => { newEmbeddedMetadata(embeddedMetadata); });\n';
// > Add export of each item
propsIndexBody += ' return {\n';
Object.keys(props).forEach((propName, i) => {
if (isFunction[propName]) {
propsIndexBody += ` ${propName}: prop_${i}.bind({ embeddedMetadata })(),\n`;
} else {
propsIndexBody += ` ${propName}: prop_${i},\n`;
}
});
propsIndexBody += ' };\n';
propsIndexBody += '};\n';
fs.writeFileSync(
path.join(propsFolder, 'index.js'),
propsIndexBody
);
// Create index.js file
const componentImportPath = `../${path.join(componentPath)}`;
const existingIndexPath = path.join(reactProjectDirectory, 'src', 'index.js');
const indexBody = fs.readFileSync(existingIndexPath, 'utf-8');
// Parse existing body for imports
const newIndexLines = [];
let reactImported;
let componentImported;
let domImported;
indexBody.split('\n').forEach((line) => {
if (
line.trim().startsWith('import')
&& line.trim().includes('from')
&& !(
line
.trim()
.split('from')[1]
.replace('\'', '')
.trim()
.startsWith('/')
)
&& !(
line
.trim()
.split('from')[1]
.replace('\'', '')
.trim()
.startsWith('.')
)
) {
newIndexLines.push(line);
}
if (line.includes('\'react\'') && line.includes('Component')) {
componentImported = true;
}
if (line.includes('\'react\'') && line.includes('React')) {
reactImported = true;
}
if (line.includes('\'react-dom\'') && line.includes('ReactDOM')) {
domImported = true;
}
});
// Import react libraries that aren't already imported
if (!domImported) {
newIndexLines.push('import ReactDOM from \'react-dom\';');
}
if (!reactImported || !componentImported) {
if (!reactImported && !componentImported) {
newIndexLines.push('import React, { Component } from \'react\';');
} else if (!reactImported) {
newIndexLines.push('import React from \'react\';');
} else {
newIndexLines.push('import { Component } from \'react\';');
}
}
// Add component to import
newIndexLines.push(`import ComponentToTest from '${componentImportPath}';`);
// Add shell index
const shellIndex = fs.readFileSync(
path.join(__dirname, 'shellIndex.js'),
'utf-8'
);
shellIndex.split('\n').forEach((line) => {
newIndexLines.push(line);
});
// Write new index
fs.writeFileSync(
path.join(injectedFilesFolder, 'index.js'),
newIndexLines.join('\n')
);
// Wait for ready
await waitForIsolatorReady();
};
/**
* Initializes a react app where the
* @param {string} componentPath - the path to the component to test relative to
* the folder of the react app. Example: MyButton.js
*/
module.exports = async (opts) => {
const { component, props, test } = opts;
// Start the shell app
await startShellApp(component, props);
// Run the tests
let testError;
if (test && typeof test === 'function') {
try {
await test('http://localhost:3030/');
} catch (err) {
testError = err;
}
}
// Cleanup
// Remove injected files
rimraf(injectedFilesFolder);
// Re-throw errors
if (testError) {
throw testError;
}
};