Skip to content

Commit 513a88b

Browse files
authored
test(NODE-4152): convert cmap test runner to ts and decouple from test context (#3192)
1 parent 1d3f1c1 commit 513a88b

File tree

4 files changed

+374
-283
lines changed

4 files changed

+374
-283
lines changed

test/tools/cmap_spec_runner.ts

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { expect } from 'chai';
2+
import { EventEmitter } from 'events';
3+
import { promisify } from 'util';
4+
5+
import { Connection, HostAddress } from '../../src';
6+
import { ConnectionPool } from '../../src/cmap/connection_pool';
7+
import { sleep } from './utils';
8+
9+
type CmapOperation =
10+
| { name: 'start' | 'waitForThread'; target: string }
11+
| { name: 'wait'; ms: number }
12+
| { name: 'waitForEvent'; event: string; count: number }
13+
| { name: 'checkOut'; thread: string; label: string }
14+
| { name: 'checkIn'; connection: string }
15+
| { name: 'clear' | 'close' | 'ready' };
16+
17+
type CmapPoolOptions = {
18+
maxPoolSize?: number;
19+
minPoolSize?: number;
20+
maxIdleTimeMS?: number;
21+
waitQueueTimeoutMS?: number;
22+
};
23+
24+
type CmapEvent = {
25+
type: string;
26+
address?: 42;
27+
connectionId?: number;
28+
options?: 42 | CmapPoolOptions;
29+
reason: string;
30+
};
31+
32+
const CMAP_TEST_KEYS: Array<keyof CmapTest> = [
33+
'name',
34+
'version',
35+
'style',
36+
'description',
37+
'poolOptions',
38+
'operations',
39+
'error',
40+
'events',
41+
'ignore'
42+
];
43+
export type CmapTest = {
44+
name?: string; // filename path added by the spec loader
45+
version: number;
46+
style: 'unit';
47+
description: string;
48+
poolOptions?: CmapPoolOptions;
49+
operations: CmapOperation[];
50+
error?: {
51+
type: string;
52+
message: string;
53+
address?: number;
54+
};
55+
events?: CmapEvent[];
56+
ignore?: string[];
57+
};
58+
59+
const ALL_POOL_EVENTS = new Set([
60+
ConnectionPool.CONNECTION_POOL_CREATED,
61+
ConnectionPool.CONNECTION_POOL_CLOSED,
62+
ConnectionPool.CONNECTION_POOL_CLEARED,
63+
ConnectionPool.CONNECTION_CREATED,
64+
ConnectionPool.CONNECTION_READY,
65+
ConnectionPool.CONNECTION_CLOSED,
66+
ConnectionPool.CONNECTION_CHECK_OUT_STARTED,
67+
ConnectionPool.CONNECTION_CHECK_OUT_FAILED,
68+
ConnectionPool.CONNECTION_CHECKED_OUT,
69+
ConnectionPool.CONNECTION_CHECKED_IN
70+
]);
71+
72+
function getEventType(event) {
73+
const eventName = event.constructor.name;
74+
return eventName.substring(0, eventName.lastIndexOf('Event'));
75+
}
76+
77+
/**
78+
* In the cmap spec and runner definition,
79+
* a "thread" refers to a concurrent execution context
80+
*/
81+
class Thread {
82+
#promise: Promise<void>;
83+
#error: Error;
84+
#killed = false;
85+
86+
#knownCommands: any;
87+
88+
start: () => void;
89+
90+
// concurrent execution context
91+
constructor(operations) {
92+
this.#promise = new Promise(resolve => {
93+
this.start = () => resolve();
94+
});
95+
96+
this.#knownCommands = operations;
97+
}
98+
99+
private async _runOperation(op: CmapOperation): Promise<void> {
100+
const operationFn = this.#knownCommands[op.name];
101+
if (!operationFn) {
102+
throw new Error(`Invalid command ${op.name}`);
103+
}
104+
105+
await operationFn(op);
106+
await sleep();
107+
}
108+
109+
queue(op: CmapOperation) {
110+
if (this.#killed || this.#error) {
111+
return;
112+
}
113+
114+
this.#promise = this.#promise.then(() => this._runOperation(op)).catch(e => (this.#error = e));
115+
}
116+
117+
async finish() {
118+
this.#killed = true;
119+
await this.#promise;
120+
if (this.#error) {
121+
throw this.#error;
122+
}
123+
}
124+
}
125+
126+
/**
127+
* Implements the spec test match function, see:
128+
* [CMAP Spec Test README](https://github.com/mongodb/specifications/tree/master/source/connection-monitoring-and-pooling/tests#spec-test-match-function)
129+
*/
130+
const compareInputToSpec = (input, expected) => {
131+
// the spec uses 42 and "42" as special keywords to express that the value does not matter
132+
// however, "42" does not appear in the spec tests, so only the numeric value is checked here
133+
if (expected === 42) {
134+
expect(input).to.be.ok; // not null or undefined
135+
return;
136+
}
137+
138+
if (Array.isArray(expected)) {
139+
expect(input).to.be.an('array');
140+
for (const [index, expectedValue] of input.entries()) {
141+
compareInputToSpec(input[index], expectedValue);
142+
}
143+
return;
144+
}
145+
146+
if (expected && typeof expected === 'object') {
147+
for (const [expectedPropName, expectedValue] of Object.entries(expected)) {
148+
expect(input).to.have.property(expectedPropName);
149+
compareInputToSpec(input[expectedPropName], expectedValue);
150+
}
151+
return;
152+
}
153+
154+
expect(input).to.equal(expected);
155+
};
156+
157+
const getTestOpDefinitions = (threadContext: ThreadContext) => ({
158+
checkOut: async function (op) {
159+
const connection: Connection = await promisify(ConnectionPool.prototype.checkOut).call(
160+
threadContext.pool
161+
);
162+
if (op.label != null) {
163+
threadContext.connections.set(op.label, connection);
164+
} else {
165+
threadContext.orphans.add(connection);
166+
}
167+
},
168+
checkIn: function (op) {
169+
const connection = threadContext.connections.get(op.connection);
170+
threadContext.connections.delete(op.connection);
171+
172+
if (!connection) {
173+
throw new Error(`Attempted to release non-existient connection ${op.connection}`);
174+
}
175+
176+
return threadContext.pool.checkIn(connection);
177+
},
178+
clear: function () {
179+
return threadContext.pool.clear();
180+
},
181+
close: async function () {
182+
return await promisify(ConnectionPool.prototype.close).call(threadContext.pool);
183+
},
184+
wait: async function (options) {
185+
const ms = options.ms;
186+
return sleep(ms);
187+
},
188+
start: function (options) {
189+
const target = options.target;
190+
const thread = threadContext.getThread(target);
191+
thread.start();
192+
},
193+
waitForThread: async function (options): Promise<void> {
194+
const name = options.name;
195+
const target = options.target;
196+
197+
const threadObj = threadContext.threads.get(target);
198+
199+
if (!threadObj) {
200+
throw new Error(`Attempted to run op ${name} on non-existent thread ${target}`);
201+
}
202+
203+
await threadObj.finish();
204+
},
205+
waitForEvent: function (options): Promise<void> {
206+
const event = options.event;
207+
const count = options.count;
208+
return new Promise(resolve => {
209+
function run() {
210+
if (threadContext.poolEvents.filter(ev => getEventType(ev) === event).length >= count) {
211+
return resolve();
212+
}
213+
214+
threadContext.poolEventsEventEmitter.once('poolEvent', run);
215+
}
216+
run();
217+
});
218+
}
219+
});
220+
221+
export class ThreadContext {
222+
pool: ConnectionPool;
223+
threads: Map<any, Thread> = new Map();
224+
connections: Map<string, Connection> = new Map();
225+
orphans: Set<Connection> = new Set();
226+
poolEvents = [];
227+
poolEventsEventEmitter = new EventEmitter();
228+
229+
#hostAddress: HostAddress;
230+
#supportedOperations: ReturnType<typeof getTestOpDefinitions>;
231+
232+
constructor(hostAddress) {
233+
this.#hostAddress = hostAddress;
234+
this.#supportedOperations = getTestOpDefinitions(this);
235+
}
236+
237+
getThread(name) {
238+
let thread = this.threads.get(name);
239+
if (!thread) {
240+
thread = new Thread(this.#supportedOperations);
241+
this.threads.set(name, thread);
242+
}
243+
244+
return thread;
245+
}
246+
247+
createPool(options) {
248+
this.pool = new ConnectionPool({ ...options, hostAddress: this.#hostAddress });
249+
ALL_POOL_EVENTS.forEach(ev => {
250+
this.pool.on(ev, x => {
251+
this.poolEvents.push(x);
252+
this.poolEventsEventEmitter.emit('poolEvent');
253+
});
254+
});
255+
}
256+
257+
closePool() {
258+
return new Promise(resolve => {
259+
ALL_POOL_EVENTS.forEach(ev => this.pool.removeAllListeners(ev));
260+
this.pool.close(resolve);
261+
});
262+
}
263+
264+
async tearDown() {
265+
if (this.pool) {
266+
await this.closePool();
267+
}
268+
const connectionsToDestroy = Array.from(this.orphans).concat(
269+
Array.from(this.connections.values())
270+
);
271+
const promises = connectionsToDestroy.map(conn => {
272+
return new Promise<void>((resolve, reject) =>
273+
conn.destroy({ force: true }, err => {
274+
if (err) return reject(err);
275+
resolve();
276+
})
277+
);
278+
});
279+
await Promise.all(promises);
280+
this.poolEventsEventEmitter.removeAllListeners();
281+
}
282+
}
283+
284+
export async function runCmapTest(test: CmapTest, threadContext: ThreadContext) {
285+
expect(CMAP_TEST_KEYS).to.include.members(Object.keys(test));
286+
287+
const poolOptions = test.poolOptions || {};
288+
const operations = test.operations;
289+
const expectedError = test.error;
290+
const expectedEvents = test.events || [];
291+
const ignoreEvents = test.ignore || [];
292+
293+
let actualError;
294+
295+
const MAIN_THREAD_KEY = Symbol('Main Thread');
296+
const mainThread = threadContext.getThread(MAIN_THREAD_KEY);
297+
mainThread.start();
298+
299+
threadContext.createPool(poolOptions);
300+
301+
for (const idx in operations) {
302+
const op = operations[idx];
303+
304+
const threadKey = op.name === 'checkOut' ? op.thread || MAIN_THREAD_KEY : MAIN_THREAD_KEY;
305+
const thread = threadContext.getThread(threadKey);
306+
307+
thread.queue(op);
308+
}
309+
310+
await mainThread.finish().catch(e => {
311+
actualError = e;
312+
});
313+
314+
if (expectedError) {
315+
expect(actualError).to.exist;
316+
const { type: errorType, ...errorPropsToCheck } = expectedError;
317+
expect(actualError).to.have.property('name', `Mongo${errorType}`);
318+
compareInputToSpec(actualError, errorPropsToCheck);
319+
} else {
320+
expect(actualError).to.not.exist;
321+
}
322+
323+
const actualEvents = threadContext.poolEvents.filter(
324+
ev => !ignoreEvents.includes(getEventType(ev))
325+
);
326+
327+
expect(actualEvents).to.have.lengthOf(expectedEvents.length);
328+
for (const expected of expectedEvents) {
329+
const actual = actualEvents.shift();
330+
const { type: eventType, ...eventPropsToCheck } = expected;
331+
expect(actual.constructor.name).to.equal(`${eventType}Event`);
332+
compareInputToSpec(actual, eventPropsToCheck);
333+
}
334+
}

test/tools/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EJSON } from 'bson';
22
import { expect } from 'chai';
3-
import util from 'util';
3+
import * as util from 'util';
44

55
import { Logger } from '../../src/logger';
66
import { deprecateOptions, DeprecateOptionsConfig } from '../../src/utils';
@@ -217,7 +217,7 @@ export const runLater = (fn: () => Promise<void>, ms: number) => {
217217
});
218218
};
219219

220-
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
220+
export const sleep = util.promisify(setTimeout);
221221

222222
/**
223223
* If you are using sinon fake timers, it can end up blocking queued IO from running

0 commit comments

Comments
 (0)