Skip to content

Commit d130de3

Browse files
committed
test(NODE-3049): drivers atlas testing
1 parent 87d172d commit d130de3

File tree

12 files changed

+440
-14
lines changed

12 files changed

+440
-14
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
"check:unit": "mocha test/unit",
144144
"check:ts": "node ./node_modules/typescript/bin/tsc -v && node ./node_modules/typescript/bin/tsc --noEmit",
145145
"check:atlas": "mocha --config test/manual/mocharc.json test/manual/atlas_connectivity.test.js",
146+
"check:drivers-atlas-testing": "mocha --config test/mocha_mongodb.json test/atlas/drivers_atlas_testing.test.ts",
146147
"check:adl": "mocha --config test/mocha_mongodb.json test/manual/atlas-data-lake-testing",
147148
"check:aws": "nyc mocha --config test/mocha_mongodb.json test/integration/auth/mongodb_aws.test.ts",
148149
"check:oidc": "mocha --config test/mocha_mongodb.json test/manual/mongodb_oidc.prose.test.ts",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { runUnifiedSuite } from '../tools/unified-spec-runner/runner';
2+
3+
describe('Node Driver Atlas Testing', async function () {
4+
// Astrolabe can, well, take some time.
5+
this.timeout(120000);
6+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
7+
const spec = JSON.parse(process.env.WORKLOAD_SPECIFICATION!);
8+
runUnifiedSuite([spec]);
9+
});

test/tools/reporter/mongodb_reporter.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class MongoDBMochaReporter extends mocha.reporters.Spec {
103103
catchErr(test => this.testEnd(test))
104104
);
105105

106-
process.on('SIGINT', () => this.end(true));
106+
process.prependListener('SIGINT', () => this.end(true));
107107
}
108108

109109
start() {}
@@ -183,7 +183,11 @@ class MongoDBMochaReporter extends mocha.reporters.Spec {
183183
} catch (error) {
184184
console.error(chalk.red(`Failed to output xunit report! ${error}`));
185185
} finally {
186-
if (ctrlC) process.exit(1);
186+
// Dont exit the process on Astrolabe testing, let it interrupt and
187+
// finish naturally.
188+
if (!process.env.WORKLOAD_SPECIFICATION) {
189+
if (ctrlC) process.exit(1);
190+
}
187191
}
188192
}
189193

test/tools/runner/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ export class TestConfiguration {
159159
}
160160

