From 296fcc9423122a0cc15a3f75d2f56c326c9e5c6f Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Thu, 14 Jun 2018 13:44:36 -0700 Subject: [PATCH] Add JS unit tests for SSE (#2484) --- .../signalr/src/ServerSentEventsTransport.ts | 16 +- clients/ts/signalr/src/WebSocketTransport.ts | 2 +- .../ts/signalr/tests/HttpConnection.test.ts | 2 +- .../tests/ServerSentEventsTransport.test.ts | 190 ++++++++++++++++++ clients/ts/signalr/tests/TestEventSource.ts | 93 +++++++++ 5 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 clients/ts/signalr/tests/ServerSentEventsTransport.test.ts create mode 100644 clients/ts/signalr/tests/TestEventSource.ts diff --git a/clients/ts/signalr/src/ServerSentEventsTransport.ts b/clients/ts/signalr/src/ServerSentEventsTransport.ts index a25d7522be..432a15a194 100644 --- a/clients/ts/signalr/src/ServerSentEventsTransport.ts +++ b/clients/ts/signalr/src/ServerSentEventsTransport.ts @@ -38,6 +38,9 @@ export class ServerSentEventsTransport implements ITransport { this.logger.log(LogLevel.Trace, "(SSE transport) Connecting"); + // set url before accessTokenFactory because this.url is only for send and we set the auth header instead of the query string for send + this.url = url; + if (this.accessTokenFactory) { const token = await this.accessTokenFactory(); if (token) { @@ -45,11 +48,11 @@ export class ServerSentEventsTransport implements ITransport { } } - this.url = url; return new Promise((resolve, reject) => { let opened = false; if (transferFormat !== TransferFormat.Text) { reject(new Error("The Server-Sent Events transport only supports the 'Text' transfer format")); + return; } const eventSource = new this.eventSourceConstructor(url, { withCredentials: true }); @@ -61,16 +64,14 @@ export class ServerSentEventsTransport implements ITransport { this.logger.log(LogLevel.Trace, `(SSE transport) data received. ${getDataDetail(e.data, this.logMessageContent)}.`); this.onreceive(e.data); } catch (error) { - if (this.onclose) { - this.onclose(error); - } + this.close(error); return; } } }; - eventSource.onerror = (e: any) => { - const error = new Error(e.message || "Error occurred"); + eventSource.onerror = (e: MessageEvent) => { + const error = new Error(e.data || "Error occurred"); if (opened) { this.close(error); } else { @@ -85,7 +86,8 @@ export class ServerSentEventsTransport implements ITransport { resolve(); }; } catch (e) { - return Promise.reject(e); + reject(e); + return; } }); } diff --git a/clients/ts/signalr/src/WebSocketTransport.ts b/clients/ts/signalr/src/WebSocketTransport.ts index ea11328d39..0adf55d5d1 100644 --- a/clients/ts/signalr/src/WebSocketTransport.ts +++ b/clients/ts/signalr/src/WebSocketTransport.ts @@ -81,7 +81,7 @@ export class WebSocketTransport implements ITransport { } public send(data: any): Promise { - if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) { + if (this.webSocket && this.webSocket.readyState === this.webSocketConstructor.OPEN) { this.logger.log(LogLevel.Trace, `(WebSockets transport) sending data. ${getDataDetail(data, this.logMessageContent)}.`); this.webSocket.send(data); return Promise.resolve(); diff --git a/clients/ts/signalr/tests/HttpConnection.test.ts b/clients/ts/signalr/tests/HttpConnection.test.ts index a4c45d5150..0ff5a6d687 100644 --- a/clients/ts/signalr/tests/HttpConnection.test.ts +++ b/clients/ts/signalr/tests/HttpConnection.test.ts @@ -747,7 +747,7 @@ describe("HttpConnection", () => { await expect(connection.start(TransferFormat.Text)) .rejects - .toThrow("Unable to initialize any of the available transports."); + .toEqual(new Error("Unable to initialize any of the available transports.")); expect(eventSourceConstructorCalled).toEqual(true); }, diff --git a/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts b/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts new file mode 100644 index 0000000000..31c9e2a5dd --- /dev/null +++ b/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +import { TransferFormat } from "../src/ITransport"; + +import { HttpClient, HttpRequest } from "../src/HttpClient"; +import { ILogger } from "../src/ILogger"; +import { ServerSentEventsTransport } from "../src/ServerSentEventsTransport"; +import { VerifyLogger } from "./Common"; +import { TestEventSource, TestMessageEvent } from "./TestEventSource"; +import { TestHttpClient } from "./TestHttpClient"; + +describe("ServerSentEventsTransport", () => { + it("does not allow non-text formats", async () => { + await VerifyLogger.run(async (logger) => { + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + + await expect(sse.connect("", TransferFormat.Binary)) + .rejects + .toEqual(new Error("The Server-Sent Events transport only supports the 'Text' transfer format")); + }); + }); + + it("connect waits for EventSource to be connected", async () => { + await VerifyLogger.run(async (logger) => { + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + + let connectComplete: boolean = false; + const connectPromise = (async () => { + await sse.connect("http://example.com", TransferFormat.Text); + connectComplete = true; + })(); + + await TestEventSource.eventSource.openSet; + + expect(connectComplete).toEqual(false); + + TestEventSource.eventSource.onopen(new TestMessageEvent()); + + await connectPromise; + expect(connectComplete).toEqual(true); + }); + }); + + [["http://example.com", "http://example.com?access_token=secretToken"], + ["http://example.com?value=null", "http://example.com?value=null&access_token=secretToken"]] + .forEach(([input, expected]) => { + it(`appends access_token to url ${input}`, async () => { + await VerifyLogger.run(async (logger) => { + await createAndStartSSE(logger, input, () => "secretToken"); + + expect(TestEventSource.eventSource.url).toEqual(expected); + }); + }); + }); + + it("sets Authorization header on sends", async () => { + await VerifyLogger.run(async (logger) => { + let request: HttpRequest; + const httpClient = new TestHttpClient().on((r) => { + request = r; + return ""; + }); + + const sse = await createAndStartSSE(logger, "http://example.com", () => "secretToken", httpClient); + + await sse.send(""); + + expect(request!.headers!.Authorization).toEqual("Bearer secretToken"); + expect(request!.url).toEqual("http://example.com"); + }); + }); + + it("can send data", async () => { + await VerifyLogger.run(async (logger) => { + let request: HttpRequest; + const httpClient = new TestHttpClient().on((r) => { + request = r; + return ""; + }); + + const sse = await createAndStartSSE(logger, "http://example.com", undefined, httpClient); + + await sse.send("send data"); + + expect(request!.content).toEqual("send data"); + }); + }); + + it("can receive data", async () => { + await VerifyLogger.run(async (logger) => { + const sse = await createAndStartSSE(logger); + + let received: string | ArrayBuffer; + sse.onreceive = (data) => { + received = data; + }; + + const message = new TestMessageEvent(); + message.data = "receive data"; + TestEventSource.eventSource.onmessage(message); + + expect(typeof received!).toEqual("string"); + expect(received!).toEqual("receive data"); + }); + }); + + it("stop closes EventSource and calls onclose", async () => { + await VerifyLogger.run(async (logger) => { + const sse = await createAndStartSSE(logger); + + let closeCalled: boolean = false; + sse.onclose = () => { + closeCalled = true; + }; + + await sse.stop(); + + expect(closeCalled).toEqual(true); + expect(TestEventSource.eventSource.closed).toEqual(true); + }); + }); + + it("can close from EventSource error", async () => { + await VerifyLogger.run(async (logger) => { + const sse = await createAndStartSSE(logger); + + let closeCalled: boolean = false; + let error: Error | undefined; + sse.onclose = (e) => { + closeCalled = true; + error = e; + }; + + const errorEvent = new TestMessageEvent(); + errorEvent.data = "error"; + TestEventSource.eventSource.onerror(errorEvent); + + expect(closeCalled).toEqual(true); + expect(TestEventSource.eventSource.closed).toEqual(true); + expect(error).toMatchObject({ message: "error" }); + }); + }); + + it("send throws if not connected", async () => { + await VerifyLogger.run(async (logger) => { + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + + await expect(sse.send("")) + .rejects + .toMatchObject({ message: "Cannot send until the transport is connected" }); + }); + }); + + it("closes on error from receive", async () => { + await VerifyLogger.run(async (logger) => { + const sse = await createAndStartSSE(logger); + + sse.onreceive = () => { + throw new Error("error parsing"); + }; + + let closeCalled: boolean = false; + let error: Error | undefined; + sse.onclose = (e) => { + closeCalled = true; + error = e; + }; + + const errorEvent = new TestMessageEvent(); + errorEvent.data = "some data"; + TestEventSource.eventSource.onmessage(errorEvent); + + expect(closeCalled).toEqual(true); + expect(TestEventSource.eventSource.closed).toEqual(true); + expect(error).toMatchObject({ message: "error parsing" }); + }); + }); +}); + +async function createAndStartSSE(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise), httpClient?: HttpClient): Promise { + const sse = new ServerSentEventsTransport(httpClient || new TestHttpClient(), accessTokenFactory, logger, true, TestEventSource); + + const connectPromise = sse.connect(url || "http://example.com", TransferFormat.Text); + await TestEventSource.eventSource.openSet; + + TestEventSource.eventSource.onopen(new TestMessageEvent()); + await connectPromise; + return sse; +} diff --git a/clients/ts/signalr/tests/TestEventSource.ts b/clients/ts/signalr/tests/TestEventSource.ts new file mode 100644 index 0000000000..f0828a544e --- /dev/null +++ b/clients/ts/signalr/tests/TestEventSource.ts @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +import { PromiseSource } from "./Utils"; + +export class TestEventSource { + public CONNECTING: number = 1; + public OPEN: number = 2; + public CLOSED: number = 3; + public onerror!: (evt: MessageEvent) => any; + public onmessage!: (evt: MessageEvent) => any; + public readyState: number = 0; + public url: string = ""; + public withCredentials: boolean = false; + + // tslint:disable-next-line:variable-name + private _onopen?: (evt: MessageEvent) => any; + public openSet: PromiseSource = new PromiseSource(); + public set onopen(value: (evt: MessageEvent) => any) { + this._onopen = value; + this.openSet.resolve(); + } + + public get onopen(): (evt: MessageEvent) => any { + return this._onopen!; + } + + public static eventSource: TestEventSource; + public closed: boolean = false; + + constructor(url: string, eventSourceInitDict?: EventSourceInit) { + this.url = url; + TestEventSource.eventSource = this; + } + + public close(): void { + this.closed = true; + } + public addEventListener(type: string, listener?: EventListener | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void { + throw new Error("Method not implemented."); + } + public dispatchEvent(evt: Event): boolean { + throw new Error("Method not implemented."); + } + public removeEventListener(type: string, listener?: EventListener | EventListenerObject | null, options?: boolean | EventListenerOptions): void { + throw new Error("Method not implemented."); + } + + public static callOnOpen(): void { + } +} + +export class TestMessageEvent { + public data: any; + public readonly origin!: string; + public readonly ports!: ReadonlyArray; + public readonly source!: Window; + public initMessageEvent(type: string, bubbles: boolean, cancelable: boolean, data: any, origin: string, lastEventId: string, source: Window): void { + throw new Error("Method not implemented."); + } + public bubbles!: boolean; + public cancelBubble!: boolean; + public cancelable!: boolean; + public currentTarget!: EventTarget; + public defaultPrevented!: boolean; + public eventPhase!: number; + public isTrusted!: boolean; + public returnValue!: boolean; + public scoped!: boolean; + public srcElement!: Element | null; + public target!: EventTarget; + public timeStamp!: number; + public type!: string; + public deepPath(): EventTarget[] { + throw new Error("Method not implemented."); + } + public initEvent(type: string, bubbles?: boolean | undefined, cancelable?: boolean | undefined): void { + throw new Error("Method not implemented."); + } + public preventDefault(): void { + throw new Error("Method not implemented."); + } + public stopImmediatePropagation(): void { + throw new Error("Method not implemented."); + } + public stopPropagation(): void { + throw new Error("Method not implemented."); + } + public AT_TARGET!: number; + public BUBBLING_PHASE!: number; + public CAPTURING_PHASE!: number; + public NONE!: number; +}