1- import type Mocha from 'mocha' ;
1+ import Mocha from 'mocha' ;
22import assert from 'assert' ;
33import type {
44 ChildProcess ,
@@ -33,49 +33,6 @@ function matches(str: string, pattern: string | RegExp): boolean {
3333 : pattern . test ( str ) ;
3434}
3535
36- /**
37- * Toggle used to ensure an appropriate hook is registered, to clean up test shells.
38- * NOTE: This is a local variable of the module instead of a static on {@link TestShell} to allow the hooks to toggle it.
39- */
40- let testShellEnabled = false ;
41-
42- /**
43- * Enables the TestShell and kill all shells after all tests of the current suite.
44- */
45- export function cleanTestShellsAfter ( ) {
46- before ( 'enabled TestShell' , function ( ) {
47- assert (
48- ! testShellEnabled ,
49- 'TestShell is already enabled, use only one cleanTestShellsAfter or cleanTestShellsAfterEach'
50- ) ;
51- testShellEnabled = true ;
52- } ) ;
53-
54- after ( 'kill all TestShell instances' , async function ( this : Mocha . Context ) {
55- testShellEnabled = false ;
56- await TestShell . killAll ( ) ;
57- } ) ;
58- }
59-
60- /**
61- * Enables the TestShell and kill all shells after each test
62- * NOTE: This also registers {@link cleanTestShellsAfter} hook internally, to allow `after` hooks to start TestShell instances.
63- */
64- export function cleanTestShellsAfterEach ( ) {
65- cleanTestShellsAfter ( ) ;
66-
67- afterEach (
68- 'kill all TestShell instances' ,
69- async function ( this : Mocha . Context ) {
70- assert ( testShellEnabled , 'Expected TestShell to be enabled' ) ;
71- if ( this . currentTest ?. state === 'failed' ) {
72- TestShell . printShells ( ) ;
73- }
74- await TestShell . killAll ( ) ;
75- }
76- ) ;
77- }
78-
7936export interface TestShellOptions {
8037 args : string [ ] ;
8138 env ?: Record < string , string > ;
@@ -89,14 +46,12 @@ export interface TestShellOptions {
8946 * Test shell helper class.
9047 */
9148export class TestShell {
92- private static _openShells : TestShell [ ] = [ ] ;
49+ private static _openShells : Set < TestShell > = new Set ( ) ;
9350
51+ /**
52+ * @deprecated Use the {@link Mocha.Context.startTestShell} hook instead
53+ */
9454 static start ( options : TestShellOptions = { args : [ ] } ) : TestShell {
95- assert (
96- testShellEnabled ,
97- 'Expected TestShell to be enabled, did you call cleanTestShellsAfter or cleanTestShellsAfterEach? Or did you call TestShell.start in an after hook?'
98- ) ;
99-
10055 let shellProcess : ChildProcessWithoutNullStreams ;
10156
10257 let env = options . env || process . env ;
@@ -141,7 +96,10 @@ export class TestShell {
14196 }
14297
14398 const shell = new TestShell ( shellProcess , options . consumeStdio ) ;
144- TestShell . _openShells . push ( shell ) ;
99+ TestShell . _openShells . add ( shell ) ;
100+ void shell . waitForExit ( ) . then ( ( ) => {
101+ TestShell . _openShells . delete ( shell ) ;
102+ } ) ;
145103
146104 return shell ;
147105 }
@@ -155,17 +113,6 @@ export class TestShell {
155113 return shell . output ;
156114 }
157115
158- static async killAll ( ) : Promise < void > {
159- // Using splice to mutate the array of open shells in-place
160- const openShells = TestShell . _openShells . splice ( 0 ) ;
161- await Promise . all (
162- openShells . map ( ( shell ) => {
163- shell . kill ( ) ;
164- return shell . waitForExit ( ) ;
165- } )
166- ) ;
167- }
168-
169116 debugInformation ( ) {
170117 return {
171118 pid : this . process . pid ,
@@ -182,6 +129,21 @@ export class TestShell {
182129 }
183130 }
184131
132+ static assertNoOpenShells ( ) {
133+ const debugInformation = [ ...TestShell . _openShells ] . map ( ( shell ) =>
134+ shell . debugInformation ( )
135+ ) ;
136+ assert . strictEqual (
137+ TestShell . _openShells . size ,
138+ 0 ,
139+ `Expected no open shells, found: ${ JSON . stringify (
140+ debugInformation ,
141+ null ,
142+ 2
143+ ) } `
144+ ) ;
145+ }
146+
185147 private _process : ChildProcessWithoutNullStreams ;
186148
187149 private _output : string ;
@@ -373,3 +335,96 @@ export class TestShell {
373335 return match . groups ! . logId ;
374336 }
375337}
338+
339+ // Context extension to manage TestShell lifetime
340+
341+ declare module 'mocha' {
342+ interface Context {
343+ /**
344+ * Starts a test shell and registers a hook to kill it after the test
345+ */
346+ startTestShell ( options ?: TestShellOptions ) : TestShell ;
347+ }
348+ }
349+
350+ const TEST_SHELLS_AFTER_ALL = Symbol ( 'test-shells-after-all' ) ;
351+ const TEST_SHELLS_AFTER_EACH = Symbol ( 'test-shells-after-each' ) ;
352+
353+ type AfterAllInjectedSuite = {
354+ [ TEST_SHELLS_AFTER_ALL ] : Set < TestShell > ;
355+ } ;
356+
357+ type AfterEachInjectedSuite = {
358+ [ TEST_SHELLS_AFTER_EACH ] : Set < TestShell > ;
359+ } ;
360+
361+ /**
362+ * Registers an after (all or each) hook to kill test shells started during the hooks or tests
363+ */
364+ function ensureAfterHook (
365+ hookName : 'afterEach' ,
366+ suite : Mocha . Suite
367+ ) : asserts suite is AfterEachInjectedSuite & Mocha . Suite ;
368+ function ensureAfterHook (
369+ hookName : 'afterAll' ,
370+ suite : Mocha . Suite
371+ ) : asserts suite is AfterAllInjectedSuite & Mocha . Suite ;
372+ function ensureAfterHook (
373+ hookName : 'afterEach' | 'afterAll' ,
374+ suite : Partial < AfterAllInjectedSuite & AfterEachInjectedSuite > & Mocha . Suite
375+ ) : void {
376+ const symbol =
377+ hookName === 'afterAll' ? TEST_SHELLS_AFTER_ALL : TEST_SHELLS_AFTER_EACH ;
378+ if ( ! suite [ symbol ] ) {
379+ // Store the set of shells to kill afterwards
380+ const shells = new Set < TestShell > ( ) ;
381+ suite [ symbol ] = shells ;
382+ suite [ hookName ] ( async ( ) => {
383+ const shellsToKill = [ ...shells ] ;
384+ shells . clear ( ) ;
385+ await Promise . all (
386+ shellsToKill . map ( ( shell ) => {
387+ // TODO: Consider if it's okay to kill those that are already killed?
388+ shell . kill ( ) ;
389+ return shell . waitForExit ( ) ;
390+ } )
391+ ) ;
392+ } ) ;
393+ }
394+ }
395+
396+ Mocha . Context . prototype . startTestShell = function (
397+ this : Mocha . Context ,
398+ options : TestShellOptions
399+ ) {
400+ const { test : runnable } = this ;
401+ assert ( runnable , 'Expected a runnable / test' ) ;
402+ const { parent } = runnable ;
403+ assert ( parent , 'Expected runnable to have a parent' ) ;
404+ // Start the shell
405+ const shell = TestShell . start ( options ) ;
406+ // Register a hook to kill the shell
407+ if ( runnable instanceof Mocha . Hook ) {
408+ if (
409+ runnable . originalTitle === '"before each" hook' ||
410+ runnable . originalTitle === '"after each" hook'
411+ ) {
412+ ensureAfterHook ( 'afterEach' , parent ) ;
413+ parent [ TEST_SHELLS_AFTER_EACH ] . add ( shell ) ;
414+ } else if (
415+ runnable . originalTitle === '"before all" hook' ||
416+ runnable . originalTitle === '"after all" hook'
417+ ) {
418+ ensureAfterHook ( 'afterAll' , parent ) ;
419+ parent [ TEST_SHELLS_AFTER_ALL ] . add ( shell ) ;
420+ } else {
421+ throw new Error ( `Unexpected ${ runnable . originalTitle || runnable . title } ` ) ;
422+ }
423+ } else if ( runnable instanceof Mocha . Test ) {
424+ ensureAfterHook ( 'afterEach' , parent ) ;
425+ parent [ TEST_SHELLS_AFTER_EACH ] . add ( shell ) ;
426+ } else {
427+ throw new Error ( 'Unexpected Runnable: Expected a Hook or a Test' ) ;
428+ }
429+ return shell ;
430+ } ;
0 commit comments