Add WebSocket and EventSource polyfills to options (#2408)
This commit is contained in:
parent
4f85ca2b1d
commit
10efae64e0
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
// 'node' indicates to Jenkins that the enclosed block runs on a node that matches
|
// 'node' indicates to Jenkins that the enclosed block runs on a node that matches
|
||||||
// the label 'windows-with-vs'
|
// the label 'windows-with-vs'
|
||||||
simpleNode('Windows.10.Enterprise.RS3.ASPNET') {
|
simpleNode('Windows.10.Amd64.EnterpriseRS3.ASPNET.Open') {
|
||||||
stage ('Checking out source') {
|
stage ('Checking out source') {
|
||||||
checkout scm
|
checkout scm
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ The following polyfills are required to use the client in Node.js applications:
|
||||||
- `XmlHttpRequest` - always
|
- `XmlHttpRequest` - always
|
||||||
- `WebSockets` - to use the WebSockets transport
|
- `WebSockets` - to use the WebSockets transport
|
||||||
- `EventSource` - to use the ServerSentEvents transport
|
- `EventSource` - to use the ServerSentEvents transport
|
||||||
- `btoa/atob` - to use binary protocols (e.g. MessagePack) over text transports (ServerSentEvents)
|
|
||||||
|
|
||||||
### Example (Browser)
|
### Example (Browser)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { IHttpConnectionOptions } from "./IHttpConnectionOptions";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { HttpTransportType, ITransport, TransferFormat } from "./ITransport";
|
import { HttpTransportType, ITransport, TransferFormat } from "./ITransport";
|
||||||
import { LongPollingTransport } from "./LongPollingTransport";
|
import { LongPollingTransport } from "./LongPollingTransport";
|
||||||
|
import { EventSourceConstructor, WebSocketConstructor } from "./Polyfills";
|
||||||
import { ServerSentEventsTransport } from "./ServerSentEventsTransport";
|
import { ServerSentEventsTransport } from "./ServerSentEventsTransport";
|
||||||
import { Arg, createLogger } from "./Utils";
|
import { Arg, createLogger } from "./Utils";
|
||||||
import { WebSocketTransport } from "./WebSocketTransport";
|
import { WebSocketTransport } from "./WebSocketTransport";
|
||||||
|
|
@ -55,6 +56,13 @@ export class HttpConnection implements IConnection {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options.logMessageContent = options.logMessageContent || false;
|
options.logMessageContent = options.logMessageContent || false;
|
||||||
|
|
||||||
|
if (typeof WebSocket !== "undefined" && !options.WebSocket) {
|
||||||
|
options.WebSocket = WebSocket;
|
||||||
|
}
|
||||||
|
if (typeof EventSource !== "undefined" && !options.EventSource) {
|
||||||
|
options.EventSource = EventSource;
|
||||||
|
}
|
||||||
|
|
||||||
this.httpClient = options.httpClient || new DefaultHttpClient(this.logger);
|
this.httpClient = options.httpClient || new DefaultHttpClient(this.logger);
|
||||||
this.connectionState = ConnectionState.Disconnected;
|
this.connectionState = ConnectionState.Disconnected;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
|
@ -253,9 +261,15 @@ export class HttpConnection implements IConnection {
|
||||||
private constructTransport(transport: HttpTransportType) {
|
private constructTransport(transport: HttpTransportType) {
|
||||||
switch (transport) {
|
switch (transport) {
|
||||||
case HttpTransportType.WebSockets:
|
case HttpTransportType.WebSockets:
|
||||||
return new WebSocketTransport(this.accessTokenFactory, this.logger, this.options.logMessageContent || false);
|
if (!this.options.WebSocket) {
|
||||||
|
throw new Error("'WebSocket' is not supported in your environment.");
|
||||||
|
}
|
||||||
|
return new WebSocketTransport(this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.WebSocket);
|
||||||
case HttpTransportType.ServerSentEvents:
|
case HttpTransportType.ServerSentEvents:
|
||||||
return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false);
|
if (!this.options.EventSource) {
|
||||||
|
throw new Error("'EventSource' is not supported in your environment.");
|
||||||
|
}
|
||||||
|
return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.EventSource);
|
||||||
case HttpTransportType.LongPolling:
|
case HttpTransportType.LongPolling:
|
||||||
return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false);
|
return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false);
|
||||||
default:
|
default:
|
||||||
|
|
@ -271,8 +285,8 @@ export class HttpConnection implements IConnection {
|
||||||
const transferFormats = endpoint.transferFormats.map((s) => TransferFormat[s]);
|
const transferFormats = endpoint.transferFormats.map((s) => TransferFormat[s]);
|
||||||
if (transportMatches(requestedTransport, transport)) {
|
if (transportMatches(requestedTransport, transport)) {
|
||||||
if (transferFormats.indexOf(requestedTransferFormat) >= 0) {
|
if (transferFormats.indexOf(requestedTransferFormat) >= 0) {
|
||||||
if ((transport === HttpTransportType.WebSockets && typeof WebSocket === "undefined") ||
|
if ((transport === HttpTransportType.WebSockets && !this.options.WebSocket) ||
|
||||||
(transport === HttpTransportType.ServerSentEvents && typeof EventSource === "undefined")) {
|
(transport === HttpTransportType.ServerSentEvents && !this.options.EventSource)) {
|
||||||
this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it is not supported in your environment.'`);
|
this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it is not supported in your environment.'`);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(LogLevel.Debug, `Selecting transport '${HttpTransportType[transport]}'`);
|
this.logger.log(LogLevel.Debug, `Selecting transport '${HttpTransportType[transport]}'`);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { HttpClient } from "./HttpClient";
|
import { HttpClient } from "./HttpClient";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { HttpTransportType, ITransport } from "./ITransport";
|
import { HttpTransportType, ITransport } from "./ITransport";
|
||||||
|
import { EventSourceConstructor, WebSocketConstructor } from "./Polyfills";
|
||||||
|
|
||||||
/** Options provided to the 'withUrl' method on {@link HubConnectionBuilder} to configure options for the HTTP-based transports. */
|
/** Options provided to the 'withUrl' method on {@link HubConnectionBuilder} to configure options for the HTTP-based transports. */
|
||||||
export interface IHttpConnectionOptions {
|
export interface IHttpConnectionOptions {
|
||||||
|
|
@ -38,4 +39,18 @@ export interface IHttpConnectionOptions {
|
||||||
* Negotiation can only be skipped when the {@link transport} property is set to 'HttpTransportType.WebSockets'.
|
* Negotiation can only be skipped when the {@link transport} property is set to 'HttpTransportType.WebSockets'.
|
||||||
*/
|
*/
|
||||||
skipNegotiation?: boolean;
|
skipNegotiation?: boolean;
|
||||||
|
|
||||||
|
// Used for unit testing and code spelunkers
|
||||||
|
/** A constructor that can be used to create a WebSocket.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
WebSocket?: WebSocketConstructor;
|
||||||
|
|
||||||
|
// Used for unit testing and code spelunkers
|
||||||
|
/** A constructor that can be used to create an EventSource.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
EventSource?: EventSourceConstructor;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// Not exported from index
|
||||||
|
|
||||||
|
export interface EventSourceConstructor {
|
||||||
|
new(url: string, eventSourceInitDict?: EventSourceInit): EventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketConstructor {
|
||||||
|
new(url: string, protocols?: string | string[]): WebSocket;
|
||||||
|
readonly CLOSED: number;
|
||||||
|
readonly CLOSING: number;
|
||||||
|
readonly CONNECTING: number;
|
||||||
|
readonly OPEN: number;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { HttpClient } from "./HttpClient";
|
import { HttpClient } from "./HttpClient";
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { ITransport, TransferFormat } from "./ITransport";
|
import { ITransport, TransferFormat } from "./ITransport";
|
||||||
|
import { EventSourceConstructor } from "./Polyfills";
|
||||||
import { Arg, getDataDetail, sendMessage } from "./Utils";
|
import { Arg, getDataDetail, sendMessage } from "./Utils";
|
||||||
|
|
||||||
export class ServerSentEventsTransport implements ITransport {
|
export class ServerSentEventsTransport implements ITransport {
|
||||||
|
|
@ -11,17 +12,20 @@ export class ServerSentEventsTransport implements ITransport {
|
||||||
private readonly accessTokenFactory: (() => string | Promise<string>) | undefined;
|
private readonly accessTokenFactory: (() => string | Promise<string>) | undefined;
|
||||||
private readonly logger: ILogger;
|
private readonly logger: ILogger;
|
||||||
private readonly logMessageContent: boolean;
|
private readonly logMessageContent: boolean;
|
||||||
|
private readonly eventSourceConstructor: EventSourceConstructor;
|
||||||
private eventSource?: EventSource;
|
private eventSource?: EventSource;
|
||||||
private url?: string;
|
private url?: string;
|
||||||
|
|
||||||
public onreceive: ((data: string | ArrayBuffer) => void) | null;
|
public onreceive: ((data: string | ArrayBuffer) => void) | null;
|
||||||
public onclose: ((error?: Error) => void) | null;
|
public onclose: ((error?: Error) => void) | null;
|
||||||
|
|
||||||
constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise<string>) | undefined, logger: ILogger, logMessageContent: boolean) {
|
constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise<string>) | undefined, logger: ILogger,
|
||||||
|
logMessageContent: boolean, eventSourceConstructor: EventSourceConstructor) {
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
this.accessTokenFactory = accessTokenFactory;
|
this.accessTokenFactory = accessTokenFactory;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.logMessageContent = logMessageContent;
|
this.logMessageContent = logMessageContent;
|
||||||
|
this.eventSourceConstructor = eventSourceConstructor;
|
||||||
|
|
||||||
this.onreceive = null;
|
this.onreceive = null;
|
||||||
this.onclose = null;
|
this.onclose = null;
|
||||||
|
|
@ -32,10 +36,6 @@ export class ServerSentEventsTransport implements ITransport {
|
||||||
Arg.isRequired(transferFormat, "transferFormat");
|
Arg.isRequired(transferFormat, "transferFormat");
|
||||||
Arg.isIn(transferFormat, TransferFormat, "transferFormat");
|
Arg.isIn(transferFormat, TransferFormat, "transferFormat");
|
||||||
|
|
||||||
if (typeof (EventSource) === "undefined") {
|
|
||||||
throw new Error("'EventSource' is not supported in your environment.");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(LogLevel.Trace, "(SSE transport) Connecting");
|
this.logger.log(LogLevel.Trace, "(SSE transport) Connecting");
|
||||||
|
|
||||||
if (this.accessTokenFactory) {
|
if (this.accessTokenFactory) {
|
||||||
|
|
@ -52,7 +52,7 @@ export class ServerSentEventsTransport implements ITransport {
|
||||||
reject(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"));
|
reject(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSource = new EventSource(url, { withCredentials: true });
|
const eventSource = new this.eventSourceConstructor(url, { withCredentials: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eventSource.onmessage = (e: MessageEvent) => {
|
eventSource.onmessage = (e: MessageEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,25 @@
|
||||||
|
|
||||||
import { ILogger, LogLevel } from "./ILogger";
|
import { ILogger, LogLevel } from "./ILogger";
|
||||||
import { ITransport, TransferFormat } from "./ITransport";
|
import { ITransport, TransferFormat } from "./ITransport";
|
||||||
|
import { WebSocketConstructor } from "./Polyfills";
|
||||||
import { Arg, getDataDetail } from "./Utils";
|
import { Arg, getDataDetail } from "./Utils";
|
||||||
|
|
||||||
export class WebSocketTransport implements ITransport {
|
export class WebSocketTransport implements ITransport {
|
||||||
private readonly logger: ILogger;
|
private readonly logger: ILogger;
|
||||||
private readonly accessTokenFactory: (() => string | Promise<string>) | undefined;
|
private readonly accessTokenFactory: (() => string | Promise<string>) | undefined;
|
||||||
private readonly logMessageContent: boolean;
|
private readonly logMessageContent: boolean;
|
||||||
|
private readonly webSocketConstructor: WebSocketConstructor;
|
||||||
private webSocket?: WebSocket;
|
private webSocket?: WebSocket;
|
||||||
|
|
||||||
public onreceive: ((data: string | ArrayBuffer) => void) | null;
|
public onreceive: ((data: string | ArrayBuffer) => void) | null;
|
||||||
public onclose: ((error?: Error) => void) | null;
|
public onclose: ((error?: Error) => void) | null;
|
||||||
|
|
||||||
constructor(accessTokenFactory: (() => string | Promise<string>) | undefined, logger: ILogger, logMessageContent: boolean) {
|
constructor(accessTokenFactory: (() => string | Promise<string>) | undefined, logger: ILogger,
|
||||||
|
logMessageContent: boolean, webSocketConstructor: WebSocketConstructor) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.accessTokenFactory = accessTokenFactory;
|
this.accessTokenFactory = accessTokenFactory;
|
||||||
this.logMessageContent = logMessageContent;
|
this.logMessageContent = logMessageContent;
|
||||||
|
this.webSocketConstructor = webSocketConstructor;
|
||||||
|
|
||||||
this.onreceive = null;
|
this.onreceive = null;
|
||||||
this.onclose = null;
|
this.onclose = null;
|
||||||
|
|
@ -28,10 +32,6 @@ export class WebSocketTransport implements ITransport {
|
||||||
Arg.isRequired(transferFormat, "transferFormat");
|
Arg.isRequired(transferFormat, "transferFormat");
|
||||||
Arg.isIn(transferFormat, TransferFormat, "transferFormat");
|
Arg.isIn(transferFormat, TransferFormat, "transferFormat");
|
||||||
|
|
||||||
if (typeof (WebSocket) === "undefined") {
|
|
||||||
throw new Error("'WebSocket' is not supported in your environment.");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(LogLevel.Trace, "(WebSockets transport) Connecting");
|
this.logger.log(LogLevel.Trace, "(WebSockets transport) Connecting");
|
||||||
|
|
||||||
if (this.accessTokenFactory) {
|
if (this.accessTokenFactory) {
|
||||||
|
|
@ -43,7 +43,7 @@ export class WebSocketTransport implements ITransport {
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
url = url.replace(/^http/, "ws");
|
url = url.replace(/^http/, "ws");
|
||||||
const webSocket = new WebSocket(url);
|
const webSocket = new this.webSocketConstructor(url);
|
||||||
if (transferFormat === TransferFormat.Binary) {
|
if (transferFormat === TransferFormat.Binary) {
|
||||||
webSocket.binaryType = "arraybuffer";
|
webSocket.binaryType = "arraybuffer";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { HttpTransportType, ITransport, TransferFormat } from "../src/ITransport
|
||||||
|
|
||||||
import { HttpError } from "../src/Errors";
|
import { HttpError } from "../src/Errors";
|
||||||
import { LogLevel } from "../src/ILogger";
|
import { LogLevel } from "../src/ILogger";
|
||||||
|
import { EventSourceConstructor, WebSocketConstructor } from "../src/Polyfills";
|
||||||
|
import { TextMessageFormat } from "../src/TextMessageFormat";
|
||||||
|
import { WebSocketTransport } from "../src/WebSocketTransport";
|
||||||
import { eachEndpointUrl, eachTransport } from "./Common";
|
import { eachEndpointUrl, eachTransport } from "./Common";
|
||||||
import { TestHttpClient } from "./TestHttpClient";
|
import { TestHttpClient } from "./TestHttpClient";
|
||||||
import { PromiseSource } from "./Utils";
|
import { PromiseSource } from "./Utils";
|
||||||
|
|
@ -577,6 +580,119 @@ describe("HttpConnection", () => {
|
||||||
// Force TypeScript to let us call the constructor incorrectly :)
|
// Force TypeScript to let us call the constructor incorrectly :)
|
||||||
expect(() => new (HttpConnection as any)()).toThrowError("The 'url' argument is required.");
|
expect(() => new (HttpConnection as any)()).toThrowError("The 'url' argument is required.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses global WebSocket if defined", async () => {
|
||||||
|
// tslint:disable-next-line:no-string-literal
|
||||||
|
global["WebSocket"] = class WebSocket {
|
||||||
|
constructor(url: string, protocols?: string | string[]) {
|
||||||
|
throw new Error("WebSocket constructor called.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: IHttpConnectionOptions = {
|
||||||
|
...commonOptions,
|
||||||
|
skipNegotiation: true,
|
||||||
|
transport: HttpTransportType.WebSockets,
|
||||||
|
} as IHttpConnectionOptions;
|
||||||
|
|
||||||
|
const connection = new HttpConnection("http://tempuri.org", options);
|
||||||
|
|
||||||
|
await expect(connection.start())
|
||||||
|
.rejects
|
||||||
|
.toThrow("WebSocket constructor called.");
|
||||||
|
|
||||||
|
// tslint:disable-next-line:no-string-literal
|
||||||
|
delete global["WebSocket"];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses global EventSource if defined", async () => {
|
||||||
|
let eventSourceConstructorCalled: boolean = false;
|
||||||
|
// tslint:disable-next-line:no-string-literal
|
||||||
|
global["EventSource"] = class EventSource {
|
||||||
|
constructor(url: string, eventSourceInitDict?: EventSourceInit) {
|
||||||
|
eventSourceConstructorCalled = true;
|
||||||
|
throw new Error("EventSource constructor called.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: IHttpConnectionOptions = {
|
||||||
|
...commonOptions,
|
||||||
|
httpClient: new TestHttpClient().on("POST", (r) => {
|
||||||
|
return {
|
||||||
|
availableTransports: [
|
||||||
|
{ transport: "ServerSentEvents", transferFormats: ["Text"] },
|
||||||
|
],
|
||||||
|
connectionId: defaultConnectionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
transport: HttpTransportType.ServerSentEvents,
|
||||||
|
} as IHttpConnectionOptions;
|
||||||
|
|
||||||
|
const connection = new HttpConnection("http://tempuri.org", options);
|
||||||
|
|
||||||
|
await expect(connection.start(TransferFormat.Text))
|
||||||
|
.rejects
|
||||||
|
.toThrow("Unable to initialize any of the available transports.");
|
||||||
|
|
||||||
|
expect(eventSourceConstructorCalled).toEqual(true);
|
||||||
|
|
||||||
|
// tslint:disable-next-line:no-string-literal
|
||||||
|
delete global["EventSource"];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses EventSource constructor from options if provided", async () => {
|
||||||
|
let eventSourceConstructorCalled: boolean = false;
|
||||||
|
|
||||||
|
const customEventSourceType = class EventSource {
|
||||||
|
constructor(url: string, eventSourceInitDict?: EventSourceInit) {
|
||||||
|
eventSourceConstructorCalled = true;
|
||||||
|
throw new Error("EventSource constructor called.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: IHttpConnectionOptions = {
|
||||||
|
...commonOptions,
|
||||||
|
EventSource: customEventSourceType as EventSourceConstructor,
|
||||||
|
httpClient: new TestHttpClient().on("POST", (r) => {
|
||||||
|
return {
|
||||||
|
availableTransports: [
|
||||||
|
{ transport: "ServerSentEvents", transferFormats: ["Text"] },
|
||||||
|
],
|
||||||
|
connectionId: defaultConnectionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
transport: HttpTransportType.ServerSentEvents,
|
||||||
|
} as IHttpConnectionOptions;
|
||||||
|
|
||||||
|
const connection = new HttpConnection("http://tempuri.org", options);
|
||||||
|
|
||||||
|
await expect(connection.start(TransferFormat.Text))
|
||||||
|
.rejects
|
||||||
|
.toThrow("Unable to initialize any of the available transports.");
|
||||||
|
|
||||||
|
expect(eventSourceConstructorCalled).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses WebSocket constructor from options if provided", async () => {
|
||||||
|
const customWebSocketType = class WebSocket {
|
||||||
|
constructor(url: string, protocols?: string | string[]) {
|
||||||
|
throw new Error("WebSocket constructor called.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: IHttpConnectionOptions = {
|
||||||
|
...commonOptions,
|
||||||
|
WebSocket: customWebSocketType as WebSocketConstructor,
|
||||||
|
skipNegotiation: true,
|
||||||
|
transport: HttpTransportType.WebSockets,
|
||||||
|
} as IHttpConnectionOptions;
|
||||||
|
|
||||||
|
const connection = new HttpConnection("http://tempuri.org", options);
|
||||||
|
|
||||||
|
await expect(connection.start())
|
||||||
|
.rejects
|
||||||
|
.toThrow("WebSocket constructor called.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("startAsync", () => {
|
describe("startAsync", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue