UNPKG

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
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; } };