diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 3835a931c8..f05a4e537d 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -17,8 +17,8 @@ import { App } from '../app'; import { deepCopy } from '../utils/deep-copy'; -import { - messagingClientErrorCode, FirebaseMessagingError, FirebaseMessagingSessionError +import { + messagingClientErrorCode, FirebaseMessagingError } from './error'; import { ErrorInfo } from '../utils/error'; import * as utils from '../utils'; @@ -157,7 +157,7 @@ export class Messaging { } return this.getUrlPath() .then((urlPath) => { - const request: { message: Message; validate_only?: boolean } = { message: copy }; + const request: { message: Message; validate_only?: boolean; } = { message: copy }; if (dryRun) { request.validate_only = true; } @@ -213,57 +213,58 @@ export class Messaging { return this.getUrlPath() .then((urlPath) => { - if (http2SessionHandler) { - let sendResponsePromise: Promise[]>; - return new Promise((resolve: (result: PromiseSettledResult[]) => void, reject) => { - // Start session listeners - http2SessionHandler.invoke().catch((error) => { - const pendingBatchResponse = - sendResponsePromise ? sendResponsePromise.then(this.parseSendResponses) : undefined; - reject(new FirebaseMessagingSessionError(error, undefined, pendingBatchResponse)); - }); - - // Start making requests - const requests: Promise[] = copy.map(async (message) => { - validateMessage(message); - const request: { message: Message; validate_only?: boolean; } = { message }; - if (dryRun) { - request.validate_only = true; - } - return this.messagingRequestHandler.invokeHttp2RequestHandlerForSendResponse( - FCM_SEND_HOST, urlPath, request, http2SessionHandler); - }); - - // Resolve once all requests have completed - sendResponsePromise = Promise.allSettled(requests); - sendResponsePromise.then(resolve); - }); - } else { - const requests: Promise[] = copy.map(async (message) => { - validateMessage(message); - const request: { message: Message; validate_only?: boolean; } = { message }; - if (dryRun) { - request.validate_only = true; - } + const requests: Promise[] = copy.map(async (message) => { + validateMessage(message); + const request: { message: Message; validate_only?: boolean; } = { message }; + if (dryRun) { + request.validate_only = true; + } + if (http2SessionHandler) { + return this.messagingRequestHandler.invokeHttp2RequestHandlerForSendResponse( + FCM_SEND_HOST, urlPath, request, http2SessionHandler); + } else { return this.messagingRequestHandler.invokeHttpRequestHandlerForSendResponse( FCM_SEND_HOST, urlPath, request); - }); - return Promise.allSettled(requests); - } + } + }); + return Promise.allSettled(requests); + }) + .then((results) => { + const sessionErrors = http2SessionHandler ? http2SessionHandler.getErrors() : []; + return this.parseSendResponses(results, sessionErrors); }) - .then(this.parseSendResponses) .finally(() => { http2SessionHandler?.close(); }); } - private parseSendResponses(results: PromiseSettledResult[]): BatchResponse { + private parseSendResponses( + results: PromiseSettledResult[], + sessionErrors: Error[] = [] + ): BatchResponse { const responses: SendResponse[] = []; results.forEach(result => { if (result.status === 'fulfilled') { responses.push(result.value); } else { // rejected - responses.push({ success: false, error: result.reason }); + let error = result.reason; + if (sessionErrors.length > 0) { + // Combine the original stream error and all session errors + const allErrors = [result.reason, ...sessionErrors]; + // TODO: AggregateError is supported in Node 18+ but only included in the ES2021+ + // We use (global as any).AggregateError as a workaround to access it in ES2020. + const cause = new (global as any).AggregateError(allErrors, 'Stream failure and session failures occurred'); + + const streamMessage = result.reason.message || 'Unknown stream error'; + const sessionMessage = `. Session failures: ${sessionErrors.map(e => e.message).join(', ')}`; + + error = new FirebaseMessagingError({ + code: messagingClientErrorCode.UNKNOWN_ERROR.code, + message: `${streamMessage}${sessionMessage}`, + cause: cause + }); + } + responses.push({ success: false, error }); } }); const successCount: number = responses.filter((resp) => resp.success).length; diff --git a/src/utils/api-request.ts b/src/utils/api-request.ts index 642a628b8c..b6b76ceb9c 100644 --- a/src/utils/api-request.ts +++ b/src/utils/api-request.ts @@ -44,7 +44,7 @@ export interface BaseRequestConfig { method: HttpMethod; /** Target URL of the request. Should be a well-formed URL including protocol, hostname, port and path. */ url: string; - headers?: {[key: string]: string}; + headers?: { [key: string]: string; }; data?: string | object | Buffer | null; /** Connect and read timeout (in milliseconds) for the outgoing request. */ timeout?: number; @@ -64,7 +64,7 @@ export interface Http2RequestConfig extends BaseRequestConfig { http2SessionHandler: Http2SessionHandler; } -type RequestConfig = HttpRequestConfig | Http2RequestConfig +type RequestConfig = HttpRequestConfig | Http2RequestConfig; /** * Represents an HTTP or HTTP/2 response received from a remote server. @@ -97,15 +97,15 @@ interface LowLevelHttpResponse extends BaseLowLevelResponse { config: HttpRequestConfig; } -type IncomingHttp2Headers = http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader +type IncomingHttp2Headers = http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader; interface LowLevelHttp2Response extends BaseLowLevelResponse { - headers: IncomingHttp2Headers + headers: IncomingHttp2Headers; request: http2.ClientHttp2Stream | null; config: Http2RequestConfig; } -type LowLevelResponse = LowLevelHttpResponse | LowLevelHttp2Response +type LowLevelResponse = LowLevelHttpResponse | LowLevelHttp2Response; interface BaseLowLevelError extends Error { code?: string; @@ -123,7 +123,7 @@ interface LowLevelHttp2Error extends BaseLowLevelError { response?: LowLevelHttp2Response; } -type LowLevelError = LowLevelHttpError | LowLevelHttp2Error +type LowLevelError = LowLevelHttpError | LowLevelHttp2Error; class DefaultRequestResponse implements RequestResponse { @@ -295,7 +295,7 @@ export class RequestClient { constructor(retry: RetryConfig | null = defaultRetryConfig()) { if (retry) { - this.retry = retry + this.retry = retry; validateRetryConfig(this.retry); } } @@ -398,7 +398,7 @@ export class RequestClient { export class HttpClient extends RequestClient { constructor(retry?: RetryConfig | null) { - super(retry) + super(retry); } /** @@ -545,7 +545,7 @@ export function parseHttpResponse( const statusLine: string = headerLines[0]; const status: string = statusLine.trim().split(/\s/)[1]; - const headers: {[key: string]: string} = {}; + const headers: { [key: string]: string; } = {}; headerLines.slice(1).forEach((line) => { const colonPos = line.indexOf(':'); const name = line.substring(0, colonPos).trim().toLowerCase(); @@ -585,7 +585,7 @@ class AsyncRequestCall { protected entity: Buffer | undefined; protected promise: Promise; - constructor(private readonly configImpl: HttpRequestConfigImpl | Http2RequestConfigImpl) {} + constructor(private readonly configImpl: HttpRequestConfigImpl | Http2RequestConfigImpl) { } /** * Extracts multipart boundary from the HTTP header. The content-type header of a multipart @@ -601,13 +601,13 @@ class AsyncRequestCall { } const segments: string[] = contentType.split(';'); - const emptyObject: {[key: string]: string} = {}; + const emptyObject: { [key: string]: string; } = {}; const headerParams = segments.slice(1) .map((segment) => segment.trim().split('=')) .reduce((curr, params) => { // Parse key=value pairs in the content-type header into properties of an object. if (params.length === 2) { - const keyValuePair: {[key: string]: string} = {}; + const keyValuePair: { [key: string]: string; } = {}; keyValuePair[params[0]] = params[1]; return Object.assign(curr, keyValuePair); } @@ -741,7 +741,7 @@ class AsyncHttpCall extends AsyncRequestCall { private constructor(config: HttpRequestConfig) { const httpConfigImpl = new HttpRequestConfigImpl(config); - super(httpConfigImpl) + super(httpConfigImpl); try { this.httpConfigImpl = httpConfigImpl; this.options = this.httpConfigImpl.buildRequestOptions(); @@ -832,7 +832,7 @@ class AsyncHttpCall extends AsyncRequestCall { } class AsyncHttp2Call extends AsyncRequestCall { - private readonly http2ConfigImpl: Http2RequestConfigImpl + private readonly http2ConfigImpl: Http2RequestConfigImpl; /** * Sends an HTTP2 request based on the provided configuration. @@ -843,7 +843,7 @@ class AsyncHttp2Call extends AsyncRequestCall { private constructor(config: Http2RequestConfig) { const http2ConfigImpl = new Http2RequestConfigImpl(config); - super(http2ConfigImpl) + super(http2ConfigImpl); try { this.http2ConfigImpl = http2ConfigImpl; this.options = this.http2ConfigImpl.buildRequestOptions(); @@ -895,7 +895,7 @@ class AsyncHttp2Call extends AsyncRequestCall { req.end(this.entity); } - private handleHttp2Response(headers: IncomingHttp2Headers, stream: http2.ClientHttp2Stream): void{ + private handleHttp2Response(headers: IncomingHttp2Headers, stream: http2.ClientHttp2Stream): void { if (stream.aborted) { return; } @@ -946,7 +946,7 @@ class AsyncHttp2Call extends AsyncRequestCall { class BaseRequestConfigImpl implements BaseRequestConfig { constructor(protected readonly config: RequestConfig) { - this.config = config + this.config = config; } get method(): HttpMethod { @@ -957,7 +957,7 @@ class BaseRequestConfigImpl implements BaseRequestConfig { return this.config.url; } - get headers(): {[key: string]: string} | undefined { + get headers(): { [key: string]: string; } | undefined { return this.config.headers; } @@ -1001,7 +1001,7 @@ class BaseRequestConfigImpl implements BaseRequestConfig { } // Parse URL and append data to query string. const parsedUrl = new url.URL(fullUrl); - const dataObj = this.data as {[key: string]: string}; + const dataObj = this.data as { [key: string]: string; }; for (const key in dataObj) { if (Object.prototype.hasOwnProperty.call(dataObj, key)) { parsedUrl.searchParams.append(key, dataObj[key]); @@ -1034,7 +1034,7 @@ class BaseRequestConfigImpl implements BaseRequestConfig { class HttpRequestConfigImpl extends BaseRequestConfigImpl implements HttpRequestConfig { constructor(private readonly httpConfig: HttpRequestConfig) { - super(httpConfig) + super(httpConfig); } get httpAgent(): http.Agent | undefined { @@ -1068,7 +1068,7 @@ class HttpRequestConfigImpl extends BaseRequestConfigImpl implements HttpRequest class Http2RequestConfigImpl extends BaseRequestConfigImpl implements Http2RequestConfig { constructor(private readonly http2Config: Http2RequestConfig) { - super(http2Config) + super(http2Config); } get http2SessionHandler(): Http2SessionHandler { @@ -1116,7 +1116,7 @@ export class AuthorizedHttpClient extends HttpClient { } if (!requestCopy.headers['X-Goog-Api-Client']) { - requestCopy.headers['X-Goog-Api-Client'] = getMetricsHeader() + requestCopy.headers['X-Goog-Api-Client'] = getMetricsHeader(); } return super.send(requestCopy); @@ -1151,13 +1151,13 @@ export class AuthorizedHttp2Client extends Http2Client { requestCopy.headers['x-goog-user-project'] = quotaProjectId; } - if (!requestCopy.headers['X-Goog-Api-Client']) { - requestCopy.headers['X-Goog-Api-Client'] = getMetricsHeader() + if (!requestCopy.headers['X-Goog-Api-Client']) { + requestCopy.headers['X-Goog-Api-Client'] = getMetricsHeader(); } return super.send(requestCopy); }); - } + } protected getToken(): Promise { return this.app.INTERNAL.getToken() @@ -1258,9 +1258,9 @@ export class ExponentialBackoffPoller extends EventEmitter { private reject: (err: object) => void; constructor( - private readonly initialPollingDelayMillis: number = 1000, - private readonly maxPollingDelayMillis: number = 10000, - private readonly masterTimeoutMillis: number = 60000) { + private readonly initialPollingDelayMillis: number = 1000, + private readonly maxPollingDelayMillis: number = 10000, + private readonly masterTimeoutMillis: number = 60000) { super(); } @@ -1306,7 +1306,7 @@ export class ExponentialBackoffPoller extends EventEmitter { if (!result) { this.repollTimer = - setTimeout(() => this.emit('poll'), this.getPollingDelayMillis()); + setTimeout(() => this.emit('poll'), this.getPollingDelayMillis()); this.numTries++; return; } @@ -1342,72 +1342,63 @@ export class ExponentialBackoffPoller extends EventEmitter { export class Http2SessionHandler { - private http2Session: http2.ClientHttp2Session - protected promise: Promise - protected resolve: () => void; - protected reject: (_: any) => void; + private http2Session: http2.ClientHttp2Session; + private sessionErrors: Error[] = []; - constructor(url: string){ - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - this.http2Session = this.createSession(url) - }); + constructor(url: string) { + this.createSession(url); } public createSession(url: string): http2.ClientHttp2Session { - if (!this.http2Session || this.isClosed ) { + if (!this.http2Session || this.isCurrentSessionClosed) { + this.sessionErrors = []; const opts: http2.SecureClientSessionOptions = { // Set local max concurrent stream limit to respect backend limit peerMaxConcurrentStreams: 100, ALPNProtocols: ['h2'] - } - const http2Session = http2.connect(url, opts) + }; + this.http2Session = http2.connect(url, opts); - http2Session.on('goaway', (errorCode, _, opaqueData) => { - this.reject(new FirebaseAppError({ + this.http2Session.on('goaway', (errorCode, _, opaqueData) => { + const error = new FirebaseAppError({ code: AppErrorCode.NETWORK_ERROR, message: `Error while making requests: GOAWAY - ${opaqueData?.toString()}, Error code: ${errorCode}` - })); - }) + }); + this.sessionErrors.push(error); + }); - http2Session.on('error', (error: any) => { + this.http2Session.on('error', (error: any) => { let errorMessage: any; - if (error.name == 'AggregateError' && error.errors) { - errorMessage = `Session error while making requests: ${error.code} - ${error.name}: ` + - `[${error.errors.map((e: any) => e.message).join(', ')}]` + if (error?.name === 'AggregateError' && Array.isArray(error.errors)) { + errorMessage = `Session error while making requests: ${error?.code} - ${error?.name}: ` + + `[${error.errors.map((e: any) => e.message).join(', ')}]`; } else { - errorMessage = `Session error while making requests: ${error.code} - ${error.message} ` + errorMessage = `Session error while making requests: ${error?.code} - ${error?.message} `; } - this.reject(new FirebaseAppError({ + const appError = new FirebaseAppError({ code: AppErrorCode.NETWORK_ERROR, message: errorMessage, cause: error, - })); - }) - - http2Session.on('close', () => { - // Resolve current promise - this.resolve() + }); + this.sessionErrors.push(appError); }); - return http2Session } - return this.http2Session + return this.http2Session; } - public invoke(): Promise { - return this.promise + public getErrors(): Error[] { + return this.sessionErrors; } get session(): http2.ClientHttp2Session { - return this.http2Session + return this.http2Session; } - get isClosed(): boolean { - return this.http2Session.closed + get isCurrentSessionClosed(): boolean { + return !!this.http2Session?.closed; } public close(): void { - this.http2Session.close() + this.http2Session?.close(); } } \ No newline at end of file diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index e3daa656e6..675b6e8430 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -34,7 +34,6 @@ import { import { HttpClient } from '../../../src/utils/api-request'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils/index'; import * as utils from '../utils'; -import { FirebaseMessagingSessionError } from '../../../src/messaging/error'; chai.should(); chai.use(sinonChai); @@ -82,10 +81,10 @@ function mockSendRequest(messageId = 'projects/projec_id/messages/message_id'): function mockHttp2SendRequestResponse(messageId = 'projects/projec_id/messages/message_id'): mocks.MockHttp2Response { return { headers: { - ':status': 200, + ':status': 200, }, data: Buffer.from(JSON.stringify({ name: `${messageId}` })), - } as mocks.MockHttp2Response + } as mocks.MockHttp2Response; } function mockSendError( @@ -119,14 +118,14 @@ function mockHttp2SendRequestError( 'content-type': contentType }, data: Buffer.from(response) - } as mocks.MockHttp2Response + } as mocks.MockHttp2Response; } -function mockHttp2Error(streamError?: any, sessionError?:any): mocks.MockHttp2Response { +function mockHttp2Error(streamError?: any, sessionError?: any): mocks.MockHttp2Response { return { streamError: streamError, sessionError: sessionError - } as mocks.MockHttp2Response + } as mocks.MockHttp2Response; } function mockErrorResponse( @@ -212,14 +211,14 @@ describe('Messaging', () => { let messaging: Messaging; let legacyMessaging: Messaging; let mockedRequests: nock.Scope[] = []; - let mockedHttp2Responses: mocks.MockHttp2Response[] = [] + let mockedHttp2Responses: mocks.MockHttp2Response[] = []; const http2Mocker: mocks.Http2Mocker = new mocks.Http2Mocker(); let httpsRequestStub: sinon.SinonStub; let getTokenStub: sinon.SinonStub; let nullAccessTokenMessaging: Messaging; - let messagingService: { [key: string]: any }; - let nullAccessTokenMessagingService: { [key: string]: any }; + let messagingService: { [key: string]: any; }; + let nullAccessTokenMessagingService: { [key: string]: any; }; const mockAccessToken: string = utils.generateRandomAccessToken(); const expectedHeaders = { @@ -251,7 +250,7 @@ describe('Messaging', () => { if (httpsRequestStub && httpsRequestStub.restore) { httpsRequestStub.restore(); } - http2Mocker.done() + http2Mocker.done(); mockedHttp2Responses = []; getTokenStub.restore(); return mockApp.delete(); @@ -519,7 +518,7 @@ describe('Messaging', () => { const messageIds = [ 'projects/projec_id/messages/1', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEach([invalidMessage, validMessage]) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); @@ -542,7 +541,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEach([validMessage, validMessage, validMessage]) .then((response: BatchResponse) => { expect(response.successCount).to.equal(3); @@ -561,7 +560,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); const message = { token: 'a', android: { @@ -595,7 +594,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(3); @@ -620,8 +619,8 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - errors.forEach(error => mockedRequests.push(mockSendError(400, 'json', error))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); + errors.forEach(error => mockedRequests.push(mockSendError(400, 'json', error))); return legacyMessaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(2); @@ -637,7 +636,7 @@ describe('Messaging', () => { }); it('should be fulfilled with a BatchResponse for all failures given an app which ' + - 'returns null access tokens', () => { + 'returns null access tokens', () => { return nullAccessTokenMessaging.sendEach( [validMessage, validMessage], ).then((response: BatchResponse) => { @@ -665,8 +664,8 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - errors.forEach(error => mockedRequests.push(mockSendError(404, 'json', error))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); + errors.forEach(error => mockedRequests.push(mockSendError(404, 'json', error))); return legacyMessaging.sendEach([validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); @@ -713,7 +712,7 @@ describe('Messaging', () => { const conditionMessage: ConditionMessage = { condition: 'test' }; const messages: Message[] = [tokenMessage, topicMessage, conditionMessage]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEach(messages) .then((response: BatchResponse) => { @@ -734,8 +733,8 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach([validMessage, validMessage, validMessage], false) .then((response: BatchResponse) => { @@ -756,8 +755,8 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); const message = { token: 'a', @@ -794,8 +793,8 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { @@ -823,9 +822,9 @@ describe('Messaging', () => { }, ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { @@ -843,7 +842,7 @@ describe('Messaging', () => { }); it('should be fulfilled with a BatchResponse for all failures given an app which ' + - 'returns null access tokens using HTTP/2', () => { + 'returns null access tokens using HTTP/2', () => { return nullAccessTokenMessaging.sendEach( [validMessage, validMessage], false).then((response: BatchResponse) => { expect(response.failureCount).to.equal(2); @@ -871,9 +870,9 @@ describe('Messaging', () => { }, ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach([validMessage, validMessage], true) .then((response: BatchResponse) => { @@ -899,7 +898,7 @@ describe('Messaging', () => { mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', error)); mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', { error: 'test error message2' })); mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'text', 'foo bar')); - http2Mocker.http2Stub(mockedHttp2Responses) + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach( [validMessage, validMessage, validMessage], false @@ -913,29 +912,74 @@ describe('Messaging', () => { }); }); - it('should throw error with BatchResponse promise on session error event using HTTP/2', () => { - mockedHttp2Responses.push(mockHttp2SendRequestResponse('projects/projec_id/messages/1')) - const sessionError = 'MOCK_SESSION_ERROR' + it('should be fulfilled with a BatchResponse containing session errors when session fails using HTTP/2', () => { + mockedHttp2Responses.push(mockHttp2SendRequestResponse('projects/projec_id/messages/1')); + const sessionError = 'MOCK_SESSION_ERROR'; mockedHttp2Responses.push(mockHttp2Error( new Error(`MOCK_STREAM_ERROR caused by ${sessionError}`), new Error(sessionError) )); - http2Mocker.http2Stub(mockedHttp2Responses) + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach( [validMessage, validMessage], true - ).catch(async (error: FirebaseMessagingSessionError) => { - expect(error.code).to.equal('messaging/app/network-error'); - expect(error.pendingBatchResponse).to.not.be.undefined; - await error.pendingBatchResponse?.then((response: BatchResponse) => { - expect(http2Mocker.requests.length).to.equal(2); - expect(response.failureCount).to.equal(1); - const responses = response.responses; - checkSendResponseSuccess(responses[0], 'projects/projec_id/messages/1'); - checkSendResponseFailure(responses[1], 'app/network-error'); - }) + ).then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(1); + const responses = response.responses; + checkSendResponseSuccess(responses[0], 'projects/projec_id/messages/1'); + checkSendResponseFailure( + responses[1], + 'messaging/unknown-error', + sessionError + ); + expect(responses[1].error!.message).to.contain(`MOCK_STREAM_ERROR caused by ${sessionError}`); + expect(responses[1].error!.cause!.constructor.name).to.equal('AggregateError'); + const cause = responses[1].error!.cause as any; + expect(cause.errors).to.be.an.instanceOf(Array); + expect(cause.errors.length).to.equal(2); + expect(cause.errors[0].message).to.contain('MOCK_STREAM_ERROR'); + expect(cause.errors[1].message).to.contain(sessionError); + }); + }); + + it('should be fulfilled with a BatchResponse containing AggregateError when multiple session errors occur' + + ' using HTTP/2', () => { + const sessionError1 = 'MOCK_SESSION_ERROR_1'; + const sessionError2 = 'MOCK_SESSION_ERROR_2'; + + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_1'), + new Error(sessionError1) + )); + mockedHttp2Responses.push(mockHttp2Error( + new Error('MOCK_STREAM_ERROR_2'), + new Error(sessionError2) + )); + + http2Mocker.http2Stub(mockedHttp2Responses); + + return messaging.sendEach( + [validMessage, validMessage], true + ).then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + expect(response.failureCount).to.equal(2); + + const failure = response.responses[0]; + expect(failure.success).to.be.false; + expect(failure.error!.code).to.equal('messaging/unknown-error'); + + const cause = failure.error!.cause; + expect(cause).to.not.be.undefined; + expect(cause!.constructor.name).to.equal('AggregateError'); + expect((cause as any).errors).to.be.an.instanceOf(Array); + expect((cause as any).errors.length).to.equal(3); + expect((cause as any).errors[0].message).to.contain('MOCK_STREAM_ERROR'); + expect((cause as any).errors[1].message).to.contain(sessionError1); + expect((cause as any).errors[2].message).to.contain(sessionError2); }); - }) + }); // This test was added to also verify https://github.com/firebase/firebase-admin-node/issues/1146 it('should be fulfilled when called with different message types using HTTP/2', () => { @@ -949,11 +993,11 @@ describe('Messaging', () => { const conditionMessage: ConditionMessage = { condition: 'test' }; const messages: Message[] = [tokenMessage, topicMessage, conditionMessage]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEach(messages, false) - .then ((response: BatchResponse) => { + .then((response: BatchResponse) => { expect(http2Mocker.requests.length).to.equal(3); expect(response.successCount).to.equal(3); expect(response.failureCount).to.equal(0); @@ -1105,7 +1149,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, @@ -1130,7 +1174,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, @@ -1161,8 +1205,8 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); + errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))); return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }) .then((response: BatchResponse) => { expect(response.successCount).to.equal(2); @@ -1178,7 +1222,7 @@ describe('Messaging', () => { }); it('should be fulfilled with a BatchResponse for all failures given an app which ' + - 'returns null access tokens', () => { + 'returns null access tokens', () => { return nullAccessTokenMessaging.sendEachForMulticast( { tokens: ['a', 'a'] }, ).then((response: BatchResponse) => { @@ -1206,8 +1250,8 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) + messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))); + errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))); return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b'] }) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); @@ -1248,8 +1292,8 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, @@ -1274,8 +1318,8 @@ describe('Messaging', () => { 'projects/projec_id/messages/2', 'projects/projec_id/messages/3', ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, @@ -1306,9 +1350,9 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }, false) .then((response: BatchResponse) => { expect(response.successCount).to.equal(2); @@ -1324,7 +1368,7 @@ describe('Messaging', () => { }); it('should be fulfilled with a BatchResponse for all failures given an app which ' + - 'returns null access tokens using HTTP/2', () => { + 'returns null access tokens using HTTP/2', () => { return nullAccessTokenMessaging.sendEachForMulticast( { tokens: ['a', 'a'] }, false ).then((response: BatchResponse) => { @@ -1352,9 +1396,9 @@ describe('Messaging', () => { }, }, ]; - messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) - errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) - http2Mocker.http2Stub(mockedHttp2Responses) + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))); + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))); + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEachForMulticast({ tokens: ['a', 'b'] }, false) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); @@ -1378,7 +1422,7 @@ describe('Messaging', () => { mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', error)); mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', { error: 'test error message2' })); mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'text', 'foo bar')); - http2Mocker.http2Stub(mockedHttp2Responses) + http2Mocker.http2Stub(mockedHttp2Responses); return messaging.sendEachForMulticast( { tokens: ['a', 'a', 'a'] }, false ).then((response: BatchResponse) => { @@ -1409,7 +1453,7 @@ describe('Messaging', () => { const invalidTtls = ['', 'abc', '123', '-123s', '1.2.3s', 'As', 's', '1s', -1]; invalidTtls.forEach((ttl) => { - it(`should throw given an invalid ttl: ${ ttl }`, () => { + it(`should throw given an invalid ttl: ${ttl}`, () => { const message: Message = { condition: 'topic-name', android: { @@ -1424,7 +1468,7 @@ describe('Messaging', () => { const invalidColors = ['', 'foo', '123', '#AABBCX', '112233', '#11223']; invalidColors.forEach((color) => { - it(`should throw given an invalid color: ${ color }`, () => { + it(`should throw given an invalid color: ${color}`, () => { const message: Message = { condition: 'topic-name', android: { @@ -1440,7 +1484,7 @@ describe('Messaging', () => { }); invalidImages.forEach((imageUrl) => { - it(`should throw given an invalid imageUrl: ${ imageUrl }`, () => { + it(`should throw given an invalid imageUrl: ${imageUrl}`, () => { const message: Message = { condition: 'topic-name', android: { @@ -1486,7 +1530,7 @@ describe('Messaging', () => { const invalidVibrateTimings = [[null, 500], [-100]]; invalidVibrateTimings.forEach((vibrateTimingsMillisMaybeNull) => { const vibrateTimingsMillis = vibrateTimingsMillisMaybeNull as number[]; - it(`should throw given an null or negative vibrateTimingsMillis: ${ vibrateTimingsMillis }`, () => { + it(`should throw given an null or negative vibrateTimingsMillis: ${vibrateTimingsMillis}`, () => { const message: Message = { condition: 'topic-name', android: { @@ -1516,7 +1560,7 @@ describe('Messaging', () => { }); invalidColors.forEach((color) => { - it(`should throw given an invalid color: ${ color }`, () => { + it(`should throw given an invalid color: ${color}`, () => { const message: Message = { condition: 'topic-name', android: { @@ -1725,14 +1769,14 @@ describe('Messaging', () => { }); }); - const invalidApnsLiveActivityTokens: any[] = [null, NaN, 0, 1, true, false] + const invalidApnsLiveActivityTokens: any[] = [null, NaN, 0, 1, true, false]; invalidApnsLiveActivityTokens.forEach((arg) => { it(`should throw given invalid apns live activity token: ${JSON.stringify(arg)}`, () => { expect(() => { messaging.send({ apns: { liveActivityToken: arg }, topic: 'test' }); }).to.throw('apns.liveActivityToken must be a string value'); }); - }) + }); it('should throw given empty apns live activity token', () => { expect(() => { @@ -2416,7 +2460,7 @@ describe('Messaging', () => { req: { apns: { liveActivityToken: 'live-activity-token', - headers:{ + headers: { 'apns-priority': '10' }, payload: { @@ -2441,7 +2485,7 @@ describe('Messaging', () => { expectedReq: { apns: { live_activity_token: 'live-activity-token', - headers:{ + headers: { 'apns-priority': '10' }, payload: { @@ -2469,7 +2513,7 @@ describe('Messaging', () => { req: { apns: { liveActivityToken: 'live-activity-token', - headers:{ + headers: { 'apns-priority': '10' }, payload: { @@ -2491,7 +2535,7 @@ describe('Messaging', () => { expectedReq: { apns: { live_activity_token: 'live-activity-token', - headers:{ + headers: { 'apns-priority': '10' }, payload: { @@ -2516,7 +2560,7 @@ describe('Messaging', () => { req: { apns: { liveActivityToken: 'live-activity-token', - 'headers':{ + 'headers': { 'apns-priority': '10' }, payload: { @@ -2539,7 +2583,7 @@ describe('Messaging', () => { expectedReq: { apns: { live_activity_token: 'live-activity-token', - 'headers':{ + 'headers': { 'apns-priority': '10' }, payload: { @@ -2666,7 +2710,7 @@ describe('Messaging', () => { const invalidTopics = [null, NaN, 0, 1, true, false, [], ['a', 1], {}, { a: 1 }, _.noop]; invalidTopics.forEach((invalidTopic) => { - it(`should throw given invalid type for topic argument: ${ JSON.stringify(invalidTopic) }`, () => { + it(`should throw given invalid type for topic argument: ${JSON.stringify(invalidTopic)}`, () => { expect(() => { messagingService[methodName](mocks.messaging.registrationToken, invalidTopic as string); }).to.throw(invalidTopicArgumentError); @@ -2687,7 +2731,7 @@ describe('Messaging', () => { const topicsWithInvalidCharacters = ['f*o*o', '/topics/f+o+o', 'foo/topics/foo', '$foo', '/topics/foo&']; topicsWithInvalidCharacters.forEach((invalidTopic) => { - it(`should be rejected given topic argument which has invalid characters: ${ invalidTopic }`, () => { + it(`should be rejected given topic argument which has invalid characters: ${invalidTopic}`, () => { return messagingService[methodName](mocks.messaging.registrationToken, invalidTopic) .should.eventually.be.rejected.and.have.property('code', 'messaging/invalid-argument'); }); @@ -2739,7 +2783,7 @@ describe('Messaging', () => { }); _.forEach(STATUS_CODE_TO_ERROR_MAP, (expectedError, statusCode) => { - it(`should be rejected given a ${ statusCode } text server response`, () => { + it(`should be rejected given a ${statusCode} text server response`, () => { mockedRequests.push(mockTopicSubscriptionRequestWithError(methodName, parseInt(statusCode, 10), 'text')); disableRetries(messaging); @@ -2772,7 +2816,7 @@ describe('Messaging', () => { }); it('should be fulfilled given a valid registration token and topic (topic name not prefixed ' + - 'with "/topics/")', () => { + 'with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); return messagingService[methodName]( @@ -2782,7 +2826,7 @@ describe('Messaging', () => { }); it('should be fulfilled given a valid registration token and topic (topic name prefixed ' + - 'with "/topics/")', () => { + 'with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); return messagingService[methodName]( @@ -2792,7 +2836,7 @@ describe('Messaging', () => { }); it('should be fulfilled given a valid array of registration tokens and topic (topic name not ' + - 'prefixed with "/topics/")', () => { + 'prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); return messagingService[methodName]( @@ -2806,7 +2850,7 @@ describe('Messaging', () => { }); it('should be fulfilled given a valid array of registration tokens and topic (topic name ' + - 'prefixed with "/topics/")', () => { + 'prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 3)); return messagingService[methodName]( @@ -2820,7 +2864,7 @@ describe('Messaging', () => { }); it('should be fulfilled with the server response given a single registration token and topic ' + - '(topic name not prefixed with "/topics/")', () => { + '(topic name not prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); return messagingService[methodName]( @@ -2834,7 +2878,7 @@ describe('Messaging', () => { }); it('should be fulfilled with the server response given a single registration token and topic ' + - '(topic name prefixed with "/topics/")', () => { + '(topic name prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1)); return messagingService[methodName]( @@ -2848,7 +2892,7 @@ describe('Messaging', () => { }); it('should be fulfilled with the server response given an array of registration tokens ' + - 'and topic (topic name not prefixed with "/topics/")', () => { + 'and topic (topic name not prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); return messagingService[methodName]( @@ -2873,7 +2917,7 @@ describe('Messaging', () => { }); it('should be fulfilled with the server response given an array of registration tokens ' + - 'and topic (topic name prefixed with "/topics/")', () => { + 'and topic (topic name prefixed with "/topics/")', () => { mockedRequests.push(mockTopicSubscriptionRequest(methodName, /* successCount */ 1, /* failureCount */ 2)); return messagingService[methodName]( @@ -2898,7 +2942,7 @@ describe('Messaging', () => { }); it('should set the appropriate request data given a single registration token and topic ' + - '(topic name not prefixed with "/topics/")', () => { + '(topic name not prefixed with "/topics/")', () => { // Wait for the initial getToken() call to complete before stubbing https.request. return mockApp.INTERNAL.getToken() .then(() => { @@ -2919,7 +2963,7 @@ describe('Messaging', () => { }); it('should set the appropriate request data given a single registration token and topic ' + - '(topic name prefixed with "/topics/")', () => { + '(topic name prefixed with "/topics/")', () => { // Wait for the initial getToken() call to complete before stubbing https.request. return mockApp.INTERNAL.getToken() .then(() => { @@ -2940,7 +2984,7 @@ describe('Messaging', () => { }); it('should set the appropriate request data given an array of registration tokens and ' + - 'topic (topic name not prefixed with "/topics/")', () => { + 'topic (topic name not prefixed with "/topics/")', () => { const registrationTokens = [ mocks.messaging.registrationToken + '0', mocks.messaging.registrationToken + '1', @@ -2967,7 +3011,7 @@ describe('Messaging', () => { }); it('should set the appropriate request data given an array of registration tokens and ' + - 'topic (topic name prefixed with "/topics/")', () => { + 'topic (topic name prefixed with "/topics/")', () => { const registrationTokens = [ mocks.messaging.registrationToken + '0', mocks.messaging.registrationToken + '1', diff --git a/test/unit/utils/api-request.spec.ts b/test/unit/utils/api-request.spec.ts index 21d7e3ce58..887804545a 100644 --- a/test/unit/utils/api-request.spec.ts +++ b/test/unit/utils/api-request.spec.ts @@ -2520,7 +2520,6 @@ describe('Http2Client', () => { it('should fail on session and stream errors', async () => { const reqData = { request: 'data' }; const streamError = 'Error while making request: test stream error. Error code: AWFUL_STREAM_ERROR'; - const sessionError = 'Session error while making requests: AWFUL_SESSION_ERROR - test session error' mockedHttp2Responses.push(mockHttp2Error( { message: 'test stream error', code: 'AWFUL_STREAM_ERROR' }, { message: 'test session error', code: 'AWFUL_SESSION_ERROR' } @@ -2549,15 +2548,17 @@ describe('Http2Client', () => { expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + + const sessionErrors = http2SessionHandler.getErrors(); + expect(sessionErrors.length).to.equal(1); + const expectedError1 = 'Session error while making requests: AWFUL_SESSION_ERROR - test session error '; + expect(sessionErrors[0].message).to.equal(expectedError1); }); - - await http2SessionHandler.invoke().should.eventually.be.rejectedWith(sessionError) - .and.have.property('code', 'app/network-error') }); it('should unwrap aggregate session errors', async () => { const reqData = { request: 'data' }; - const streamError = { message: 'test stream error', code: 'AWFUL_STREAM_ERROR' } + const streamError = { message: 'test stream error', code: 'AWFUL_STREAM_ERROR' }; const expectedStreamErrorMessage = 'Error while making request: test stream error. Error code: AWFUL_STREAM_ERROR'; const aggregateSessionError = { name: 'AggregateError', @@ -2566,15 +2567,12 @@ describe('Http2Client', () => { { message: 'Error message 1' }, { message: 'Error message 2' }, ] - } - const expectedAggregateErrorMessage = 'Session error while making requests: AWFUL_SESSION_ERROR - ' + - 'AggregateError: [Error message 1, Error message 2]' - + }; mockedHttp2Responses.push(mockHttp2Error(streamError, aggregateSessionError)); http2Mocker.http2Stub(mockedHttp2Responses); const client = new Http2Client(); - http2SessionHandler = new Http2SessionHandler(mockHostUrl) + http2SessionHandler = new Http2SessionHandler(mockHostUrl); await client.send({ method: 'POST', @@ -2595,10 +2593,13 @@ describe('Http2Client', () => { expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); - }); - await http2SessionHandler.invoke().should.eventually.be.rejectedWith(expectedAggregateErrorMessage) - .and.have.property('code', 'app/network-error') + const sessionErrors = http2SessionHandler.getErrors(); + expect(sessionErrors.length).to.equal(1); + const expectedError2 = 'Session error while making requests: AWFUL_SESSION_ERROR - AggregateError: ' + + '[Error message 1, Error message 2]'; + expect(sessionErrors[0].message).to.equal(expectedError2); + }); }); });