diff --git a/packages/env/lib/commands/clean.js b/packages/env/lib/commands/clean.js index 817b5050211078..e3977b3b63b8c0 100644 --- a/packages/env/lib/commands/clean.js +++ b/packages/env/lib/commands/clean.js @@ -66,7 +66,7 @@ module.exports = async function clean( { await Promise.all( tasks ); if ( scripts ) { - executeLifecycleScript( 'afterClean', config, spinner ); + await executeLifecycleScript( 'afterClean', config, spinner ); } spinner.text = `Cleaned ${ description }.`; diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index 3543a50de59662..af4dc289d43204 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -58,7 +58,7 @@ module.exports = async function destroy( { spinner, scripts, debug } ) { } if ( scripts ) { - executeLifecycleScript( 'beforeDestroy', config, spinner ); + await executeLifecycleScript( 'beforeDestroy', config, spinner ); } spinner.text = 'Removing docker images, volumes, and networks.'; diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index ac23b2094d29d4..fed05e4f3d16fa 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -210,7 +210,7 @@ module.exports = async function start( { } if ( scripts ) { - executeLifecycleScript( 'afterStart', config, spinner ); + await executeLifecycleScript( 'afterStart', config, spinner ); } const siteUrl = config.env.development.config.WP_SITEURL; diff --git a/packages/env/lib/execute-lifecycle-script.js b/packages/env/lib/execute-lifecycle-script.js index 88c1fd25a10d62..388d63851948b2 100644 --- a/packages/env/lib/execute-lifecycle-script.js +++ b/packages/env/lib/execute-lifecycle-script.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const { execSync } = require( 'child_process' ); +const { exec } = require( 'child_process' ); /** * @typedef {import('./config').WPConfig} WPConfig @@ -25,31 +25,59 @@ class LifecycleScriptError extends Error { * @param {string} event The lifecycle event to run the script for. * @param {WPConfig} config The config object to use. * @param {Object} spinner A CLI spinner which indciates progress. + * + * @return {Promise} Resolves when the script has completed and rejects when there is an error. */ function executeLifecycleScript( event, config, spinner ) { if ( ! config.lifecycleScripts[ event ] ) { - return; + return Promise.resolve(); } - spinner.text = `Executing ${ event } Script`; + return new Promise( ( resolve, reject ) => { + // We're going to append the script output to the spinner while it's executing. + const spinnerMessage = `Executing ${ event } Script`; + spinner.text = spinnerMessage; - try { - let output = execSync( config.lifecycleScripts[ event ], { + // Execute the script asynchronously so that it won't block the spinner. + const childProc = exec( config.lifecycleScripts[ event ], { encoding: 'utf-8', stdio: 'pipe', env: process.env, } ); - // Remove any trailing whitespace for nicer output. - output = output.trimRight(); + // Collect all of the output so that we can make use of it. + let output = ''; + childProc.stdout.on( 'data', ( data ) => { + output += data; - // We don't need to bother with any output if there isn't any. - if ( output ) { - spinner.info( `${ event }:\n${ output }` ); - } - } catch ( error ) { - throw new LifecycleScriptError( event, error.stderr ); - } + // Keep the spinner updated with the command output. + spinner.text = `${ spinnerMessage }\n${ output }`; + } ); + + // Listen for any error output so we can display it if the command fails. + let error = ''; + childProc.stderr.on( 'data', ( data ) => { + error += data; + } ); + + // Pass any process creation errors directly up. + childProc.on( 'error', reject ); + + // Handle the completion of the command based on whether it was successful or not. + childProc.on( 'exit', ( code ) => { + if ( code === 0 ) { + // Keep the output of the command in debug mode. + if ( config.debug ) { + spinner.info( `${ event } Script:\n${ output.trimEnd() }` ); + } + + resolve(); + return; + } + + reject( new LifecycleScriptError( event, error.trimEnd() ) ); + } ); + } ); } module.exports = { diff --git a/packages/env/lib/test/execute-lifecycle-script.js b/packages/env/lib/test/execute-lifecycle-script.js index 8d08749f8abdcb..ddabd71e3c2957 100644 --- a/packages/env/lib/test/execute-lifecycle-script.js +++ b/packages/env/lib/test/execute-lifecycle-script.js @@ -1,9 +1,5 @@ 'use strict'; -/** - * External dependencies - */ -const { execSync } = require( 'child_process' ); - +/* eslint-disable jest/no-conditional-expect */ /** * Internal dependencies */ @@ -12,10 +8,6 @@ const { executeLifecycleScript, } = require( '../execute-lifecycle-script' ); -jest.mock( 'child_process', () => ( { - execSync: jest.fn(), -} ) ); - describe( 'executeLifecycleScript', () => { const spinner = { info: jest.fn(), @@ -25,58 +17,42 @@ describe( 'executeLifecycleScript', () => { jest.clearAllMocks(); } ); - it( 'should do nothing without event option', () => { - executeLifecycleScript( + it( 'should do nothing without event option when debugging', async () => { + await executeLifecycleScript( 'test', - { lifecycleScripts: { test: null } }, + { lifecycleScripts: { test: null }, debug: true }, spinner ); expect( spinner.info ).not.toHaveBeenCalled(); } ); - it( 'should run event option and print output without extra whitespace', () => { - execSync.mockReturnValue( 'Test \n' ); - - executeLifecycleScript( + it( 'should run event option and print output when debugging', async () => { + await executeLifecycleScript( 'test', - { lifecycleScripts: { test: 'Test' } }, + { lifecycleScripts: { test: 'echo "Test \n"' }, debug: true }, spinner ); - expect( execSync ).toHaveBeenCalled(); - expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test' ); - expect( spinner.info ).toHaveBeenCalledWith( 'test:\nTest' ); + expect( spinner.info ).toHaveBeenCalledWith( 'test Script:\nTest' ); } ); - it( 'should print nothing if event returns no output', () => { - execSync.mockReturnValue( '' ); - - executeLifecycleScript( - 'test', - { lifecycleScripts: { test: 'Test' } }, - spinner - ); - - expect( execSync ).toHaveBeenCalled(); - expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test' ); - expect( spinner.info ).not.toHaveBeenCalled(); - } ); - - it( 'should throw LifecycleScriptError when process errors', () => { - execSync.mockImplementation( ( command ) => { - expect( command ).toEqual( 'Test' ); - throw { stderr: 'Something bad happened.' }; - } ); - - expect( () => - executeLifecycleScript( + it( 'should throw LifecycleScriptError when process errors', async () => { + try { + await executeLifecycleScript( 'test', - { lifecycleScripts: { test: 'Test' } }, + { + lifecycleScripts: { + test: 'echo "test error" 1>&2 && false', + }, + }, spinner - ) - ).toThrow( - new LifecycleScriptError( 'test', 'Something bad happened.' ) - ); + ); + } catch ( error ) { + expect( error ).toEqual( + new LifecycleScriptError( 'test', 'test error' ) + ); + } } ); } ); +/* eslint-enable jest/no-conditional-expect */