diff --git a/entrypoints.json b/entrypoints.json
index 6b507911ae..dd17bd46fb 100644
--- a/entrypoints.json
+++ b/entrypoints.json
@@ -20,6 +20,10 @@
"typings": "./lib/database/index.d.ts",
"dist": "./lib/database/index.js"
},
+ "firebase-admin/extensions": {
+ "typings": "./lib/extensions/index.d.ts",
+ "dist": "./lib/extensions/index.js"
+ },
"firebase-admin/firestore": {
"typings": "./lib/firestore/index.d.ts",
"dist": "./lib/firestore/index.js"
diff --git a/etc/firebase-admin.extensions.api.md b/etc/firebase-admin.extensions.api.md
new file mode 100644
index 0000000000..ed9090aaee
--- /dev/null
+++ b/etc/firebase-admin.extensions.api.md
@@ -0,0 +1,24 @@
+## API Report File for "firebase-admin.extensions"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+///
+
+import { Agent } from 'http';
+
+// @public
+export class Extensions {
+ // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
+ //
+ // (undocumented)
+ readonly app: App;
+ // Warning: (ae-forgotten-export) The symbol "Runtime" needs to be exported by the entry point index.d.ts
+ runtime(): Runtime;
+}
+
+// @public
+export function getExtensions(app?: App): Extensions;
+
+```
diff --git a/package.json b/package.json
index 00cdee8e35..d5f678f5ec 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,9 @@
"eventarc": [
"lib/eventarc"
],
+ "extensions": [
+ "lib/extensions"
+ ],
"database": [
"lib/database"
],
@@ -136,6 +139,11 @@
"require": "./lib/eventarc/index.js",
"import": "./lib/esm/eventarc/index.js"
},
+ "./extensions": {
+ "types": "./lib/extensions/index.d.ts",
+ "require": "./lib/extensions/index.js",
+ "import": "./lib/esm/extensions/index.js"
+ },
"./firestore": {
"types": "./lib/firestore/index.d.ts",
"require": "./lib/firestore/index.js",
diff --git a/src/extensions/extensions-api-client-internal.ts b/src/extensions/extensions-api-client-internal.ts
new file mode 100644
index 0000000000..407dd5a82e
--- /dev/null
+++ b/src/extensions/extensions-api-client-internal.ts
@@ -0,0 +1,150 @@
+/*!
+ * @license
+ * Copyright 2022 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from '../app';
+import { FirebaseApp } from '../app/firebase-app';
+import { AuthorizedHttpClient, HttpClient, HttpError, HttpRequestConfig } from '../utils/api-request';
+import { FirebaseAppError, PrefixedFirebaseError } from '../utils/error';
+import * as validator from '../utils/validator';
+import * as utils from '../utils';
+
+const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
+ 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
+};
+const EXTENSIONS_API_VERSION = 'v1beta';
+// Note - use getExtensionsApiUri() instead so that changing environments is consistent.
+const EXTENSIONS_URL = 'https://firebaseextensions.googleapis.com';
+
+/**
+ * Class that facilitates sending requests to the Firebase Extensions backend API.
+ *
+ * @internal
+ */
+export class ExtensionsApiClient {
+ private readonly httpClient: HttpClient;
+
+ constructor(private readonly app: App) {
+ if (!validator.isNonNullObject(app) || !('options' in app)) {
+ throw new FirebaseAppError(
+ 'invalid-argument',
+ 'First argument passed to getExtensions() must be a valid Firebase app instance.');
+ }
+ this.httpClient = new AuthorizedHttpClient(this.app as FirebaseApp);
+ }
+
+ async updateRuntimeData(
+ projectId: string,
+ instanceId: string,
+ runtimeData: RuntimeData
+ ): Promise {
+ const url = this.getRuntimeDataUri(projectId, instanceId);
+ const request: HttpRequestConfig = {
+ method: 'PATCH',
+ url,
+ headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
+ data: runtimeData,
+ };
+ try {
+ const res = await this.httpClient.send(request);
+ return res.data
+ } catch (err: any) {
+ throw this.toFirebaseError(err);
+ }
+ }
+
+ private getExtensionsApiUri(): string {
+ return process.env['FIREBASE_EXT_URL'] ?? EXTENSIONS_URL;
+ }
+
+ private getRuntimeDataUri(projectId: string, instanceId: string): string {
+ return `${
+ this.getExtensionsApiUri()
+ }/${EXTENSIONS_API_VERSION}/projects/${projectId}/instances/${instanceId}/runtimeData`;
+ }
+
+ private toFirebaseError(err: HttpError): PrefixedFirebaseError {
+ if (err instanceof PrefixedFirebaseError) {
+ return err;
+ }
+
+ const response = err.response;
+ if (!response?.isJson()) {
+ return new FirebaseExtensionsError(
+ 'unknown-error',
+ `Unexpected response with status: ${response.status} and body: ${response.text}`);
+ }
+ const error = response.data?.error;
+ const message = error?.message || `Unknown server error: ${response.text}`;
+ switch (error.code) {
+ case 403:
+ return new FirebaseExtensionsError('forbidden', message);
+ case 404:
+ return new FirebaseExtensionsError('not-found', message);
+ case 500:
+ return new FirebaseExtensionsError('internal-error', message);
+ }
+ return new FirebaseExtensionsError('unknown-error', message);
+ }
+}
+
+interface RuntimeData {
+
+ //oneof
+ processingState?: ProcessingState;
+ fatalError?: FatalError;
+}
+
+interface RuntimeDataResponse extends RuntimeData{
+ name: string,
+ updateTime: string,
+}
+
+interface FatalError {
+ errorMessage: string;
+}
+
+interface ProcessingState {
+ detailMessage: string;
+ state: State;
+}
+
+type State = 'STATE_UNSPECIFIED' |
+ 'NONE' |
+ 'PROCESSING' |
+ 'PROCESSING_COMPLETE' |
+ 'PROCESSING_WARNING' |
+ 'PROCESSING_FAILED';
+
+type ExtensionsErrorCode = 'invalid-argument' | 'not-found' | 'forbidden' | 'internal-error' | 'unknown-error';
+/**
+ * Firebase Extensions error code structure. This extends PrefixedFirebaseError.
+ *
+ * @param code - The error code.
+ * @param message - The error message.
+ * @constructor
+ */
+export class FirebaseExtensionsError extends PrefixedFirebaseError {
+ constructor(code: ExtensionsErrorCode, message: string) {
+ super('Extensions', code, message);
+
+ /* tslint:disable:max-line-length */
+ // Set the prototype explicitly. See the following link for more details:
+ // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
+ /* tslint:enable:max-line-length */
+ (this as any).__proto__ = FirebaseExtensionsError.prototype;
+ }
+}
diff --git a/src/extensions/extensions-api.ts b/src/extensions/extensions-api.ts
new file mode 100644
index 0000000000..b5ef4fcb56
--- /dev/null
+++ b/src/extensions/extensions-api.ts
@@ -0,0 +1,29 @@
+/*!
+ * @license
+ * Copyright 2022 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * SettableProcessingState represents all the Processing states that can be set on an ExtensionInstance's runtimeData.
+ *
+ * - NONE: No relevant lifecycle event work has been done. Set this to clear out old statuses.
+ * - PROCESSING_COMPLETE: Lifecycle event work completed with no errors.
+ * - PROCESSING_WARNING: Lifecycle event work succeeded partially,
+ * or something happened that the user should be warned about.
+ * - PROCESSING_FAILED: Lifecycle event work failed completely,
+ * but the instance will still work correctly going forward.
+ * - If the extension instance is in a broken state due to the errors, instead set FatalError.
+ */
+export type SettableProcessingState = 'NONE' | 'PROCESSING_COMPLETE' | 'PROCESSING_WARNING' | 'PROCESSING_FAILED';
diff --git a/src/extensions/extensions.ts b/src/extensions/extensions.ts
new file mode 100644
index 0000000000..6e98f3575c
--- /dev/null
+++ b/src/extensions/extensions.ts
@@ -0,0 +1,138 @@
+/*!
+ * @license
+ * Copyright 2022 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from '../app';
+import { SettableProcessingState } from './extensions-api';
+import { ExtensionsApiClient, FirebaseExtensionsError } from './extensions-api-client-internal';
+import * as validator from '../utils/validator';
+
+/**
+ * The Firebase `Extensions` service interface.
+ */
+export class Extensions {
+ private readonly client: ExtensionsApiClient;
+ /**
+ * @param app - The app for this `Extensions` service.
+ * @constructor
+ * @internal
+ */
+ constructor(readonly app: App) {
+ this.client = new ExtensionsApiClient(app);
+ }
+
+ /**
+ * The runtime() method returns a new Runtime, which provides methods to modify an extension instance's runtime data.
+ *
+ * @returns A new Runtime object.
+ */
+ public runtime(): Runtime {
+ return new Runtime(this.client);
+ }
+}
+
+/**
+ * Runtime provides methods to modify an extension instance's runtime data.
+ */
+class Runtime {
+ private projectId: string;
+ private extensionInstanceId: string;
+ private readonly client: ExtensionsApiClient;
+ /**
+ * @param client - The API client for this `Runtime` service.
+ * @constructor
+ * @internal
+ */
+ constructor(client: ExtensionsApiClient) {
+ this.projectId = this.getProjectId();
+ if (!validator.isNonEmptyString(process.env['EXT_INSTANCE_ID'])) {
+ throw new FirebaseExtensionsError(
+ 'invalid-argument',
+ 'Runtime is only available from within a running Extension instance.'
+ );
+ }
+ this.extensionInstanceId = process.env['EXT_INSTANCE_ID'];
+ if (!validator.isNonNullObject(client) || !('updateRuntimeData' in client)) {
+ throw new FirebaseExtensionsError(
+ 'invalid-argument',
+ 'Must provide a valid ExtensionsApiClient instance to create a new Runtime.');
+ }
+ this.client = client;
+ }
+
+ /**
+ * Sets the processing state of an extension instance.
+ *
+ * Use this method to report the results of a lifecycle event handler. If the
+ * lifecycle event failed & the extension instance will no longer work
+ * correctly, use `setFatalError` instead.
+ *
+ * @param state - The state to set the instance to.
+ * @param detailMessage - A message explaining the results of the lifecycle function.
+ */
+ public async setProcessingState(state: SettableProcessingState, detailMessage: string): Promise {
+ await this.client.updateRuntimeData(
+ this.projectId,
+ this.extensionInstanceId,
+ {
+ processingState: {
+ state,
+ detailMessage,
+ },
+ },
+ );
+ }
+
+ /**
+ * Reports a fatal error while running a lifecycle event handler.
+ *
+ * Call this method when a lifecycle event handler fails in a way that makes
+ * the Instance inoperable.
+ * If the lifecycle event failed but the instance will still work as expected,
+ * call `setProcessingState` with the "PROCESSING_WARNING" or
+ * "PROCESSING_FAILED" state instead.
+ *
+ * @param errorMessage - A message explaining what went wrong and how to fix it.
+ */
+ public async setFatalError(errorMessage: string): Promise {
+ if (!validator.isNonEmptyString(errorMessage)) {
+ throw new FirebaseExtensionsError(
+ 'invalid-argument',
+ 'errorMessage must not be empty'
+ );
+ }
+ await this.client.updateRuntimeData(
+ this.projectId,
+ this.extensionInstanceId,
+ {
+ fatalError: {
+ errorMessage,
+ },
+ },
+ );
+ }
+
+ private getProjectId(): string {
+ const projectId = process.env['PROJECT_ID'];
+ if (!validator.isNonEmptyString(projectId)) {
+ throw new FirebaseExtensionsError(
+ 'invalid-argument',
+ 'PROJECT_ID must not be undefined in Extensions runtime environment'
+ );
+ }
+ return projectId;
+ }
+}
diff --git a/src/extensions/index.ts b/src/extensions/index.ts
new file mode 100644
index 0000000000..6283e630dc
--- /dev/null
+++ b/src/extensions/index.ts
@@ -0,0 +1,63 @@
+/*!
+ * @license
+ * Copyright 2021 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Firebase Extensions service.
+ *
+ * @packageDocumentation
+ */
+
+import { App, getApp } from '../app';
+import { FirebaseApp } from '../app/firebase-app';
+import { Extensions } from './extensions';
+
+export { Extensions } from './extensions';
+
+/**
+ * Gets the {@link Extensions} service for the default app
+ * or a given app.
+ *
+ * `getExtensions()` can be called with no arguments to access the default
+ * app's `Extensions` service or as `getExtensions(app)` to access the
+ * `Extensions` service associated with a specific app.
+ *
+ * @example
+ * ```javascript
+ * // Get the `Extensions` service for the default app
+ * const defaultExtensions = getExtensions();
+ * ```
+ *
+ * @example
+ * ```javascript
+ * // Get the `Extensions` service for a given app
+ * const otherExtensions = getExtensions(otherApp);
+ * ```
+ *
+ * @param app - Optional app for which to return the `Extensions` service.
+ * If not provided, the default `Extensions` service is returned.
+ *
+ * @returns The default `Extensions` service if no app is provided, or the `Extensions`
+ * service associated with the provided app.
+ */
+export function getExtensions(app?: App): Extensions {
+ if (typeof app === 'undefined') {
+ app = getApp();
+ }
+
+ const firebaseApp: FirebaseApp = app as FirebaseApp;
+ return firebaseApp.getOrInitService('extensions', (app) => new Extensions(app));
+}
diff --git a/test/unit/extensions/extensions-api-client-internal.spec.ts b/test/unit/extensions/extensions-api-client-internal.spec.ts
new file mode 100644
index 0000000000..a95baff154
--- /dev/null
+++ b/test/unit/extensions/extensions-api-client-internal.spec.ts
@@ -0,0 +1,90 @@
+/*!
+ * @license
+ * Copyright 2022 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect } from 'chai';
+import * as sinon from 'sinon';
+
+import * as utils from '../utils';
+import * as mocks from '../../resources/mocks';
+import { FirebaseApp } from '../../../src/app/firebase-app';
+import { ExtensionsApiClient, FirebaseExtensionsError } from '../../../src/extensions/extensions-api-client-internal';
+import { HttpClient, HttpRequestConfig } from '../../../src/utils/api-request';
+import { SettableProcessingState } from '../../../src/extensions/extensions-api';
+
+const testProjectId = 'test-project';
+const testInstanceId = 'test-instance';
+
+describe('Extension API client', () => {
+ let app: FirebaseApp;
+ let apiClient: ExtensionsApiClient;
+
+ let httpClientStub: sinon.SinonStub;
+ const mockOptions = {
+ credential: new mocks.MockCredential(),
+ projectId: 'test-project',
+ serviceAccountId: 'service-acct@email.com'
+ };
+
+ before(() => {
+ app = mocks.appWithOptions(mockOptions);
+ apiClient = new ExtensionsApiClient(app);
+ });
+
+ after(() => {
+ return app.delete();
+ });
+
+ beforeEach(() => {
+ httpClientStub = sinon.stub(HttpClient.prototype, 'send');
+ });
+
+ afterEach(() => {
+ httpClientStub.restore();
+ });
+
+ describe('Constructor', () => {
+ it('should reject when the app is null', () => {
+ expect(() => new ExtensionsApiClient(null as unknown as FirebaseApp))
+ .to.throw('First argument passed to getExtensions() must be a valid Firebase app instance.');
+ });
+ });
+
+ describe('updateRuntimeData', () => {
+ it('should updateRuntimeData', async () => {
+ const testRuntimeData = {
+ processingState: {
+ state: 'PROCESSING_COMPLETE' as SettableProcessingState,
+ detailMessage: 'done processing',
+ },
+ }
+ const expected = sinon.match((req: HttpRequestConfig) => {
+ const url = 'https://firebaseextensions.googleapis.com/' +
+ 'v1beta/projects/test-project/instances/test-instance/runtimeData';
+ return req.method == 'PATCH' && req.url == url && req.data == testRuntimeData;
+ }, 'Incorrect URL or Method');
+ httpClientStub.withArgs(expected).resolves(utils.responseFrom(testRuntimeData, 200));
+ await expect(apiClient.updateRuntimeData(testProjectId, testInstanceId, testRuntimeData))
+ .to.eventually.deep.equal(testRuntimeData);
+ });
+
+ it('should convert errors in FirebaseErrors', async () => {
+ httpClientStub.rejects(utils.errorFrom('Something went wrong', 404));
+ await expect(apiClient.updateRuntimeData(testProjectId, testInstanceId, {}))
+ .to.eventually.be.rejectedWith(FirebaseExtensionsError);
+ });
+ });
+});
diff --git a/test/unit/extensions/extensions.spec.ts b/test/unit/extensions/extensions.spec.ts
new file mode 100644
index 0000000000..0b3ffb3004
--- /dev/null
+++ b/test/unit/extensions/extensions.spec.ts
@@ -0,0 +1,179 @@
+/*!
+ * @license
+ * Copyright 2022 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as sinon from 'sinon';
+import { expect } from 'chai';
+
+import * as mocks from '../../resources/mocks';
+import * as utils from '../utils';
+import { FirebaseApp } from '../../../src/app/firebase-app';
+import { Extensions } from '../../../src/extensions/extensions';
+import { FirebaseAppError } from '../../../src/utils/error';
+import { HttpClient, HttpRequestConfig } from '../../../src/utils/api-request';
+import { SettableProcessingState } from '../../../src/extensions/extensions-api';
+import { FirebaseExtensionsError } from '../../../src/extensions/extensions-api-client-internal';
+
+describe('Extensions', () => {
+ const mockOptions = {
+ credential: new mocks.MockCredential(),
+ projectId: 'test-project',
+ };
+
+ let extensions: Extensions;
+ let mockApp: FirebaseApp;
+
+ beforeEach(() => {
+ mockApp = mocks.appWithOptions(mockOptions);
+ extensions = new Extensions(mockApp);
+ });
+
+ afterEach(() => {
+ return mockApp.delete();
+ });
+
+ describe('Constructor', () => {
+ it('should reject when the app is null', () => {
+ expect(() => new Extensions(null as unknown as FirebaseApp))
+ .to.throw(FirebaseAppError);
+ });
+ });
+
+ describe('app', () => {
+ it('returns the app from the constructor', () => {
+ // We expect referential equality here
+ expect(extensions.app).to.equal(mockApp);
+ });
+ });
+
+ describe('Runtime', () => {
+ let processEnvCopy: Record;
+ beforeEach(() => {
+ processEnvCopy = JSON.parse(JSON.stringify(process.env)) as Record;
+ });
+
+ afterEach(() => {
+ process.env = processEnvCopy;
+ });
+
+ describe('Constructor', () => {
+ it('should error if called without PROJECT_ID', () => {
+ process.env['EXT_INSTANCE_ID'] = 'test-instance';
+ expect(() => extensions.runtime())
+ .to.throw('PROJECT_ID must not be undefined in Extensions runtime environment');
+ });
+
+
+ it('should error if called without EXT_INSTANCE_ID', () => {
+ process.env['PROJECT_ID'] = 'test-project';
+ expect(() => extensions.runtime())
+ .to.throw('Runtime is only available from within a running Extension instance.');
+ });
+
+ it('should not error if called from an extension', () => {
+ process.env['PROJECT_ID'] = 'test-project';
+ process.env['EXT_INSTANCE_ID'] = 'test-instance';
+ expect(() => extensions.runtime()).not.to.throw();
+ });
+ });
+
+ describe('setProcessingState', () => {
+ let httpClientStub: sinon.SinonStub;
+ beforeEach(() => {
+ process.env['PROJECT_ID'] = 'test-project';
+ process.env['EXT_INSTANCE_ID'] = 'test-instance';
+ httpClientStub = sinon.stub(HttpClient.prototype, 'send');
+ });
+
+ afterEach(() => {
+ httpClientStub.restore();
+ });
+
+ for (const state of ['PROCESSING_FAILED', 'PROCESSING_WARNING','PROCESSING_COMPLETE', 'NONE']) {
+ it(`should set ${state} state`, async () => {
+ const expectedRuntimeData = {
+ processingState: {
+ state: state as SettableProcessingState,
+ detailMessage: 'done processing',
+ },
+ }
+ const expected = sinon.match((req: HttpRequestConfig) => {
+ const url = 'https://firebaseextensions.googleapis.com/' +
+ 'v1beta/projects/test-project/instances/test-instance/runtimeData';
+ return req.method == 'PATCH' &&
+ req.url == url &&
+ JSON.stringify(req.data) == JSON.stringify(expectedRuntimeData);
+ }, 'Incorrect URL or Method');
+ httpClientStub.withArgs(expected).resolves(utils.responseFrom(expectedRuntimeData, 200));
+
+
+ await extensions.runtime().setProcessingState(state as SettableProcessingState, 'done processing');
+ expect(httpClientStub).to.have.been.calledOnce;
+ });
+ }
+
+ it('should covert errors in FirebaseErrors', async () => {
+ httpClientStub.rejects(utils.errorFrom('Something went wrong', 404));
+ await expect(extensions.runtime().setProcessingState('PROCESSING_COMPLETE', 'a message'))
+ .to.eventually.be.rejectedWith(FirebaseExtensionsError);
+ });
+ });
+
+ describe('setFatalError', () => {
+ let httpClientStub: sinon.SinonStub;
+ beforeEach(() => {
+ process.env['PROJECT_ID'] = 'test-project';
+ process.env['EXT_INSTANCE_ID'] = 'test-instance';
+ httpClientStub = sinon.stub(HttpClient.prototype, 'send');
+ });
+
+ afterEach(() => {
+ httpClientStub.restore();
+ });
+
+ it('should set fatal error', async () => {
+ const expectedRuntimeData = {
+ fatalError: {
+ errorMessage: 'A bad error!',
+ },
+ };
+ const expected = sinon.match((req: HttpRequestConfig) => {
+ const url = 'https://firebaseextensions.googleapis.com/' +
+ 'v1beta/projects/test-project/instances/test-instance/runtimeData';
+ return req.method == 'PATCH' &&
+ req.url == url &&
+ JSON.stringify(req.data) == JSON.stringify(expectedRuntimeData);
+ }, 'Incorrect URL or Method');
+ httpClientStub.withArgs(expected).resolves(utils.responseFrom(expectedRuntimeData, 200));
+
+
+ await extensions.runtime().setFatalError('A bad error!');
+ expect(httpClientStub).to.have.been.calledOnce;
+ });
+
+ it('should error if errorMessage is empty', async () => {
+ await expect(extensions.runtime().setFatalError(''))
+ .to.eventually.be.rejectedWith(FirebaseExtensionsError, 'errorMessage must not be empty');
+ });
+
+ it('should convert errors in FirebaseErrors', async () => {
+ httpClientStub.rejects(utils.errorFrom('Something went wrong', 404));
+ await expect(extensions.runtime().setFatalError('a message'))
+ .to.eventually.be.rejectedWith(FirebaseExtensionsError);
+ });
+ })
+ });
+});
diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts
index aa7b262111..ffa46e7ef4 100644
--- a/test/unit/index.spec.ts
+++ b/test/unit/index.spec.ts
@@ -107,7 +107,12 @@ import './app-check/token-verifier.spec.ts';
// Eventarc
import './eventarc/eventarc.spec';
import './eventarc/eventarc-utils.spec';
+
// Functions
import './functions/index.spec';
import './functions/functions.spec';
import './functions/functions-api-client-internal.spec';
+
+// Extensions
+import './extensions/extensions.spec';
+import './extensions/extensions-api-client-internal.spec';