161161
newClient(dbOptions?: string | Record<string, any>, serverOptions?: Record<string, any>) {
162+
if (process.env.DRIVERS_ATLAS_TESTING_URI) {
163+
return new MongoClient(process.env.DRIVERS_ATLAS_TESTING_URI);
164+
}
165+
162166
serverOptions = Object.assign({}, getEnvironmentalOptions(), serverOptions);
163167

164168
// support MongoClient constructor form (url, options) for `newClient`
@@ -258,6 +262,10 @@ export class TestConfiguration {
258262
...options
259263
};
260264

265+
if (process.env.DRIVERS_ATLAS_TESTING_URI) {
266+
return process.env.DRIVERS_ATLAS_TESTING_URI;
267+
}
268+
261269
const FILLER_HOST = 'fillerHost';
262270

263271
const protocol = this.isServerless ? 'mongodb+srv' : 'mongodb';

test/tools/runner/hooks/configuration.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ const skipBrokenAuthTestBeforeEachHook = function ({ skippedTests } = { skippedT
105105
};
106106

107107
const testConfigBeforeHook = async function () {
108+
if (process.env.DRIVERS_ATLAS_TESTING_URI) {
109+
this.configuration = new TestConfiguration(process.env.DRIVERS_ATLAS_TESTING_URI, {});
110+
return;
111+
}
108112
// TODO(NODE-5035): Implement OIDC support. Creating the MongoClient will fail
109113
// with "MongoInvalidArgumentError: AuthMechanism 'MONGODB-OIDC' not supported"
110114
// as is expected until that ticket goes in. Then this condition gets removed.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { writeFile } from 'node:fs/promises';
2+
3+
import * as path from 'path';
4+
5+
import type { EntitiesMap } from './entities';
6+
import { trace } from './runner';
7+
8+
/**
9+
* Writes the entities saved from the loop operations run in the
10+
* Astrolabe workload executor to the required files.
11+
*/
12+
export class AstrolabeResultsWriter {
13+
constructor(private entities: EntitiesMap) {
14+
this.entities = entities;
15+
}
16+
17+
async write(): Promise<void> {
18+
// Write the events.json to the execution directory.
19+
const errors = this.entities.getEntity('errors', 'errors', false);
20+
const failures = this.entities.getEntity('failures', 'failures', false);
21+
const events = this.entities.getEntity('events', 'events', false);
22+
const iterations = this.entities.getEntity('iterations', 'iterations', false);
23+
const successes = this.entities.getEntity('successes', 'successes', false);
24+
25+
// Write the events.json to the execution directory.
26+
trace('writing events.json');
27+
await writeFile(
28+
path.join(process.env.OUTPUT_DIRECTORY ?? '', 'events.json'),
29+
JSON.stringify({ events: events ?? [], errors: errors ?? [], failures: failures ?? [] })
30+
);
31+
32+
// Write the results.json to the execution directory.
33+
trace('writing results.json');
34+
await writeFile(
35+
path.join(process.env.OUTPUT_DIRECTORY ?? '', 'results.json'),
36+
JSON.stringify({
37+
numErrors: errors?.length ?? 0,
38+
numFailures: failures?.length ?? 0,
39+
numSuccesses: successes ?? 0,
40+
numIterations: iterations ?? 0
41+
})
42+
);
43+
}
44+
}

test/tools/unified-spec-runner/entities.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EventEmitter } from 'events';
55
import {
66
AbstractCursor,
77
ChangeStream,
8+
ClientEncryption,
89
ClientSession,
910
Collection,
1011
type CommandFailedEvent,
@@ -37,13 +38,9 @@ import {
3738
} from '../../mongodb';
3839
import { ejson, getEnvironmentalOptions } from '../../tools/utils';
3940
import type { TestConfiguration } from '../runner/config';
41+
import { EntityEventRegistry } from './entity_event_registry';
4042
import { trace } from './runner';
41-
import type {
42-
ClientEncryption,
43-
ClientEntity,
44-
EntityDescription,
45-
ExpectedLogMessage
46-
} from './schema';
43+
import type { ClientEntity, EntityDescription, ExpectedLogMessage } from './schema';
4744
import {
4845
createClientEncryption,
4946
makeConnectionString,
@@ -357,9 +354,10 @@ export type Entity =
357354
| AbstractCursor
358355
| UnifiedChangeStream
359356
| GridFSBucket
357+
| Document
360358
| ClientEncryption
361359
| TopologyDescription // From recordTopologyDescription operation
362-
| Document; // Results from operations
360+
| number;
363361

364362
export type EntityCtor =
365363
| typeof UnifiedMongoClient
@@ -370,7 +368,7 @@ export type EntityCtor =
370368
| typeof AbstractCursor
371369
| typeof GridFSBucket
372370
| typeof UnifiedThread
373-
| ClientEncryption;
371+
| typeof ClientEncryption;
374372

375373
export type EntityTypeId =
376374
| 'client'
@@ -381,18 +379,26 @@ export type EntityTypeId =
381379
| 'thread'
382380
| 'cursor'
383381
| 'stream'
384-
| 'clientEncryption';
382+
| 'clientEncryption'
383+
| 'errors'
384+
| 'failures'
385+
| 'events'
386+
| 'iterations'
387+
| 'successes';
385388

386389
const ENTITY_CTORS = new Map<EntityTypeId, EntityCtor>();
387390
ENTITY_CTORS.set('client', UnifiedMongoClient);
388391
ENTITY_CTORS.set('db', Db);
392+
ENTITY_CTORS.set('clientEncryption', ClientEncryption);
389393
ENTITY_CTORS.set('collection', Collection);
390394
ENTITY_CTORS.set('session', ClientSession);
391395
ENTITY_CTORS.set('bucket', GridFSBucket);
392396
ENTITY_CTORS.set('thread', UnifiedThread);
393397
ENTITY_CTORS.set('cursor', AbstractCursor);
394398
ENTITY_CTORS.set('stream', ChangeStream);
395399

400+
const NO_INSTANCE_CHECK = ['errors', 'failures', 'events', 'successes', 'iterations'];
401+
396402
export class EntitiesMap<E = Entity> extends Map<string, E> {
397403
failPoints: FailPointMap;
398404

@@ -435,15 +441,20 @@ export class EntitiesMap<E = Entity> extends Map<string, E> {
435441
getEntity(type: 'thread', key: string, assertExists?: boolean): UnifiedThread;
436442
getEntity(type: 'cursor', key: string, assertExists?: boolean): AbstractCursor;
437443
getEntity(type: 'stream', key: string, assertExists?: boolean): UnifiedChangeStream;
444+
getEntity(type: 'iterations', key: string, assertExists?: boolean): number;
445+
getEntity(type: 'successes', key: string, assertExists?: boolean): number;
446+
getEntity(type: 'errors', key: string, assertExists?: boolean): Document[];
447+
getEntity(type: 'failures', key: string, assertExists?: boolean): Document[];
448+
getEntity(type: 'events', key: string, assertExists?: boolean): Document[];
438449
getEntity(type: 'clientEncryption', key: string, assertExists?: boolean): ClientEncryption;
439450
getEntity(type: EntityTypeId, key: string, assertExists = true): Entity | undefined {
440451
const entity = this.get(key);
441452
if (!entity) {
442453
if (assertExists) throw new Error(`Entity '${key}' does not exist`);
443454
return;
444455
}
445-
if (type === 'clientEncryption') {
446-
// we do not have instanceof checking here since csfle might not be installed
456+
if (NO_INSTANCE_CHECK.includes(type)) {
457+
// Skip constructor checks for interfaces.
447458
return entity;
448459
}
449460
const ctor = ENTITY_CTORS.get(type);
@@ -499,6 +510,7 @@ export class EntitiesMap<E = Entity> extends Map<string, E> {
499510
entity.client.uriOptions
500511
);
501512
const client = new UnifiedMongoClient(uri, entity.client);
513+
new EntityEventRegistry(client, entity.client, map).register();
502514
try {
503515
await client.connect();
504516
} catch (error) {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
COMMAND_FAILED,
3+
COMMAND_STARTED,
4+
COMMAND_SUCCEEDED,
5+
CONNECTION_CHECK_OUT_FAILED,
6+
CONNECTION_CHECK_OUT_STARTED,
7+
CONNECTION_CHECKED_IN,
8+
CONNECTION_CHECKED_OUT,
9+
CONNECTION_CLOSED,
10+
CONNECTION_CREATED,
11+
CONNECTION_POOL_CLEARED,
12+
CONNECTION_POOL_CLOSED,
13+
CONNECTION_POOL_CREATED,
14+
CONNECTION_POOL_READY,
15+
CONNECTION_READY
16+
} from '../../mongodb';
17+
import { type EntitiesMap, type UnifiedMongoClient } from './entities';
18+
import { type ClientEntity } from './schema';
19+
20+
/**
21+
* Maps the names of the events the unified runner passes and maps
22+
* them to the names of the events emitted in the driver.
23+
*/
24+
const MAPPINGS = {
25+
PoolCreatedEvent: CONNECTION_POOL_CREATED,
26+
PoolReadyEvent: CONNECTION_POOL_READY,
27+
PoolClearedEvent: CONNECTION_POOL_CLEARED,
28+
PoolClosedEvent: CONNECTION_POOL_CLOSED,
29+
ConnectionCreatedEvent: CONNECTION_CREATED,
30+
ConnectionReadyEvent: CONNECTION_READY,
31+
ConnectionClosedEvent: CONNECTION_CLOSED,
32+
ConnectionCheckOutStartedEvent: CONNECTION_CHECK_OUT_STARTED,
33+
ConnectionCheckOutFailedEvent: CONNECTION_CHECK_OUT_FAILED,
34+
ConnectionCheckedOutEvent: CONNECTION_CHECKED_OUT,
35+
ConnectionCheckedInEvent: CONNECTION_CHECKED_IN,
36+
CommandStartedEvent: COMMAND_STARTED,
37+
CommandSucceededEvent: COMMAND_SUCCEEDED,
38+
CommandFailedEvent: COMMAND_FAILED
39+
};
40+
41+
/**
42+
* Registers events that need to be stored in the entities map, since
43+
* the UnifiedMongoClient does not contain a ciclical dependency on the
44+
* entities map itself.
45+
*/
46+
export class EntityEventRegistry {
47+
constructor(
48+
private client: UnifiedMongoClient,
49+
private clientEntity: ClientEntity,
50+
private entitiesMap: EntitiesMap
51+
) {
52+
this.client = client;
53+
this.clientEntity = clientEntity;
54+
this.entitiesMap = entitiesMap;
55+
}
56+
57+
/**
58+
* Connect the event listeners on the client and the entities map.
59+
*/
60+
register(): void {
61+
if (this.clientEntity.storeEventsAsEntities) {
62+
for (const { id, events } of this.clientEntity.storeEventsAsEntities) {
63+
this.entitiesMap.set(id, []);
64+
for (const eventName of events) {
65+
// Need to map the event names to the Node event names.
66+
this.client.on(MAPPINGS[eventName], () => {
67+
this.entitiesMap.getEntity('events', id).push({
68+
name: eventName,
69+
observedAt: Date.now()
70+
});
71+
});
72+
}
73+
}
74+
}
75+
}
76+
}

test/tools/unified-spec-runner/operations.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/* eslint-disable @typescript-eslint/no-non-null-assertion */
33
import { once, Writable } from 'node:stream';
44

5-
import { expect } from 'chai';
5+
import { AssertionError, expect } from 'chai';
66

77
import {
88
AbstractCursor,
@@ -377,6 +377,76 @@ operations.set('listIndexes', async ({ entities, operation }) => {
377377
return collection.listIndexes(operation.arguments!).toArray();
378378
});
379379

380+
operations.set('loop', async ({ entities, operation, client, testConfig }) => {
381+
const controller = new AbortController();
382+
// We always want the process to exit on SIGINT last, so all other
383+
// SIGINT events listeners must be prepended.
384+
process.prependListener('SIGINT', () => {
385+
controller.abort('Process received SIGINT, aborting operation loop.');
386+
});
387+
const args = operation.arguments!;
388+
const storeIterationsAsEntity = args.storeIterationsAsEntity;
389+
const storeSuccessesAsEntity = args.storeSuccessesAsEntity;
390+
const storeErrorsAsEntity = args.storeErrorsAsEntity;
391+
const storeFailuresAsEntity = args.storeFailuresAsEntity;
392+
393+
if (storeErrorsAsEntity) {
394+
entities.set(storeErrorsAsEntity, []);
395+
}
396+
if (storeFailuresAsEntity) {
397+
entities.set(storeFailuresAsEntity, []);
398+
}
399+
400+
let iterations = 0;
401+
let successes = 0;
402+
while (!controller.signal.aborted) {
403+
if (storeIterationsAsEntity) {
404+
entities.set(storeIterationsAsEntity, iterations++);
405+
}
406+
for (const op of args.operations) {
407+
try {
408+
await executeOperationAndCheck(op, entities, client, testConfig);
409+
if (storeSuccessesAsEntity) {
410+
entities.set(storeSuccessesAsEntity, successes++);
411+
}
412+
} catch (error) {
413+
// From the unified spec:
414+
// If neither storeErrorsAsEntity nor storeFailuresAsEntity are specified,
415+
// the loop MUST terminate and raise the error/failure (i.e. the error/failure
416+
// will interrupt the test).
417+
if (!storeErrorsAsEntity && !storeFailuresAsEntity) {
418+
entities.set('errors', [
419+
{
420+
error: 'Neither storeErrorsAsEntity or storeFailuresAsEntity specified',
421+
time: Date.now()
422+
}
423+
]);
424+
controller.abort('Neither storeErrorsAsEntity or storeFailuresAsEntity specified');
425+
return;
426+
}
427+
428+
// From the unified spec format specification for the loop operation:
429+
// A failure is when the result or outcome of an operation executed by the test
430+
// runner differs from its expected outcome. For example, an expectResult assertion
431+
// failing to match a BSON document or an expectError assertion failing to match
432+
// an error message would be considered a failure.
433+
// An error is any other type of error raised by the test runner. For example, an
434+
// unsupported operation or inability to resolve an entity name would be considered
435+
// an error.
436+
if (storeFailuresAsEntity && error instanceof AssertionError) {
437+
entities
438+
.getEntity('failures', storeFailuresAsEntity)
439+
.push({ error: error.message, time: Date.now() });
440+
} else if (storeErrorsAsEntity) {
441+
entities
442+
.getEntity('errors', storeErrorsAsEntity)
443+
.push({ error: error.message, time: Date.now() });
444+
}
445+
}
446+
}
447+
}
448+
});
449+
380450
operations.set('replaceOne', async ({ entities, operation }) => {
381451
const collection = entities.getEntity('collection', operation.object);
382452
const { filter, replacement, ...opts } = operation.arguments!;

0 commit comments

Comments
 (0)