11import { ExecOptions , exec as execWithCallback } from 'child_process' ;
22import { promisify } from 'util' ;
33
4+ import { escapeRegex } from './regex' ;
5+
46/**
57 * Promisified version of `child_process.exec`.
68 *
@@ -21,6 +23,11 @@ import { promisify } from 'util';
2123 */
2224export const exec = promisify ( execWithCallback ) ;
2325
26+ /**
27+ * `exec` function used by `sh`. This is mutable to allow faking.
28+ */
29+ let shExec : ( command : string , options ?: ExecOptions ) => Promise < { stdout : string ; stderr : string ; } > = exec ;
30+
2431/**
2532 * Options for `sh`.
2633 */
@@ -47,14 +54,178 @@ export type ShOptions = ExecOptions & {
4754 * // => 'hello\n'
4855 * ```
4956 *
57+ * `sh` has a mock mode that allows you to control its output, which can be
58+ * useful when testing scripts. Activate it using `sh.mock()`.
59+ *
5060 * @param command The command to run.
5161 * @param options Options to be passed to `exec`.
5262 */
5363export async function sh ( command : string , options ?: ShOptions ) : Promise < string > {
54- const result = await exec ( command , options ) ;
64+ const result = await shExec ( command , options ) ;
5565 let output = result . stdout . toString ( ) ;
5666 if ( options ?. trim ?? true ) {
5767 output = output . trim ( ) ;
5868 }
5969 return output ;
6070}
71+
72+ export interface MockShCommandController {
73+ /**
74+ * Adds a new command-specific mock result.
75+ *
76+ * @param mock The mock result.
77+ * @param [mock.exitCode] The mock process exit code.
78+ * @param [mock.stdout] The mock stdout output.
79+ * @param [mock.stderr] The mock stderr output.
80+ */
81+ returns ( mock : { exitCode ?: number , stdout ?: string , stderr ?: string } ) : MockShCommandController ;
82+ }
83+
84+ export interface MockShController {
85+ /**
86+ * Adds a new default mock result.
87+ *
88+ * @param mock The mock result.
89+ * @param [mock.exitCode] The mock process exit code.
90+ * @param [mock.stdout] The mock stdout output.
91+ * @param [mock.stderr] The mock stderr output.
92+ */
93+ returns ( mock : { exitCode ?: number , stdout ?: string , stderr ?: string } ) : MockShController ;
94+
95+ /**
96+ * Adds a command matcher.
97+ *
98+ * @param command The command to match.
99+ */
100+ command ( command : string | RegExp ) : MockShCommandController ;
101+
102+ /**
103+ * Asserts that a corresponding `sh` call was made for each mock.
104+ *
105+ * Throws if there are mocks that haven't been used.
106+ */
107+ assertDone ( ) : void ;
108+
109+ /**
110+ * Removes all mocks.
111+ *
112+ * `sh` remains in mock mode. To restore it, use `sh.restore()`.
113+ */
114+ reset ( ) : void ;
115+ }
116+
117+ /**
118+ * Start mocking `sh`.
119+ *
120+ * Returns an object with methods to control `sh` behaviour. You can provide
121+ * sequential mock results to return, either as default (ignoring the command
122+ * being run) or matching to a specific command.
123+ *
124+ * When you're done, use `sh.restore()` to stop mocking.
125+ *
126+ * ```ts
127+ * // activate mock mode
128+ * const mock = sh.mock();
129+ *
130+ * // add some default mocks
131+ * mock.returns({ stdout: 'first call' });
132+ * mock.returns({ stdout: 'second call' });
133+ *
134+ * // add some command-specific mocks
135+ * mock.command('cat file.txt').returns({ stdout: 'file contents' });
136+ * mock.command(/echo/).returns({ stdout: 'mock echo' });
137+ *
138+ * await sh('some arbitrary command');
139+ * // => 'first call'
140+ *
141+ * await sh('some arbitrary command');
142+ * // => 'second call'
143+ *
144+ * await sh('echo "hi there"');
145+ * // => 'mock echo'
146+ *
147+ * await sh('cat file.txt');
148+ * // => 'file contents'
149+ *
150+ * // assert that all expected `sh` calls were made
151+ * mock.assertDone();
152+ * sh.restore();
153+ * ```
154+ */
155+ sh . mock = ( ) => {
156+ if ( shExec !== exec ) {
157+ throw new Error ( '`sh` is already mocked' ) ;
158+ }
159+
160+ type Mock = { exitCode : number , stdout : string , stderr : string } ;
161+ const matchers : [ RegExp , Mock [ ] ] [ ] = [ ] ;
162+ const defaults : Mock [ ] = [ ] ;
163+
164+ shExec = async ( command ) => {
165+ const match = matchers . find ( ( [ r , m ] ) => m . length > 0 && r . exec ( command ) ) ;
166+ const mock = match ? match [ 1 ] . shift ( ) : defaults . shift ( ) ;
167+ if ( ! mock ) {
168+ throw new Error ( `No mock found for command: ${ command } ` ) ;
169+ }
170+ if ( mock . exitCode !== 0 ) {
171+ throw new Error ( `Command failed: ${ command } \n${ mock . stderr } ` ) ;
172+ }
173+ return { stdout : mock . stdout , stderr : mock . stderr } ;
174+ } ;
175+
176+ const mockController : MockShController = {
177+ returns ( mock : { exitCode ?: number , stdout ?: string , stderr ?: string } ) {
178+ const {
179+ exitCode = 0 ,
180+ stdout = '' ,
181+ stderr = '' ,
182+ } = mock ;
183+ defaults . push ( { exitCode, stdout, stderr } ) ;
184+ return mockController ;
185+ } ,
186+
187+ command ( command : string | RegExp ) {
188+ const re = typeof command === 'string'
189+ ? new RegExp ( `^${ escapeRegex ( command ) } $` )
190+ : command ;
191+ const mocks : Mock [ ] = [ ] ;
192+ matchers . push ( [ re , mocks ] ) ;
193+
194+ const cmdController : MockShCommandController = {
195+ returns ( mock : { exitCode ?: number , stdout ?: string , stderr ?: string } ) {
196+ const {
197+ exitCode = 0 ,
198+ stdout = '' ,
199+ stderr = '' ,
200+ } = mock ;
201+ mocks . push ( { exitCode, stdout, stderr } ) ;
202+ return cmdController ;
203+ } ,
204+ } ;
205+ return cmdController ;
206+ } ,
207+
208+ assertDone ( ) {
209+ if (
210+ defaults . length > 0
211+ || matchers . some ( ( [ , unconsumed ] ) => unconsumed . length > 0 )
212+ ) {
213+ throw new Error ( 'Some `sh` mocks were not used' ) ;
214+ }
215+ } ,
216+
217+ reset ( ) {
218+ matchers . splice ( 0 , matchers . length ) ;
219+ defaults . splice ( 0 , defaults . length ) ;
220+ } ,
221+ } ;
222+
223+ return mockController ;
224+ } ;
225+
226+ /**
227+ * Stop mocking `sh`.
228+ */
229+ sh . restore = ( ) => {
230+ shExec = exec ;
231+ } ;
0 commit comments