From 05e32287b4b4fa7914a393c085336a7b3f629b58 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 12:25:04 -0700 Subject: [PATCH 01/35] injectable IScheduler, WebSocket, ajax services --- src/directLine.ts | 96 ++++++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/src/directLine.ts b/src/directLine.ts index 54c426c64..c4bb564e9 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -1,8 +1,9 @@ // In order to keep file size down, only import the parts of rxjs that we use -import { AjaxResponse, AjaxRequest } from 'rxjs/observable/dom/AjaxObservable'; +import { AjaxResponse, AjaxCreationMethod } from 'rxjs/observable/dom/AjaxObservable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Observable } from 'rxjs/Observable'; +import { IScheduler } from 'rxjs/Scheduler'; import { Subscriber } from 'rxjs/Subscriber'; import { Subscription } from 'rxjs/Subscription'; @@ -333,7 +334,7 @@ export interface EventActivity extends IActivity { export type Activity = Message | Typing | EventActivity; -interface ActivityGroup { +export interface ActivityGroup { activities: Activity[], watermark: string } @@ -362,6 +363,18 @@ export interface DirectLineOptions { botAgent?: string } +export interface Services { + scheduler: IScheduler; + WebSocket: typeof WebSocket; + ajax: AjaxCreationMethod; +} + +const makeServices = (services: Partial): Services => ({ + scheduler: services.scheduler, + ajax: services.ajax || Observable.ajax, + WebSocket: services.WebSocket || WebSocket, +}); + const lifetimeRefreshToken = 30 * 60 * 1000; const intervalRefreshToken = lifetimeRefreshToken / 2; const timeout = 20 * 1000; @@ -403,6 +416,7 @@ export class DirectLine implements IBotConnection { private watermark = ''; private streamUrl: string; private _botAgent = ''; + private services: Services; private _userAgent: string; public referenceGrammarId: string; @@ -410,7 +424,7 @@ export class DirectLine implements IBotConnection { private tokenRefreshSubscription: Subscription; - constructor(options: DirectLineOptions) { + constructor(options: DirectLineOptions & Partial) { this.secret = options.secret; this.token = options.secret || options.token; this.webSocket = (options.webSocket === undefined ? true : options.webSocket) && typeof WebSocket !== 'undefined' && WebSocket !== undefined; @@ -437,6 +451,8 @@ export class DirectLine implements IBotConnection { this._botAgent = this.getBotAgent(options.botAgent); + this.services = makeServices(options); + const parsedPollingInterval = ~~options.pollingInterval; if (parsedPollingInterval < POLLING_INTERVAL_LOWER_BOUND) { @@ -470,7 +486,7 @@ export class DirectLine implements IBotConnection { //if token and streamUrl are defined it means reconnect has already been done. Skipping it. if (this.token && this.streamUrl) { this.connectionStatus$.next(ConnectionStatus.Online); - return Observable.of(connectionStatus); + return Observable.of(connectionStatus, this.services.scheduler); } else { return this.startConversation().do(conversation => { this.conversationId = conversation.conversationId; @@ -488,23 +504,23 @@ export class DirectLine implements IBotConnection { } } else { - return Observable.of(connectionStatus); + return Observable.of(connectionStatus, this.services.scheduler); } }) .filter(connectionStatus => connectionStatus != ConnectionStatus.Uninitialized && connectionStatus != ConnectionStatus.Connecting) .flatMap(connectionStatus => { switch (connectionStatus) { case ConnectionStatus.Ended: - return Observable.throw(errorConversationEnded); + return Observable.throw(errorConversationEnded, this.services.scheduler); case ConnectionStatus.FailedToConnect: - return Observable.throw(errorFailedToConnect); + return Observable.throw(errorFailedToConnect, this.services.scheduler); case ConnectionStatus.ExpiredToken: - return Observable.of(connectionStatus); + return Observable.of(connectionStatus, this.services.scheduler); default: - return Observable.of(connectionStatus); + return Observable.of(connectionStatus, this.services.scheduler); } }) @@ -546,7 +562,7 @@ export class DirectLine implements IBotConnection { : `${this.domain}/conversations`; const method = this.conversationId ? "GET" : "POST"; - return Observable.ajax({ + return this.services.ajax({ method, url, timeout, @@ -561,16 +577,16 @@ export class DirectLine implements IBotConnection { // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while error$.mergeMap(error => error.status >= 400 && error.status < 600 - ? Observable.throw(error) - : Observable.of(error) + ? Observable.throw(error, this.services.scheduler) + : Observable.of(error, this.services.scheduler) ) - .delay(timeout) + .delay(timeout, this.services.scheduler) .take(retries) ) } private refreshTokenLoop() { - this.tokenRefreshSubscription = Observable.interval(intervalRefreshToken) + this.tokenRefreshSubscription = Observable.interval(intervalRefreshToken, this.services.scheduler) .flatMap(_ => this.refreshToken()) .subscribe(token => { konsole.log("refreshing token", token, "at", new Date()); @@ -581,7 +597,7 @@ export class DirectLine implements IBotConnection { private refreshToken() { return this.checkConnection(true) .flatMap(_ => - Observable.ajax({ + this.services.ajax({ method: "POST", url: `${this.domain}/tokens/refresh`, timeout, @@ -595,15 +611,15 @@ export class DirectLine implements IBotConnection { if (error.status === 403) { // if the token is expired there's no reason to keep trying this.expiredToken(); - return Observable.throw(error); + return Observable.throw(error, this.services.scheduler); } else if (error.status === 404) { // If the bot is gone, we should stop retrying - return Observable.throw(error); + return Observable.throw(error, this.services.scheduler); } - return Observable.of(error); + return Observable.of(error, this.services.scheduler); }) - .delay(timeout) + .delay(timeout, this.services.scheduler) .take(retries) ) ) @@ -634,7 +650,7 @@ export class DirectLine implements IBotConnection { konsole.log("getSessionId"); return this.checkConnection(true) .flatMap(_ => - Observable.ajax({ + this.services.ajax({ method: "GET", url: `${this.domain}/session/getsessionid`, withCredentials: true, @@ -653,7 +669,7 @@ export class DirectLine implements IBotConnection { }) .catch(error => { konsole.log("getSessionId error: " + error.status); - return Observable.of(''); + return Observable.of('', this.services.scheduler); }) ) .catch(error => this.catchExpiredToken(error)); @@ -671,7 +687,7 @@ export class DirectLine implements IBotConnection { konsole.log("postActivity", activity); return this.checkConnection(true) .flatMap(_ => - Observable.ajax({ + this.services.ajax({ method: "POST", url: `${this.domain}/conversations/${this.conversationId}/activities`, body: activity, @@ -711,9 +727,9 @@ export class DirectLine implements IBotConnection { attachments: cleansedAttachments.map(({ contentUrl: string, ...others }) => ({ ...others })) })], { type: 'application/vnd.microsoft.activity' })); - return Observable.from(cleansedAttachments) + return Observable.from(cleansedAttachments, this.services.scheduler) .flatMap((media: Media) => - Observable.ajax({ + this.services.ajax({ method: "GET", url: media.contentUrl, responseType: 'arraybuffer' @@ -725,7 +741,7 @@ export class DirectLine implements IBotConnection { .count() }) .flatMap(_ => - Observable.ajax({ + this.services.ajax({ method: "POST", url: `${this.domain}/conversations/${this.conversationId}/upload?userId=${message.from.id}`, body: formData, @@ -746,14 +762,14 @@ export class DirectLine implements IBotConnection { this.expiredToken(); else if (error.status >= 400 && error.status < 500) // more unrecoverable errors - return Observable.throw(error); - return Observable.of("retry"); + return Observable.throw(error, this.services.scheduler); + return Observable.of("retry", this.services.scheduler); } private catchExpiredToken(error: any) { return error === errorExpiredToken - ? Observable.of("retry") - : Observable.throw(error); + ? Observable.of("retry", this.services.scheduler) + : Observable.throw(error, this.services.scheduler); } private pollingGetActivity$() { @@ -762,11 +778,13 @@ export class DirectLine implements IBotConnection { // the first event is produced immediately. const trigger$ = new BehaviorSubject({}); + // TODO: remove Date.now, use reactive interval to space out every request + trigger$.subscribe(() => { if (this.connectionStatus$.getValue() === ConnectionStatus.Online) { const startTimestamp = Date.now(); - Observable.ajax({ + this.services.ajax({ headers: { Accept: 'application/json', ...this.commonHeaders() @@ -811,7 +829,7 @@ export class DirectLine implements IBotConnection { private observableFromActivityGroup(activityGroup: ActivityGroup) { if (activityGroup.watermark) this.watermark = activityGroup.watermark; - return Observable.from(activityGroup.activities); + return Observable.from(activityGroup.activities, this.services.scheduler); } private webSocketActivity$(): Observable { @@ -821,7 +839,7 @@ export class DirectLine implements IBotConnection { // WebSockets can be closed by the server or the browser. In the former case we need to // retrieve a new streamUrl. In the latter case we could first retry with the current streamUrl, // but it's simpler just to always fetch a new one. - .retryWhen(error$ => error$.delay(this.getRetryDelay()).mergeMap(error => this.reconnectToConversation())) + .retryWhen(error$ => error$.delay(this.getRetryDelay(), this.services.scheduler).mergeMap(error => this.reconnectToConversation())) ) .flatMap(activityGroup => this.observableFromActivityGroup(activityGroup)) } @@ -831,13 +849,13 @@ export class DirectLine implements IBotConnection { return Math.floor(3000 + Math.random() * 12000); } - // Originally we used Observable.webSocket, but it's fairly opionated and I ended up writing + // Originally we used Observable.webSocket, but it's fairly opinionated and I ended up writing // a lot of code to work around their implemention details. Since WebChat is meant to be a reference // implementation, I decided roll the below, where the logic is more purposeful. - @billba private observableWebSocket() { return Observable.create((subscriber: Subscriber) => { konsole.log("creating WebSocket", this.streamUrl); - const ws = new WebSocket(this.streamUrl); + const ws = new this.services.WebSocket(this.streamUrl); let sub: Subscription; ws.onopen = open => { @@ -846,7 +864,7 @@ export class DirectLine implements IBotConnection { // If we periodically ping the server with empty messages, it helps Chrome // realize when connection breaks, and close the socket. We then throw an // error, and that give us the opportunity to attempt to reconnect. - sub = Observable.interval(timeout).subscribe(_ => { + sub = Observable.interval(timeout, this.services.scheduler).subscribe(_ => { try { ws.send("") } catch(e) { @@ -876,7 +894,7 @@ export class DirectLine implements IBotConnection { private reconnectToConversation() { return this.checkConnection(true) .flatMap(_ => - Observable.ajax({ + this.services.ajax({ method: "GET", url: `${this.domain}/conversations/${this.conversationId}?watermark=${this.watermark}`, timeout, @@ -898,12 +916,12 @@ export class DirectLine implements IBotConnection { // website might eventually call reconnect() with a new token and streamUrl. this.expiredToken(); } else if (error.status === 404) { - return Observable.throw(errorConversationEnded); + return Observable.throw(errorConversationEnded, this.services.scheduler); } - return Observable.of(error); + return Observable.of(error, this.services.scheduler); }) - .delay(timeout) + .delay(timeout, this.services.scheduler) .take(retries) ) ) From 7335badc7b12df1a7cabfc6d93e7718190ca1e1a Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 12:28:29 -0700 Subject: [PATCH 02/35] basic test with mocks --- src/directLine.test.ts | 243 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 98486c7a4..c07adf505 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -1,4 +1,8 @@ import * as DirectLineExport from "./directLine"; +import { TestScheduler, Observable, BehaviorSubject, ReplaySubject, AsyncSubject, Subscription, Subscriber } from "rxjs"; +import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; +import { IScheduler } from "rxjs/Scheduler"; +import { Action } from "rxjs/scheduler/Action"; declare var process: { arch: string; @@ -17,7 +21,7 @@ test("#setConnectionStatusFallback", () => { const testFallback = setConnectionStatusFallback(0, 1); let idx = 4; while (idx--) { - expect(testFallback(0)).toBe(0); + expect(testFallback(0)).toBe(0); } // fallback will be triggered expect(testFallback(0)).toBe(1); @@ -61,3 +65,240 @@ describe("#commonHeaders", () => { }); }) }); + +interface MockServer { + scheduler: TestScheduler; + sockets: Set; + conversation: Array; +} + +const notImplemented = () => { throw new Error('not implemented') }; + +const mockAjax = (server: MockServer): AjaxCreationMethod => { + + const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { + if (typeof urlOrRequest !== 'string') { + const { url } = urlOrRequest; + console.log(url); + const parts = url.split(/[\/\?]/); + if (parts[5] === 'conversations') { + if (parts[7] === 'activities') { + const activity: DirectLineExport.Activity = urlOrRequest.body; + + const watermark = server.conversation.push(activity).toString(); + + const activityGroup: DirectLineExport.ActivityGroup = { + activities: [ + activity + ], + watermark, + } + const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); + + for (const socket of server.sockets) { + schedule( + server.scheduler, + () => socket.onmessage(message)); + } + + const response: Partial = { + response: { id: 'messageId' }, + } + + return response as AjaxResponse; + } + else { + const conversation: DirectLineExport.Conversation = { + conversationId: 'conversationId', + token: 'token', + streamUrl: 'streamUrl', + }; + + const response: Partial = { + response: conversation, + } + + return response as AjaxResponse; + } + } + } + + throw new Error(); + }; + + const method = (urlOrRequest: string | AjaxRequest): Observable => + new Observable(subscriber => { + schedule( + server.scheduler, + () => { + try { + subscriber.next(jax(urlOrRequest)); + subscriber.complete(); + } + catch (error) { + subscriber.error(error); + } + }); + }); + + type ValueType = { + [K in keyof T]: T[K] extends V ? T[K] : never; + } + + type Properties = ValueType; + + const properties: Properties = { + get: (url: string, headers?: Object): Observable => notImplemented(), + post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + delete: (url: string, headers?: Object): Observable => notImplemented(), + getJSON: (url: string, headers?: Object) => notImplemented(), + }; + + return Object.assign(method, properties); +} + +type WebSocketConstructor = typeof WebSocket; +type EventHandler = (this: WebSocket, ev: E) => any; + +type Work = (this: Action, state?: T) => void; +const schedule = (scheduler: IScheduler, ...works: Array>) => { + for (const work of works) { + scheduler.schedule(work); + } +} + +const mockWebSocket = (server: MockServer): WebSocketConstructor => + class MockWebSocket implements WebSocket { + constructor(url: string, protocols?: string | string[]) { + this.server = server; + schedule( + this.server.scheduler, + () => this.readyState = WebSocket.CONNECTING, + () => { + this.server.sockets.add(this); + this.onopen(new Event('open')); + }, + () => this.readyState = WebSocket.OPEN); + } + + private readonly server: MockServer; + + binaryType: BinaryType = 'arraybuffer'; + readonly bufferedAmount: number = 0; + readonly extensions: string = ''; + readonly protocol: string = 'https'; + readyState: number = WebSocket.CLOSED; + readonly url: string = ''; + readonly CLOSED: number = WebSocket.CLOSED; + readonly CLOSING: number = WebSocket.CLOSING; + readonly CONNECTING: number = WebSocket.CONNECTING; + readonly OPEN: number = WebSocket.OPEN; + + onclose: EventHandler; + onerror: EventHandler; + onmessage: EventHandler; + onopen: EventHandler; + + close(code?: number, reason?: string): void { + schedule( + this.server.scheduler, + () => this.readyState = WebSocket.CLOSING, + () => { + this.onclose(new CloseEvent('close')) + this.server.sockets.delete(this); + }, + () => this.readyState = WebSocket.CLOSED); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + } + + addEventListener() { throw new Error(); } + removeEventListener() { throw new Error(); } + dispatchEvent(): boolean { throw new Error(); } + + static CLOSED = WebSocket.CLOSED; + static CLOSING = WebSocket.CLOSING; + static CONNECTING = WebSocket.CONNECTING; + static OPEN = WebSocket.OPEN; + }; + +test('TestWithMocks', () => { + + const { DirectLine } = DirectLineExport; + + // setup + + const scheduler = new TestScheduler((actual, expected) => + expect(expected).toBe(actual)); + + const server: MockServer = { + scheduler, + sockets: new Set(), + conversation: [], + }; + + const makeActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); + + const expected = { + x: makeActivity('x'), + y: makeActivity('y'), + }; + + // arrange + + const options: DirectLineExport.Services = { + scheduler, + WebSocket: mockWebSocket(server), + ajax: mockAjax(server), + }; + + const directline = new DirectLine(options); + + const subscriptions: Array = []; + + try { + + // const activity$ = scheduler.createColdObservable('--x--y--|', expected); + // subscriptions.push(activity$.flatMap(a => + // directline.postActivity(a)).observeOn(scheduler).subscribe()); + + const scenario = [ + Observable.empty().delay(200, scheduler), + directline.postActivity(expected.x), + // Observable.of(3).do(() => { + // server.sockets.forEach(s => s.onerror(new Event('error'))) + // server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) + // }), + Observable.empty().delay(200, scheduler), + directline.postActivity(expected.y), + Observable.empty().delay(200, scheduler), + ]; + + subscriptions.push(Observable.concat(...scenario, scheduler).observeOn(scheduler).subscribe()); + + const actual: Array = []; + subscriptions.push(directline.activity$.subscribe(a => { + actual.push(a); + })); + + // scheduler.expectObservable(directline.activity$).toBe('--x--y--|', activities); + + // act + + scheduler.flush(); + + // assert + + expect(actual).toStrictEqual([expected.x, expected.y]); + } + finally { + // cleanup + + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + } +}); \ No newline at end of file From 410b05a60a59b153f1c3eb28625885f472a5fef6 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 12:29:11 -0700 Subject: [PATCH 03/35] enable auto attach for VSCode debugging: npm run watch node --inspect .\node_modules\jest\bin\jest.js directLine.test.ts -t TestWithMocks --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a6f21c48..b2433c70b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "files.trimTrailingWhitespace": true, "search.exclude": { "lib": true - } + }, + "debug.node.autoAttach": "on" } From 2b7a4fcff6e8924208dcdd908f34fc4cdf488cac Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 15:42:19 -0700 Subject: [PATCH 04/35] default to async scheduler --- src/directLine.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/directLine.ts b/src/directLine.ts index c4bb564e9..97e802cfe 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs/Observable'; import { IScheduler } from 'rxjs/Scheduler'; import { Subscriber } from 'rxjs/Subscriber'; import { Subscription } from 'rxjs/Subscription'; +import { async } from 'rxjs/Scheduler/async'; import 'rxjs/add/operator/catch'; import 'rxjs/add/operator/combineLatest'; @@ -370,7 +371,7 @@ export interface Services { } const makeServices = (services: Partial): Services => ({ - scheduler: services.scheduler, + scheduler: services.scheduler || async, ajax: services.ajax || Observable.ajax, WebSocket: services.WebSocket || WebSocket, }); From 76f52aac6d11f1979d20615da0a208ebaa3d0858 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 15:42:54 -0700 Subject: [PATCH 05/35] log verbs for ajax requests --- src/directLine.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index c07adf505..b1f974949 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -78,8 +78,8 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { if (typeof urlOrRequest !== 'string') { - const { url } = urlOrRequest; - console.log(url); + const { method, url } = urlOrRequest; + console.log(`${method}: ${url}`); const parts = url.split(/[\/\?]/); if (parts[5] === 'conversations') { if (parts[7] === 'activities') { From b776757fbe6857cfc00504879c00f8e03bed834f Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Fri, 1 Nov 2019 17:57:54 -0700 Subject: [PATCH 06/35] implement watermarks and replay on reconnect --- src/directLine.test.ts | 231 ++++++++++++++++++++++++++--------------- 1 file changed, 146 insertions(+), 85 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index b1f974949..80ebdb53c 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -1,8 +1,9 @@ import * as DirectLineExport from "./directLine"; -import { TestScheduler, Observable, BehaviorSubject, ReplaySubject, AsyncSubject, Subscription, Subscriber } from "rxjs"; +import { TestScheduler, Observable, Subscription } from "rxjs"; import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; import { IScheduler } from "rxjs/Scheduler"; import { Action } from "rxjs/scheduler/Action"; +import { URL, URLSearchParams } from 'url'; declare var process: { arch: string; @@ -66,61 +67,111 @@ describe("#commonHeaders", () => { }) }); +interface ActivitySocket { + play: (start: number, after: number) => void; +} + interface MockServer { scheduler: TestScheduler; - sockets: Set; + sockets: Set; conversation: Array; } const notImplemented = () => { throw new Error('not implemented') }; +const keyWatermark = 'watermark'; + const mockAjax = (server: MockServer): AjaxCreationMethod => { + const uriBase = new URL('https://directline.botframework.com/v3/directline/'); + const createStreamUrl = (watermark: number): string => { + const uri = new URL('conversations/stream', uriBase); + if (watermark > 0) { + const params = new URLSearchParams(); + params.append(keyWatermark, watermark.toString(10)); + uri.search = params.toString(); + } + + return uri.toString(); + } + const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { - if (typeof urlOrRequest !== 'string') { - const { method, url } = urlOrRequest; - console.log(`${method}: ${url}`); - const parts = url.split(/[\/\?]/); - if (parts[5] === 'conversations') { - if (parts[7] === 'activities') { - const activity: DirectLineExport.Activity = urlOrRequest.body; - - const watermark = server.conversation.push(activity).toString(); - - const activityGroup: DirectLineExport.ActivityGroup = { - activities: [ - activity - ], - watermark, - } - const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); - - for (const socket of server.sockets) { - schedule( - server.scheduler, - () => socket.onmessage(message)); - } - - const response: Partial = { - response: { id: 'messageId' }, - } - - return response as AjaxResponse; - } - else { - const conversation: DirectLineExport.Conversation = { - conversationId: 'conversationId', - token: 'token', - streamUrl: 'streamUrl', - }; - - const response: Partial = { - response: conversation, - } - - return response as AjaxResponse; - } + if (typeof urlOrRequest === 'string') { + throw new Error(); + } + + console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); + const uri = new URL(urlOrRequest.url); + + const { pathname, searchParams } = uri; + + const conversationId = 'SingleConversation'; + const token = 'token'; + + const parts = pathname.split('/'); + + if (parts[3] === 'tokens' && parts[4] === 'refresh') { + + const response: Partial = { + response: { token } + }; + + return response as AjaxResponse; + } + + if (parts[3] !== 'conversations') { + throw new Error(); + } + + if (parts.length === 4) { + const conversation: DirectLineExport.Conversation = { + conversationId, + token, + streamUrl: createStreamUrl(0), + }; + + const response: Partial = { + response: conversation, } + + return response as AjaxResponse; + } + + if (parts[4] !== conversationId) { + throw new Error(); + } + + if (parts[5] === 'activities') { + const activity: DirectLineExport.Activity = urlOrRequest.body; + + const after = server.conversation.push(activity); + const start = after - 1; + + for (const socket of server.sockets) { + socket.play(start, after); + } + + const response: Partial = { + response: { id: 'messageId' }, + } + + return response as AjaxResponse; + } + else if (parts.length === 5) { + const watermark = searchParams.get('watermark'); + const start = Number.parseInt(watermark, 10); + + const conversation: DirectLineExport.Conversation = { + conversationId, + token, + streamUrl: createStreamUrl(start), + }; + + const response: Partial = { + response: conversation, + } + + return response as AjaxResponse; } throw new Error(); @@ -128,17 +179,13 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { const method = (urlOrRequest: string | AjaxRequest): Observable => new Observable(subscriber => { - schedule( - server.scheduler, - () => { - try { - subscriber.next(jax(urlOrRequest)); - subscriber.complete(); - } - catch (error) { - subscriber.error(error); - } - }); + try { + subscriber.next(jax(urlOrRequest)); + subscriber.complete(); + } + catch (error) { + subscriber.error(error); + } }); type ValueType = { @@ -162,25 +209,38 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { type WebSocketConstructor = typeof WebSocket; type EventHandler = (this: WebSocket, ev: E) => any; -type Work = (this: Action, state?: T) => void; -const schedule = (scheduler: IScheduler, ...works: Array>) => { - for (const work of works) { - scheduler.schedule(work); - } -} - const mockWebSocket = (server: MockServer): WebSocketConstructor => - class MockWebSocket implements WebSocket { + class MockWebSocket implements WebSocket, ActivitySocket { constructor(url: string, protocols?: string | string[]) { this.server = server; - schedule( - this.server.scheduler, - () => this.readyState = WebSocket.CONNECTING, - () => { - this.server.sockets.add(this); - this.onopen(new Event('open')); - }, - () => this.readyState = WebSocket.OPEN); + + server.scheduler.schedule(() => { + this.readyState = WebSocket.CONNECTING; + this.server.sockets.add(this); + this.onopen(new Event('open')); + this.readyState = WebSocket.OPEN; + const uri = new URL(url); + const watermark = uri.searchParams.get(keyWatermark) + if (watermark !== null) { + const start = Number.parseInt(watermark, 10); + this.play(start, this.server.conversation.length); + } + }); + } + + play(start: number, after: number) { + + const { conversation } = this.server; + const activities = conversation.slice(start, after); + const watermark = conversation.length.toString(); + const activityGroup: DirectLineExport.ActivityGroup = { + activities, + watermark, + } + + const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); + + this.onmessage(message); } private readonly server: MockServer; @@ -202,14 +262,10 @@ const mockWebSocket = (server: MockServer): WebSocketConstructor => onopen: EventHandler; close(code?: number, reason?: string): void { - schedule( - this.server.scheduler, - () => this.readyState = WebSocket.CLOSING, - () => { - this.onclose(new CloseEvent('close')) - this.server.sockets.delete(this); - }, - () => this.readyState = WebSocket.CLOSED); + this.readyState = WebSocket.CLOSING; + this.onclose(new CloseEvent('close')) + this.server.sockets.delete(this); + this.readyState = WebSocket.CLOSED; } send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { @@ -234,9 +290,11 @@ test('TestWithMocks', () => { const scheduler = new TestScheduler((actual, expected) => expect(expected).toBe(actual)); + scheduler.maxFrames = 60 * 1000; + const server: MockServer = { scheduler, - sockets: new Set(), + sockets: new Set(), conversation: [], }; @@ -268,10 +326,9 @@ test('TestWithMocks', () => { const scenario = [ Observable.empty().delay(200, scheduler), directline.postActivity(expected.x), - // Observable.of(3).do(() => { - // server.sockets.forEach(s => s.onerror(new Event('error'))) - // server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) - // }), + Observable.of(3).do(() => { + server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) + }), Observable.empty().delay(200, scheduler), directline.postActivity(expected.y), Observable.empty().delay(200, scheduler), @@ -290,6 +347,10 @@ test('TestWithMocks', () => { scheduler.flush(); + // if (scheduler.actions.length > 0) { + // throw new Error(); + // } + // assert expect(actual).toStrictEqual([expected.x, expected.y]); From 26530c72a323a2c696e20c87aa4a414bde5ac48d Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 13:55:42 -0700 Subject: [PATCH 07/35] mock Math.random for repeatable tests --- src/directLine.test.ts | 1 + src/directLine.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 80ebdb53c..7f7bc2867 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -311,6 +311,7 @@ test('TestWithMocks', () => { scheduler, WebSocket: mockWebSocket(server), ajax: mockAjax(server), + random: () => 0, }; const directline = new DirectLine(options); diff --git a/src/directLine.ts b/src/directLine.ts index 97e802cfe..8f727fb60 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -368,12 +368,14 @@ export interface Services { scheduler: IScheduler; WebSocket: typeof WebSocket; ajax: AjaxCreationMethod; + random: () => number; } const makeServices = (services: Partial): Services => ({ scheduler: services.scheduler || async, ajax: services.ajax || Observable.ajax, WebSocket: services.WebSocket || WebSocket, + random: services.random || Math.random, }); const lifetimeRefreshToken = 30 * 60 * 1000; @@ -847,7 +849,7 @@ export class DirectLine implements IBotConnection { // Returns the delay duration in milliseconds private getRetryDelay() { - return Math.floor(3000 + Math.random() * 12000); + return Math.floor(3000 + this.services.random() * 12000); } // Originally we used Observable.webSocket, but it's fairly opinionated and I ended up writing From 8b4b02e6a93a08e9335ddf5ae8319ce6e26192fa Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 14:32:43 -0700 Subject: [PATCH 08/35] mock conversation token support --- src/directLine.test.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 7f7bc2867..70fb0c094 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -75,6 +75,21 @@ interface MockServer { scheduler: TestScheduler; sockets: Set; conversation: Array; + token: string; +} + +const tokenResponse = (server: MockServer, request: AjaxRequest): AjaxResponse | null => { + const { headers } = request; + const authorization = headers['Authorization']; + if (authorization === `Bearer ${server.token}`) { + return null; + } + + const response: Partial = { + status: 403, + } + + return response as AjaxResponse; } const notImplemented = () => { throw new Error('not implemented') }; @@ -106,14 +121,13 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { const { pathname, searchParams } = uri; const conversationId = 'SingleConversation'; - const token = 'token'; const parts = pathname.split('/'); if (parts[3] === 'tokens' && parts[4] === 'refresh') { const response: Partial = { - response: { token } + response: { token: server.token } }; return response as AjaxResponse; @@ -126,7 +140,7 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { if (parts.length === 4) { const conversation: DirectLineExport.Conversation = { conversationId, - token, + token: server.token, streamUrl: createStreamUrl(0), }; @@ -142,6 +156,11 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { } if (parts[5] === 'activities') { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } + const activity: DirectLineExport.Activity = urlOrRequest.body; const after = server.conversation.push(activity); @@ -158,12 +177,17 @@ const mockAjax = (server: MockServer): AjaxCreationMethod => { return response as AjaxResponse; } else if (parts.length === 5) { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } + const watermark = searchParams.get('watermark'); const start = Number.parseInt(watermark, 10); const conversation: DirectLineExport.Conversation = { conversationId, - token, + token: server.token, streamUrl: createStreamUrl(start), }; @@ -296,6 +320,7 @@ test('TestWithMocks', () => { scheduler, sockets: new Set(), conversation: [], + token: 'tokenA', }; const makeActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); From 0a2d637354f9696289e018186f037191a7e913e0 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 14:42:45 -0700 Subject: [PATCH 09/35] refactor mocks to separate module --- src/directLine.mock.ts | 244 +++++++++++++++++++++++++++++++++++++++ src/directLine.test.ts | 251 +---------------------------------------- 2 files changed, 249 insertions(+), 246 deletions(-) create mode 100644 src/directLine.mock.ts diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts new file mode 100644 index 000000000..f37e46929 --- /dev/null +++ b/src/directLine.mock.ts @@ -0,0 +1,244 @@ +import * as DirectLineExport from "./directLine"; +import { TestScheduler, Observable } from "rxjs"; +import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; +import { URL, URLSearchParams } from 'url'; + +interface ActivitySocket { + play: (start: number, after: number) => void; +} + +export type Socket = WebSocket & ActivitySocket; + +export interface Server { + scheduler: TestScheduler; + sockets: Set; + conversation: Array; + token: string; +} + +const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | null => { + const { headers } = request; + const authorization = headers['Authorization']; + if (authorization === `Bearer ${server.token}`) { + return null; + } + + const response: Partial = { + status: 403, + } + + return response as AjaxResponse; +} + +const notImplemented = () => { throw new Error('not implemented') }; + +const keyWatermark = 'watermark'; + +export const mockAjax = (server: Server): AjaxCreationMethod => { + + const uriBase = new URL('https://directline.botframework.com/v3/directline/'); + const createStreamUrl = (watermark: number): string => { + const uri = new URL('conversations/stream', uriBase); + if (watermark > 0) { + const params = new URLSearchParams(); + params.append(keyWatermark, watermark.toString(10)); + uri.search = params.toString(); + } + + return uri.toString(); + } + + const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { + if (typeof urlOrRequest === 'string') { + throw new Error(); + } + + console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); + const uri = new URL(urlOrRequest.url); + + const { pathname, searchParams } = uri; + + const conversationId = 'SingleConversation'; + + const parts = pathname.split('/'); + + if (parts[3] === 'tokens' && parts[4] === 'refresh') { + + const response: Partial = { + response: { token: server.token } + }; + + return response as AjaxResponse; + } + + if (parts[3] !== 'conversations') { + throw new Error(); + } + + if (parts.length === 4) { + const conversation: DirectLineExport.Conversation = { + conversationId, + token: server.token, + streamUrl: createStreamUrl(0), + }; + + const response: Partial = { + response: conversation, + } + + return response as AjaxResponse; + } + + if (parts[4] !== conversationId) { + throw new Error(); + } + + if (parts[5] === 'activities') { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } + + const activity: DirectLineExport.Activity = urlOrRequest.body; + + const after = server.conversation.push(activity); + const start = after - 1; + + for (const socket of server.sockets) { + socket.play(start, after); + } + + const response: Partial = { + response: { id: 'messageId' }, + } + + return response as AjaxResponse; + } + else if (parts.length === 5) { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } + + const watermark = searchParams.get('watermark'); + const start = Number.parseInt(watermark, 10); + + const conversation: DirectLineExport.Conversation = { + conversationId, + token: server.token, + streamUrl: createStreamUrl(start), + }; + + const response: Partial = { + response: conversation, + } + + return response as AjaxResponse; + } + + throw new Error(); + }; + + const method = (urlOrRequest: string | AjaxRequest): Observable => + new Observable(subscriber => { + try { + subscriber.next(jax(urlOrRequest)); + subscriber.complete(); + } + catch (error) { + subscriber.error(error); + } + }); + + type ValueType = { + [K in keyof T]: T[K] extends V ? T[K] : never; + } + + type Properties = ValueType; + + const properties: Properties = { + get: (url: string, headers?: Object): Observable => notImplemented(), + post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + delete: (url: string, headers?: Object): Observable => notImplemented(), + getJSON: (url: string, headers?: Object) => notImplemented(), + }; + + return Object.assign(method, properties); +} + +type WebSocketConstructor = typeof WebSocket; +type EventHandler = (this: WebSocket, ev: E) => any; + +export const mockWebSocket = (server: Server): WebSocketConstructor => + class MockWebSocket implements WebSocket, ActivitySocket { + constructor(url: string, protocols?: string | string[]) { + this.server = server; + + server.scheduler.schedule(() => { + this.readyState = WebSocket.CONNECTING; + this.server.sockets.add(this); + this.onopen(new Event('open')); + this.readyState = WebSocket.OPEN; + const uri = new URL(url); + const watermark = uri.searchParams.get(keyWatermark) + if (watermark !== null) { + const start = Number.parseInt(watermark, 10); + this.play(start, this.server.conversation.length); + } + }); + } + + play(start: number, after: number) { + + const { conversation } = this.server; + const activities = conversation.slice(start, after); + const watermark = conversation.length.toString(); + const activityGroup: DirectLineExport.ActivityGroup = { + activities, + watermark, + } + + const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); + + this.onmessage(message); + } + + private readonly server: Server; + + binaryType: BinaryType = 'arraybuffer'; + readonly bufferedAmount: number = 0; + readonly extensions: string = ''; + readonly protocol: string = 'https'; + readyState: number = WebSocket.CLOSED; + readonly url: string = ''; + readonly CLOSED: number = WebSocket.CLOSED; + readonly CLOSING: number = WebSocket.CLOSING; + readonly CONNECTING: number = WebSocket.CONNECTING; + readonly OPEN: number = WebSocket.OPEN; + + onclose: EventHandler; + onerror: EventHandler; + onmessage: EventHandler; + onopen: EventHandler; + + close(code?: number, reason?: string): void { + this.readyState = WebSocket.CLOSING; + this.onclose(new CloseEvent('close')) + this.server.sockets.delete(this); + this.readyState = WebSocket.CLOSED; + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + } + + addEventListener() { throw new Error(); } + removeEventListener() { throw new Error(); } + dispatchEvent(): boolean { throw new Error(); } + + static CLOSED = WebSocket.CLOSED; + static CLOSING = WebSocket.CLOSING; + static CONNECTING = WebSocket.CONNECTING; + static OPEN = WebSocket.OPEN; + }; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 70fb0c094..118023db7 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -1,9 +1,6 @@ import * as DirectLineExport from "./directLine"; +import * as DirectLineMock from './directLine.mock'; import { TestScheduler, Observable, Subscription } from "rxjs"; -import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; -import { IScheduler } from "rxjs/Scheduler"; -import { Action } from "rxjs/scheduler/Action"; -import { URL, URLSearchParams } from 'url'; declare var process: { arch: string; @@ -67,244 +64,6 @@ describe("#commonHeaders", () => { }) }); -interface ActivitySocket { - play: (start: number, after: number) => void; -} - -interface MockServer { - scheduler: TestScheduler; - sockets: Set; - conversation: Array; - token: string; -} - -const tokenResponse = (server: MockServer, request: AjaxRequest): AjaxResponse | null => { - const { headers } = request; - const authorization = headers['Authorization']; - if (authorization === `Bearer ${server.token}`) { - return null; - } - - const response: Partial = { - status: 403, - } - - return response as AjaxResponse; -} - -const notImplemented = () => { throw new Error('not implemented') }; - -const keyWatermark = 'watermark'; - -const mockAjax = (server: MockServer): AjaxCreationMethod => { - - const uriBase = new URL('https://directline.botframework.com/v3/directline/'); - const createStreamUrl = (watermark: number): string => { - const uri = new URL('conversations/stream', uriBase); - if (watermark > 0) { - const params = new URLSearchParams(); - params.append(keyWatermark, watermark.toString(10)); - uri.search = params.toString(); - } - - return uri.toString(); - } - - const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { - if (typeof urlOrRequest === 'string') { - throw new Error(); - } - - console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); - const uri = new URL(urlOrRequest.url); - - const { pathname, searchParams } = uri; - - const conversationId = 'SingleConversation'; - - const parts = pathname.split('/'); - - if (parts[3] === 'tokens' && parts[4] === 'refresh') { - - const response: Partial = { - response: { token: server.token } - }; - - return response as AjaxResponse; - } - - if (parts[3] !== 'conversations') { - throw new Error(); - } - - if (parts.length === 4) { - const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, - streamUrl: createStreamUrl(0), - }; - - const response: Partial = { - response: conversation, - } - - return response as AjaxResponse; - } - - if (parts[4] !== conversationId) { - throw new Error(); - } - - if (parts[5] === 'activities') { - const responseToken = tokenResponse(server, urlOrRequest); - if (responseToken !== null) { - return responseToken; - } - - const activity: DirectLineExport.Activity = urlOrRequest.body; - - const after = server.conversation.push(activity); - const start = after - 1; - - for (const socket of server.sockets) { - socket.play(start, after); - } - - const response: Partial = { - response: { id: 'messageId' }, - } - - return response as AjaxResponse; - } - else if (parts.length === 5) { - const responseToken = tokenResponse(server, urlOrRequest); - if (responseToken !== null) { - return responseToken; - } - - const watermark = searchParams.get('watermark'); - const start = Number.parseInt(watermark, 10); - - const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, - streamUrl: createStreamUrl(start), - }; - - const response: Partial = { - response: conversation, - } - - return response as AjaxResponse; - } - - throw new Error(); - }; - - const method = (urlOrRequest: string | AjaxRequest): Observable => - new Observable(subscriber => { - try { - subscriber.next(jax(urlOrRequest)); - subscriber.complete(); - } - catch (error) { - subscriber.error(error); - } - }); - - type ValueType = { - [K in keyof T]: T[K] extends V ? T[K] : never; - } - - type Properties = ValueType; - - const properties: Properties = { - get: (url: string, headers?: Object): Observable => notImplemented(), - post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - delete: (url: string, headers?: Object): Observable => notImplemented(), - getJSON: (url: string, headers?: Object) => notImplemented(), - }; - - return Object.assign(method, properties); -} - -type WebSocketConstructor = typeof WebSocket; -type EventHandler = (this: WebSocket, ev: E) => any; - -const mockWebSocket = (server: MockServer): WebSocketConstructor => - class MockWebSocket implements WebSocket, ActivitySocket { - constructor(url: string, protocols?: string | string[]) { - this.server = server; - - server.scheduler.schedule(() => { - this.readyState = WebSocket.CONNECTING; - this.server.sockets.add(this); - this.onopen(new Event('open')); - this.readyState = WebSocket.OPEN; - const uri = new URL(url); - const watermark = uri.searchParams.get(keyWatermark) - if (watermark !== null) { - const start = Number.parseInt(watermark, 10); - this.play(start, this.server.conversation.length); - } - }); - } - - play(start: number, after: number) { - - const { conversation } = this.server; - const activities = conversation.slice(start, after); - const watermark = conversation.length.toString(); - const activityGroup: DirectLineExport.ActivityGroup = { - activities, - watermark, - } - - const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); - - this.onmessage(message); - } - - private readonly server: MockServer; - - binaryType: BinaryType = 'arraybuffer'; - readonly bufferedAmount: number = 0; - readonly extensions: string = ''; - readonly protocol: string = 'https'; - readyState: number = WebSocket.CLOSED; - readonly url: string = ''; - readonly CLOSED: number = WebSocket.CLOSED; - readonly CLOSING: number = WebSocket.CLOSING; - readonly CONNECTING: number = WebSocket.CONNECTING; - readonly OPEN: number = WebSocket.OPEN; - - onclose: EventHandler; - onerror: EventHandler; - onmessage: EventHandler; - onopen: EventHandler; - - close(code?: number, reason?: string): void { - this.readyState = WebSocket.CLOSING; - this.onclose(new CloseEvent('close')) - this.server.sockets.delete(this); - this.readyState = WebSocket.CLOSED; - } - - send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { - } - - addEventListener() { throw new Error(); } - removeEventListener() { throw new Error(); } - dispatchEvent(): boolean { throw new Error(); } - - static CLOSED = WebSocket.CLOSED; - static CLOSING = WebSocket.CLOSING; - static CONNECTING = WebSocket.CONNECTING; - static OPEN = WebSocket.OPEN; - }; - test('TestWithMocks', () => { const { DirectLine } = DirectLineExport; @@ -316,9 +75,9 @@ test('TestWithMocks', () => { scheduler.maxFrames = 60 * 1000; - const server: MockServer = { + const server: DirectLineMock.Server = { scheduler, - sockets: new Set(), + sockets: new Set(), conversation: [], token: 'tokenA', }; @@ -334,8 +93,8 @@ test('TestWithMocks', () => { const options: DirectLineExport.Services = { scheduler, - WebSocket: mockWebSocket(server), - ajax: mockAjax(server), + WebSocket: DirectLineMock.mockWebSocket(server), + ajax: DirectLineMock.mockAjax(server), random: () => 0, }; From e24d915d8316200ad12ae070dd98a53de2e41bd6 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 14:46:38 -0700 Subject: [PATCH 10/35] factor out mockActivity --- src/directLine.mock.ts | 4 +++- src/directLine.test.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index f37e46929..b1cedcd8f 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -3,6 +3,8 @@ import { TestScheduler, Observable } from "rxjs"; import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; import { URL, URLSearchParams } from 'url'; +export const mockActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); + interface ActivitySocket { play: (start: number, after: number) => void; } @@ -30,7 +32,7 @@ const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | nul return response as AjaxResponse; } -const notImplemented = () => { throw new Error('not implemented') }; +const notImplemented = (): never => { throw new Error('not implemented') }; const keyWatermark = 'watermark'; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 118023db7..4d94ad41d 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -82,11 +82,9 @@ test('TestWithMocks', () => { token: 'tokenA', }; - const makeActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); - const expected = { - x: makeActivity('x'), - y: makeActivity('y'), + x: DirectLineMock.mockActivity('x'), + y: DirectLineMock.mockActivity('y'), }; // arrange From 0d1b3755e266d6027388a17d2e503c26aaaee1eb Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 14:57:37 -0700 Subject: [PATCH 11/35] move into suite --- src/directLine.test.ts | 114 +++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 4d94ad41d..3e489ca31 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -64,85 +64,79 @@ describe("#commonHeaders", () => { }) }); -test('TestWithMocks', () => { +describe("MockSuite", () => { - const { DirectLine } = DirectLineExport; - - // setup - - const scheduler = new TestScheduler((actual, expected) => - expect(expected).toBe(actual)); + test('ReconnectOnClose', () => { - scheduler.maxFrames = 60 * 1000; + const { DirectLine } = DirectLineExport; - const server: DirectLineMock.Server = { - scheduler, - sockets: new Set(), - conversation: [], - token: 'tokenA', - }; + // setup - const expected = { - x: DirectLineMock.mockActivity('x'), - y: DirectLineMock.mockActivity('y'), - }; + const scheduler = new TestScheduler((actual, expected) => + expect(expected).toBe(actual)); - // arrange + scheduler.maxFrames = 60 * 1000; - const options: DirectLineExport.Services = { - scheduler, - WebSocket: DirectLineMock.mockWebSocket(server), - ajax: DirectLineMock.mockAjax(server), - random: () => 0, - }; + const server: DirectLineMock.Server = { + scheduler, + sockets: new Set(), + conversation: [], + token: 'tokenA', + }; - const directline = new DirectLine(options); + const expected = { + x: DirectLineMock.mockActivity('x'), + y: DirectLineMock.mockActivity('y'), + }; - const subscriptions: Array = []; + // arrange - try { + const options: DirectLineExport.Services = { + scheduler, + WebSocket: DirectLineMock.mockWebSocket(server), + ajax: DirectLineMock.mockAjax(server), + random: () => 0, + }; - // const activity$ = scheduler.createColdObservable('--x--y--|', expected); - // subscriptions.push(activity$.flatMap(a => - // directline.postActivity(a)).observeOn(scheduler).subscribe()); + const directline = new DirectLine(options); - const scenario = [ - Observable.empty().delay(200, scheduler), - directline.postActivity(expected.x), - Observable.of(3).do(() => { - server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) - }), - Observable.empty().delay(200, scheduler), - directline.postActivity(expected.y), - Observable.empty().delay(200, scheduler), - ]; + const subscriptions: Array = []; - subscriptions.push(Observable.concat(...scenario, scheduler).observeOn(scheduler).subscribe()); + try { - const actual: Array = []; - subscriptions.push(directline.activity$.subscribe(a => { - actual.push(a); - })); + const scenario = [ + Observable.empty().delay(200, scheduler), + directline.postActivity(expected.x), + Observable.of(3).do(() => { + server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) + }), + Observable.empty().delay(200, scheduler), + directline.postActivity(expected.y), + Observable.empty().delay(200, scheduler), + ]; - // scheduler.expectObservable(directline.activity$).toBe('--x--y--|', activities); + subscriptions.push(Observable.concat(...scenario, scheduler).observeOn(scheduler).subscribe()); - // act + const actual: Array = []; + subscriptions.push(directline.activity$.subscribe(a => { + actual.push(a); + })); - scheduler.flush(); + // act - // if (scheduler.actions.length > 0) { - // throw new Error(); - // } + scheduler.flush(); - // assert + // assert - expect(actual).toStrictEqual([expected.x, expected.y]); - } - finally { - // cleanup + expect(actual).toStrictEqual([expected.x, expected.y]); + } + finally { + // cleanup - for (const subscription of subscriptions) { - subscription.unsubscribe(); + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } } - } + }); + }); \ No newline at end of file From d8f86b551f3e20405d1ddc5c9ba86127324ee61d Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 14:59:41 -0700 Subject: [PATCH 12/35] factor out mockServer --- src/directLine.mock.ts | 7 +++++++ src/directLine.test.ts | 7 +------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index b1cedcd8f..0506fd0b4 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -18,6 +18,13 @@ export interface Server { token: string; } +export const mockServer = (scheduler: TestScheduler): Server => ({ + scheduler, + sockets: new Set(), + conversation: [], + token: 'tokenA', +}); + const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | null => { const { headers } = request; const authorization = headers['Authorization']; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 3e489ca31..c7197f023 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -77,12 +77,7 @@ describe("MockSuite", () => { scheduler.maxFrames = 60 * 1000; - const server: DirectLineMock.Server = { - scheduler, - sockets: new Set(), - conversation: [], - token: 'tokenA', - }; + const server = DirectLineMock.mockServer(scheduler); const expected = { x: DirectLineMock.mockActivity('x'), From 0f3b2a07562a71ab768e88340d404d879e9bf8ca Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 15:02:49 -0700 Subject: [PATCH 13/35] factor out mockServices --- src/directLine.mock.ts | 321 +++++++++++++++++++++-------------------- src/directLine.test.ts | 7 +- 2 files changed, 165 insertions(+), 163 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index 0506fd0b4..d17e198ea 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -3,6 +3,13 @@ import { TestScheduler, Observable } from "rxjs"; import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; import { URL, URLSearchParams } from 'url'; +export const mockServices = (server: Server, scheduler: TestScheduler): DirectLineExport.Services => ({ + scheduler, + WebSocket: mockWebSocket(server), + ajax: mockAjax(server), + random: () => 0, +}); + export const mockActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); interface ActivitySocket { @@ -19,21 +26,21 @@ export interface Server { } export const mockServer = (scheduler: TestScheduler): Server => ({ - scheduler, - sockets: new Set(), - conversation: [], - token: 'tokenA', + scheduler, + sockets: new Set(), + conversation: [], + token: 'tokenA', }); const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | null => { const { headers } = request; const authorization = headers['Authorization']; if (authorization === `Bearer ${server.token}`) { - return null; + return null; } const response: Partial = { - status: 403, + status: 403, } return response as AjaxResponse; @@ -47,131 +54,131 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { const uriBase = new URL('https://directline.botframework.com/v3/directline/'); const createStreamUrl = (watermark: number): string => { - const uri = new URL('conversations/stream', uriBase); - if (watermark > 0) { - const params = new URLSearchParams(); - params.append(keyWatermark, watermark.toString(10)); - uri.search = params.toString(); - } - - return uri.toString(); + const uri = new URL('conversations/stream', uriBase); + if (watermark > 0) { + const params = new URLSearchParams(); + params.append(keyWatermark, watermark.toString(10)); + uri.search = params.toString(); + } + + return uri.toString(); } const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { - if (typeof urlOrRequest === 'string') { - throw new Error(); - } - - console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); - const uri = new URL(urlOrRequest.url); + if (typeof urlOrRequest === 'string') { + throw new Error(); + } - const { pathname, searchParams } = uri; + console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); + const uri = new URL(urlOrRequest.url); - const conversationId = 'SingleConversation'; + const { pathname, searchParams } = uri; - const parts = pathname.split('/'); + const conversationId = 'SingleConversation'; - if (parts[3] === 'tokens' && parts[4] === 'refresh') { + const parts = pathname.split('/'); - const response: Partial = { - response: { token: server.token } - }; + if (parts[3] === 'tokens' && parts[4] === 'refresh') { - return response as AjaxResponse; - } + const response: Partial = { + response: { token: server.token } + }; - if (parts[3] !== 'conversations') { - throw new Error(); - } + return response as AjaxResponse; + } - if (parts.length === 4) { - const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, - streamUrl: createStreamUrl(0), - }; + if (parts[3] !== 'conversations') { + throw new Error(); + } - const response: Partial = { - response: conversation, - } + if (parts.length === 4) { + const conversation: DirectLineExport.Conversation = { + conversationId, + token: server.token, + streamUrl: createStreamUrl(0), + }; - return response as AjaxResponse; + const response: Partial = { + response: conversation, } - if (parts[4] !== conversationId) { - throw new Error(); - } + return response as AjaxResponse; + } - if (parts[5] === 'activities') { - const responseToken = tokenResponse(server, urlOrRequest); - if (responseToken !== null) { - return responseToken; - } + if (parts[4] !== conversationId) { + throw new Error(); + } - const activity: DirectLineExport.Activity = urlOrRequest.body; + if (parts[5] === 'activities') { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } - const after = server.conversation.push(activity); - const start = after - 1; + const activity: DirectLineExport.Activity = urlOrRequest.body; - for (const socket of server.sockets) { - socket.play(start, after); - } + const after = server.conversation.push(activity); + const start = after - 1; - const response: Partial = { - response: { id: 'messageId' }, - } + for (const socket of server.sockets) { + socket.play(start, after); + } - return response as AjaxResponse; + const response: Partial = { + response: { id: 'messageId' }, } - else if (parts.length === 5) { - const responseToken = tokenResponse(server, urlOrRequest); - if (responseToken !== null) { - return responseToken; - } - const watermark = searchParams.get('watermark'); - const start = Number.parseInt(watermark, 10); + return response as AjaxResponse; + } + else if (parts.length === 5) { + const responseToken = tokenResponse(server, urlOrRequest); + if (responseToken !== null) { + return responseToken; + } - const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, - streamUrl: createStreamUrl(start), - }; + const watermark = searchParams.get('watermark'); + const start = Number.parseInt(watermark, 10); - const response: Partial = { - response: conversation, - } + const conversation: DirectLineExport.Conversation = { + conversationId, + token: server.token, + streamUrl: createStreamUrl(start), + }; - return response as AjaxResponse; + const response: Partial = { + response: conversation, } - throw new Error(); + return response as AjaxResponse; + } + + throw new Error(); }; const method = (urlOrRequest: string | AjaxRequest): Observable => - new Observable(subscriber => { - try { - subscriber.next(jax(urlOrRequest)); - subscriber.complete(); - } - catch (error) { - subscriber.error(error); - } - }); + new Observable(subscriber => { + try { + subscriber.next(jax(urlOrRequest)); + subscriber.complete(); + } + catch (error) { + subscriber.error(error); + } + }); type ValueType = { - [K in keyof T]: T[K] extends V ? T[K] : never; + [K in keyof T]: T[K] extends V ? T[K] : never; } type Properties = ValueType; const properties: Properties = { - get: (url: string, headers?: Object): Observable => notImplemented(), - post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - delete: (url: string, headers?: Object): Observable => notImplemented(), - getJSON: (url: string, headers?: Object) => notImplemented(), + get: (url: string, headers?: Object): Observable => notImplemented(), + post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + delete: (url: string, headers?: Object): Observable => notImplemented(), + getJSON: (url: string, headers?: Object) => notImplemented(), }; return Object.assign(method, properties); @@ -182,72 +189,72 @@ type EventHandler = (this: WebSocket, ev: E) => any; export const mockWebSocket = (server: Server): WebSocketConstructor => class MockWebSocket implements WebSocket, ActivitySocket { - constructor(url: string, protocols?: string | string[]) { - this.server = server; - - server.scheduler.schedule(() => { - this.readyState = WebSocket.CONNECTING; - this.server.sockets.add(this); - this.onopen(new Event('open')); - this.readyState = WebSocket.OPEN; - const uri = new URL(url); - const watermark = uri.searchParams.get(keyWatermark) - if (watermark !== null) { - const start = Number.parseInt(watermark, 10); - this.play(start, this.server.conversation.length); - } - }); - } - - play(start: number, after: number) { - - const { conversation } = this.server; - const activities = conversation.slice(start, after); - const watermark = conversation.length.toString(); - const activityGroup: DirectLineExport.ActivityGroup = { - activities, - watermark, - } - - const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); - - this.onmessage(message); - } + constructor(url: string, protocols?: string | string[]) { + this.server = server; + + server.scheduler.schedule(() => { + this.readyState = WebSocket.CONNECTING; + this.server.sockets.add(this); + this.onopen(new Event('open')); + this.readyState = WebSocket.OPEN; + const uri = new URL(url); + const watermark = uri.searchParams.get(keyWatermark) + if (watermark !== null) { + const start = Number.parseInt(watermark, 10); + this.play(start, this.server.conversation.length); + } + }); + } - private readonly server: Server; - - binaryType: BinaryType = 'arraybuffer'; - readonly bufferedAmount: number = 0; - readonly extensions: string = ''; - readonly protocol: string = 'https'; - readyState: number = WebSocket.CLOSED; - readonly url: string = ''; - readonly CLOSED: number = WebSocket.CLOSED; - readonly CLOSING: number = WebSocket.CLOSING; - readonly CONNECTING: number = WebSocket.CONNECTING; - readonly OPEN: number = WebSocket.OPEN; - - onclose: EventHandler; - onerror: EventHandler; - onmessage: EventHandler; - onopen: EventHandler; - - close(code?: number, reason?: string): void { - this.readyState = WebSocket.CLOSING; - this.onclose(new CloseEvent('close')) - this.server.sockets.delete(this); - this.readyState = WebSocket.CLOSED; - } + play(start: number, after: number) { - send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + const { conversation } = this.server; + const activities = conversation.slice(start, after); + const watermark = conversation.length.toString(); + const activityGroup: DirectLineExport.ActivityGroup = { + activities, + watermark, } - addEventListener() { throw new Error(); } - removeEventListener() { throw new Error(); } - dispatchEvent(): boolean { throw new Error(); } - - static CLOSED = WebSocket.CLOSED; - static CLOSING = WebSocket.CLOSING; - static CONNECTING = WebSocket.CONNECTING; - static OPEN = WebSocket.OPEN; + const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); + + this.onmessage(message); + } + + private readonly server: Server; + + binaryType: BinaryType = 'arraybuffer'; + readonly bufferedAmount: number = 0; + readonly extensions: string = ''; + readonly protocol: string = 'https'; + readyState: number = WebSocket.CLOSED; + readonly url: string = ''; + readonly CLOSED: number = WebSocket.CLOSED; + readonly CLOSING: number = WebSocket.CLOSING; + readonly CONNECTING: number = WebSocket.CONNECTING; + readonly OPEN: number = WebSocket.OPEN; + + onclose: EventHandler; + onerror: EventHandler; + onmessage: EventHandler; + onopen: EventHandler; + + close(code?: number, reason?: string): void { + this.readyState = WebSocket.CLOSING; + this.onclose(new CloseEvent('close')) + this.server.sockets.delete(this); + this.readyState = WebSocket.CLOSED; + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + } + + addEventListener() { throw new Error(); } + removeEventListener() { throw new Error(); } + dispatchEvent(): boolean { throw new Error(); } + + static CLOSED = WebSocket.CLOSED; + static CLOSING = WebSocket.CLOSING; + static CONNECTING = WebSocket.CONNECTING; + static OPEN = WebSocket.OPEN; }; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index c7197f023..84ed41fc7 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -86,12 +86,7 @@ describe("MockSuite", () => { // arrange - const options: DirectLineExport.Services = { - scheduler, - WebSocket: DirectLineMock.mockWebSocket(server), - ajax: DirectLineMock.mockAjax(server), - random: () => 0, - }; + const options = DirectLineMock.mockServices(server, scheduler); const directline = new DirectLine(options); From d5a689b79ffa60353036d84e6dd039ad51b20e87 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 16:34:11 -0700 Subject: [PATCH 14/35] lazy concat for observable generator --- src/directLine.test.ts | 61 +++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 84ed41fc7..ac5c57a87 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -66,9 +66,34 @@ describe("#commonHeaders", () => { describe("MockSuite", () => { - test('ReconnectOnClose', () => { + const lazyConcat = (items: Iterable>): Observable => + new Observable(subscriber => { + const iterator = items[Symbol.iterator](); + let inner: Subscription | undefined; + + const pump = () => { + const result = iterator.next(); + if (result.done === true) { + subscriber.complete(); + } + else { + inner = result.value.subscribe( + value => subscriber.next(value), + error => subscriber.error(error), + pump); + } + }; + + pump(); + + return () => { + if (inner !== undefined) { + inner.unsubscribe(); + } + }; + }); - const { DirectLine } = DirectLineExport; + test('ReconnectOnClose', () => { // setup @@ -79,33 +104,31 @@ describe("MockSuite", () => { const server = DirectLineMock.mockServer(scheduler); + const options = DirectLineMock.mockServices(server, scheduler); + + // arrange + const expected = { x: DirectLineMock.mockActivity('x'), y: DirectLineMock.mockActivity('y'), }; - // arrange - - const options = DirectLineMock.mockServices(server, scheduler); - - const directline = new DirectLine(options); + const directline = new DirectLineExport.DirectLine(options); const subscriptions: Array = []; try { - const scenario = [ - Observable.empty().delay(200, scheduler), - directline.postActivity(expected.x), - Observable.of(3).do(() => { - server.sockets.forEach(s => s.onclose(new CloseEvent('close'))) - }), - Observable.empty().delay(200, scheduler), - directline.postActivity(expected.y), - Observable.empty().delay(200, scheduler), - ]; - - subscriptions.push(Observable.concat(...scenario, scheduler).observeOn(scheduler).subscribe()); + const scenario = function* (): IterableIterator> { + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.x); + server.sockets.forEach(s => s.onclose(new CloseEvent('close'))); + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.y); + yield Observable.timer(200, scheduler); + }; + + subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); const actual: Array = []; subscriptions.push(directline.activity$.subscribe(a => { From 087781563442934dcd0b0a6fccd136070e29fe8e Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 16:51:29 -0700 Subject: [PATCH 15/35] handle errors in pump --- src/directLine.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index ac5c57a87..a1809ae32 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -72,15 +72,20 @@ describe("MockSuite", () => { let inner: Subscription | undefined; const pump = () => { - const result = iterator.next(); - if (result.done === true) { - subscriber.complete(); + try { + const result = iterator.next(); + if (result.done === true) { + subscriber.complete(); + } + else { + inner = result.value.subscribe( + value => subscriber.next(value), + error => subscriber.error(error), + pump); + } } - else { - inner = result.value.subscribe( - value => subscriber.next(value), - error => subscriber.error(error), - pump); + catch (error) { + subscriber.error(error); } }; From 88d653d9bdecfe504f5a97f59ce6d2b14d7d7ef7 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 17:01:36 -0700 Subject: [PATCH 16/35] factor out injectClose --- src/directLine.mock.ts | 3 +++ src/directLine.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index d17e198ea..d16919763 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -46,6 +46,9 @@ const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | nul return response as AjaxResponse; } +export const injectClose = (server: Server): void => + server.sockets.forEach(s => s.onclose(new CloseEvent('close'))); + const notImplemented = (): never => { throw new Error('not implemented') }; const keyWatermark = 'watermark'; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index a1809ae32..2321e04d6 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -127,7 +127,7 @@ describe("MockSuite", () => { const scenario = function* (): IterableIterator> { yield Observable.timer(200, scheduler); yield directline.postActivity(expected.x); - server.sockets.forEach(s => s.onclose(new CloseEvent('close'))); + DirectLineMock.injectClose(server); yield Observable.timer(200, scheduler); yield directline.postActivity(expected.y); yield Observable.timer(200, scheduler); From 7fa4a80c8aa2f1fe10b62364cb8376bb7f5acccd Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 17:15:19 -0700 Subject: [PATCH 17/35] factor out beforeEach and afterEach --- src/directLine.test.ts | 76 +++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 2321e04d6..df4c719c0 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -98,19 +98,28 @@ describe("MockSuite", () => { }; }); - test('ReconnectOnClose', () => { - - // setup - - const scheduler = new TestScheduler((actual, expected) => - expect(expected).toBe(actual)); + let scheduler: TestScheduler; + let server: DirectLineMock.Server; + let services: DirectLineExport.Services; + let subscriptions: Array; + let directline: DirectLineExport.DirectLine; + beforeEach(() => { + scheduler = new TestScheduler((actual, expected) => expect(expected).toBe(actual)); scheduler.maxFrames = 60 * 1000; + server = DirectLineMock.mockServer(scheduler); + services = DirectLineMock.mockServices(server, scheduler); + directline = new DirectLineExport.DirectLine(services); + subscriptions = []; + }); - const server = DirectLineMock.mockServer(scheduler); - - const options = DirectLineMock.mockServices(server, scheduler); + afterEach(() => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }) + test('ReconnectOnClose', () => { // arrange const expected = { @@ -118,43 +127,28 @@ describe("MockSuite", () => { y: DirectLineMock.mockActivity('y'), }; - const directline = new DirectLineExport.DirectLine(options); - - const subscriptions: Array = []; - - try { - - const scenario = function* (): IterableIterator> { - yield Observable.timer(200, scheduler); - yield directline.postActivity(expected.x); - DirectLineMock.injectClose(server); - yield Observable.timer(200, scheduler); - yield directline.postActivity(expected.y); - yield Observable.timer(200, scheduler); - }; - - subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); + const scenario = function* (): IterableIterator> { + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.x); + DirectLineMock.injectClose(server); + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.y); + yield Observable.timer(200, scheduler); + }; - const actual: Array = []; - subscriptions.push(directline.activity$.subscribe(a => { - actual.push(a); - })); + subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); - // act + const actual: Array = []; + subscriptions.push(directline.activity$.subscribe(a => { + actual.push(a); + })); - scheduler.flush(); + // act - // assert + scheduler.flush(); - expect(actual).toStrictEqual([expected.x, expected.y]); - } - finally { - // cleanup + // assert - for (const subscription of subscriptions) { - subscription.unsubscribe(); - } - } + expect(actual).toStrictEqual([expected.x, expected.y]); }); - }); \ No newline at end of file From 6773bb24f36bf4482f9214168c56ebc449ce8a45 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 17:16:30 -0700 Subject: [PATCH 18/35] share test data --- src/directLine.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index df4c719c0..74e686e82 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -119,14 +119,15 @@ describe("MockSuite", () => { } }) + const expected = { + x: DirectLineMock.mockActivity('x'), + y: DirectLineMock.mockActivity('y'), + z: DirectLineMock.mockActivity('z'), + }; + test('ReconnectOnClose', () => { // arrange - const expected = { - x: DirectLineMock.mockActivity('x'), - y: DirectLineMock.mockActivity('y'), - }; - const scenario = function* (): IterableIterator> { yield Observable.timer(200, scheduler); yield directline.postActivity(expected.x); From 2a96aabf4d4c4ae2d70a36d071f64e562ce02a9c Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Sat, 2 Nov 2019 17:19:25 -0700 Subject: [PATCH 19/35] simple happy path test --- src/directLine.test.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 74e686e82..a94a2fef9 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -125,6 +125,31 @@ describe("MockSuite", () => { z: DirectLineMock.mockActivity('z'), }; + test('HappyPath', () => { + // arrange + + const scenario = function* (): IterableIterator> { + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.x); + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.y); + yield Observable.timer(200, scheduler); + }; + + subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); + + const actual: Array = []; + subscriptions.push(directline.activity$.subscribe(a => actual.push(a))); + + // act + + scheduler.flush(); + + // assert + + expect(actual).toStrictEqual([expected.x, expected.y]); + }); + test('ReconnectOnClose', () => { // arrange @@ -140,9 +165,7 @@ describe("MockSuite", () => { subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); const actual: Array = []; - subscriptions.push(directline.activity$.subscribe(a => { - actual.push(a); - })); + subscriptions.push(directline.activity$.subscribe(a => actual.push(a))); // act From 31cfa759a27cf08db967984973e8175a2492a468 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Mon, 4 Nov 2019 11:59:18 -0800 Subject: [PATCH 20/35] rearrange mocks to logical order --- src/directLine.mock.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index d16919763..e64ec0dd0 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -3,20 +3,15 @@ import { TestScheduler, Observable } from "rxjs"; import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; import { URL, URLSearchParams } from 'url'; -export const mockServices = (server: Server, scheduler: TestScheduler): DirectLineExport.Services => ({ - scheduler, - WebSocket: mockWebSocket(server), - ajax: mockAjax(server), - random: () => 0, -}); +// MOCK helpers -export const mockActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); +const notImplemented = (): never => { throw new Error('not implemented') }; -interface ActivitySocket { - play: (start: number, after: number) => void; -} +// MOCK Activity -export type Socket = WebSocket & ActivitySocket; +export const mockActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); + +// MOCK DirectLine Server (shared state used by Observable.ajax and WebSocket mocks) export interface Server { scheduler: TestScheduler; @@ -49,10 +44,10 @@ const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | nul export const injectClose = (server: Server): void => server.sockets.forEach(s => s.onclose(new CloseEvent('close'))); -const notImplemented = (): never => { throw new Error('not implemented') }; - const keyWatermark = 'watermark'; +// MOCK Observable.ajax (uses shared state in Server) + export const mockAjax = (server: Server): AjaxCreationMethod => { const uriBase = new URL('https://directline.botframework.com/v3/directline/'); @@ -187,6 +182,14 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { return Object.assign(method, properties); } +// MOCK WebSocket (uses shared state in Server) + +interface ActivitySocket { + play: (start: number, after: number) => void; +} + +export type Socket = WebSocket & ActivitySocket; + type WebSocketConstructor = typeof WebSocket; type EventHandler = (this: WebSocket, ev: E) => any; @@ -261,3 +264,12 @@ export const mockWebSocket = (server: Server): WebSocketConstructor => static CONNECTING = WebSocket.CONNECTING; static OPEN = WebSocket.OPEN; }; + +// MOCK services (top-level aggregation of all mocks) + +export const mockServices = (server: Server, scheduler: TestScheduler): DirectLineExport.Services => ({ + scheduler, + WebSocket: mockWebSocket(server), + ajax: mockAjax(server), + random: () => 0, +}); From f3cfc9f5354c461cf1f0959cf5255267d4d91151 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Mon, 4 Nov 2019 12:18:40 -0800 Subject: [PATCH 21/35] factor out conversation state --- src/directLine.mock.ts | 69 ++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index e64ec0dd0..b1a8b02ec 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -13,24 +13,38 @@ export const mockActivity = (text: string): DirectLineExport.Activity => ({ type // MOCK DirectLine Server (shared state used by Observable.ajax and WebSocket mocks) +interface ActivitySocket { + play: (start: number, after: number) => void; +} + +export type Socket = WebSocket & ActivitySocket; + +export interface Conversation { + sockets: Set; + conversationId: string; + history: Array; + token: string; +} + export interface Server { scheduler: TestScheduler; - sockets: Set; - conversation: Array; - token: string; + conversation: Conversation; } export const mockServer = (scheduler: TestScheduler): Server => ({ scheduler, - sockets: new Set(), - conversation: [], - token: 'tokenA', + conversation: { + sockets: new Set(), + conversationId: 'OneConversation', + history: [], + token: 'tokenA', + } }); const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | null => { const { headers } = request; const authorization = headers['Authorization']; - if (authorization === `Bearer ${server.token}`) { + if (authorization === `Bearer ${server.conversation.token}`) { return null; } @@ -42,7 +56,7 @@ const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | nul } export const injectClose = (server: Server): void => - server.sockets.forEach(s => s.onclose(new CloseEvent('close'))); + server.conversation.sockets.forEach(s => s.onclose(new CloseEvent('close'))); const keyWatermark = 'watermark'; @@ -72,14 +86,12 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { const { pathname, searchParams } = uri; - const conversationId = 'SingleConversation'; - const parts = pathname.split('/'); if (parts[3] === 'tokens' && parts[4] === 'refresh') { const response: Partial = { - response: { token: server.token } + response: { token: server.conversation.token } }; return response as AjaxResponse; @@ -91,8 +103,8 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { if (parts.length === 4) { const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, + conversationId: server.conversation.conversationId, + token: server.conversation.token, streamUrl: createStreamUrl(0), }; @@ -103,7 +115,7 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { return response as AjaxResponse; } - if (parts[4] !== conversationId) { + if (parts[4] !== server.conversation.conversationId) { throw new Error(); } @@ -115,10 +127,10 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { const activity: DirectLineExport.Activity = urlOrRequest.body; - const after = server.conversation.push(activity); + const after = server.conversation.history.push(activity); const start = after - 1; - for (const socket of server.sockets) { + for (const socket of server.conversation.sockets) { socket.play(start, after); } @@ -138,8 +150,8 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { const start = Number.parseInt(watermark, 10); const conversation: DirectLineExport.Conversation = { - conversationId, - token: server.token, + conversationId: server.conversation.conversationId, + token: server.conversation.token, streamUrl: createStreamUrl(start), }; @@ -184,39 +196,32 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { // MOCK WebSocket (uses shared state in Server) -interface ActivitySocket { - play: (start: number, after: number) => void; -} - -export type Socket = WebSocket & ActivitySocket; - type WebSocketConstructor = typeof WebSocket; type EventHandler = (this: WebSocket, ev: E) => any; export const mockWebSocket = (server: Server): WebSocketConstructor => class MockWebSocket implements WebSocket, ActivitySocket { constructor(url: string, protocols?: string | string[]) { - this.server = server; server.scheduler.schedule(() => { this.readyState = WebSocket.CONNECTING; - this.server.sockets.add(this); + server.conversation.sockets.add(this); this.onopen(new Event('open')); this.readyState = WebSocket.OPEN; const uri = new URL(url); const watermark = uri.searchParams.get(keyWatermark) if (watermark !== null) { const start = Number.parseInt(watermark, 10); - this.play(start, this.server.conversation.length); + this.play(start, server.conversation.history.length); } }); } play(start: number, after: number) { - const { conversation } = this.server; - const activities = conversation.slice(start, after); - const watermark = conversation.length.toString(); + const { conversation: { history } } = server; + const activities = history.slice(start, after); + const watermark = history.length.toString(); const activityGroup: DirectLineExport.ActivityGroup = { activities, watermark, @@ -227,8 +232,6 @@ export const mockWebSocket = (server: Server): WebSocketConstructor => this.onmessage(message); } - private readonly server: Server; - binaryType: BinaryType = 'arraybuffer'; readonly bufferedAmount: number = 0; readonly extensions: string = ''; @@ -248,7 +251,7 @@ export const mockWebSocket = (server: Server): WebSocketConstructor => close(code?: number, reason?: string): void { this.readyState = WebSocket.CLOSING; this.onclose(new CloseEvent('close')) - this.server.sockets.delete(this); + server.conversation.sockets.delete(this); this.readyState = WebSocket.CLOSED; } From 264faaa11bce92478f28190a69848428fb663195 Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Mon, 4 Nov 2019 14:11:26 -0800 Subject: [PATCH 22/35] add version info to useragent in dljs --- .babelrc.js | 8 +++++++- src/directLine.test.ts | 10 ++++++++++ src/directLine.ts | 3 ++- tsconfig.json | 3 ++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.babelrc.js b/.babelrc.js index 402fb2b7d..0d36eacc0 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -21,7 +21,13 @@ module.exports = { '@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-object-rest-spread', '@babel/plugin-transform-runtime', - 'babel-plugin-transform-inline-environment-variables' + [ + "transform-inline-environment-variables", { + "include": [ + "npm_package_version" + ] + } + ] ], presets: [ ['@babel/preset-env', { diff --git a/src/directLine.test.ts b/src/directLine.test.ts index a94a2fef9..2057d0513 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -1,6 +1,8 @@ import * as DirectLineExport from "./directLine"; import * as DirectLineMock from './directLine.mock'; import { TestScheduler, Observable, Subscription } from "rxjs"; +//@ts-ignore +import {version} from "../package.json"; declare var process: { arch: string; @@ -175,4 +177,12 @@ describe("MockSuite", () => { expect(actual).toStrictEqual([expected.x, expected.y]); }); + + test('BotAgentWithMocks', () => { + const expected: string = `DirectLine/3.0 (directlinejs ${version})`; + + //@ts-ignore + const actual: string = directline.commonHeaders()["x-ms-bot-agent"]; + expect(actual).toStrictEqual(expected) + }) }); \ No newline at end of file diff --git a/src/directLine.ts b/src/directLine.ts index 8f727fb60..4d353d117 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -35,6 +35,7 @@ declare var process: { arch: string; env: { VERSION: string; + npm_package_version: string; }; platform: string; release: string; @@ -944,6 +945,6 @@ export class DirectLine implements IBotConnection { clientAgent += `; ${customAgent}` } - return `${DIRECT_LINE_VERSION} (${clientAgent})`; + return `${DIRECT_LINE_VERSION} (${clientAgent} ${process.env.npm_package_version})`; } } diff --git a/tsconfig.json b/tsconfig.json index 3aa4bb3ec..19d29b690 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "declaration": true, "declarationDir": "lib", "emitDeclarationOnly": true, - "sourceMap": true + "sourceMap": true, + "resolveJsonModule": true }, "exclude": [ "__tests__/**/*.js", From c197cb8c2ab1ec36d62f4bb988b7bb1a455deaa3 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Mon, 4 Nov 2019 13:22:05 -0800 Subject: [PATCH 23/35] injectNewToken for mock server --- src/directLine.mock.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index b1a8b02ec..b8bb8fa6d 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -31,13 +31,15 @@ export interface Server { conversation: Conversation; } +const tokenPrefix = 'token'; + export const mockServer = (scheduler: TestScheduler): Server => ({ scheduler, conversation: { sockets: new Set(), conversationId: 'OneConversation', history: [], - token: 'tokenA', + token: tokenPrefix + '1', } }); @@ -58,6 +60,12 @@ const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | nul export const injectClose = (server: Server): void => server.conversation.sockets.forEach(s => s.onclose(new CloseEvent('close'))); +export const injectNewToken = (server: Server): void => { + const { conversation } = server; + const suffix = Number.parseInt(conversation.token.substring(tokenPrefix.length), 10) + 1 + conversation.token = tokenPrefix + suffix; +} + const keyWatermark = 'watermark'; // MOCK Observable.ajax (uses shared state in Server) From 657feecbb1a0bde3045f14dda4c5e8c2346b99da Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Mon, 4 Nov 2019 16:51:41 -0800 Subject: [PATCH 24/35] untested:support retry-after header for 429 --- src/directLine.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/directLine.ts b/src/directLine.ts index 4d353d117..9f7e2238f 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -1,6 +1,6 @@ // In order to keep file size down, only import the parts of rxjs that we use -import { AjaxResponse, AjaxCreationMethod } from 'rxjs/observable/dom/AjaxObservable'; +import { AjaxResponse, AjaxCreationMethod, AjaxRequest } from 'rxjs/observable/dom/AjaxObservable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Observable } from 'rxjs/Observable'; import { IScheduler } from 'rxjs/Scheduler'; @@ -16,6 +16,7 @@ import 'rxjs/add/operator/do'; import 'rxjs/add/operator/filter'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/mergeMap'; +import 'rxjs/add/operator/concatMap'; import 'rxjs/add/operator/retryWhen'; import 'rxjs/add/operator/share'; import 'rxjs/add/operator/take'; @@ -947,4 +948,35 @@ export class DirectLine implements IBotConnection { return `${DIRECT_LINE_VERSION} (${clientAgent} ${process.env.npm_package_version})`; } + + private wrapWithRetry = (source: AjaxCreationMethod): AjaxCreationMethod => { + + const notImplemented = (): never => { throw new Error('not implemented') }; + + const inner = (response$ : Observable) => { + return response$ + .concatMap((response: AjaxResponse) => { + if(response.status === 429){ + const retryAfter = response.xhr.getResponseHeader("retry-after"); + if(retryAfter && !isNaN(Number(retryAfter))){ + return Observable.of(response).delay(Number(retryAfter), this.services.scheduler); + } + } + return Observable.of(response); + }) + }; + + const outer = (urlOrRequest: string| AjaxRequest) => { + return inner(source(urlOrRequest)); + }; + + return Object.assign(outer, { + get: (url: string, headers?: Object): Observable => notImplemented(), + post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + delete: (url: string, headers?: Object): Observable => notImplemented(), + getJSON: (url: string, headers?: Object): Observable => notImplemented() + }); + } } From 1d4afd58e0fd2bfb527905f3e7e92da122fb1521 Mon Sep 17 00:00:00 2001 From: swagat mishra Date: Tue, 5 Nov 2019 01:54:53 -0800 Subject: [PATCH 25/35] found bug in start conversation. failing test for retry after added --- src/directLine.mock.ts | 8 +++-- src/directLine.test.ts | 37 ++++++++++++++++++--- src/directLine.ts | 74 ++++++++++++++++++++++-------------------- tsconfig.json | 3 +- 4 files changed, 78 insertions(+), 44 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index b8bb8fa6d..c50d683b7 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -68,9 +68,11 @@ export const injectNewToken = (server: Server): void => { const keyWatermark = 'watermark'; +type ajaxType = (urlOrRequest: string | AjaxRequest) => AjaxResponse; + // MOCK Observable.ajax (uses shared state in Server) -export const mockAjax = (server: Server): AjaxCreationMethod => { +export const mockAjax = (server: Server, customAjax?: ajaxType): AjaxCreationMethod => { const uriBase = new URL('https://directline.botframework.com/v3/directline/'); const createStreamUrl = (watermark: number): string => { @@ -84,7 +86,7 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { return uri.toString(); } - const jax = (urlOrRequest: string | AjaxRequest): AjaxResponse => { + const jax = customAjax || ((urlOrRequest: string | AjaxRequest): AjaxResponse => { if (typeof urlOrRequest === 'string') { throw new Error(); } @@ -171,7 +173,7 @@ export const mockAjax = (server: Server): AjaxCreationMethod => { } throw new Error(); - }; + }); const method = (urlOrRequest: string | AjaxRequest): Observable => new Observable(subscriber => { diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 2057d0513..c22f5e57e 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -1,7 +1,7 @@ import * as DirectLineExport from "./directLine"; import * as DirectLineMock from './directLine.mock'; -import { TestScheduler, Observable, Subscription } from "rxjs"; -//@ts-ignore +import { TestScheduler, Observable, Subscription, AjaxResponse } from "rxjs"; +// @ts-ignore import {version} from "../package.json"; declare var process: { @@ -14,6 +14,12 @@ declare var process: { version: string; }; +// mock delay observable +jest.mock('rxjs/add/operator/delay', () => { + const Observable = require('rxjs/Observable').Observable; + Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay + }); + test("#setConnectionStatusFallback", () => { const { DirectLine } = DirectLineExport; expect(typeof DirectLine.prototype.setConnectionStatusFallback).toBe("function") @@ -184,5 +190,28 @@ describe("MockSuite", () => { //@ts-ignore const actual: string = directline.commonHeaders()["x-ms-bot-agent"]; expect(actual).toStrictEqual(expected) - }) -}); \ No newline at end of file + }); + + test('MockRetry', () => { + services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { + const response: Partial = { + status: 429, + xhr:{ + getResponseHeader: (name) => "10", + } as XMLHttpRequest + }; + return response as AjaxResponse; + }) + directline = new DirectLineExport.DirectLine(services); + + const scenario = function* (): IterableIterator> { + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.x); + }; + + subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); + scheduler.flush(); + + expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, "10"); + }); +}); diff --git a/src/directLine.ts b/src/directLine.ts index 9f7e2238f..005c72711 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -373,12 +373,45 @@ export interface Services { random: () => number; } -const makeServices = (services: Partial): Services => ({ - scheduler: services.scheduler || async, - ajax: services.ajax || Observable.ajax, +const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxCreationMethod =>{ + + const notImplemented = (): never => { throw new Error('not implemented') }; + + const inner = (response$ : Observable) => { + return response$ + .concatMap((response: AjaxResponse) => { + if(response.status === 429){ + const retryAfter = response.xhr.getResponseHeader("Retry-After"); + if(retryAfter && !isNaN(Number(retryAfter))){ + return Observable.of(response).delay(Number(retryAfter), scheduler); + } + } + return Observable.of(response); + }) + }; + + const outer = (urlOrRequest: string| AjaxRequest) => { + return inner(source(urlOrRequest)); + }; + + return Object.assign(outer, { + get: (url: string, headers?: Object): Observable => notImplemented(), + post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), + delete: (url: string, headers?: Object): Observable => notImplemented(), + getJSON: (url: string, headers?: Object): Observable => notImplemented() + }); +} + +const makeServices = (services: Partial): Services => { + const serviceScheduler = services.scheduler || async; + return { + scheduler: serviceScheduler, + ajax: wrapWithRetry(services.ajax || Observable.ajax, serviceScheduler), WebSocket: services.WebSocket || WebSocket, random: services.random || Math.random, -}); +}}; const lifetimeRefreshToken = 30 * 60 * 1000; const intervalRefreshToken = lifetimeRefreshToken / 2; @@ -581,9 +614,9 @@ export class DirectLine implements IBotConnection { .retryWhen(error$ => // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while - error$.mergeMap(error => error.status >= 400 && error.status < 600 + error$.mergeMap(error => {console.log(error);return error.status >= 400 && error.status < 600 ? Observable.throw(error, this.services.scheduler) - : Observable.of(error, this.services.scheduler) + : Observable.of(error, this.services.scheduler)} ) .delay(timeout, this.services.scheduler) .take(retries) @@ -949,34 +982,5 @@ export class DirectLine implements IBotConnection { return `${DIRECT_LINE_VERSION} (${clientAgent} ${process.env.npm_package_version})`; } - private wrapWithRetry = (source: AjaxCreationMethod): AjaxCreationMethod => { - - const notImplemented = (): never => { throw new Error('not implemented') }; - const inner = (response$ : Observable) => { - return response$ - .concatMap((response: AjaxResponse) => { - if(response.status === 429){ - const retryAfter = response.xhr.getResponseHeader("retry-after"); - if(retryAfter && !isNaN(Number(retryAfter))){ - return Observable.of(response).delay(Number(retryAfter), this.services.scheduler); - } - } - return Observable.of(response); - }) - }; - - const outer = (urlOrRequest: string| AjaxRequest) => { - return inner(source(urlOrRequest)); - }; - - return Object.assign(outer, { - get: (url: string, headers?: Object): Observable => notImplemented(), - post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), - delete: (url: string, headers?: Object): Observable => notImplemented(), - getJSON: (url: string, headers?: Object): Observable => notImplemented() - }); - } } diff --git a/tsconfig.json b/tsconfig.json index 19d29b690..3aa4bb3ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,7 @@ "declaration": true, "declarationDir": "lib", "emitDeclarationOnly": true, - "sourceMap": true, - "resolveJsonModule": true + "sourceMap": true }, "exclude": [ "__tests__/**/*.js", From fe3588a224f3b3e3d2bd8da151d22bdb0a06ba61 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Tue, 5 Nov 2019 12:07:57 -0800 Subject: [PATCH 26/35] use proper casing of async scheduler --- src/directLine.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/directLine.ts b/src/directLine.ts index 005c72711..8a22d4e0d 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs/Observable'; import { IScheduler } from 'rxjs/Scheduler'; import { Subscriber } from 'rxjs/Subscriber'; import { Subscription } from 'rxjs/Subscription'; -import { async } from 'rxjs/Scheduler/async'; +import { async as AsyncScheduler } from 'rxjs/scheduler/async'; import 'rxjs/add/operator/catch'; import 'rxjs/add/operator/combineLatest'; @@ -405,10 +405,10 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC } const makeServices = (services: Partial): Services => { - const serviceScheduler = services.scheduler || async; + const scheduler = services.scheduler || AsyncScheduler; return { - scheduler: serviceScheduler, - ajax: wrapWithRetry(services.ajax || Observable.ajax, serviceScheduler), + scheduler, + ajax: wrapWithRetry(services.ajax || Observable.ajax, scheduler), WebSocket: services.WebSocket || WebSocket, random: services.random || Math.random, }}; From bcc5aac31d39e999dbb33934089f87ee80322657 Mon Sep 17 00:00:00 2001 From: swagat mishra Date: Thu, 7 Nov 2019 00:14:16 -0800 Subject: [PATCH 27/35] almost got retries to work --- src/directLine.test.ts | 18 +++++++++++++++--- src/directLine.ts | 31 +++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index c22f5e57e..b6fa6c389 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -14,6 +14,17 @@ declare var process: { version: string; }; +class AjaxError extends Error{ + message: string; + response: AjaxResponse; + constructor(m: string, response: AjaxResponse){ + super(m); + this.message = m; + this.response = response; + Object.setPrototypeOf(this, AjaxError.prototype); + } +} + // mock delay observable jest.mock('rxjs/add/operator/delay', () => { const Observable = require('rxjs/Observable').Observable; @@ -200,8 +211,9 @@ describe("MockSuite", () => { getResponseHeader: (name) => "10", } as XMLHttpRequest }; - return response as AjaxResponse; - }) + const error = new Error('Ajax Error') + throw Object.assign(error, response); + }); directline = new DirectLineExport.DirectLine(services); const scenario = function* (): IterableIterator> { @@ -212,6 +224,6 @@ describe("MockSuite", () => { subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); scheduler.flush(); - expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, "10"); + expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, 10, expect.anything()); }); }); diff --git a/src/directLine.ts b/src/directLine.ts index 8a22d4e0d..30c03bd91 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -1,6 +1,6 @@ // In order to keep file size down, only import the parts of rxjs that we use -import { AjaxResponse, AjaxCreationMethod, AjaxRequest } from 'rxjs/observable/dom/AjaxObservable'; +import { AjaxResponse, AjaxCreationMethod, AjaxRequest, AjaxError } from 'rxjs/observable/dom/AjaxObservable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Observable } from 'rxjs/Observable'; import { IScheduler } from 'rxjs/Scheduler'; @@ -379,15 +379,25 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC const inner = (response$ : Observable) => { return response$ - .concatMap((response: AjaxResponse) => { - if(response.status === 429){ - const retryAfter = response.xhr.getResponseHeader("Retry-After"); + .catch((err) => { + console.log(err.message); + // console.log("the value is " + err.response.xhr.getResponseHeader('Retry-After')); + if(err.status === 429){ + const retryAfter = err.xhr.getResponseHeader('Retry-After'); if(retryAfter && !isNaN(Number(retryAfter))){ - return Observable.of(response).delay(Number(retryAfter), scheduler); + return Observable.throw(err).delay(Number(retryAfter), scheduler); } } - return Observable.of(response); - }) + }); + // .concatMap((response: AjaxResponse) => { + // if(response.status === 429){ + // const retryAfter = response.xhr.getResponseHeader("Retry-After"); + // if(retryAfter && !isNaN(Number(retryAfter))){ + // return Observable.of(response).delay(Number(retryAfter), scheduler); + // } + // } + // return Observable.of(response); + // }) }; const outer = (urlOrRequest: string| AjaxRequest) => { @@ -614,10 +624,11 @@ export class DirectLine implements IBotConnection { .retryWhen(error$ => // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while - error$.mergeMap(error => {console.log(error);return error.status >= 400 && error.status < 600 + error$.mergeMap((error) { + return error.status >= 400 && error.status < 600 ? Observable.throw(error, this.services.scheduler) - : Observable.of(error, this.services.scheduler)} - ) + : Observable.of(error, this.services.scheduler) + }) .delay(timeout, this.services.scheduler) .take(retries) ) From b8c2151d7a93854427c79b7a691adbf745de814f Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Thu, 7 Nov 2019 12:23:45 -0800 Subject: [PATCH 28/35] use scheduler and return on all paths from wrapWithRetry --- src/directLine.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/directLine.ts b/src/directLine.ts index 30c03bd91..93cac36e3 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -385,9 +385,11 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC if(err.status === 429){ const retryAfter = err.xhr.getResponseHeader('Retry-After'); if(retryAfter && !isNaN(Number(retryAfter))){ - return Observable.throw(err).delay(Number(retryAfter), scheduler); + return Observable.throw(err, scheduler).delay(Number(retryAfter), scheduler); } } + + return Observable.throw(err, scheduler); }); // .concatMap((response: AjaxResponse) => { // if(response.status === 429){ @@ -624,7 +626,7 @@ export class DirectLine implements IBotConnection { .retryWhen(error$ => // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while - error$.mergeMap((error) { + error$.mergeMap((error) => { return error.status >= 400 && error.status < 600 ? Observable.throw(error, this.services.scheduler) : Observable.of(error, this.services.scheduler) From 42695bf83a50358808db20a0dbeba6652cf314f5 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Thu, 7 Nov 2019 12:24:08 -0800 Subject: [PATCH 29/35] catch expected failure from post activity --- src/directLine.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index b6fa6c389..ded58ad4c 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -218,7 +218,7 @@ describe("MockSuite", () => { const scenario = function* (): IterableIterator> { yield Observable.timer(200, scheduler); - yield directline.postActivity(expected.x); + yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); }; subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); From 57f3bb005c4403695b95ad17c87d0c7470c6d869 Mon Sep 17 00:00:00 2001 From: Will Portnoy Date: Thu, 7 Nov 2019 12:26:37 -0800 Subject: [PATCH 30/35] extra comments --- src/directLine.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index ded58ad4c..030aca77a 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -221,6 +221,9 @@ describe("MockSuite", () => { yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); }; + // lack of subscribe arguments means that the empty subscriber is used + // the empty subscriber will propagate observable errors on the JS call stack + // within the scheduler notification action handling loop because of the observeOn subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); scheduler.flush(); From 4b2603fb8af9e5b2e649bae12cc0f0cefb84c3a2 Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Thu, 7 Nov 2019 16:46:20 -0800 Subject: [PATCH 31/35] added debug header support and happy path test for it. Tests pass separately only --- src/directLine.mock.ts | 1 - src/directLine.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++- src/directLine.ts | 19 ++++++++++------ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/directLine.mock.ts b/src/directLine.mock.ts index c50d683b7..152fba1af 100644 --- a/src/directLine.mock.ts +++ b/src/directLine.mock.ts @@ -91,7 +91,6 @@ export const mockAjax = (server: Server, customAjax?: ajaxType): AjaxCreationMet throw new Error(); } - console.log(`${urlOrRequest.method}: ${urlOrRequest.url}`); const uri = new URL(urlOrRequest.url); const { pathname, searchParams } = uri; diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 030aca77a..a1e0914a3 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -203,7 +203,12 @@ describe("MockSuite", () => { expect(actual).toStrictEqual(expected) }); - test('MockRetry', () => { + test('RetryAfterHeader', () => { + // jest.mock('rxjs/add/operator/delay', () => { + // const Observable = require('rxjs/Observable').Observable; + // Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay + // }); + services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { const response: Partial = { status: 429, @@ -229,4 +234,48 @@ describe("MockSuite", () => { expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, 10, expect.anything()); }); + + test('SendDebugHeader', () => { + let expectedBotId:string; + const actual = 'botid'; + services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { + if(typeof urlOrRequest === 'string'){ + throw new Error(); + } + + if(urlOrRequest.url && urlOrRequest.url.indexOf(server.conversation.conversationId)>0){ + const response: Partial = { + response: {id:'blah'}, + status: 200 + }; + expectedBotId = urlOrRequest.headers['x-ms-botid']; + return response as AjaxResponse; + } + else if(urlOrRequest.url && urlOrRequest.url.indexOf('/conversations') > 0){ + // start conversation + const response: Partial = { + response: server.conversation, + status: 201, + xhr: { + getResponseHeader: (name) => actual + } as XMLHttpRequest + }; + return response as AjaxResponse; + } + throw new Error(); + }); + directline = new DirectLineExport.DirectLine(services); + const scenario = function* (): IterableIterator> { + yield Observable.timer(200, scheduler); + yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); + }; + + // lack of subscribe arguments means that the empty subscriber is used + // the empty subscriber will propagate observable errors on the JS call stack + // within the scheduler notification action handling loop because of the observeOn + subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); + scheduler.flush(); + + expect(expectedBotId).toBe(actual); + }); }); diff --git a/src/directLine.ts b/src/directLine.ts index 93cac36e3..a70688ecc 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -29,6 +29,7 @@ import 'rxjs/add/observable/of'; import 'rxjs/add/observable/throw'; import dedupeFilenames from './dedupeFilenames'; +import { objectExpression } from '@babel/types'; const DIRECT_LINE_VERSION = 'DirectLine/3.0'; @@ -380,8 +381,6 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC const inner = (response$ : Observable) => { return response$ .catch((err) => { - console.log(err.message); - // console.log("the value is " + err.response.xhr.getResponseHeader('Retry-After')); if(err.status === 429){ const retryAfter = err.xhr.getResponseHeader('Retry-After'); if(retryAfter && !isNaN(Number(retryAfter))){ @@ -469,6 +468,7 @@ export class DirectLine implements IBotConnection { private services: Services; private _userAgent: string; public referenceGrammarId: string; + private botIdHeader: string; private pollingInterval: number = 1000; //ms @@ -622,7 +622,12 @@ export class DirectLine implements IBotConnection { } }) // .do(ajaxResponse => konsole.log("conversation ajaxResponse", ajaxResponse.response)) - .map(ajaxResponse => ajaxResponse.response as Conversation) + .map(ajaxResponse => { + if(!this.botIdHeader){ + this.botIdHeader = ajaxResponse.xhr.getResponseHeader('x-ms-botid'); + } + return ajaxResponse.response as Conversation; + }) .retryWhen(error$ => // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while @@ -979,10 +984,10 @@ export class DirectLine implements IBotConnection { } private commonHeaders() { - return { - "Authorization": `Bearer ${this.token}`, - "x-ms-bot-agent": this._botAgent - }; + return Object.assign({ + "Authorization": `Bearer ${this.token}`, + "x-ms-bot-agent": this._botAgent + }, this.botIdHeader ? {'x-ms-botid': this.botIdHeader}: null); } private getBotAgent(customAgent: string = ''): string { From 7eb8afce6372af562d33f8cb80f9f4ec76f30116 Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Fri, 8 Nov 2019 12:31:25 -0800 Subject: [PATCH 32/35] fixing retry after test. (work in progress) --- src/directLine.test.ts | 30 ++++++++++++++++++------------ src/directLine.ts | 4 +++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index a1e0914a3..e850b3691 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -26,10 +26,10 @@ class AjaxError extends Error{ } // mock delay observable -jest.mock('rxjs/add/operator/delay', () => { - const Observable = require('rxjs/Observable').Observable; - Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay - }); +// jest.mock('rxjs/add/operator/delay', () => { +// const Observable = require('rxjs/Observable').Observable; +// Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay +// }); test("#setConnectionStatusFallback", () => { const { DirectLine } = DirectLineExport; @@ -204,12 +204,11 @@ describe("MockSuite", () => { }); test('RetryAfterHeader', () => { - // jest.mock('rxjs/add/operator/delay', () => { - // const Observable = require('rxjs/Observable').Observable; - // Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay - // }); - services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { + if(typeof urlOrRequest === 'string'){ + throw new Error(); + } + console.log('called with url ' + urlOrRequest.url); const response: Partial = { status: 429, xhr:{ @@ -219,20 +218,27 @@ describe("MockSuite", () => { const error = new Error('Ajax Error') throw Object.assign(error, response); }); - directline = new DirectLineExport.DirectLine(services); + // directline = new DirectLineExport.DirectLine(services); + let startTime: number; const scenario = function* (): IterableIterator> { yield Observable.timer(200, scheduler); - yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); + yield Observable.of(2).do(_ => {startTime = scheduler.now()}); + yield Observable.of(2).delay(300, scheduler); + // yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); }; // lack of subscribe arguments means that the empty subscriber is used // the empty subscriber will propagate observable errors on the JS call stack // within the scheduler notification action handling loop because of the observeOn + // const startTime = scheduler.now(); + // startTime = scheduler.now(); subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); scheduler.flush(); + const endTime = scheduler.now(); + console.log('the time difference is ' + (endTime - startTime)); - expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, 10, expect.anything()); + // expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, 10, expect.anything()); }); test('SendDebugHeader', () => { diff --git a/src/directLine.ts b/src/directLine.ts index a70688ecc..64d2cd951 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -384,6 +384,7 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC if(err.status === 429){ const retryAfter = err.xhr.getResponseHeader('Retry-After'); if(retryAfter && !isNaN(Number(retryAfter))){ + console.log('delaying by ' + retryAfter); return Observable.throw(err, scheduler).delay(Number(retryAfter), scheduler); } } @@ -611,7 +612,7 @@ export class DirectLine implements IBotConnection { ? `${this.domain}/conversations/${this.conversationId}?watermark=${this.watermark}` : `${this.domain}/conversations`; const method = this.conversationId ? "GET" : "POST"; - + console.log('start conversation called'); return this.services.ajax({ method, url, @@ -632,6 +633,7 @@ export class DirectLine implements IBotConnection { // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while error$.mergeMap((error) => { + console.log(error.status); return error.status >= 400 && error.status < 600 ? Observable.throw(error, this.services.scheduler) : Observable.of(error, this.services.scheduler) From 1a4df49b7546b72326875dcd145ef2d92c38e17e Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Tue, 12 Nov 2019 14:16:01 -0800 Subject: [PATCH 33/35] have all test cases pass --- src/directLine.test.ts | 80 +++++++++++++++++++++--------------------- src/directLine.ts | 27 ++++++-------- 2 files changed, 50 insertions(+), 57 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index e850b3691..0915984d4 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -14,23 +14,6 @@ declare var process: { version: string; }; -class AjaxError extends Error{ - message: string; - response: AjaxResponse; - constructor(m: string, response: AjaxResponse){ - super(m); - this.message = m; - this.response = response; - Object.setPrototypeOf(this, AjaxError.prototype); - } -} - -// mock delay observable -// jest.mock('rxjs/add/operator/delay', () => { -// const Observable = require('rxjs/Observable').Observable; -// Observable.prototype.delay = jest.fn(function (item) { return this; }); // <= mock delay -// }); - test("#setConnectionStatusFallback", () => { const { DirectLine } = DirectLineExport; expect(typeof DirectLine.prototype.setConnectionStatusFallback).toBe("function") @@ -50,7 +33,7 @@ test("#setConnectionStatusFallback", () => { }); describe("#commonHeaders", () => { - const botAgent = "DirectLine/3.0 (directlinejs; custom-bot-agent)"; + const botAgent = `DirectLine/3.0 (directlinejs; custom-bot-agent ${version})`; let botConnection; beforeEach(() => { @@ -205,40 +188,57 @@ describe("MockSuite", () => { test('RetryAfterHeader', () => { services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { + if(typeof urlOrRequest === 'string'){ throw new Error(); } - console.log('called with url ' + urlOrRequest.url); - const response: Partial = { - status: 429, - xhr:{ - getResponseHeader: (name) => "10", - } as XMLHttpRequest - }; - const error = new Error('Ajax Error') - throw Object.assign(error, response); + + if(urlOrRequest.url && urlOrRequest.url.indexOf(server.conversation.conversationId)>0){ + const response: Partial = { + status: 429, + xhr:{ + getResponseHeader: (name) => "10" + } as XMLHttpRequest + }; + const error = new Error('Ajax Error'); + throw Object.assign(error, response); + } + else if(urlOrRequest.url && urlOrRequest.url.indexOf('/conversations') > 0){ + // start conversation + const response: Partial = { + response: server.conversation, + status: 201, + xhr: { + getResponseHeader: (name) => 'n/a' + } as XMLHttpRequest + }; + return response as AjaxResponse; + } + throw new Error(); }); - // directline = new DirectLineExport.DirectLine(services); + directline = new DirectLineExport.DirectLine(services); let startTime: number; + let endTime: number; const scenario = function* (): IterableIterator> { yield Observable.timer(200, scheduler); - yield Observable.of(2).do(_ => {startTime = scheduler.now()}); - yield Observable.of(2).delay(300, scheduler); - // yield directline.postActivity(expected.x).catch(() => Observable.empty(scheduler)); + startTime = scheduler.now(); + yield directline.postActivity(expected.x); }; - // lack of subscribe arguments means that the empty subscriber is used - // the empty subscriber will propagate observable errors on the JS call stack - // within the scheduler notification action handling loop because of the observeOn - // const startTime = scheduler.now(); - // startTime = scheduler.now(); + let actualError: Error; + try{ subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); scheduler.flush(); - const endTime = scheduler.now(); - console.log('the time difference is ' + (endTime - startTime)); - - // expect(Observable.prototype.delay).toHaveBeenNthCalledWith(1, 10, expect.anything()); + } + catch(error){ + actualError = error; + endTime = scheduler.now(); + } + expect(actualError.message).toStrictEqual('Ajax Error'); + // @ts-ignore + expect(actualError.status).toStrictEqual(429); + expect(endTime - startTime).toStrictEqual(10); }); test('SendDebugHeader', () => { diff --git a/src/directLine.ts b/src/directLine.ts index 64d2cd951..e75882e06 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -382,24 +382,16 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC return response$ .catch((err) => { if(err.status === 429){ - const retryAfter = err.xhr.getResponseHeader('Retry-After'); - if(retryAfter && !isNaN(Number(retryAfter))){ - console.log('delaying by ' + retryAfter); - return Observable.throw(err, scheduler).delay(Number(retryAfter), scheduler); + const retryAfterValue = err.xhr.getResponseHeader('Retry-After'); + const retryAfter = Number(retryAfterValue); + if(!isNaN(retryAfter)){ + return Observable.timer(Number(retryAfter), scheduler) + .flatMap(_ => Observable.throw(err, scheduler)); } } return Observable.throw(err, scheduler); }); - // .concatMap((response: AjaxResponse) => { - // if(response.status === 429){ - // const retryAfter = response.xhr.getResponseHeader("Retry-After"); - // if(retryAfter && !isNaN(Number(retryAfter))){ - // return Observable.of(response).delay(Number(retryAfter), scheduler); - // } - // } - // return Observable.of(response); - // }) }; const outer = (urlOrRequest: string| AjaxRequest) => { @@ -612,7 +604,6 @@ export class DirectLine implements IBotConnection { ? `${this.domain}/conversations/${this.conversationId}?watermark=${this.watermark}` : `${this.domain}/conversations`; const method = this.conversationId ? "GET" : "POST"; - console.log('start conversation called'); return this.services.ajax({ method, url, @@ -624,16 +615,18 @@ export class DirectLine implements IBotConnection { }) // .do(ajaxResponse => konsole.log("conversation ajaxResponse", ajaxResponse.response)) .map(ajaxResponse => { - if(!this.botIdHeader){ - this.botIdHeader = ajaxResponse.xhr.getResponseHeader('x-ms-botid'); + try{ + if(!this.botIdHeader ){ + this.botIdHeader = ajaxResponse.xhr.getResponseHeader('x-ms-botid'); + } } + catch{/*don't care if the above throws for any reason*/} return ajaxResponse.response as Conversation; }) .retryWhen(error$ => // for now we deem 4xx and 5xx errors as unrecoverable // for everything else (timeouts), retry for a while error$.mergeMap((error) => { - console.log(error.status); return error.status >= 400 && error.status < 600 ? Observable.throw(error, this.services.scheduler) : Observable.of(error, this.services.scheduler) From 506c918dc72266f7cd5f14ec28b849197b03d466 Mon Sep 17 00:00:00 2001 From: swagat mishra Date: Tue, 10 Dec 2019 16:19:34 -0800 Subject: [PATCH 34/35] Apply suggestions from code review simpler changes from PR feedback Co-Authored-By: William Wong --- src/directLine.test.ts | 7 ++++--- src/directLine.ts | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 0915984d4..84b6a9d71 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -2,7 +2,7 @@ import * as DirectLineExport from "./directLine"; import * as DirectLineMock from './directLine.mock'; import { TestScheduler, Observable, Subscription, AjaxResponse } from "rxjs"; // @ts-ignore -import {version} from "../package.json"; +import { version } from "../package.json"; declare var process: { arch: string; @@ -66,7 +66,7 @@ describe("#commonHeaders", () => { }) }); -describe("MockSuite", () => { +describe('MockSuite', () => { const lazyConcat = (items: Iterable>): Observable => new Observable(subscriber => { @@ -83,7 +83,8 @@ describe("MockSuite", () => { inner = result.value.subscribe( value => subscriber.next(value), error => subscriber.error(error), - pump); + pump + ); } } catch (error) { diff --git a/src/directLine.ts b/src/directLine.ts index e75882e06..10170a730 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -376,7 +376,7 @@ export interface Services { const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxCreationMethod =>{ - const notImplemented = (): never => { throw new Error('not implemented') }; + const notImplemented = (): never => { throw new Error('not implemented'); }; const inner = (response$ : Observable) => { return response$ @@ -385,7 +385,7 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC const retryAfterValue = err.xhr.getResponseHeader('Retry-After'); const retryAfter = Number(retryAfterValue); if(!isNaN(retryAfter)){ - return Observable.timer(Number(retryAfter), scheduler) + return Observable.timer(retryAfter, scheduler) .flatMap(_ => Observable.throw(err, scheduler)); } } @@ -398,7 +398,7 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC return inner(source(urlOrRequest)); }; - return Object.assign(outer, { + return Object.assign(outer, { get: (url: string, headers?: Object): Observable => notImplemented(), post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), @@ -617,7 +617,7 @@ export class DirectLine implements IBotConnection { .map(ajaxResponse => { try{ if(!this.botIdHeader ){ - this.botIdHeader = ajaxResponse.xhr.getResponseHeader('x-ms-botid'); + this.botIdHeader = ajaxResponse.xhr.getResponseHeader('x-ms-bot-id'); } } catch{/*don't care if the above throws for any reason*/} @@ -982,7 +982,7 @@ export class DirectLine implements IBotConnection { return Object.assign({ "Authorization": `Bearer ${this.token}`, "x-ms-bot-agent": this._botAgent - }, this.botIdHeader ? {'x-ms-botid': this.botIdHeader}: null); + }, this.botIdHeader ? {'x-ms-bot-id': this.botIdHeader}: null); } private getBotAgent(customAgent: string = ''): string { From bef233e1560b68a076aa571293ea003f3c988381 Mon Sep 17 00:00:00 2001 From: Swagat Mishra Date: Tue, 10 Dec 2019 16:27:03 -0800 Subject: [PATCH 35/35] address more comments --- src/directLine.test.ts | 2 +- src/directLine.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/directLine.test.ts b/src/directLine.test.ts index 84b6a9d71..a2c33a730 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -95,7 +95,7 @@ describe('MockSuite', () => { pump(); return () => { - if (inner !== undefined) { + if (typeof inner !== 'undefined') { inner.unsubscribe(); } }; diff --git a/src/directLine.ts b/src/directLine.ts index 10170a730..0b9e174a5 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -374,7 +374,7 @@ export interface Services { random: () => number; } -const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxCreationMethod =>{ +const wrapAjaxWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxCreationMethod =>{ const notImplemented = (): never => { throw new Error('not implemented'); }; @@ -411,11 +411,12 @@ const wrapWithRetry = (source: AjaxCreationMethod, scheduler: IScheduler): AjaxC const makeServices = (services: Partial): Services => { const scheduler = services.scheduler || AsyncScheduler; return { - scheduler, - ajax: wrapWithRetry(services.ajax || Observable.ajax, scheduler), - WebSocket: services.WebSocket || WebSocket, - random: services.random || Math.random, -}}; + scheduler, + ajax: wrapAjaxWithRetry(services.ajax || Observable.ajax, scheduler), + WebSocket: services.WebSocket || WebSocket, + random: services.random || Math.random, + } +} const lifetimeRefreshToken = 30 * 60 * 1000; const intervalRefreshToken = lifetimeRefreshToken / 2;