botbuilder-core
Version:
Core components for Microsoft Bot Builder. Components in this library can run either in a browser or on the server.
1,095 lines (1,004 loc) • 42.3 kB
text/typescript
/**
* @module botbuilder
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
// tslint:disable-next-line:no-require-imports
import assert from 'assert';
import { v4 as uuidv4 } from 'uuid';
import {
Activity,
ActivityTypes,
ConversationReference,
ResourceResponse,
TokenResponse,
TokenExchangeRequest,
SignInUrlResponse,
ConversationAccount,
ChannelAccount,
Channels,
RoleTypes,
ActivityEx,
} from 'botframework-schema';
import { BotAdapter } from './botAdapter';
import { ExtendedUserTokenProvider } from './extendedUserTokenProvider';
import { TurnContext } from './turnContext';
/**
* Signature for a function that can be used to inspect individual activities returned by a bot
* that's being tested using the `TestAdapter`.
*
* ```TypeScript
* type TestActivityInspector = (activity: Partial<Activity>, description: string) => void;
* ```
*
* @param TestActivityInspector.activity The activity being inspected.
* @param TestActivityInspector.description Text to log in the event of an error.
*/
export type TestActivityInspector = (activity: Partial<Activity>, description?: string) => void;
/**
* Test adapter used for unit tests. This adapter can be used to simulate sending messages from the
* user to the bot.
*
* @remarks
* The following example sets up the test adapter and then executes a simple test:
*
* ```JavaScript
* const { TestAdapter } = require('botbuilder');
*
* const adapter = new TestAdapter(async (context) => {
* await context.sendActivity(`Hello World`);
* });
*
* adapter.test(`hi`, `Hello World`)
* .then(() => done());
* ```
*/
export class TestAdapter extends BotAdapter implements ExtendedUserTokenProvider {
/**
* Creates a new TestAdapter instance.
*
* @param logicOrConversation The bots logic that's under test.
* @param template (Optional) activity containing default values to assign to all test messages received.
* @param sendTraceActivity Indicates whether the adapter should add to its queue any trace activities generated by the bot.
*/
constructor(
logicOrConversation?: ((context: TurnContext) => Promise<void>) | ConversationReference,
template?: Partial<Activity>,
sendTraceActivity = false,
) {
super();
this._sendTraceActivity = sendTraceActivity;
this.template = template || {};
if (logicOrConversation) {
if (typeof logicOrConversation === 'function') {
this._logic = logicOrConversation;
this.conversation = TestAdapter.createConversation('Convo1');
} else {
this.conversation = logicOrConversation;
}
} else {
this.conversation = TestAdapter.createConversation('Convo1');
}
Object.assign(this.conversation, {
locale: this.template.locale || this.conversation.locale || this.locale,
serviceUrl: this.template.serviceUrl || this.conversation.serviceUrl,
channelId: this.template.channelId || this.conversation.channelId,
bot: this.template.recipient || this.conversation.bot,
user: this.template.from || this.conversation.user,
});
}
/**
* @private
* INTERNAL: used to drive the promise chain forward when running tests.
*/
get activityBuffer(): Partial<Activity>[] {
return this.activeQueue;
}
/**
* Gets a value indicating whether to send trace activities.
*
* @returns A value indicating whether to send trace activities.
*/
get enableTrace(): boolean {
return this._sendTraceActivity;
}
/**
* Sets a value inidicating whether to send trace activities.
*/
set enableTrace(value: boolean) {
this._sendTraceActivity = value;
}
/**
* Gets or sets the locale for the conversation.
*/
locale = 'en-us';
/**
* Gets the queue of responses from the bot.
*/
readonly activeQueue: Partial<Activity>[] = [];
/**
* Gets or sets a reference to the current conversation.
*/
conversation: ConversationReference;
/**
* Create a ConversationReference.
*
* @param name name of the conversation (also id).
* @param user name of the user (also id) default: User1.
* @param bot name of the bot (also id) default: Bot.
* @returns The [ConversationReference](xref:botframework-schema.ConversationReference).
*/
static createConversation(name: string, user = 'User1', bot = 'Bot'): ConversationReference {
const conversationReference: ConversationReference = {
channelId: Channels.Test,
serviceUrl: 'https://test.com',
conversation: { isGroup: false, id: name, name: name } as ConversationAccount,
user: { id: user.toLowerCase(), name: user } as ChannelAccount,
bot: { id: bot.toLowerCase(), name: bot } as ChannelAccount,
locale: 'en-us',
};
return conversationReference;
}
/**
* Dequeues and returns the next bot response from the activeQueue.
*
* @returns The next activity in the queue; or undefined, if the queue is empty.
*/
getNextReply(): Partial<Activity> {
if (this.activeQueue.length > 0) {
return this.activeQueue.shift();
}
return undefined;
}
/**
* Creates a message activity from text and the current conversational context.
*
* @param text The message text.
* @returns An appropriate message activity.
*/
makeActivity(text?: string): Partial<Activity> {
const activity: Partial<Activity> = {
type: ActivityTypes.Message,
locale: this.locale,
from: this.conversation.user,
recipient: this.conversation.bot,
conversation: this.conversation.conversation,
serviceUrl: this.conversation.serviceUrl,
id: (this._nextId++).toString(),
text: text,
};
return activity;
}
/**
* Processes a message activity from a user.
*
* @param userSays The text of the user's message.
* @param callback The bot logic to invoke.
* @returns {Promise<any>} A promise representing the async operation.
*/
sendTextToBot(userSays: string, callback: (context: TurnContext) => Promise<any>): Promise<any> {
return this.processActivity(this.makeActivity(userSays), callback);
}
/**
* `Activity` template that will be merged with all activities sent to the logic under test.
*/
readonly template: Partial<Activity>;
private _logic: (context: TurnContext) => Promise<void>;
private _sendTraceActivity = false;
private _nextId = 0;
private readonly ExceptionExpected: string = 'ExceptionExpected';
/**
* Receives an activity and runs it through the middleware pipeline.
*
* @param activity The activity to process.
* @param callback The bot logic to invoke.
* @returns {Promise<any>} A promise representing the async operation.
*/
async processActivity(
activity: string | Partial<Activity>,
callback?: (context: TurnContext) => Promise<any>,
): Promise<any> {
const request: Partial<Activity> =
typeof activity === 'string' ? { type: ActivityTypes.Message, text: activity } : activity;
request.type = request.type || ActivityTypes.Message;
request.channelId = request.channelId || this.conversation.channelId;
if (!request.from || request.from.id === 'unknown' || request.from.role === RoleTypes.Bot) {
request.from = this.conversation.user;
}
request.recipient = request.recipient || this.conversation.bot;
request.conversation = request.conversation || this.conversation.conversation;
request.serviceUrl = request.serviceUrl || this.conversation.serviceUrl;
request.id = request.id || (this._nextId++).toString();
request.timestamp = request.timestamp || new Date();
Object.assign(request, this.template);
const context = this.createContext(request);
if (callback) {
return await this.runMiddleware(context, callback);
} else if (this._logic) {
return await this.runMiddleware(context, this._logic);
}
}
/**
* @private
* Sends activities to the conversation.
* @param context Context object for the current turn of conversation with the user.
* @param activities Set of activities sent by logic under test.
*/
async sendActivities(context: TurnContext, activities: Partial<Activity>[]): Promise<ResourceResponse[]> {
if (!context) {
throw new Error('TurnContext cannot be null.');
}
if (!activities) {
throw new Error('Activities cannot be null.');
}
if (activities.length == 0) {
throw new Error('Expecting one or more activities, but the array was empty.');
}
const responses: ResourceResponse[] = [];
for (let i = 0; i < activities.length; i++) {
const activity = activities[i];
if (!activity.id) {
activity.id = uuidv4();
}
if (!activity.timestamp) {
activity.timestamp = new Date();
}
if (activity.type === 'delay') {
const delayMs = parseInt(activity.value);
await new Promise((resolve) => setTimeout(resolve, delayMs));
} else if (activity.type === ActivityTypes.Trace) {
if (this._sendTraceActivity) {
this.activeQueue.push(activity);
}
} else {
this.activeQueue.push(activity);
}
responses.push({ id: activity.id } as ResourceResponse);
}
return responses;
}
/**
* @private
* Replaces an existing activity in the activeQueue.
* @param context Context object for the current turn of conversation with the user.
* @param activity Activity being updated.
* @returns promise representing async operation
*/
updateActivity(context: TurnContext, activity: Partial<Activity>): Promise<ResourceResponse | void> {
if (activity.id) {
const idx = this.activeQueue.findIndex((a) => a.id === activity.id);
if (idx !== -1) {
this.activeQueue.splice(idx, 1, activity);
}
return Promise.resolve({ id: activity.id });
}
return Promise.resolve();
}
/**
* @private
* Deletes an existing activity in the activeQueue.
* @param context Context object for the current turn of conversation with the user.
* @param reference `ConversationReference` for activity being deleted.
*/
deleteActivity(context: TurnContext, reference: Partial<ConversationReference>): Promise<void> {
if (reference.activityId) {
const idx = this.activeQueue.findIndex((a) => a.id === reference.activityId);
if (idx !== -1) {
this.activeQueue.splice(idx, 1);
}
}
return Promise.resolve();
}
/**
* @private
* INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot.
* This will cause the adapters middleware pipe to be run and it's logic to be called.
* @param activity Text or activity from user. The current conversation reference [template](#template) will be merged the passed in activity to properly address the activity. Fields specified in the activity override fields in the template.
*/
receiveActivity(activity: string | Partial<Activity>): Promise<void> {
return this.processActivity(activity);
}
/**
* The `TestAdapter` doesn't implement `continueConversation()` and will return an error if it's
* called.
*
* @param _reference A reference to the conversation to continue.
* @param _logic The asynchronous method to call after the adapter middleware runs.
* @returns {Promise<void>} A promise representing the async operation.
*/
continueConversation(
_reference: Partial<ConversationReference>,
_logic: (revocableContext: TurnContext) => Promise<void>,
): Promise<void> {
return Promise.reject(new Error('not implemented'));
}
/**
* Creates a turn context.
*
* @param request An incoming request body.
* @returns The created [TurnContext](xref:botbuilder-core.TurnContext).
* @remarks
* Override this in a derived class to modify how the adapter creates a turn context.
*/
protected createContext(request: Partial<Activity>): TurnContext {
return new TurnContext(this, request);
}
/**
* Sends something to the bot. This returns a new `TestFlow` instance which can be used to add
* additional steps for inspecting the bots reply and then sending additional activities.
*
* @remarks
* This example shows how to send a message and then verify that the response was as expected:
*
* ```JavaScript
* adapter.send('hi')
* .assertReply('Hello World')
* .then(() => done());
* ```
* @param userSays Text or activity simulating user input.
* @returns a new [TestFlow](xref:botbuilder-core.TestFlow) instance which can be used to add additional steps
* for inspecting the bots reply and then sending additional activities.
*/
send(userSays: string | Partial<Activity>): TestFlow {
return new TestFlow(this.processActivity(userSays), this);
}
/**
* Send something to the bot and expects the bot to return with a given reply.
*
* @remarks
* This is simply a wrapper around calls to `send()` and `assertReply()`. This is such a
* common pattern that a helper is provided.
*
* ```JavaScript
* adapter.test('hi', 'Hello World')
* .then(() => done());
* ```
* @param userSays Text or activity simulating user input.
* @param expected Expected text or activity of the reply sent by the bot.
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param _timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
test(
userSays: string | Partial<Activity>,
expected: string | Partial<Activity> | ((activity: Partial<Activity>, description?: string) => void),
description?: string,
_timeout?: number,
): TestFlow {
return this.send(userSays).assertReply(expected, description);
}
/**
* Test a list of activities.
*
* @remarks
* Each activity with the "bot" role will be processed with assertReply() and every other
* activity will be processed as a user message with send().
* @param activities Array of activities.
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
testActivities(activities: Partial<Activity>[], description?: string, timeout?: number): TestFlow {
if (!activities) {
throw new Error('Missing array of activities');
}
const activityInspector: any =
(expected: Partial<Activity>): TestActivityInspector =>
(actual: Partial<Activity>, description2: string): any =>
validateTranscriptActivity(actual, expected, description2);
// Chain all activities in a TestFlow, check if its a user message (send) or a bot reply (assert)
return activities.reduce(
(flow: TestFlow, activity: Partial<Activity>) => {
// tslint:disable-next-line:prefer-template
const assertDescription = `reply ${description ? ' from ' + description : ''}`;
return this.isReply(activity)
? flow.assertReply(activityInspector(activity, description), assertDescription, timeout)
: flow.send(activity);
},
new TestFlow(Promise.resolve(), this),
);
}
private _userTokens: UserToken[] = [];
private _magicCodes: TokenMagicCode[] = [];
/**
* Adds a fake user token so it can later be retrieved.
*
* @param connectionName The connection name.
* @param channelId The channel id.
* @param userId The user id.
* @param token The token to store.
* @param magicCode (Optional) The optional magic code to associate with this token.
*/
addUserToken(connectionName: string, channelId: string, userId: string, token: string, magicCode?: string) {
const key: UserToken = new UserToken();
key.channelId = channelId;
key.connectionName = connectionName;
key.userId = userId;
key.token = token;
if (!magicCode) {
this._userTokens.push(key);
} else {
const mc = new TokenMagicCode();
mc.key = key;
mc.magicCode = magicCode;
this._magicCodes.push(mc);
}
}
/**
* Asynchronously retrieves the token status for each configured connection for the given user.
* In testAdapter, retrieves tokens which were previously added via addUserToken.
*
* @param context The context object for the turn.
* @param userId The ID of the user to retrieve the token status for.
* @param includeFilter Optional. A comma-separated list of connection's to include. If present,
* the `includeFilter` parameter limits the tokens this method returns.
* @param _oAuthAppCredentials AppCredentials for OAuth.
* @returns The [TokenStatus](xref:botframework-connector.TokenStatus) objects retrieved.
*/
async getTokenStatus(
context: TurnContext,
userId: string,
includeFilter?: string,
_oAuthAppCredentials?: any,
): Promise<any[]> {
if (!context || !context.activity) {
throw new Error('testAdapter.getTokenStatus(): context with activity is required');
}
if (!userId && (!context.activity.from || !context.activity.from.id)) {
throw new Error('testAdapter.getTokenStatus(): missing userId, from or from.id');
}
const filter = includeFilter ? includeFilter.split(',') : undefined;
if (!userId) {
userId = context.activity.from.id;
}
return this._userTokens
.filter(
(x) =>
x.channelId === context.activity.channelId &&
x.userId === userId &&
(!filter || filter.includes(x.connectionName)),
)
.map((token) => ({
ConnectionName: token.connectionName,
HasToken: true,
ServiceProviderDisplayName: token.connectionName,
}));
}
/**
* Retrieves the OAuth token for a user that is in a sign-in flow.
*
* @param context Context for the current turn of conversation with the user.
* @param connectionName Name of the auth connection to use.
* @param magicCode (Optional) Optional user entered code to validate.
* @returns The OAuth token for a user that is in a sign-in flow.
*/
async getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise<TokenResponse> {
const key: UserToken = new UserToken();
key.channelId = context.activity.channelId;
key.connectionName = connectionName;
key.userId = context.activity.from.id;
if (magicCode) {
const magicCodeRecord = this._magicCodes.find(
(item) => key.equalsKey(item.key) && item.magicCode === magicCode,
);
if (magicCodeRecord) {
// move the token to long term dictionary
this.addUserToken(connectionName, key.channelId, key.userId, magicCodeRecord.key.token);
// remove from the magic code list
const idx = this._magicCodes.indexOf(magicCodeRecord);
this._magicCodes.splice(idx, 1);
}
}
const userToken = this._userTokens.find((token) => key.equalsKey(token));
return userToken && Object.assign({ expiration: undefined }, userToken);
}
/**
* Signs the user out with the token server.
*
* @param context Context for the current turn of conversation with the user.
* @param connectionName Name of the auth connection to use.
* @param userId User ID to sign out.
*/
async signOutUser(context: TurnContext, connectionName?: string, userId?: string): Promise<void> {
const channelId = context.activity.channelId;
userId = userId || context.activity.from.id;
this._userTokens = this._userTokens.filter(
(token) =>
connectionName &&
(connectionName !== token.connectionName || channelId !== token.channelId || userId !== token.userId),
);
}
/**
* Gets a signin link from the token server that can be sent as part of a SigninCard.
*
* @param context Context for the current turn of conversation with the user.
* @param connectionName Name of the auth connection to use.
* @returns A signin link from the token server that can be sent as part of a SigninCard.
*/
async getSignInLink(context: TurnContext, connectionName: string): Promise<string> {
return `https://fake.com/oauthsignin/${connectionName}/${context.activity.channelId}/${context.activity.from.id}`;
}
/**
* Signs the user out with the token server.
*
* @param _context Context for the current turn of conversation with the user.
* @param _connectionName Name of the auth connection to use.
* @param _resourceUrls The list of resource URLs to retrieve tokens for.
* @returns A Dictionary of resourceUrl to the corresponding TokenResponse.
*/
async getAadTokens(
_context: TurnContext,
_connectionName: string,
_resourceUrls: string[],
): Promise<{
[propertyName: string]: TokenResponse;
}> {
return undefined;
}
private exchangeableTokens: { [key: string]: ExchangeableToken } = {};
/**
* Adds a fake exchangeable token so it can be exchanged later.
*
* @param connectionName Name of the auth connection to use.
* @param channelId Channel ID.
* @param userId User ID.
* @param exchangeableItem Exchangeable token or resource URI.
* @param token Token to store.
*/
addExchangeableToken(
connectionName: string,
channelId: string,
userId: string,
exchangeableItem: string,
token: string,
) {
const key: ExchangeableToken = new ExchangeableToken();
key.channelId = channelId;
key.connectionName = connectionName;
key.userId = userId;
key.exchangeableItem = exchangeableItem;
key.token = token;
this.exchangeableTokens[key.toKey()] = key;
}
/**
* Gets a sign-in resource.
*
* @param context [TurnContext](xref:botbuilder-core.TurnContext) for the current turn of conversation with the user.
* @param connectionName Name of the auth connection to use.
* @param userId User ID
* @param _finalRedirect Final redirect URL.
* @returns A `Promise` with a new [SignInUrlResponse](xref:botframework-schema.SignInUrlResponse) object.
*/
async getSignInResource(
context: TurnContext,
connectionName: string,
userId?: string,
_finalRedirect?: string,
): Promise<SignInUrlResponse> {
return {
signInLink: `https://botframeworktestadapter.com/oauthsignin/${connectionName}/${context.activity.channelId}/${userId}`,
tokenExchangeResource: {
id: String(Math.random()),
providerId: null,
uri: `api://${connectionName}/resource`,
},
};
}
/**
* Performs a token exchange operation such as for single sign-on.
*
* @param context [TurnContext](xref:botbuilder-core.TurnContext) for the current turn of conversation with the user.
* @param connectionName Name of the auth connection to use.
* @param userId User id associated with the token.
* @param tokenExchangeRequest Exchange request details, either a token to exchange or a uri to exchange.
* @returns If the promise completes, the exchanged token is returned.
*/
async exchangeToken(
context: TurnContext,
connectionName: string,
userId: string,
tokenExchangeRequest: TokenExchangeRequest,
): Promise<TokenResponse> {
const exchangeableValue: string = tokenExchangeRequest.token
? tokenExchangeRequest.token
: tokenExchangeRequest.uri;
const key = new ExchangeableToken();
key.channelId = context.activity.channelId;
key.connectionName = connectionName;
key.exchangeableItem = exchangeableValue;
key.userId = userId;
const tokenExchangeResponse = this.exchangeableTokens[key.toKey()];
if (tokenExchangeResponse && tokenExchangeResponse.token === this.ExceptionExpected) {
throw new Error('Exception occurred during exchanging tokens');
}
return tokenExchangeResponse
? {
channelId: key.channelId,
connectionName: key.connectionName,
token: tokenExchangeResponse.token,
expiration: null,
}
: null;
}
/**
* Adds an instruction to throw an exception during exchange requests.
*
* @param connectionName The connection name.
* @param channelId The channel id.
* @param userId The user id.
* @param exchangeableItem The exchangeable token or resource URI.
*/
throwOnExchangeRequest(connectionName: string, channelId: string, userId: string, exchangeableItem: string): void {
const token: ExchangeableToken = new ExchangeableToken();
token.channelId = channelId;
token.connectionName = connectionName;
token.userId = userId;
token.exchangeableItem = exchangeableItem;
const key = token.toKey();
token.token = this.ExceptionExpected;
this.exchangeableTokens[key] = token;
}
/**
* Indicates if the activity is a reply from the bot (role == 'bot')
*
* @remarks
* Checks to see if the from property and if from.role exists on the Activity before
* checking to see who the activity is from. Otherwise returns false by default.
* @param activity Activity to check.
* @returns True if the activity is a reply from the bot, otherwise, false.
*/
private isReply(activity: Partial<Activity>): boolean {
if (activity.from && activity.from.role) {
return activity.from.role && activity.from.role.toLocaleLowerCase() === 'bot';
} else {
return false;
}
}
}
class UserToken {
connectionName: string;
userId: string;
channelId: string;
token: string;
equalsKey(rhs: UserToken): boolean {
return (
rhs &&
this.connectionName === rhs.connectionName &&
this.userId === rhs.userId &&
this.channelId === rhs.channelId
);
}
}
class TokenMagicCode {
key: UserToken;
magicCode: string;
}
class ExchangeableToken extends UserToken {
exchangeableItem: string;
equalsKey(rhs: ExchangeableToken): boolean {
return rhs != null && this.exchangeableItem === rhs.exchangeableItem && super.equalsKey(rhs);
}
toKey(): string {
return this.exchangeableItem;
}
}
/**
* Support class for `TestAdapter` that allows for the simple construction of a sequence of tests.
*
* @remarks
* Calling `adapter.send()` or `adapter.test()` will create a new test flow which you can chain
* together additional tests using a fluent syntax.
*
* ```JavaScript
* const { TestAdapter } = require('botbuilder');
*
* const adapter = new TestAdapter(async (context) => {
* if (context.text === 'hi') {
* await context.sendActivity(`Hello World`);
* } else if (context.text === 'bye') {
* await context.sendActivity(`Goodbye`);
* }
* });
*
* adapter.test(`hi`, `Hello World`)
* .test(`bye`, `Goodbye`)
* .then(() => done());
* ```
*/
export class TestFlow {
/**
* @private
* INTERNAL: creates a new TestFlow instance.
* @param previous Promise chain for the current test sequence.
* @param adapter Adapter under tested.
* @param callback The bot turn processing logic to test.
*/
constructor(
public previous: Promise<void>,
private adapter: TestAdapter,
private callback?: (turnContext: TurnContext) => Promise<unknown>,
) {}
/**
* Send something to the bot and expects the bot to return with a given reply. This is simply a
* wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a
* helper is provided.
*
* @param userSays Text or activity simulating user input.
* @param expected Expected text or activity of the reply sent by the bot.
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
test(
userSays: string | Partial<Activity>,
expected: string | Partial<Activity> | ((activity: Partial<Activity>, description?: string) => void),
description?: string,
timeout?: number,
): TestFlow {
return this.send(userSays).assertReply(expected, description || `test("${userSays}", "${expected}")`, timeout);
}
/**
* Sends something to the bot.
*
* @param userSays Text or activity simulating user input.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
send(userSays: string | Partial<Activity>): TestFlow {
return new TestFlow(
this.previous.then(() => this.adapter.processActivity(userSays, this.callback)),
this.adapter,
this.callback,
);
}
/**
* Creates a conversation update activity and process the activity.
*
* @returns {TestFlow} A new TestFlow object.
*/
sendConversationUpdate(): TestFlow {
return new TestFlow(
this.previous.then(() => {
const cu = ActivityEx.createConversationUpdateActivity();
cu.membersAdded ??= [];
cu.membersAdded.push(this.adapter.conversation.user);
return this.adapter.processActivity(cu, this.callback);
}),
this.adapter,
this.callback,
);
}
/**
* Generates an assertion if the bots response doesn't match the expected text/activity.
*
* @param expected Expected text or activity from the bot. Can be a callback to inspect the response using custom logic.
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
assertReply(
expected: string | Partial<Activity> | TestActivityInspector,
description?: string,
timeout?: number,
): TestFlow {
function defaultInspector(reply: Partial<Activity>, description2?: string): void {
if (typeof expected === 'object') {
validateActivity(reply, expected);
} else {
assert.equal(reply.type, ActivityTypes.Message, `${description2} type === '${reply.type}'. `);
assert.equal(reply.text, expected, `${description2} text === "${reply.text}"`);
}
}
if (!description) {
description = '';
}
const inspector: TestActivityInspector = typeof expected === 'function' ? expected : defaultInspector;
return new TestFlow(
this.previous.then(() => {
// tslint:disable-next-line:promise-must-complete
return new Promise<void>((resolve: any, reject: any): void => {
if (!timeout) {
timeout = 3000;
}
const start: number = new Date().getTime();
const adapter: TestAdapter = this.adapter;
function waitForActivity(): void {
const current: number = new Date().getTime();
if (current - start > <number>timeout) {
// Operation timed out
let expecting: string;
switch (typeof expected) {
case 'string':
default:
expecting = `"${expected.toString()}"`;
break;
case 'object':
expecting = `"${(expected as Activity).text}`;
break;
case 'function':
expecting = expected.toString();
break;
}
reject(
new Error(
`TestAdapter.assertReply(${expecting}): ${description} Timed out after ${
current - start
}ms.`,
),
);
} else if (adapter.activeQueue.length > 0) {
// Activity received
const reply: Partial<Activity> = adapter.activeQueue.shift() as Activity;
try {
inspector(reply, description as string);
} catch (err) {
reject(err);
}
resolve();
} else {
setTimeout(waitForActivity, 5);
}
}
waitForActivity();
});
}),
this.adapter,
this.callback,
);
}
/**
* Generates an assertion that the turn processing logic did not generate a reply from the bot, as expected.
*
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
assertNoReply(description?: string, timeout?: number): TestFlow {
return new TestFlow(
this.previous.then(() => {
// tslint:disable-next-line:promise-must-complete
return new Promise<void>((resolve: any): void => {
if (!timeout) {
timeout = 3000;
}
const start: number = new Date().getTime();
const adapter: TestAdapter = this.adapter;
function waitForActivity(): void {
const current: number = new Date().getTime();
if (current - start > <number>timeout) {
// Operation timed out and received no reply
resolve();
} else if (adapter.activeQueue.length > 0) {
// Activity received
const reply: Partial<Activity> = adapter.activeQueue.shift() as Activity;
assert.strictEqual(
reply,
undefined,
`${JSON.stringify(reply)} is responded when waiting for no reply: '${description}'`,
);
resolve();
} else {
setTimeout(waitForActivity, 5);
}
}
waitForActivity();
});
}),
this.adapter,
this.callback,
);
}
/**
* Generates an assertion if the bots response is not one of the candidate strings.
*
* @param candidates List of candidate responses.
* @param description (Optional) Description of the test case. If not provided one will be generated.
* @param timeout (Optional) number of milliseconds to wait for a response from bot. Defaults to a value of `3000`.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
assertReplyOneOf(candidates: string[], description?: string, timeout?: number): TestFlow {
return this.assertReply(
(activity: Partial<Activity>, description2: string) => {
for (const candidate of candidates) {
if (activity.text === candidate) {
return;
}
}
assert.fail(
`TestAdapter.assertReplyOneOf(): ${description2 || ''} FAILED, Expected one of :${JSON.stringify(
candidates,
)}`,
);
},
description,
timeout,
);
}
/**
* Inserts a delay before continuing.
*
* @param ms ms to wait.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
delay(ms: number): TestFlow {
return new TestFlow(
this.previous.then(() => {
return new Promise<void>((resolve: any, _reject: any): void => {
setTimeout(resolve, ms);
});
}),
this.adapter,
this.callback,
);
}
/**
* Adds a `then()` step to the tests promise chain.
*
* @param onFulfilled Code to run if the test is currently passing.
* @param onRejected Code to run if the test has thrown an error.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
then(onFulfilled?: () => void, onRejected?: (err) => void): TestFlow {
return new TestFlow(this.previous.then(onFulfilled, onRejected), this.adapter, this.callback);
}
/**
* Adds a finally clause. Note that you can't keep chaining afterwards.
*
* @param onFinally Code to run after the test chain.
* @returns {Promise<void>} A promise representing the async operation.
*/
finally(onFinally: () => void): Promise<void> {
return Promise.resolve(this.previous.finally(onFinally));
}
/**
* Adds a `catch()` clause to the tests promise chain.
*
* @param onRejected Code to run if the test has thrown an error.
* @returns A new [TestFlow](xref:botbuilder-core.TestFlow) object that appends this exchange to the modeled exchange.
*/
catch(onRejected?: (reason: any) => void): TestFlow {
return new TestFlow(this.previous.catch(onRejected), this.adapter, this.callback);
}
/**
* Start the test sequence, returning a promise to await.
*
* @returns {Promise<void>} A promise representing the async operation.
*/
startTest(): Promise<void> {
return this.previous;
}
}
/**
* @private
* @param activity an activity object to validate
* @param expected expected object to validate against
*/
function validateActivity(activity: Partial<Activity>, expected: Partial<Activity>): void {
// tslint:disable-next-line:forin
Object.keys(expected).forEach((prop: any) => {
assert.equal((<any>activity)[prop], (<any>expected)[prop]);
});
}
/**
* @private
* Does a shallow comparison of:
* - type
* - text
* - speak
* - suggestedActions
*/
function validateTranscriptActivity(
activity: Partial<Activity>,
expected: Partial<Activity>,
description: string,
): void {
assert.equal(activity.type, expected.type, `failed "type" assert on ${description}`);
assert.equal(activity.text, expected.text, `failed "text" assert on ${description}`);
assert.equal(activity.speak, expected.speak, `failed "speak" assert on ${description}`);
assert.deepEqual(
activity.suggestedActions,
expected.suggestedActions,
`failed "suggestedActions" assert on ${description}`,
);
}