Skip to content

Commit 8365e4d

Browse files
committed
Add a startTestShell onto the Mocha.Context
1 parent 186b2ec commit 8365e4d

File tree

2 files changed

+151
-62
lines changed

2 files changed

+151
-62
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { TestShell } from './test-shell';
2+
3+
describe('TestShell', function () {
4+
context('sub-suite', function () {
5+
before(async function () {
6+
const shell = this.startTestShell({ args: ['--nodb'] });
7+
await shell.waitForPrompt();
8+
});
9+
10+
beforeEach(async function () {
11+
const shell = this.startTestShell({ args: ['--nodb'] });
12+
await shell.waitForPrompt();
13+
});
14+
15+
after(async function () {
16+
const shell = this.startTestShell({ args: ['--nodb'] });
17+
await shell.waitForPrompt();
18+
});
19+
20+
afterEach(async function () {
21+
const shell = this.startTestShell({ args: ['--nodb'] });
22+
await shell.waitForPrompt();
23+
});
24+
25+
it("doesn't explode", async function () {
26+
const shell = this.startTestShell({ args: ['--nodb'] });
27+
await shell.waitForPrompt();
28+
});
29+
});
30+
31+
after(function () {
32+
TestShell.assertNoOpenShells();
33+
});
34+
});

packages/e2e-tests/test/test-shell.ts

Lines changed: 117 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type Mocha from 'mocha';
1+
import Mocha from 'mocha';
22
import assert from 'assert';
33
import 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-
7936
export interface TestShellOptions {
8037
args: string[];
8138
env?: Record<string, string>;
@@ -89,14 +46,12 @@ export interface TestShellOptions {
8946
* Test shell helper class.
9047
*/
9148
export 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

Comments
 (0)