diff --git a/test/functional/change_stream.test.js b/test/functional/change_stream.test.js index 470a077742..37cfbde808 100644 --- a/test/functional/change_stream.test.js +++ b/test/functional/change_stream.test.js @@ -5,6 +5,7 @@ const { MongoNetworkError } = require('../../src/error'); const { delay, setupDatabase, withClient, withCursor } = require('./shared'); const co = require('co'); const mock = require('../tools/mock'); +const { EventCollector } = require('../tools/utils'); const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); @@ -233,56 +234,6 @@ describe('Change Streams', function () { } }); - class EventCollector { - constructor(obj, events, options) { - this._events = []; - this._timeout = options ? options.timeout : 5000; - - events.forEach(eventName => { - this._events[eventName] = []; - obj.on(eventName, event => this._events[eventName].push(event)); - }); - } - - waitForEvent(eventName, count, callback) { - if (typeof count === 'function') { - callback = count; - count = 1; - } - - waitForEventImpl(this, Date.now(), eventName, count, callback); - } - - reset(eventName) { - if (eventName == null) { - Object.keys(this._events).forEach(eventName => { - this._events[eventName] = []; - }); - - return; - } - - if (this._events[eventName] == null) { - throw new TypeError(`invalid event name "${eventName}" specified for reset`); - } - - this._events[eventName] = []; - } - } - - function waitForEventImpl(collector, start, eventName, count, callback) { - const events = collector._events[eventName]; - if (events.length >= count) { - return callback(undefined, events); - } - - if (Date.now() - start >= collector._timeout) { - return callback(new Error(`timed out waiting for event "${eventName}"`)); - } - - setTimeout(() => waitForEventImpl(collector, start, eventName, count, callback), 10); - } - it('should create a ChangeStream on a collection and emit `change` events', { metadata: { requires: { topology: 'replicaset', mongodb: '>=3.6' } }, diff --git a/test/functional/unified-spec-runner/entities.ts b/test/functional/unified-spec-runner/entities.ts index 9acf9064fc..32ee805b44 100644 --- a/test/functional/unified-spec-runner/entities.ts +++ b/test/functional/unified-spec-runner/entities.ts @@ -9,11 +9,18 @@ import type { } from '../../../src/cmap/events'; import { patchCollectionOptions, patchDbOptions } from './unified-utils'; import { TestConfiguration } from './unified.test'; +import { expect } from 'chai'; + +interface UnifiedChangeStream extends ChangeStream { + eventCollector: InstanceType; +} export type CommandEvent = CommandStartedEvent | CommandSucceededEvent | CommandFailedEvent; export class UnifiedMongoClient extends MongoClient { events: CommandEvent[]; + failPoints: Document[]; + ignoredEvents: string[]; observedEvents: ('commandStarted' | 'commandSucceeded' | 'commandFailed')[]; static EVENT_NAME_LOOKUP = { @@ -25,6 +32,11 @@ export class UnifiedMongoClient extends MongoClient { constructor(url: string, description: ClientEntity) { super(url, { monitorCommands: true, ...description.uriOptions }); this.events = []; + this.failPoints = []; + this.ignoredEvents = [ + ...(description.ignoreCommandMonitoringEvents ?? []), + 'configureFailPoint' + ]; // apm this.observedEvents = (description.observeEvents ?? []).map( e => UnifiedMongoClient.EVENT_NAME_LOOKUP[e] @@ -34,9 +46,11 @@ export class UnifiedMongoClient extends MongoClient { } } - // NOTE: this must be an arrow function for `this` to work. + // NOTE: pushEvent must be an arrow function pushEvent: (e: CommandEvent) => void = e => { - this.events.push(e); + if (!this.ignoredEvents.includes(e.commandName)) { + this.events.push(e); + } }; /** Disables command monitoring for the client and returns a list of the captured events. */ @@ -46,6 +60,25 @@ export class UnifiedMongoClient extends MongoClient { } return this.events; } + + async enableFailPoint(failPoint: Document): Promise { + const admin = this.db().admin(); + const result = await admin.command(failPoint); + expect(result).to.have.property('ok', 1); + this.failPoints.push(failPoint.configureFailPoint); + return result; + } + + async disableFailPoints(): Promise { + return Promise.all( + this.failPoints.map(configureFailPoint => + this.db().admin().command({ + configureFailPoint, + mode: 'off' + }) + ) + ); + } } export type Entity = @@ -53,7 +86,7 @@ export type Entity = | Db | Collection | ClientSession - | ChangeStream + | UnifiedChangeStream | GridFSBucket | Document; // Results from operations @@ -81,7 +114,7 @@ export class EntitiesMap extends Map { mapOf(type: 'collection'): EntitiesMap; mapOf(type: 'session'): EntitiesMap; mapOf(type: 'bucket'): EntitiesMap; - mapOf(type: 'stream'): EntitiesMap; + mapOf(type: 'stream'): EntitiesMap; mapOf(type: EntityTypeId): EntitiesMap { const ctor = ENTITY_CTORS.get(type); if (!ctor) { @@ -95,7 +128,7 @@ export class EntitiesMap extends Map { getEntity(type: 'collection', key: string, assertExists?: boolean): Collection; getEntity(type: 'session', key: string, assertExists?: boolean): ClientSession; getEntity(type: 'bucket', key: string, assertExists?: boolean): GridFSBucket; - getEntity(type: 'stream', key: string, assertExists?: boolean): ChangeStream; + getEntity(type: 'stream', key: string, assertExists?: boolean): UnifiedChangeStream; getEntity(type: EntityTypeId, key: string, assertExists = true): Entity { const entity = this.get(key); if (!entity) { @@ -114,6 +147,7 @@ export class EntitiesMap extends Map { async cleanup(): Promise { for (const [, client] of this.mapOf('client')) { + await client.disableFailPoints(); await client.close(); } for (const [, session] of this.mapOf('session')) { diff --git a/test/functional/unified-spec-runner/match.ts b/test/functional/unified-spec-runner/match.ts new file mode 100644 index 0000000000..b9781ed7cc --- /dev/null +++ b/test/functional/unified-spec-runner/match.ts @@ -0,0 +1,244 @@ +import { expect } from 'chai'; +import { isDeepStrictEqual } from 'util'; +import { Binary, Document, Long, ObjectId } from '../../../src'; +import { + CommandFailedEvent, + CommandStartedEvent, + CommandSucceededEvent +} from '../../../src/cmap/events'; +import { CommandEvent, EntitiesMap } from './entities'; +import { ExpectedEvent } from './schema'; + +export interface ExistsOperator { + $$exists: boolean; +} +export function isExistsOperator(value: unknown): value is ExistsOperator { + return typeof value === 'object' && value != null && '$$exists' in value; +} +export interface TypeOperator { + $$type: boolean; +} +export function isTypeOperator(value: unknown): value is TypeOperator { + return typeof value === 'object' && value != null && '$$type' in value; +} +export interface MatchesEntityOperator { + $$matchesEntity: string; +} +export function isMatchesEntityOperator(value: unknown): value is MatchesEntityOperator { + return typeof value === 'object' && value != null && '$$matchesEntity' in value; +} +export interface MatchesHexBytesOperator { + $$matchesHexBytes: string; +} +export function isMatchesHexBytesOperator(value: unknown): value is MatchesHexBytesOperator { + return typeof value === 'object' && value != null && '$$matchesHexBytes' in value; +} +export interface UnsetOrMatchesOperator { + $$unsetOrMatches: unknown; +} +export function isUnsetOrMatchesOperator(value: unknown): value is UnsetOrMatchesOperator { + return typeof value === 'object' && value != null && '$$unsetOrMatches' in value; +} +export interface SessionLsidOperator { + $$sessionLsid: string; +} +export function isSessionLsidOperator(value: unknown): value is SessionLsidOperator { + return typeof value === 'object' && value != null && '$$sessionLsid' in value; +} + +export const SpecialOperatorKeys = [ + '$$exists', + '$$type', + '$$matchesEntity', + '$$matchesHexBytes', + '$$unsetOrMatches', + '$$sessionLsid' +]; + +export type SpecialOperator = + | ExistsOperator + | TypeOperator + | MatchesEntityOperator + | MatchesHexBytesOperator + | UnsetOrMatchesOperator + | SessionLsidOperator; + +// eslint-disable-next-line @typescript-eslint/ban-types +type KeysOfUnion = T extends object ? keyof T : never; +export type SpecialOperatorKey = KeysOfUnion; +export function isSpecialOperator(value: unknown): value is SpecialOperator { + return ( + isExistsOperator(value) || + isTypeOperator(value) || + isMatchesEntityOperator(value) || + isMatchesHexBytesOperator(value) || + isUnsetOrMatchesOperator(value) || + isSessionLsidOperator(value) + ); +} + +const TYPE_MAP = new Map(); + +TYPE_MAP.set('double', actual => typeof actual === 'number' || actual._bsontype === 'Double'); +TYPE_MAP.set('string', actual => typeof actual === 'string'); +TYPE_MAP.set('object', actual => typeof actual === 'object' && actual !== null); +TYPE_MAP.set('array', actual => Array.isArray(actual)); +TYPE_MAP.set('binData', actual => actual instanceof Binary); +TYPE_MAP.set('undefined', actual => actual === undefined); +TYPE_MAP.set('objectId', actual => actual instanceof ObjectId); +TYPE_MAP.set('bool', actual => typeof actual === 'boolean'); +TYPE_MAP.set('date', actual => actual instanceof Date); +TYPE_MAP.set('null', actual => actual === null); +TYPE_MAP.set('regex', actual => actual instanceof RegExp || actual._bsontype === 'BSONRegExp'); +TYPE_MAP.set('dbPointer', actual => actual._bsontype === 'DBRef'); +TYPE_MAP.set('javascript', actual => actual._bsontype === 'Code'); +TYPE_MAP.set('symbol', actual => actual._bsontype === 'Symbol'); +TYPE_MAP.set('javascriptWithScope', actual => actual._bsontype === 'Code' && actual.scope); +TYPE_MAP.set('timestamp', actual => actual._bsontype === 'Timestamp'); +TYPE_MAP.set('decimal', actual => actual._bsontype === 'Decimal128'); +TYPE_MAP.set('minKey', actual => actual._bsontype === 'MinKey'); +TYPE_MAP.set('maxKey', actual => actual._bsontype === 'MaxKey'); +TYPE_MAP.set( + 'int', + actual => (typeof actual === 'number' && Number.isInteger(actual)) || actual._bsontype === 'Int32' +); +TYPE_MAP.set( + 'long', + actual => (typeof actual === 'number' && Number.isInteger(actual)) || Long.isLong(actual) +); + +export function expectResultCheck( + actual: Document, + expected: Document | number | string | boolean, + entities: EntitiesMap, + path: string[] = [], + depth = 0 +): boolean { + const ok = resultCheck(actual, expected, entities, path, depth); + if (ok === false) { + const pathString = path.join(''); + const expectedJSON = JSON.stringify(expected, undefined, 2); + const actualJSON = JSON.stringify(actual, undefined, 2); + expect.fail(`Unable to match ${expectedJSON} to ${actualJSON} at ${pathString}`); + } + return ok; +} + +export function resultCheck( + actual: Document, + expected: Document | number | string | boolean, + entities: EntitiesMap, + path: string[], + depth = 0 +): boolean { + if (typeof expected === 'object' && expected !== null) { + // Expected is an object + // either its a special operator or just an object to check equality against + + if (isSpecialOperator(expected)) { + // Special operation check is a base condition + // specialCheck may recurse depending upon the check ($$unsetOrMatches) + return specialCheck(actual, expected, entities, path, depth); + } else { + // Just a plain object, however this object can contain special operations + // So we need to recurse over each key,value + let ok = true; + const expectedEntries = Object.entries(expected); + + if (depth > 1 && Object.keys(actual).length !== Object.keys(expected).length) { + throw new Error(`[${Object.keys(actual)}] length !== [${Object.keys(expected)}]`); + } + + for (const [key, value] of expectedEntries) { + path.push(Array.isArray(expected) ? `[${key}]` : `.${key}`); // record what key we're at + depth += 1; + ok &&= expectResultCheck(actual[key], value, entities, path, depth); + depth -= 1; + path.pop(); // if the recursion was successful we can drop the tested key + } + return ok; + } + } else { + // Here's our recursion base case + // expected is: number | string | boolean | null + return isDeepStrictEqual(actual, expected); + } +} + +export function specialCheck( + actual: Document, + expected: SpecialOperator, + entities: EntitiesMap, + path: string[] = [], + depth = 0 +): boolean { + let ok = false; + if (isUnsetOrMatchesOperator(expected)) { + // $$unsetOrMatches + ok = true; // start with true assumption + if (actual === null || actual === undefined) ok = true; + else { + depth += 1; + ok &&= expectResultCheck(actual, expected.$$unsetOrMatches, entities, path, depth); + depth -= 1; + } + } else if (isMatchesEntityOperator(expected)) { + // $$matchesEntity + const entity = entities.get(expected.$$matchesEntity); + if (!entity) ok = false; + else ok = isDeepStrictEqual(actual, entity); + } else if (isMatchesHexBytesOperator(expected)) { + // $$matchesHexBytes + const expectedBuffer = Buffer.from(expected.$$matchesHexBytes, 'hex'); + ok = expectedBuffer.every((byte, index) => byte === actual[index]); + } else if (isSessionLsidOperator(expected)) { + // $$sessionLsid + const session = entities.getEntity('session', expected.$$sessionLsid, false); + if (!session) ok = false; + else ok = session.id.id.buffer.equals(actual.lsid.id.buffer); + } else if (isTypeOperator(expected)) { + // $$type + const types = Array.isArray(expected.$$type) ? expected.$$type : [expected.$$type]; + for (const type of types) { + ok ||= TYPE_MAP.get(type)(actual); + } + } else if (isExistsOperator(expected)) { + // $$exists - unique, this op uses the path to check if the key is (not) in actual + const actualExists = actual !== undefined && actual !== null; + ok = (expected.$$exists && actualExists) || (!expected.$$exists && !actualExists); + } else { + throw new Error(`Unknown special operator: ${JSON.stringify(expected)}`); + } + + return ok; +} + +export function matchesEvents( + expected: ExpectedEvent[], + actual: CommandEvent[], + entities: EntitiesMap +): void { + // TODO: NodeJS Driver has extra events + // expect(actual).to.have.lengthOf(expected.length); + + for (const [index, actualEvent] of actual.entries()) { + const expectedEvent = expected[index]; + + if (expectedEvent.commandStartedEvent && actualEvent instanceof CommandStartedEvent) { + expectResultCheck(actualEvent, expectedEvent.commandStartedEvent, entities, [ + `events[${index}].commandStartedEvent` + ]); + } else if ( + expectedEvent.commandSucceededEvent && + actualEvent instanceof CommandSucceededEvent + ) { + expectResultCheck(actualEvent, expectedEvent.commandSucceededEvent, entities, [ + `events[${index}].commandSucceededEvent` + ]); + } else if (expectedEvent.commandFailedEvent && actualEvent instanceof CommandFailedEvent) { + expect(actualEvent.commandName).to.equal(expectedEvent.commandFailedEvent.commandName); + } else { + expect.fail(`Events must be one of the known types, got ${actualEvent}`); + } + } +} diff --git a/test/functional/unified-spec-runner/operations.ts b/test/functional/unified-spec-runner/operations.ts index 6697d690e9..5878872224 100644 --- a/test/functional/unified-spec-runner/operations.ts +++ b/test/functional/unified-spec-runner/operations.ts @@ -1,18 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { expect } from 'chai'; -import { Document, MongoError } from '../../../src'; -import type { EntitiesMap } from './entities'; +import { ChangeStream, Document, InsertOneOptions, MongoError } from '../../../src'; +import { EventCollector } from '../../tools/utils'; +import { EntitiesMap } from './entities'; +import { expectResultCheck } from './match'; import type * as uni from './schema'; -import { - isExistsOperator, - isMatchesEntityOperator, - isMatchesHexBytesOperator, - isSessionLsidOperator, - isSpecialOperator, - isTypeOperator, - isUnsetOrMatchesOperator, - SpecialOperator -} from './unified-utils'; export class UnifiedOperation { name: string; @@ -114,8 +106,31 @@ async function commitTransactionOperation( async function createChangeStreamOperation( entities: EntitiesMap, op: uni.OperationDescription -): Promise { - throw new Error('not implemented.'); +): Promise { + const watchable = entities.get(op.object); + if (!('watch' in watchable)) { + throw new Error(`Entity ${op.object} must be watchable`); + } + const changeStream = watchable.watch(op.arguments.pipeline, { + fullDocument: op.arguments.fullDocument, + maxAwaitTimeMS: op.arguments.maxAwaitTimeMS, + resumeAfter: op.arguments.resumeAfter, + startAfter: op.arguments.startAfter, + startAtOperationTime: op.arguments.startAtOperationTime, + batchSize: op.arguments.batchSize + }); + changeStream.eventCollector = new EventCollector(changeStream, ['init', 'change', 'error']); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Change stream never started')); + }, 2000); + + changeStream.cursor.once('init', () => { + clearTimeout(timeout); + resolve(changeStream); + }); + }); } async function createCollectionOperation( entities: EntitiesMap, @@ -151,7 +166,9 @@ async function findOperation( entities: EntitiesMap, op: uni.OperationDescription ): Promise { - throw new Error('not implemented.'); + const collection = entities.getEntity('collection', op.object); + const { filter, sort, batchSize, limit } = op.arguments; + return await collection.find(filter, { sort, batchSize, limit }).toArray(); } async function findOneAndReplaceOperation( entities: EntitiesMap, @@ -169,34 +186,48 @@ async function failPointOperation( entities: EntitiesMap, op: uni.OperationDescription ): Promise { - throw new Error('not implemented.'); + const client = entities.getEntity('client', op.arguments.client); + return client.enableFailPoint(op.arguments.failPoint); } async function insertOneOperation( entities: EntitiesMap, op: uni.OperationDescription ): Promise { const collection = entities.getEntity('collection', op.object); + const session = entities.getEntity('session', op.arguments.session, false); - const result = await collection.insertOne(op.arguments.document); - return result; + + const options = { + session + } as InsertOneOptions; + + return await collection.insertOne(op.arguments.document, options); } async function insertManyOperation( entities: EntitiesMap, op: uni.OperationDescription ): Promise { const collection = entities.getEntity('collection', op.object); + const session = entities.getEntity('session', op.arguments.session, false); + const options = { + session, ordered: op.arguments.ordered ?? true }; - const result = await collection.insertMany(op.arguments.documents, options); - return result; + + return await collection.insertMany(op.arguments.documents, options); } async function iterateUntilDocumentOrErrorOperation( entities: EntitiesMap, op: uni.OperationDescription ): Promise { - throw new Error('not implemented.'); + const changeStream = entities.getEntity('stream', op.object); + // Either change or error promise will finish + return Promise.race([ + changeStream.eventCollector.waitAndShiftEvent('change'), + changeStream.eventCollector.waitAndShiftEvent('error') + ]); } async function listDatabasesOperation( entities: EntitiesMap, @@ -294,71 +325,34 @@ export async function executeOperationAndCheck( operation: uni.OperationDescription, entities: EntitiesMap ): Promise { - const operationName = operation.name; - const opFunc = operations.get(operationName); - expect(opFunc, `Unknown operation: ${operationName}`).to.exist; - try { - const result = await opFunc(entities, operation); - - if (operation.expectError) { - expect.fail(`Operation ${operationName} succeeded but was not supposed to`); - } + const opFunc = operations.get(operation.name); + expect(opFunc, `Unknown operation: ${operation.name}`).to.exist; - if (operation.expectResult) { - if (isSpecialOperator(operation.expectResult)) { - specialCheck(result, operation.expectResult); - } else { - for (const [resultKey, resultValue] of Object.entries(operation.expectResult)) { - // each key/value expectation can be a special op - if (isSpecialOperator(resultValue)) { - specialCheck(result, resultValue); - } else { - expect(result[resultKey]).to.deep.equal(resultValue); - } - } - } - } + let result; - if (operation.saveResultAsEntity) { - entities.set(operation.saveResultAsEntity, result); - } + try { + result = await opFunc(entities, operation); } catch (error) { if (operation.expectError) { expect(error).to.be.instanceof(MongoError); - // TODO more checking of the error + // expectErrorCheck(error, operation.expectError); } else { - expect.fail(`Operation ${operationName} failed with ${error.message}`); + expect.fail(`Operation ${operation.name} failed with ${error.message}`); } + return; } -} -export function specialCheck(result: Document, check: SpecialOperator): void { - if (isUnsetOrMatchesOperator(check)) { - if (result == null) return; // acceptable unset - if (typeof check.$$unsetOrMatches === 'object') { - // We need to a "deep equals" check but the props can also point to special checks - for (const [k, v] of Object.entries(check.$$unsetOrMatches)) { - expect(result).to.have.property(k); - if (isSpecialOperator(v)) { - specialCheck(result[k], v); - } else { - expect(v).to.equal(check.$$unsetOrMatches); - } - } - } else { - expect(result).to.equal(check.$$unsetOrMatches); - } - } else if (isExistsOperator(check)) { - throw new Error('not implemented.'); - } else if (isMatchesEntityOperator(check)) { - throw new Error('not implemented.'); - } else if (isMatchesHexBytesOperator(check)) { - throw new Error('not implemented.'); - } else if (isSessionLsidOperator(check)) { - throw new Error('not implemented.'); - } else if (isTypeOperator(check)) { - throw new Error('not implemented.'); - } else { - throw new Error('not implemented.'); + // We check the positive outcome here so the try-catch above doesn't catch our chai assertions + + if (operation.expectError) { + expect.fail(`Operation ${operation.name} succeeded but was not supposed to`); + } + + if (operation.expectResult) { + expect(expectResultCheck(result, operation.expectResult, entities)).to.be.true; + } + + if (operation.saveResultAsEntity) { + entities.set(operation.saveResultAsEntity, result); } } diff --git a/test/functional/unified-spec-runner/schema.ts b/test/functional/unified-spec-runner/schema.ts index 55526faf02..90680f81b3 100644 --- a/test/functional/unified-spec-runner/schema.ts +++ b/test/functional/unified-spec-runner/schema.ts @@ -46,7 +46,7 @@ export interface ClientEntity { uriOptions?: Document; useMultipleMongoses?: boolean; observeEvents?: ObservableEventId[]; - ignoreCommandMonitoringEvents?: [string, ...string[]]; + ignoreCommandMonitoringEvents?: string[]; serverApi?: ServerApi; } export interface DatabaseEntity { diff --git a/test/functional/unified-spec-runner/unified-utils.ts b/test/functional/unified-spec-runner/unified-utils.ts index 572792f82c..6abb2148bb 100644 --- a/test/functional/unified-spec-runner/unified-utils.ts +++ b/test/functional/unified-spec-runner/unified-utils.ts @@ -1,21 +1,16 @@ import { expect } from 'chai'; -import { - CommandFailedEvent, - CommandStartedEvent, - CommandSucceededEvent -} from '../../../src/cmap/events'; -import type { CommandEvent } from './entities'; -import type { CollectionOrDatabaseOptions, ExpectedEvent, RunOnRequirement } from './schema'; +import type { CollectionOrDatabaseOptions, RunOnRequirement } from './schema'; import type { TestConfiguration } from './unified.test'; import { gte as semverGte, lte as semverLte } from 'semver'; import { CollectionOptions, DbOptions } from '../../../src'; +import { isDeepStrictEqual } from 'util'; const ENABLE_UNIFIED_TEST_LOGGING = false; export function log(message: unknown, ...optionalParameters: unknown[]): void { if (ENABLE_UNIFIED_TEST_LOGGING) console.warn(message, ...optionalParameters); } -export function getUnmetRequirements(config: TestConfiguration, r: RunOnRequirement): boolean { +export function topologySatisfies(config: TestConfiguration, r: RunOnRequirement): boolean { let ok = true; if (r.minServerVersion) { const minVersion = patchVersion(r.minServerVersion); @@ -38,9 +33,12 @@ export function getUnmetRequirements(config: TestConfiguration, r: RunOnRequirem } if (r.serverParameters) { - // for (const [name, value] of Object.entries(r.serverParameters)) { - // // TODO - // } + if (!config.parameters) throw new Error('Configuration does not have server parameters'); + for (const [name, value] of Object.entries(r.serverParameters)) { + if (name in config.parameters) { + ok &&= isDeepStrictEqual(config.parameters[name], value); + } + } } return ok; @@ -58,30 +56,6 @@ export function* zip( } } -export function matchesEvents(expected: ExpectedEvent[], actual: CommandEvent[]): void { - expect(expected).to.have.lengthOf(actual.length); - - for (const [index, actualEvent] of actual.entries()) { - const expectedEvent = expected[index]; - - if (expectedEvent.commandStartedEvent && actualEvent instanceof CommandStartedEvent) { - expect(actualEvent.commandName).to.equal(expectedEvent.commandStartedEvent.commandName); - expect(actualEvent.command).to.containSubset(expectedEvent.commandStartedEvent.command); - expect(actualEvent.databaseName).to.equal(expectedEvent.commandStartedEvent.databaseName); - } else if ( - expectedEvent.commandSucceededEvent && - actualEvent instanceof CommandSucceededEvent - ) { - expect(actualEvent.commandName).to.equal(expectedEvent.commandSucceededEvent.commandName); - expect(actualEvent.reply).to.containSubset(expectedEvent.commandSucceededEvent.reply); - } else if (expectedEvent.commandFailedEvent && actualEvent instanceof CommandFailedEvent) { - expect(actualEvent.commandName).to.equal(expectedEvent.commandFailedEvent.commandName); - } else { - expect.fail(`Events must be one of the known types, got ${actualEvent}`); - } - } -} - /** Correct schema version to be semver compliant */ export function patchVersion(version: string): string { expect(version).to.be.a('string'); @@ -98,71 +72,3 @@ export function patchCollectionOptions(options: CollectionOrDatabaseOptions): Co // TODO return { ...options } as CollectionOptions; } - -export interface ExistsOperator { - $$exists: boolean; -} -export function isExistsOperator(value: unknown): value is ExistsOperator { - return typeof value === 'object' && value != null && '$$exists' in value; -} -export interface TypeOperator { - $$type: boolean; -} -export function isTypeOperator(value: unknown): value is TypeOperator { - return typeof value === 'object' && value != null && '$$type' in value; -} -export interface MatchesEntityOperator { - $$matchesEntity: string; -} -export function isMatchesEntityOperator(value: unknown): value is MatchesEntityOperator { - return typeof value === 'object' && value != null && '$$matchesEntity' in value; -} -export interface MatchesHexBytesOperator { - $$matchesHexBytes: string; -} -export function isMatchesHexBytesOperator(value: unknown): value is MatchesHexBytesOperator { - return typeof value === 'object' && value != null && '$$matchesHexBytes' in value; -} -export interface UnsetOrMatchesOperator { - $$unsetOrMatches: unknown; -} -export function isUnsetOrMatchesOperator(value: unknown): value is UnsetOrMatchesOperator { - return typeof value === 'object' && value != null && '$$unsetOrMatches' in value; -} -export interface SessionLsidOperator { - $$sessionLsid: unknown; -} -export function isSessionLsidOperator(value: unknown): value is SessionLsidOperator { - return typeof value === 'object' && value != null && '$$sessionLsid' in value; -} - -export const SpecialOperatorKeys = [ - '$$exists', - '$$type', - '$$matchesEntity', - '$$matchesHexBytes', - '$$unsetOrMatches', - '$$sessionLsid' -]; - -export type SpecialOperator = - | ExistsOperator - | TypeOperator - | MatchesEntityOperator - | MatchesHexBytesOperator - | UnsetOrMatchesOperator - | SessionLsidOperator; - -// eslint-disable-next-line @typescript-eslint/ban-types -type KeysOfUnion = T extends object ? keyof T : never; -export type SpecialOperatorKey = KeysOfUnion; -export function isSpecialOperator(value: unknown): value is SpecialOperator { - return ( - isExistsOperator(value) || - isTypeOperator(value) || - isMatchesEntityOperator(value) || - isMatchesHexBytesOperator(value) || - isUnsetOrMatchesOperator(value) || - isSessionLsidOperator(value) - ); -} diff --git a/test/functional/unified-spec-runner/unified.test.ts b/test/functional/unified-spec-runner/unified.test.ts index 82e8d3cfe3..2dc973d210 100644 --- a/test/functional/unified-spec-runner/unified.test.ts +++ b/test/functional/unified-spec-runner/unified.test.ts @@ -2,11 +2,12 @@ import { expect } from 'chai'; import { ReadPreference } from '../../../src/read_preference'; import { loadSpecTests } from '../../spec/index'; import * as uni from './schema'; -import { getUnmetRequirements, matchesEvents, patchVersion, zip, log } from './unified-utils'; -import { EntitiesMap } from './entities'; +import { patchVersion, zip, log, topologySatisfies } from './unified-utils'; +import { CommandEvent, EntitiesMap } from './entities'; import { ns } from '../../../src/utils'; import { executeOperationAndCheck } from './operations'; import { satisfies as semverSatisfies } from 'semver'; +import { matchesEvents } from './match'; export type TestConfiguration = InstanceType< typeof import('../../tools/runner/config')['TestConfiguration'] @@ -37,10 +38,17 @@ async function runOne( await UTIL_CLIENT.connect(); ctx.defer(async () => await UTIL_CLIENT.close()); + // Must fetch parameters before checking runOnRequirements + ctx.configuration.parameters = await UTIL_CLIENT.db().admin().command({ getParameter: '*' }); + // If test.runOnRequirements is specified, the test runner MUST skip the test unless one or more // runOnRequirement objects are satisfied. - if (test.runOnRequirements) { - if (!test.runOnRequirements.some(r => getUnmetRequirements(ctx.configuration, r))) { + const allRequirements = [ + ...(unifiedSuite.runOnRequirements ?? []), + ...(test.runOnRequirements ?? []) + ]; + for (const requirement of allRequirements) { + if (!topologySatisfies(ctx.configuration, requirement)) { ctx.skip(); } } @@ -94,7 +102,7 @@ async function runOne( await executeOperationAndCheck(operation, entities); } - const clientEvents = new Map(); + const clientEvents = new Map(); // If any event listeners were enabled on any client entities, // the test runner MUST now disable those event listeners. for (const [id, client] of entities.mapOf('client')) { @@ -107,7 +115,7 @@ async function runOne( const actualEvents = clientEvents.get(clientId); expect(actualEvents, `No client entity found with id ${clientId}`).to.exist; - matchesEvents(expectedEventList.events, actualEvents); + matchesEvents(expectedEventList.events, actualEvents, entities); } } @@ -144,6 +152,7 @@ describe('Unified test format', function unifiedTestRunner() { } catch (error) { if (error.message.includes('not implemented.')) { log(`${test.description}: was skipped due to missing functionality`); + log(error.stack); this.skip(); } else { throw error; diff --git a/test/tools/runner/config.js b/test/tools/runner/config.js index fd450365d5..ddc1ae9275 100644 --- a/test/tools/runner/config.js +++ b/test/tools/runner/config.js @@ -28,6 +28,7 @@ class TestConfiguration { this.topologyType = context.topologyType; this.version = context.version; this.clientSideEncryption = context.clientSideEncryption; + this.parameters = undefined; this.options = { hosts, hostAddresses, @@ -185,7 +186,7 @@ class TestConfiguration { } else { multipleHosts = this.options.hostAddresses .reduce((built, host) => { - built.push(host.type === 'tcp' ? `${host.host}:${host.port}` : host.host); + built.push(typeof host.port === 'number' ? `${host.host}:${host.port}` : host.host); return built; }, []) .join(','); diff --git a/test/tools/utils.js b/test/tools/utils.js index 3be582b242..9f82abaaf6 100644 --- a/test/tools/utils.js +++ b/test/tools/utils.js @@ -177,7 +177,77 @@ function visualizeMonitoringEvents(client) { }); } +class EventCollector { + constructor(obj, events, options) { + this._events = Object.create(null); + this._timeout = options ? options.timeout : 5000; + + events.forEach(eventName => { + this._events[eventName] = []; + obj.on(eventName, event => this._events[eventName].push(event)); + }); + } + + waitForEvent(eventName, count, callback) { + if (typeof count === 'function') { + callback = count; + count = 1; + } + + this.waitForEventImpl(this, Date.now(), eventName, count, callback); + } + + /** + * Will only return one event at a time from the front of the list + * Useful for iterating over the events in the order they occurred + * + * @param {string} eventName + * @returns {Promise>} + */ + waitAndShiftEvent(eventName) { + return new Promise((resolve, reject) => { + if (this._events[eventName].length > 0) { + return resolve(this._events[eventName].shift()); + } + this.waitForEventImpl(this, Date.now(), eventName, 1, error => { + if (error) return reject(error); + resolve(this._events[eventName].shift()); + }); + }); + } + + reset(eventName) { + if (eventName == null) { + Object.keys(this._events).forEach(eventName => { + this._events[eventName] = []; + }); + + return; + } + + if (this._events[eventName] == null) { + throw new TypeError(`invalid event name "${eventName}" specified for reset`); + } + + this._events[eventName] = []; + } + + waitForEventImpl(collector, start, eventName, count, callback) { + const events = collector._events[eventName]; + if (events.length >= count) { + return callback(undefined, events); + } + + if (Date.now() - start >= collector._timeout) { + return callback(new Error(`timed out waiting for event "${eventName}"`)); + } + + setTimeout(() => this.waitForEventImpl(collector, start, eventName, count, callback), 10); + } +} + module.exports = { + EventCollector, makeTestFunction, ensureCalledWith, ClassWithLogger,