From 7c06d1a93ada4cd78d3c78338f1f82c2d1935e18 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 29 Jul 2025 15:39:19 -0700 Subject: [PATCH 1/5] Gemini wrote some tests for app-* commands --- src/commands/apps-android-sha-create.spec.ts | 68 +++++++ src/commands/apps-android-sha-create.ts | 2 +- src/commands/apps-android-sha-delete.spec.ts | 40 ++++ src/commands/apps-android-sha-list.spec.ts | 106 +++++++++++ src/commands/apps-android-sha-list.ts | 4 +- src/commands/apps-create.spec.ts | 146 +++++++++++++++ src/commands/apps-create.ts | 2 +- src/commands/apps-list.spec.ts | 109 +++++++++++ src/commands/apps-list.ts | 4 +- src/commands/apps-sdkconfig.spec.ts | 186 +++++++++++++++++++ src/management/apps.ts | 2 +- 11 files changed, 662 insertions(+), 7 deletions(-) create mode 100644 src/commands/apps-android-sha-create.spec.ts create mode 100644 src/commands/apps-android-sha-delete.spec.ts create mode 100644 src/commands/apps-android-sha-list.spec.ts create mode 100644 src/commands/apps-create.spec.ts create mode 100644 src/commands/apps-list.spec.ts create mode 100644 src/commands/apps-sdkconfig.spec.ts diff --git a/src/commands/apps-android-sha-create.spec.ts b/src/commands/apps-android-sha-create.spec.ts new file mode 100644 index 00000000000..e9a672a023d --- /dev/null +++ b/src/commands/apps-android-sha-create.spec.ts @@ -0,0 +1,68 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Command } from "../command"; +import * as projectUtils from "../projectUtils"; +import * as apps from "../management/apps"; +import { ShaCertificateType } from "../management/apps"; +import * as utils from "../utils"; +import { command, getCertHashType } from "./apps-android-sha-create"; + +describe("apps:android:sha:create", () => { + let sandbox: sinon.SinonSandbox; + let needProjectIdStub: sinon.SinonStub; + let createAppAndroidShaStub: sinon.SinonStub; + let promiseWithSpinnerStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); + createAppAndroidShaStub = sandbox + .stub(apps, "createAppAndroidSha") + .resolves({ name: "test-sha", shaHash: "test-hash", certType: ShaCertificateType.SHA_1 }); + promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner").callThrough(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should create a SHA certificate", async () => { + const shaHash = "A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2"; // SHA-1 + await command.runner()("test-app-id", shaHash, {}); + + expect(needProjectIdStub).to.have.been.calledOnce; + expect(createAppAndroidShaStub).to.have.been.calledOnce; + const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; + expect(spinnerText).to.include("Creating Android SHA certificate"); + }); + }); + + describe("getCertHashType", () => { + it("should return SHA_1 for a 40-character hash", () => { + const shaHash = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"; + expect(getCertHashType(shaHash)).to.equal(ShaCertificateType.SHA_1); + }); + + it("should return SHA_256 for a 64-character hash", () => { + const shaHash = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"; + expect(getCertHashType(shaHash)).to.equal(ShaCertificateType.SHA_256); + }); + + it("should return UNSPECIFIED for other hash lengths", () => { + const shaHash = "A1B2C3D4E5F6"; + expect(getCertHashType(shaHash)).to.equal( + ShaCertificateType.SHA_CERTIFICATE_TYPE_UNSPECIFIED, + ); + }); + + it("should handle colons in the hash", () => { + const shaHash = "A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2"; + expect(getCertHashType(shaHash)).to.equal(ShaCertificateType.SHA_1); + }); + }); +}); diff --git a/src/commands/apps-android-sha-create.ts b/src/commands/apps-android-sha-create.ts index 7a7609ea9ee..8ddd2359010 100644 --- a/src/commands/apps-android-sha-create.ts +++ b/src/commands/apps-android-sha-create.ts @@ -6,7 +6,7 @@ import { AppAndroidShaData, createAppAndroidSha, ShaCertificateType } from "../m import { requireAuth } from "../requireAuth"; import { promiseWithSpinner } from "../utils"; -function getCertHashType(shaHash: string): string { +export function getCertHashType(shaHash: string): string { shaHash = shaHash.replace(/:/g, ""); const shaHashCount = shaHash.length; if (shaHashCount === 40) return ShaCertificateType.SHA_1.toString(); diff --git a/src/commands/apps-android-sha-delete.spec.ts b/src/commands/apps-android-sha-delete.spec.ts new file mode 100644 index 00000000000..a2c2cb4edf7 --- /dev/null +++ b/src/commands/apps-android-sha-delete.spec.ts @@ -0,0 +1,40 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Command } from "../command"; +import * as projectUtils from "../projectUtils"; +import * as apps from "../management/apps"; +import * as utils from "../utils"; +import { command } from "./apps-android-sha-delete"; + +describe("apps:android:sha:delete", () => { + let sandbox: sinon.SinonSandbox; + let needProjectIdStub: sinon.SinonStub; + let deleteAppAndroidShaStub: sinon.SinonStub; + let promiseWithSpinnerStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); + deleteAppAndroidShaStub = sandbox.stub(apps, "deleteAppAndroidSha").resolves(); + promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner").callThrough(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should delete a SHA certificate", async () => { + await command.runner()("test-app-id", "test-sha-id", {}); + + expect(needProjectIdStub).to.have.been.calledOnce; + expect(deleteAppAndroidShaStub).to.have.been.calledOnce; + const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; + expect(spinnerText).to.include("Deleting Android SHA certificate hash"); + }); + }); +}); diff --git a/src/commands/apps-android-sha-list.spec.ts b/src/commands/apps-android-sha-list.spec.ts new file mode 100644 index 00000000000..e28c465f3fc --- /dev/null +++ b/src/commands/apps-android-sha-list.spec.ts @@ -0,0 +1,106 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as Table from "cli-table3"; +import { Command } from "../command"; +import * as projectUtils from "../projectUtils"; +import * as apps from "../management/apps"; +import { AppAndroidShaData, ShaCertificateType } from "../management/apps"; +import * as utils from "../utils"; +import { command, logCertificatesList, logCertificatesCount } from "./apps-android-sha-list"; + +describe("apps:android:sha:list", () => { + let sandbox: sinon.SinonSandbox; + let promiseWithSpinnerStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); + sandbox.stub(apps, "listAppAndroidSha"); + promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should list SHA certificates", async () => { + const certificates: AppAndroidShaData[] = [ + { + name: "projects/p/androidApps/a/sha/s1", + shaHash: "h1", + certType: ShaCertificateType.SHA_1, + }, + { + name: "projects/p/androidApps/a/sha/s2", + shaHash: "h2", + certType: ShaCertificateType.SHA_256, + }, + ]; + promiseWithSpinnerStub.resolves(certificates); + + await command.runner()("test-app-id", {}); + + expect(promiseWithSpinnerStub).to.have.been.calledOnce; + const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; + expect(spinnerText).to.include("Preparing the list"); + }); + + it('should display "No SHA certificate hashes found." if no certificates exist', async () => { + promiseWithSpinnerStub.resolves([]); + + await command.runner()("test-app-id", {}); + + // No assertion needed here, we are just checking that it does not throw. + }); + }); + + describe("logCertificatesList", () => { + it("should print a table of certificates", () => { + const certificates: AppAndroidShaData[] = [ + { + name: "projects/p/androidApps/app1/sha/sha1", + shaHash: "hash1", + certType: ShaCertificateType.SHA_1, + }, + { + name: "projects/p/androidApps/app2/sha/sha2", + shaHash: "hash2", + certType: ShaCertificateType.SHA_256, + }, + ]; + const tableSpy = sandbox.spy(Table.prototype, "push"); + + logCertificatesList(certificates); + + expect(tableSpy.getCall(0).args[0]).to.deep.equal([ + "app1", + "sha1", + "hash1", + ShaCertificateType.SHA_1, + ]); + expect(tableSpy.getCall(1).args[0]).to.deep.equal([ + "app2", + "sha2", + "hash2", + ShaCertificateType.SHA_256, + ]); + }); + }); + + describe("logCertificatesCount", () => { + it("should print the total number of certificates", () => { + logCertificatesCount(5); + // No assertion needed here, we are just checking that it does not throw. + }); + + it("should not print if count is 0", () => { + logCertificatesCount(0); + // No assertion needed here, we are just checking that it does not throw. + }); + }); +}); diff --git a/src/commands/apps-android-sha-list.ts b/src/commands/apps-android-sha-list.ts index 65e0f260f1c..d1928c782fb 100644 --- a/src/commands/apps-android-sha-list.ts +++ b/src/commands/apps-android-sha-list.ts @@ -6,7 +6,7 @@ import { requireAuth } from "../requireAuth"; import { logger } from "../logger"; import { promiseWithSpinner } from "../utils"; -function logCertificatesList(certificates: AppAndroidShaData[]): void { +export function logCertificatesList(certificates: AppAndroidShaData[]): void { if (certificates.length === 0) { logger.info("No SHA certificate hashes found."); return; @@ -27,7 +27,7 @@ function logCertificatesList(certificates: AppAndroidShaData[]): void { logger.info(table.toString()); } -function logCertificatesCount(count: number = 0): void { +export function logCertificatesCount(count: number = 0): void { if (count === 0) { return; } diff --git a/src/commands/apps-create.spec.ts b/src/commands/apps-create.spec.ts new file mode 100644 index 00000000000..754d80581ec --- /dev/null +++ b/src/commands/apps-create.spec.ts @@ -0,0 +1,146 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Command } from "../command"; +import * as projectUtils from "../projectUtils"; +import { FirebaseError } from "../error"; +import * as apps from "../management/apps"; +import { AppPlatform } from "../management/apps"; +import * as prompt from "../prompt"; +import { command, logPostAppCreationInformation } from "./apps-create"; + +describe("apps:create", () => { + let sandbox: sinon.SinonSandbox; + let needProjectIdStub: sinon.SinonStub; + let getAppPlatformStub: sinon.SinonStub; + let sdkInitStub: sinon.SinonStub; + let selectStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); + getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); + sdkInitStub = sandbox.stub(apps, "sdkInit").resolves({ + name: "test-name", + projectId: "test-project-id", + appId: "test-app-id", + platform: AppPlatform.WEB, + displayName: "test-display-name", + }); + selectStub = sandbox.stub(prompt, "select"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should throw if platform is not provided in non-interactive mode", async () => { + getAppPlatformStub.returns(AppPlatform.ANY); + const options = { nonInteractive: true }; + await expect(command.runner()("", undefined, options)).to.be.rejectedWith( + FirebaseError, + "App platform must be provided", + ); + expect(needProjectIdStub).to.have.been.calledOnce; + }); + + it("should prompt for platform if not provided in interactive mode", async () => { + getAppPlatformStub.withArgs("").returns(AppPlatform.ANY); + getAppPlatformStub.withArgs("IOS").returns(AppPlatform.IOS); + selectStub.resolves("IOS"); + const options = { nonInteractive: false }; + await command.runner()("", "test-display-name", options); + expect(selectStub).to.have.been.calledOnce; + expect(sdkInitStub).to.have.been.calledOnceWith( + AppPlatform.IOS, + sinon.match({ + displayName: "test-display-name", + nonInteractive: false, + }), + ); + }); + + it("should create an iOS app", async () => { + getAppPlatformStub.returns(AppPlatform.IOS); + const options = { bundleId: "test-bundle-id" }; + await command.runner()("IOS", "test-display-name", options); + expect(sdkInitStub).to.have.been.calledOnceWith( + AppPlatform.IOS, + sinon.match({ + bundleId: "test-bundle-id", + displayName: "test-display-name", + }), + ); + }); + + it("should create an Android app", async () => { + getAppPlatformStub.returns(AppPlatform.ANDROID); + const options = { packageName: "test-package-name" }; + await command.runner()("ANDROID", "test-display-name", options); + expect(sdkInitStub).to.have.been.calledOnceWith( + AppPlatform.ANDROID, + sinon.match({ + packageName: "test-package-name", + displayName: "test-display-name", + }), + ); + }); + + it("should create a Web app", async () => { + getAppPlatformStub.returns(AppPlatform.WEB); + const options = {}; + await command.runner()("WEB", "test-display-name", options); + expect(sdkInitStub).to.have.been.calledOnceWith( + AppPlatform.WEB, + sinon.match({ + displayName: "test-display-name", + }), + ); + }); + }); + + describe("logPostAppCreationInformation", () => { + it("should log basic app information", () => { + const appMetadata: apps.WebAppMetadata = { + name: "test-name", + projectId: "test-project-id", + appId: "test-app-id", + platform: AppPlatform.WEB, + displayName: "test-display-name", + }; + logPostAppCreationInformation(appMetadata, AppPlatform.WEB); + // No assertion needed here, we are just checking that it does not throw. + }); + + it("should log iOS specific information", () => { + const appMetadata: apps.IosAppMetadata = { + name: "test-name", + projectId: "test-project-id", + appId: "test-app-id", + platform: AppPlatform.IOS, + displayName: "test-display-name", + bundleId: "test-bundle-id", + appStoreId: "test-app-store-id", + }; + logPostAppCreationInformation(appMetadata, AppPlatform.IOS); + // No assertion needed here, we are just checking that it does not throw. + }); + + it("should log Android specific information", () => { + const appMetadata: apps.AndroidAppMetadata = { + name: "test-name", + projectId: "test-project-id", + appId: "test-app-id", + platform: AppPlatform.ANDROID, + displayName: "test-display-name", + packageName: "test-package-name", + }; + logPostAppCreationInformation(appMetadata, AppPlatform.ANDROID); + // No assertion needed here, we are just checking that it does not throw. + }); + }); +}); diff --git a/src/commands/apps-create.ts b/src/commands/apps-create.ts index 452ec2cf093..5cfe4c086ea 100644 --- a/src/commands/apps-create.ts +++ b/src/commands/apps-create.ts @@ -18,7 +18,7 @@ import { logger } from "../logger"; import { Options } from "../options"; import { select } from "../prompt"; -function logPostAppCreationInformation( +export function logPostAppCreationInformation( appMetadata: IosAppMetadata | AndroidAppMetadata | WebAppMetadata, appPlatform: AppPlatform, ): void { diff --git a/src/commands/apps-list.spec.ts b/src/commands/apps-list.spec.ts new file mode 100644 index 00000000000..a18c3352fc7 --- /dev/null +++ b/src/commands/apps-list.spec.ts @@ -0,0 +1,109 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as clc from "colorette"; +import * as Table from "cli-table3"; +import { Command } from "../command"; +import * as projectUtils from "../projectUtils"; +import * as apps from "../management/apps"; +import { AppMetadata, AppPlatform } from "../management/apps"; +import { command, logAppsList, logAppCount } from "./apps-list"; + +const NOT_SPECIFIED = clc.yellow("[Not specified]"); + +describe("apps:list", () => { + let sandbox: sinon.SinonSandbox; + let listFirebaseAppsStub: sinon.SinonStub; + let getAppPlatformStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); + listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); + getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should list all apps if no platform is provided", async () => { + const appsList: AppMetadata[] = [ + { name: "n1", projectId: "p1", appId: "1", displayName: "app1", platform: AppPlatform.IOS }, + { + name: "n2", + projectId: "p2", + appId: "2", + displayName: "app2", + platform: AppPlatform.ANDROID, + }, + ]; + listFirebaseAppsStub.resolves(appsList); + getAppPlatformStub.returns(AppPlatform.ANY); + + await command.runner()(undefined, {}); + + expect(listFirebaseAppsStub).to.have.been.calledOnceWith("test-project-id", AppPlatform.ANY); + }); + + it("should list apps for a specific platform", async () => { + const appsList: AppMetadata[] = [ + { name: "n1", projectId: "p1", appId: "1", displayName: "app1", platform: AppPlatform.IOS }, + ]; + listFirebaseAppsStub.resolves(appsList); + getAppPlatformStub.returns(AppPlatform.IOS); + + await command.runner()("IOS", {}); + + expect(listFirebaseAppsStub).to.have.been.calledOnceWith("test-project-id", AppPlatform.IOS); + }); + + it('should display "No apps found." if no apps exist', async () => { + listFirebaseAppsStub.resolves([]); + getAppPlatformStub.returns(AppPlatform.ANY); + + await command.runner()(undefined, {}); + + // No assertion needed here, we are just checking that it does not throw. + }); + }); + + describe("logAppsList", () => { + it("should print a table of apps", () => { + const appsList: AppMetadata[] = [ + { name: "n1", projectId: "p1", appId: "1", displayName: "app1", platform: AppPlatform.IOS }, + { + name: "n2", + projectId: "p2", + appId: "2", + displayName: "app2", + platform: AppPlatform.ANDROID, + }, + { name: "n3", projectId: "p3", appId: "3", platform: AppPlatform.WEB }, + ]; + const tableSpy = sandbox.spy(Table.prototype, "push"); + + logAppsList(appsList); + + expect(tableSpy.getCall(0).args[0]).to.deep.equal(["app1", "1", "IOS"]); + expect(tableSpy.getCall(1).args[0]).to.deep.equal(["app2", "2", "ANDROID"]); + expect(tableSpy.getCall(2).args[0]).to.deep.equal([NOT_SPECIFIED, "3", "WEB"]); + }); + }); + + describe("logAppCount", () => { + it("should print the total number of apps", () => { + logAppCount(5); + // No assertion needed here, we are just checking that it does not throw. + }); + + it("should not print if count is 0", () => { + logAppCount(0); + // No assertion needed here, we are just checking that it does not throw. + }); + }); +}); diff --git a/src/commands/apps-list.ts b/src/commands/apps-list.ts index c3f3fa4fd4a..6f0ffab2cf7 100644 --- a/src/commands/apps-list.ts +++ b/src/commands/apps-list.ts @@ -10,7 +10,7 @@ import { logger } from "../logger"; const NOT_SPECIFIED = clc.yellow("[Not specified]"); -function logAppsList(apps: AppMetadata[]): void { +export function logAppsList(apps: AppMetadata[]): void { if (apps.length === 0) { logger.info(clc.bold("No apps found.")); return; @@ -24,7 +24,7 @@ function logAppsList(apps: AppMetadata[]): void { logger.info(table.toString()); } -function logAppCount(count: number = 0): void { +export function logAppCount(count: number = 0): void { if (count === 0) { return; } diff --git a/src/commands/apps-sdkconfig.spec.ts b/src/commands/apps-sdkconfig.spec.ts new file mode 100644 index 00000000000..4f46fda2ef1 --- /dev/null +++ b/src/commands/apps-sdkconfig.spec.ts @@ -0,0 +1,186 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as fs from "fs-extra"; +import { Command } from "../command"; +import * as apps from "../management/apps"; +import { AppPlatform } from "../management/apps"; +import * as projectUtils from "../projectUtils"; +import * as projects from "../management/projects"; +import { FirebaseError } from "../error"; +import * as prompt from "../prompt"; +import { command } from "./apps-sdkconfig"; + +import { logger } from "../logger"; + +describe("apps:sdkconfig", () => { + let sandbox: sinon.SinonSandbox; + let needProjectIdStub: sinon.SinonStub; + let getAppConfigStub: sinon.SinonStub; + let getAppConfigFileStub: sinon.SinonStub; + let listFirebaseAppsStub: sinon.SinonStub; + let getOrPromptProjectStub: sinon.SinonStub; + let selectStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + let writeFileSyncStub: sinon.SinonStub; + let existsSyncStub: sinon.SinonStub; + let loggerInfoStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + needProjectIdStub = sandbox.stub(projectUtils, "needProjectId"); + getAppConfigStub = sandbox.stub(apps, "getAppConfig"); + getAppConfigFileStub = sandbox.stub(apps, "getAppConfigFile"); + listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); + getOrPromptProjectStub = sandbox.stub(projects, "getOrPromptProject"); + selectStub = sandbox.stub(prompt, "select"); + confirmStub = sandbox.stub(prompt, "confirm"); + writeFileSyncStub = sandbox.stub(fs, "writeFileSync"); + existsSyncStub = sandbox.stub(fs, "existsSync"); + loggerInfoStub = sandbox.stub(logger, "info"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should be a Command", () => { + expect(command).to.be.an.instanceOf(Command); + }); + + describe("action", () => { + it("should get config for a specified app", async () => { + getAppConfigStub.resolves({} as any); + getAppConfigFileStub.returns({ fileContents: "test-config" } as any); + + await command.runner()("IOS", "test-app-id", {}); + + expect(getAppConfigStub).to.have.been.calledOnceWith("test-app-id", AppPlatform.IOS); + expect(loggerInfoStub).to.have.been.calledWith("test-config"); + }); + + it("should get config for the only app when no app id is provided", async () => { + needProjectIdStub.returns("test-project-id"); + listFirebaseAppsStub.resolves([ + { name: "n1", projectId: "p1", appId: "test-app-id", platform: AppPlatform.ANDROID }, + ]); + getAppConfigStub.resolves({ fileContents: "test-config" }); + getAppConfigFileStub.returns({ fileContents: "test-config" }); + + await command.runner()("ANDROID", "", {}); + + expect(listFirebaseAppsStub).to.have.been.calledOnceWith( + "test-project-id", + AppPlatform.ANDROID, + ); + expect(getAppConfigStub).to.have.been.calledOnceWith("test-app-id", AppPlatform.ANDROID); + }); + + it("should prompt for app if multiple apps exist", async () => { + needProjectIdStub.returns("test-project-id"); + const app1 = { + name: "n1", + projectId: "p1", + appId: "app1", + platform: AppPlatform.IOS, + displayName: "app1", + }; + const app2 = { + name: "n2", + projectId: "p2", + appId: "app2", + platform: AppPlatform.IOS, + displayName: "app2", + }; + listFirebaseAppsStub.resolves([app1, app2]); + selectStub.resolves(app1); + getAppConfigStub.resolves({ fileContents: "test-config" }); + getAppConfigFileStub.returns({ fileContents: "test-config" } as any); + + await command.runner()("IOS", "", { nonInteractive: false }); + + expect(selectStub).to.have.been.calledOnce; + expect(getAppConfigStub).to.have.been.calledOnceWith("app1", AppPlatform.IOS); + }); + + it("should throw if multiple apps exist in non-interactive mode", async () => { + needProjectIdStub.returns("test-project-id"); + const app1 = { name: "n1", projectId: "p1", appId: "app1", platform: AppPlatform.IOS }; + const app2 = { name: "n2", projectId: "p2", appId: "app2", platform: AppPlatform.IOS }; + listFirebaseAppsStub.resolves([app1, app2]); + + await expect(command.runner()("IOS", "", { nonInteractive: true })).to.be.rejectedWith( + FirebaseError, + "Project test-project-id has multiple apps, must specify an app id.", + ); + }); + + it("should throw if no apps exist", async () => { + needProjectIdStub.returns("test-project-id"); + listFirebaseAppsStub.resolves([]); + + await expect(command.runner()("IOS", "", {})).to.be.rejectedWith( + FirebaseError, + "There are no IOS apps associated with this Firebase project", + ); + }); + + it("should write config to a file", async () => { + getAppConfigStub.resolves({}); + getAppConfigFileStub.returns({ fileName: "test.json", fileContents: "test-config" }); + existsSyncStub.returns(false); + + await command.runner()("WEB", "test-app-id", { out: "out.json" }); + + expect(writeFileSyncStub).to.have.been.calledOnceWith("out.json", "test-config"); + expect(loggerInfoStub).to.have.been.calledWith("App configuration is written in out.json"); + }); + + it("should overwrite existing file if confirmed", async () => { + getAppConfigStub.resolves({}); + getAppConfigFileStub.returns({ fileName: "test.json", fileContents: "test-config" }); + existsSyncStub.returns(true); + confirmStub.resolves(true); + + await command.runner()("WEB", "test-app-id", { out: "out.json" }); + + expect(confirmStub).to.have.been.calledOnce; + expect(writeFileSyncStub).to.have.been.calledOnceWith("out.json", "test-config"); + }); + + it("should not overwrite existing file if not confirmed", async () => { + getAppConfigStub.resolves({}); + getAppConfigFileStub.returns({ fileName: "test.json", fileContents: "test-config" }); + existsSyncStub.returns(true); + confirmStub.resolves(false); + + await command.runner()("WEB", "test-app-id", { out: "out.json" }); + + expect(confirmStub).to.have.been.calledOnce; + expect(writeFileSyncStub).to.not.have.been.called; + }); + + it("should throw if file exists in non-interactive mode", async () => { + getAppConfigStub.resolves({}); + getAppConfigFileStub.returns({ fileName: "test.json", fileContents: "test-config" }); + existsSyncStub.returns(true); + + await expect( + command.runner()("WEB", "test-app-id", { out: "out.json", nonInteractive: true }), + ).to.be.rejectedWith(FirebaseError, "out.json already exists"); + }); + + it("should prompt for project if not available", async () => { + needProjectIdStub.returns(undefined); + getOrPromptProjectStub.resolves({ projectId: "test-project-id" }); + listFirebaseAppsStub.resolves([ + { name: "n1", projectId: "p1", appId: "test-app-id", platform: AppPlatform.ANDROID }, + ]); + getAppConfigStub.resolves({ fileContents: "test-config" }); + getAppConfigFileStub.returns({ fileContents: "test-config" }); + + await command.runner()("ANDROID", "", {}); + + expect(getOrPromptProjectStub).to.have.been.calledOnce; + }); + }); +}); diff --git a/src/management/apps.ts b/src/management/apps.ts index 8acd8455c7e..6c66c6fb725 100644 --- a/src/management/apps.ts +++ b/src/management/apps.ts @@ -309,7 +309,7 @@ export interface AppConfigurationData { export interface AppAndroidShaData { name: string; shaHash: string; - certType: ShaCertificateType.SHA_1; + certType: ShaCertificateType; } export enum AppPlatform { From 18eb9e8f7852ffad4c638de04fff9d54ed0c91f3 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 29 Jul 2025 16:11:59 -0700 Subject: [PATCH 2/5] Stub requireAuth --- src/commands/apps-android-sha-create.spec.ts | 3 +++ src/commands/apps-android-sha-delete.spec.ts | 3 +++ src/commands/apps-android-sha-list.spec.ts | 3 +++ src/commands/apps-create.spec.ts | 3 +++ src/commands/apps-list.spec.ts | 3 +++ src/commands/apps-sdkconfig.spec.ts | 3 +++ 6 files changed, 18 insertions(+) diff --git a/src/commands/apps-android-sha-create.spec.ts b/src/commands/apps-android-sha-create.spec.ts index e9a672a023d..89b68db5b14 100644 --- a/src/commands/apps-android-sha-create.spec.ts +++ b/src/commands/apps-android-sha-create.spec.ts @@ -6,15 +6,18 @@ import * as apps from "../management/apps"; import { ShaCertificateType } from "../management/apps"; import * as utils from "../utils"; import { command, getCertHashType } from "./apps-android-sha-create"; +import * as auth from "../auth"; describe("apps:android:sha:create", () => { let sandbox: sinon.SinonSandbox; let needProjectIdStub: sinon.SinonStub; let createAppAndroidShaStub: sinon.SinonStub; let promiseWithSpinnerStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); createAppAndroidShaStub = sandbox .stub(apps, "createAppAndroidSha") diff --git a/src/commands/apps-android-sha-delete.spec.ts b/src/commands/apps-android-sha-delete.spec.ts index a2c2cb4edf7..c188262829d 100644 --- a/src/commands/apps-android-sha-delete.spec.ts +++ b/src/commands/apps-android-sha-delete.spec.ts @@ -5,15 +5,18 @@ import * as projectUtils from "../projectUtils"; import * as apps from "../management/apps"; import * as utils from "../utils"; import { command } from "./apps-android-sha-delete"; +import * as auth from "../auth"; describe("apps:android:sha:delete", () => { let sandbox: sinon.SinonSandbox; let needProjectIdStub: sinon.SinonStub; let deleteAppAndroidShaStub: sinon.SinonStub; let promiseWithSpinnerStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); deleteAppAndroidShaStub = sandbox.stub(apps, "deleteAppAndroidSha").resolves(); promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner").callThrough(); diff --git a/src/commands/apps-android-sha-list.spec.ts b/src/commands/apps-android-sha-list.spec.ts index e28c465f3fc..140e66a23dd 100644 --- a/src/commands/apps-android-sha-list.spec.ts +++ b/src/commands/apps-android-sha-list.spec.ts @@ -7,13 +7,16 @@ import * as apps from "../management/apps"; import { AppAndroidShaData, ShaCertificateType } from "../management/apps"; import * as utils from "../utils"; import { command, logCertificatesList, logCertificatesCount } from "./apps-android-sha-list"; +import * as auth from "../auth"; describe("apps:android:sha:list", () => { let sandbox: sinon.SinonSandbox; let promiseWithSpinnerStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); sandbox.stub(apps, "listAppAndroidSha"); promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner"); diff --git a/src/commands/apps-create.spec.ts b/src/commands/apps-create.spec.ts index 754d80581ec..0e61ac24f70 100644 --- a/src/commands/apps-create.spec.ts +++ b/src/commands/apps-create.spec.ts @@ -7,6 +7,7 @@ import * as apps from "../management/apps"; import { AppPlatform } from "../management/apps"; import * as prompt from "../prompt"; import { command, logPostAppCreationInformation } from "./apps-create"; +import * as auth from "../auth"; describe("apps:create", () => { let sandbox: sinon.SinonSandbox; @@ -14,9 +15,11 @@ describe("apps:create", () => { let getAppPlatformStub: sinon.SinonStub; let sdkInitStub: sinon.SinonStub; let selectStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); sdkInitStub = sandbox.stub(apps, "sdkInit").resolves({ diff --git a/src/commands/apps-list.spec.ts b/src/commands/apps-list.spec.ts index a18c3352fc7..b4731b1fdec 100644 --- a/src/commands/apps-list.spec.ts +++ b/src/commands/apps-list.spec.ts @@ -7,6 +7,7 @@ import * as projectUtils from "../projectUtils"; import * as apps from "../management/apps"; import { AppMetadata, AppPlatform } from "../management/apps"; import { command, logAppsList, logAppCount } from "./apps-list"; +import * as auth from "../auth"; const NOT_SPECIFIED = clc.yellow("[Not specified]"); @@ -14,9 +15,11 @@ describe("apps:list", () => { let sandbox: sinon.SinonSandbox; let listFirebaseAppsStub: sinon.SinonStub; let getAppPlatformStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); diff --git a/src/commands/apps-sdkconfig.spec.ts b/src/commands/apps-sdkconfig.spec.ts index 4f46fda2ef1..f64be431143 100644 --- a/src/commands/apps-sdkconfig.spec.ts +++ b/src/commands/apps-sdkconfig.spec.ts @@ -9,6 +9,7 @@ import * as projects from "../management/projects"; import { FirebaseError } from "../error"; import * as prompt from "../prompt"; import { command } from "./apps-sdkconfig"; +import * as auth from "../auth"; import { logger } from "../logger"; @@ -24,9 +25,11 @@ describe("apps:sdkconfig", () => { let writeFileSyncStub: sinon.SinonStub; let existsSyncStub: sinon.SinonStub; let loggerInfoStub: sinon.SinonStub; + let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); + requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId"); getAppConfigStub = sandbox.stub(apps, "getAppConfig"); getAppConfigFileStub = sandbox.stub(apps, "getAppConfigFile"); From c15e1b9b11e146b819b77009220bd8152276a531 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 29 Jul 2025 16:19:00 -0700 Subject: [PATCH 3/5] Lint --- src/commands/apps-android-sha-create.spec.ts | 5 ++--- src/commands/apps-android-sha-delete.spec.ts | 5 ++--- src/commands/apps-android-sha-list.spec.ts | 5 ++--- src/commands/apps-create.spec.ts | 5 ++--- src/commands/apps-list.spec.ts | 5 ++--- src/commands/apps-sdkconfig.spec.ts | 5 ++--- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/commands/apps-android-sha-create.spec.ts b/src/commands/apps-android-sha-create.spec.ts index 89b68db5b14..67d9118c26a 100644 --- a/src/commands/apps-android-sha-create.spec.ts +++ b/src/commands/apps-android-sha-create.spec.ts @@ -6,18 +6,17 @@ import * as apps from "../management/apps"; import { ShaCertificateType } from "../management/apps"; import * as utils from "../utils"; import { command, getCertHashType } from "./apps-android-sha-create"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; describe("apps:android:sha:create", () => { let sandbox: sinon.SinonSandbox; let needProjectIdStub: sinon.SinonStub; let createAppAndroidShaStub: sinon.SinonStub; let promiseWithSpinnerStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); createAppAndroidShaStub = sandbox .stub(apps, "createAppAndroidSha") diff --git a/src/commands/apps-android-sha-delete.spec.ts b/src/commands/apps-android-sha-delete.spec.ts index c188262829d..a2de617caf9 100644 --- a/src/commands/apps-android-sha-delete.spec.ts +++ b/src/commands/apps-android-sha-delete.spec.ts @@ -5,18 +5,17 @@ import * as projectUtils from "../projectUtils"; import * as apps from "../management/apps"; import * as utils from "../utils"; import { command } from "./apps-android-sha-delete"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; describe("apps:android:sha:delete", () => { let sandbox: sinon.SinonSandbox; let needProjectIdStub: sinon.SinonStub; let deleteAppAndroidShaStub: sinon.SinonStub; let promiseWithSpinnerStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); deleteAppAndroidShaStub = sandbox.stub(apps, "deleteAppAndroidSha").resolves(); promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner").callThrough(); diff --git a/src/commands/apps-android-sha-list.spec.ts b/src/commands/apps-android-sha-list.spec.ts index 140e66a23dd..f03a04d4992 100644 --- a/src/commands/apps-android-sha-list.spec.ts +++ b/src/commands/apps-android-sha-list.spec.ts @@ -7,16 +7,15 @@ import * as apps from "../management/apps"; import { AppAndroidShaData, ShaCertificateType } from "../management/apps"; import * as utils from "../utils"; import { command, logCertificatesList, logCertificatesCount } from "./apps-android-sha-list"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; describe("apps:android:sha:list", () => { let sandbox: sinon.SinonSandbox; let promiseWithSpinnerStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); sandbox.stub(apps, "listAppAndroidSha"); promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner"); diff --git a/src/commands/apps-create.spec.ts b/src/commands/apps-create.spec.ts index 0e61ac24f70..f10811bdf15 100644 --- a/src/commands/apps-create.spec.ts +++ b/src/commands/apps-create.spec.ts @@ -7,7 +7,7 @@ import * as apps from "../management/apps"; import { AppPlatform } from "../management/apps"; import * as prompt from "../prompt"; import { command, logPostAppCreationInformation } from "./apps-create"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; describe("apps:create", () => { let sandbox: sinon.SinonSandbox; @@ -15,11 +15,10 @@ describe("apps:create", () => { let getAppPlatformStub: sinon.SinonStub; let sdkInitStub: sinon.SinonStub; let selectStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); sdkInitStub = sandbox.stub(apps, "sdkInit").resolves({ diff --git a/src/commands/apps-list.spec.ts b/src/commands/apps-list.spec.ts index b4731b1fdec..1b3b99cd343 100644 --- a/src/commands/apps-list.spec.ts +++ b/src/commands/apps-list.spec.ts @@ -7,7 +7,7 @@ import * as projectUtils from "../projectUtils"; import * as apps from "../management/apps"; import { AppMetadata, AppPlatform } from "../management/apps"; import { command, logAppsList, logAppCount } from "./apps-list"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; const NOT_SPECIFIED = clc.yellow("[Not specified]"); @@ -15,11 +15,10 @@ describe("apps:list", () => { let sandbox: sinon.SinonSandbox; let listFirebaseAppsStub: sinon.SinonStub; let getAppPlatformStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); getAppPlatformStub = sandbox.stub(apps, "getAppPlatform"); diff --git a/src/commands/apps-sdkconfig.spec.ts b/src/commands/apps-sdkconfig.spec.ts index f64be431143..52802b0433c 100644 --- a/src/commands/apps-sdkconfig.spec.ts +++ b/src/commands/apps-sdkconfig.spec.ts @@ -9,7 +9,7 @@ import * as projects from "../management/projects"; import { FirebaseError } from "../error"; import * as prompt from "../prompt"; import { command } from "./apps-sdkconfig"; -import * as auth from "../auth"; +import * as auth from "../requireAuth"; import { logger } from "../logger"; @@ -25,11 +25,10 @@ describe("apps:sdkconfig", () => { let writeFileSyncStub: sinon.SinonStub; let existsSyncStub: sinon.SinonStub; let loggerInfoStub: sinon.SinonStub; - let requireAuthStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); - requireAuthStub = sandbox.stub(auth, "requireAuth").resolves(); + sandbox.stub(auth, "requireAuth").resolves(); needProjectIdStub = sandbox.stub(projectUtils, "needProjectId"); getAppConfigStub = sandbox.stub(apps, "getAppConfig"); getAppConfigFileStub = sandbox.stub(apps, "getAppConfigFile"); From 046f691fe7e5ac7305394869a5e4ea72b45f6565 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 5 Aug 2025 09:31:10 -0700 Subject: [PATCH 4/5] Jules apps tests fix (#8942) * Fix apptesting enablement (#8905) * Fix an issue with apptesting enablement Co-authored-by: Joe Hanley * Fix issue where login didnt work as expected on studio (#8914) * Fix issue where login didnt work as expected on studio * Update src/commands/login.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * 14.11.2 * [firebase-release] Removed change log and reset repo after 14.11.2 release * Enable FDC API in `firebase init dataconnect` for Spark projects (#8927) * feat: address pr comments on app-* commands * Fetch active Firebase Project from Studio Workspace when running in Studio (#8904) * Disable broken VSCode integration tests (#8934) * Add userinfo.email scope when in studio (#8935) * Add userinfo.email scope when in studio * Update src/requireAuth.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/requireAuth.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat: Display Firestore database edition (#8926) * feat: Display Firestore database edition Adds the 'Edition' to the output of the `firestore:databases:get` command. The `Database` resource in the Firestore API now includes a `databaseEdition` field. This change updates the `DatabaseResp` type to include this new field and modifies the `prettyPrintDatabase` function to display the database edition in the output table. The possible values for the edition are `STANDARD` and `ENTERPRISE`. If the edition is not specified or is `DATABASE_EDITION_UNSPECIFIED`, it will default to `STANDARD`. * feat: Display Firestore database edition Adds the 'Edition' to the output of the `firestore:databases:get` command. The `Database` resource in the Firestore API now includes a `databaseEdition` field. This change updates the `DatabaseResp` type to include this new field and modifies the `prettyPrintDatabase` function to display the database edition in the output table. The possible values for the edition are `STANDARD` and `ENTERPRISE`. If the edition is not specified or is `DATABASE_EDITION_UNSPECIFIED`, it will default to `STANDARD`. * feat: Display Firestore database edition Adds the 'Edition' to the output of the `firestore:databases:get` command. The `Database` resource in the Firestore API now includes a `databaseEdition` field. This change updates the `DatabaseResp` type to include this new field and modifies the `prettyPrintDatabase` function to display the database edition in the output table. The possible values for the edition are `STANDARD` and `ENTERPRISE`. If the edition is not specified or is `DATABASE_EDITION_UNSPECIFIED`, it will default to `STANDARD`. Also refactors the tests for `prettyPrintDatabase` to improve readability and maintainability. * feat: Display Firestore database edition Adds the 'Edition' to the output of the `firestore:databases:get` command. The `Database` resource in the Firestore API now includes a `databaseEdition` field. This change updates the `DatabaseResp` type to include this new field and modifies the `prettyPrintDatabase` function to display the database edition in the output table. The possible values for the edition are `STANDARD` and `ENTERPRISE`. If the edition is not specified or is `DATABASE_EDITION_UNSPECIFIED`, it will default to `STANDARD`. Also refactors the tests for `prettyPrintDatabase` to improve readability and maintainability and adds a test case for the `STANDARD` edition. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * Formatted --------- Co-authored-by: Jake Ouellette Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Google Open Source Bot Co-authored-by: Fred Zhang Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Sam Edson Co-authored-by: Ehsan --- .github/workflows/node-test.yml | 114 +++++----- CHANGELOG.md | 3 +- npm-shrinkwrap.json | 4 +- package.json | 2 +- src/api.ts | 2 + .../ensureProjectConfigured.spec.ts | 26 ++- src/apptesting/ensureProjectConfigured.ts | 9 +- src/command.ts | 18 +- src/commands/apps-android-sha-create.spec.ts | 15 +- src/commands/apps-android-sha-delete.spec.ts | 12 +- src/commands/apps-android-sha-list.spec.ts | 41 ++-- src/commands/apps-create.spec.ts | 34 ++- src/commands/login.ts | 4 +- src/commands/use.ts | 7 + src/dataconnect/ensureApis.ts | 1 + src/firestore/api-types.ts | 7 + src/firestore/pretty-print.spec.ts | 76 +++++++ src/firestore/pretty-print.ts | 6 + src/management/studio.spec.ts | 195 ++++++++++++++++++ src/management/studio.ts | 158 ++++++++++++++ src/requireAuth.ts | 6 +- src/scopes.ts | 1 + 22 files changed, 635 insertions(+), 106 deletions(-) create mode 100644 src/management/studio.spec.ts create mode 100644 src/management/studio.ts diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 7eaee3433ef..fcb46816eb9 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -67,63 +67,63 @@ jobs: - uses: codecov/codecov-action@v3 if: matrix.node-version == '20' - vscode_integration: - runs-on: macos-latest - strategy: - matrix: - node-version: - - "20" - - env: - FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache - # This overrides the binary which runs firebase commands in the extension tasks such as emulator start. - # Currently, CI fails to start with npx so we change it to the global firebase binary. - FIREBASE_BINARY: firebase - - steps: - - name: Setup Java JDK - uses: actions/setup-java@v3.3.0 - with: - java-version: 17 - distribution: temurin - - - uses: actions/checkout@v4 - - name: Setup Chrome - uses: browser-actions/setup-chrome@v1.7.2 - with: - install-dependencies: true - install-chromedriver: true - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: npm - cache-dependency-path: firebase-vscode/package-lock.json - - # TODO temporary workaround for GitHub Actions CI issue: - # npm ERR! Your cache folder contains root-owned files, due to a bug in - # npm ERR! previous versions of npm which has since been addressed. - - run: sudo chown -R 501:20 "/Users/runner/.npm" || exit 1 - - run: npm ci - - run: npm install - working-directory: firebase-vscode - - run: npm run build - working-directory: firebase-vscode - - - run: npm i -g firebase-tools@latest - - - uses: GabrielBB/xvfb-action@v1 - with: - run: npm run test:e2e - working-directory: firebase-vscode - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: firebase-vscode/src/test/screenshots - - - uses: codecov/codecov-action@v3 - if: matrix.node-version == '20' + # vscode_integration: + # runs-on: macos-latest + # strategy: + # matrix: + # node-version: + # - "20" + + # env: + # FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache + # # This overrides the binary which runs firebase commands in the extension tasks such as emulator start. + # # Currently, CI fails to start with npx so we change it to the global firebase binary. + # FIREBASE_BINARY: firebase + + # steps: + # - name: Setup Java JDK + # uses: actions/setup-java@v3.3.0 + # with: + # java-version: 17 + # distribution: temurin + + # - uses: actions/checkout@v4 + # - name: Setup Chrome + # uses: browser-actions/setup-chrome@v1.7.2 + # with: + # install-dependencies: true + # install-chromedriver: true + # - uses: actions/setup-node@v3 + # with: + # node-version: ${{ matrix.node-version }} + # cache: npm + # cache-dependency-path: firebase-vscode/package-lock.json + + # # TODO temporary workaround for GitHub Actions CI issue: + # # npm ERR! Your cache folder contains root-owned files, due to a bug in + # # npm ERR! previous versions of npm which has since been addressed. + # - run: sudo chown -R 501:20 "/Users/runner/.npm" || exit 1 + # - run: npm ci + # - run: npm install + # working-directory: firebase-vscode + # - run: npm run build + # working-directory: firebase-vscode + + # - run: npm i -g firebase-tools@latest + + # - uses: GabrielBB/xvfb-action@v1 + # with: + # run: npm run test:e2e + # working-directory: firebase-vscode + + # - uses: actions/upload-artifact@v4 + # if: failure() + # with: + # name: screenshots + # path: firebase-vscode/src/test/screenshots + + # - uses: codecov/codecov-action@v3 + # if: matrix.node-version == '20' unit: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 102a77301fb..419d831e7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1 @@ -- Fixed ext:export command so that it correctly returns system params in the .env file (#8881) -- Fixed an issue where the MCP server could not successfully use Application Default Credentials. (#8896) +- Fixed an issue where `firebase init dataconnect` didn't enable the Data Connect API by default (#8927). diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3f298b64fa6..f9e48967921 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "firebase-tools", - "version": "14.11.1", + "version": "14.11.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "firebase-tools", - "version": "14.11.1", + "version": "14.11.2", "license": "MIT", "dependencies": { "@electric-sql/pglite": "^0.3.3", diff --git a/package.json b/package.json index ce64041c414..3400845fc82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebase-tools", - "version": "14.11.1", + "version": "14.11.2", "description": "Command-Line Interface for Firebase", "main": "./lib/index.js", "bin": { diff --git a/src/api.ts b/src/api.ts index c4235643e91..c7118000dde 100755 --- a/src/api.ts +++ b/src/api.ts @@ -143,6 +143,8 @@ export const cloudRunApiOrigin = () => utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com"); export const serviceUsageOrigin = () => utils.envOverride("FIREBASE_SERVICE_USAGE_URL", "https://serviceusage.googleapis.com"); +export const studioApiOrigin = () => + utils.envOverride("FIREBASE_STUDIO_URL", "https://monospace-pa.googleapis.com"); export const githubOrigin = () => utils.envOverride("GITHUB_URL", "https://github.com"); export const githubApiOrigin = () => utils.envOverride("GITHUB_API_URL", "https://api.github.com"); diff --git a/src/apptesting/ensureProjectConfigured.spec.ts b/src/apptesting/ensureProjectConfigured.spec.ts index 584c0b9f6fc..217c23a7c2e 100644 --- a/src/apptesting/ensureProjectConfigured.spec.ts +++ b/src/apptesting/ensureProjectConfigured.spec.ts @@ -41,13 +41,29 @@ describe("ensureProjectConfigured", () => { await apptesting.ensureProjectConfigured(projectId); - expect(ensureApiEnabledStub).to.be.calledThrice; - expect(ensureApiEnabledStub).to.be.calledWith(projectId, sinon.match.any, "storage", false); - expect(ensureApiEnabledStub).to.be.calledWith(projectId, sinon.match.any, "run", false); + expect(ensureApiEnabledStub).to.be.callCount(4); expect(ensureApiEnabledStub).to.be.calledWith( projectId, - sinon.match.any, - "artifactregistry", + "https://firebaseapptesting.googleapis.com", + "Firebase App Testing", + false, + ); + expect(ensureApiEnabledStub).to.be.calledWith( + projectId, + "https://run.googleapis.com", + "Cloud Run", + false, + ); + expect(ensureApiEnabledStub).to.be.calledWith( + projectId, + "https://storage.googleapis.com", + "Cloud Storage", + false, + ); + expect(ensureApiEnabledStub).to.be.calledWith( + projectId, + "https://artifactregistry.googleapis.com", + "Artifact Registry", false, ); }); diff --git a/src/apptesting/ensureProjectConfigured.ts b/src/apptesting/ensureProjectConfigured.ts index 5349eeef4ea..1b5fa964cd2 100644 --- a/src/apptesting/ensureProjectConfigured.ts +++ b/src/apptesting/ensureProjectConfigured.ts @@ -1,6 +1,6 @@ import { addServiceAccountToRoles, serviceAccountHasRoles } from "../gcp/resourceManager"; import { ensure } from "../ensureApiEnabled"; -import { appTestingOrigin } from "../api"; +import { appTestingOrigin, artifactRegistryDomain, cloudRunApiOrigin, storageOrigin } from "../api"; import { logBullet, logWarning } from "../utils"; import { FirebaseError, getErrStatus } from "../error"; import * as iam from "../gcp/iam"; @@ -10,9 +10,10 @@ const TEST_RUNNER_ROLE = "roles/firebaseapptesting.testRunner"; const TEST_RUNNER_SERVICE_ACCOUNT_NAME = "firebaseapptesting-test-runner"; export async function ensureProjectConfigured(projectId: string) { - await ensure(projectId, appTestingOrigin(), "storage", false); - await ensure(projectId, appTestingOrigin(), "run", false); - await ensure(projectId, appTestingOrigin(), "artifactregistry", false); + await ensure(projectId, appTestingOrigin(), "Firebase App Testing", false); + await ensure(projectId, cloudRunApiOrigin(), "Cloud Run", false); + await ensure(projectId, storageOrigin(), "Cloud Storage", false); + await ensure(projectId, artifactRegistryDomain(), "Artifact Registry", false); const serviceAccount = runnerServiceAccount(projectId); const serviceAccountExistsAndIsRunner = await serviceAccountHasRoles( diff --git a/src/command.ts b/src/command.ts index 325654c7953..b32e03ff540 100644 --- a/src/command.ts +++ b/src/command.ts @@ -11,9 +11,11 @@ import { detectProjectRoot } from "./detectProjectRoot"; import { trackEmulator, trackGA4 } from "./track"; import { selectAccount, setActiveAccount } from "./auth"; import { getProject } from "./management/projects"; +import { reconcileStudioFirebaseProject } from "./management/studio"; import { requireAuth } from "./requireAuth"; import { Options } from "./options"; import { useConsoleLoggers } from "./logger"; +import { isFirebaseStudio } from "./env"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ActionFunction = (...args: any[]) => any; @@ -338,7 +340,7 @@ export class Command { setActiveAccount(options, activeAccount); } - this.applyRC(options); + await this.applyRC(options); if (options.project) { await this.resolveProjectIdentifiers(options); validateProjectId(options.projectId); @@ -350,12 +352,22 @@ export class Command { * @param options the command options object. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private applyRC(options: Options): void { + private async applyRC(options: Options) { const rc = loadRC(options); options.rc = rc; - const activeProject = options.projectRoot + let activeProject = options.projectRoot ? (configstore.get("activeProjects") ?? {})[options.projectRoot] : undefined; + + // Only fetch the Studio Workspace project if we're running in Firebase + // Studio. If the user passes the project via --project, it should take + // priority. + // If this is the firebase use command, don't worry about reconciling - the user is changing it anyway + const isUseCommand = process.argv.includes("use"); + if (isFirebaseStudio() && !options.project && !isUseCommand) { + activeProject = await reconcileStudioFirebaseProject(options, activeProject); + } + options.project = options.project ?? activeProject; // support deprecated "firebase" key in firebase.json if (options.config && !options.project) { diff --git a/src/commands/apps-android-sha-create.spec.ts b/src/commands/apps-android-sha-create.spec.ts index 67d9118c26a..7533909fce0 100644 --- a/src/commands/apps-android-sha-create.spec.ts +++ b/src/commands/apps-android-sha-create.spec.ts @@ -35,10 +35,21 @@ describe("apps:android:sha:create", () => { describe("action", () => { it("should create a SHA certificate", async () => { const shaHash = "A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2:C3:D4:E5:F6:A1:B2"; // SHA-1 - await command.runner()("test-app-id", shaHash, {}); + const options = { + user: { email: "test@example.com" }, + tokens: { access_token: "an_access_token" }, + }; + await command.runner()("test-app-id", shaHash, options); expect(needProjectIdStub).to.have.been.calledOnce; - expect(createAppAndroidShaStub).to.have.been.calledOnce; + expect(createAppAndroidShaStub).to.have.been.calledOnceWith( + "test-project-id", + "test-app-id", + { + shaHash: shaHash, + certType: ShaCertificateType.SHA_1, + }, + ); const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; expect(spinnerText).to.include("Creating Android SHA certificate"); }); diff --git a/src/commands/apps-android-sha-delete.spec.ts b/src/commands/apps-android-sha-delete.spec.ts index a2de617caf9..5b06d555038 100644 --- a/src/commands/apps-android-sha-delete.spec.ts +++ b/src/commands/apps-android-sha-delete.spec.ts @@ -31,10 +31,18 @@ describe("apps:android:sha:delete", () => { describe("action", () => { it("should delete a SHA certificate", async () => { - await command.runner()("test-app-id", "test-sha-id", {}); + const options = { + user: { email: "test@example.com" }, + tokens: { access_token: "an_access_token" }, + }; + await command.runner()("test-app-id", "test-sha-id", options); expect(needProjectIdStub).to.have.been.calledOnce; - expect(deleteAppAndroidShaStub).to.have.been.calledOnce; + expect(deleteAppAndroidShaStub).to.have.been.calledOnceWith( + "test-project-id", + "test-app-id", + "test-sha-id", + ); const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; expect(spinnerText).to.include("Deleting Android SHA certificate hash"); }); diff --git a/src/commands/apps-android-sha-list.spec.ts b/src/commands/apps-android-sha-list.spec.ts index f03a04d4992..3b18c5c381d 100644 --- a/src/commands/apps-android-sha-list.spec.ts +++ b/src/commands/apps-android-sha-list.spec.ts @@ -5,20 +5,21 @@ import { Command } from "../command"; import * as projectUtils from "../projectUtils"; import * as apps from "../management/apps"; import { AppAndroidShaData, ShaCertificateType } from "../management/apps"; -import * as utils from "../utils"; import { command, logCertificatesList, logCertificatesCount } from "./apps-android-sha-list"; import * as auth from "../requireAuth"; +import { logger } from "../logger"; describe("apps:android:sha:list", () => { let sandbox: sinon.SinonSandbox; - let promiseWithSpinnerStub: sinon.SinonStub; + let listAppAndroidShaStub: sinon.SinonStub; + let loggerInfoStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); sandbox.stub(auth, "requireAuth").resolves(); sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); - sandbox.stub(apps, "listAppAndroidSha"); - promiseWithSpinnerStub = sandbox.stub(utils, "promiseWithSpinner"); + listAppAndroidShaStub = sandbox.stub(apps, "listAppAndroidSha"); + loggerInfoStub = sandbox.stub(logger, "info"); }); afterEach(() => { @@ -30,6 +31,11 @@ describe("apps:android:sha:list", () => { }); describe("action", () => { + const options = { + user: { email: "test@example.com" }, + tokens: { access_token: "an_access_token" }, + }; + it("should list SHA certificates", async () => { const certificates: AppAndroidShaData[] = [ { @@ -43,21 +49,22 @@ describe("apps:android:sha:list", () => { certType: ShaCertificateType.SHA_256, }, ]; - promiseWithSpinnerStub.resolves(certificates); + listAppAndroidShaStub.resolves(certificates); - await command.runner()("test-app-id", {}); + await command.runner()("test-app-id", options); - expect(promiseWithSpinnerStub).to.have.been.calledOnce; - const spinnerText = promiseWithSpinnerStub.getCall(0).args[1]; - expect(spinnerText).to.include("Preparing the list"); + expect(listAppAndroidShaStub).to.have.been.calledOnceWith("test-project-id", "test-app-id"); + expect(loggerInfoStub).to.have.been.calledWith(sinon.match("s1")); + expect(loggerInfoStub).to.have.been.calledWith(sinon.match("s2")); }); it('should display "No SHA certificate hashes found." if no certificates exist', async () => { - promiseWithSpinnerStub.resolves([]); + listAppAndroidShaStub.resolves([]); - await command.runner()("test-app-id", {}); + await command.runner()("test-app-id", options); - // No assertion needed here, we are just checking that it does not throw. + expect(listAppAndroidShaStub).to.have.been.calledOnceWith("test-project-id", "test-app-id"); + expect(loggerInfoStub).to.have.been.calledWith("No SHA certificate hashes found."); }); }); @@ -92,17 +99,23 @@ describe("apps:android:sha:list", () => { ShaCertificateType.SHA_256, ]); }); + + it('should print "No SHA certificate hashes found." if no certificates exist', () => { + logCertificatesList([]); + expect(loggerInfoStub).to.have.been.calledWith("No SHA certificate hashes found."); + }); }); describe("logCertificatesCount", () => { it("should print the total number of certificates", () => { logCertificatesCount(5); - // No assertion needed here, we are just checking that it does not throw. + expect(loggerInfoStub).to.have.been.calledWith(""); + expect(loggerInfoStub).to.have.been.calledWith("5 SHA hash(es) total."); }); it("should not print if count is 0", () => { logCertificatesCount(0); - // No assertion needed here, we are just checking that it does not throw. + expect(loggerInfoStub).to.not.have.been.called; }); }); }); diff --git a/src/commands/apps-create.spec.ts b/src/commands/apps-create.spec.ts index f10811bdf15..847fd7d4411 100644 --- a/src/commands/apps-create.spec.ts +++ b/src/commands/apps-create.spec.ts @@ -8,6 +8,7 @@ import { AppPlatform } from "../management/apps"; import * as prompt from "../prompt"; import { command, logPostAppCreationInformation } from "./apps-create"; import * as auth from "../requireAuth"; +import { logger } from "../logger"; describe("apps:create", () => { let sandbox: sinon.SinonSandbox; @@ -15,6 +16,7 @@ describe("apps:create", () => { let getAppPlatformStub: sinon.SinonStub; let sdkInitStub: sinon.SinonStub; let selectStub: sinon.SinonStub; + let loggerInfoStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); @@ -29,6 +31,7 @@ describe("apps:create", () => { displayName: "test-display-name", }); selectStub = sandbox.stub(prompt, "select"); + loggerInfoStub = sandbox.stub(logger, "info"); }); afterEach(() => { @@ -40,10 +43,16 @@ describe("apps:create", () => { }); describe("action", () => { + const options = { + nonInteractive: false, + user: { email: "test@example.com" }, + tokens: { access_token: "an_access_token" }, + }; + it("should throw if platform is not provided in non-interactive mode", async () => { getAppPlatformStub.returns(AppPlatform.ANY); - const options = { nonInteractive: true }; - await expect(command.runner()("", undefined, options)).to.be.rejectedWith( + const nonInteractiveOptions = { ...options, nonInteractive: true }; + await expect(command.runner()("", undefined, nonInteractiveOptions)).to.be.rejectedWith( FirebaseError, "App platform must be provided", ); @@ -54,7 +63,6 @@ describe("apps:create", () => { getAppPlatformStub.withArgs("").returns(AppPlatform.ANY); getAppPlatformStub.withArgs("IOS").returns(AppPlatform.IOS); selectStub.resolves("IOS"); - const options = { nonInteractive: false }; await command.runner()("", "test-display-name", options); expect(selectStub).to.have.been.calledOnce; expect(sdkInitStub).to.have.been.calledOnceWith( @@ -68,8 +76,8 @@ describe("apps:create", () => { it("should create an iOS app", async () => { getAppPlatformStub.returns(AppPlatform.IOS); - const options = { bundleId: "test-bundle-id" }; - await command.runner()("IOS", "test-display-name", options); + const iosOptions = { ...options, bundleId: "test-bundle-id" }; + await command.runner()("IOS", "test-display-name", iosOptions); expect(sdkInitStub).to.have.been.calledOnceWith( AppPlatform.IOS, sinon.match({ @@ -81,8 +89,8 @@ describe("apps:create", () => { it("should create an Android app", async () => { getAppPlatformStub.returns(AppPlatform.ANDROID); - const options = { packageName: "test-package-name" }; - await command.runner()("ANDROID", "test-display-name", options); + const androidOptions = { ...options, packageName: "test-package-name" }; + await command.runner()("ANDROID", "test-display-name", androidOptions); expect(sdkInitStub).to.have.been.calledOnceWith( AppPlatform.ANDROID, sinon.match({ @@ -94,7 +102,6 @@ describe("apps:create", () => { it("should create a Web app", async () => { getAppPlatformStub.returns(AppPlatform.WEB); - const options = {}; await command.runner()("WEB", "test-display-name", options); expect(sdkInitStub).to.have.been.calledOnceWith( AppPlatform.WEB, @@ -115,7 +122,7 @@ describe("apps:create", () => { displayName: "test-display-name", }; logPostAppCreationInformation(appMetadata, AppPlatform.WEB); - // No assertion needed here, we are just checking that it does not throw. + expect(loggerInfoStub).to.have.been.calledWith(sinon.match("App ID: test-app-id")); }); it("should log iOS specific information", () => { @@ -129,7 +136,10 @@ describe("apps:create", () => { appStoreId: "test-app-store-id", }; logPostAppCreationInformation(appMetadata, AppPlatform.IOS); - // No assertion needed here, we are just checking that it does not throw. + expect(loggerInfoStub).to.have.been.calledWith(sinon.match("Bundle ID: test-bundle-id")); + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match("App Store ID: test-app-store-id"), + ); }); it("should log Android specific information", () => { @@ -142,7 +152,9 @@ describe("apps:create", () => { packageName: "test-package-name", }; logPostAppCreationInformation(appMetadata, AppPlatform.ANDROID); - // No assertion needed here, we are just checking that it does not throw. + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match("Package name: test-package-name"), + ); }); }); }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 5db8ceb1d5c..9c4dbc768de 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -28,7 +28,7 @@ export const command = new Command("login") const user = options.user as User | undefined; const tokens = options.tokens as Tokens | undefined; - if (user && tokens && !options.reauth) { + if (user && tokens?.refresh_token && !options.reauth) { logger.info("Already logged in as", clc.bold(user.email)); return user; } @@ -53,7 +53,7 @@ export const command = new Command("login") if (geminiUsage || collectUsage) { logger.info(); utils.logBullet( - "To change your the preference at any time, run `firebase logout` and `firebase login` again.", + "To change your preferences at any time, run `firebase logout` and `firebase login` again.", ); } } diff --git a/src/commands/use.ts b/src/commands/use.ts index 939c478b891..a8e2bc905ea 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -2,6 +2,7 @@ import * as clc from "colorette"; import { Command } from "../command"; import { getProject, listFirebaseProjects, ProjectInfo } from "../management/projects"; +import { updateStudioFirebaseProject } from "../management/studio"; import { logger } from "../logger"; import { Options } from "../options"; import { input, select } from "../prompt"; @@ -10,6 +11,7 @@ import { validateProjectId } from "../command"; import * as utils from "../utils"; import { FirebaseError } from "../error"; import { RC } from "../rc"; +import { isFirebaseStudio } from "../env"; function listAliases(options: Options) { if (options.rc.hasProjects) { @@ -49,6 +51,11 @@ export async function setNewActive( throw new FirebaseError("Invalid project selection, " + verifyMessage(projectOrAlias)); } + // Only update if running in Firebase Studio + if (isFirebaseStudio()) { + await updateStudioFirebaseProject(resolvedProject); + } + if (aliasOpt) { // firebase use [project] --alias [alias] if (!project) { diff --git a/src/dataconnect/ensureApis.ts b/src/dataconnect/ensureApis.ts index 95fd2e8e6ef..67e133c20b5 100644 --- a/src/dataconnect/ensureApis.ts +++ b/src/dataconnect/ensureApis.ts @@ -12,6 +12,7 @@ export async function ensureApis(projectId: string): Promise { export async function ensureSparkApis(projectId: string): Promise { // These are the APIs that can be enabled without a billing account. await ensure(projectId, api.cloudSQLAdminOrigin(), prefix); + await ensure(projectId, api.dataconnectOrigin(), prefix); } export async function ensureGIFApis(projectId: string): Promise { diff --git a/src/firestore/api-types.ts b/src/firestore/api-types.ts index 701d7e4097c..826cc1ff401 100644 --- a/src/firestore/api-types.ts +++ b/src/firestore/api-types.ts @@ -121,6 +121,12 @@ export enum PointInTimeRecoveryEnablement { DISABLED = "POINT_IN_TIME_RECOVERY_DISABLED", } +export enum DatabaseEdition { + DATABASE_EDITION_UNSPECIFIED = "DATABASE_EDITION_UNSPECIFIED", + STANDARD = "STANDARD", + ENTERPRISE = "ENTERPRISE", +} + export interface DatabaseReq { locationId?: string; type?: DatabaseType; @@ -155,6 +161,7 @@ export interface DatabaseResp { versionRetentionPeriod: string; earliestVersionTime: string; cmekConfig?: CmekConfig; + databaseEdition?: DatabaseEdition; } export interface RestoreDatabaseReq { diff --git a/src/firestore/pretty-print.spec.ts b/src/firestore/pretty-print.spec.ts index 81b637d902c..17eb755b34c 100644 --- a/src/firestore/pretty-print.spec.ts +++ b/src/firestore/pretty-print.spec.ts @@ -1,6 +1,8 @@ import { expect } from "chai"; +import * as sinon from "sinon"; import * as API from "./api-types"; import { PrettyPrint } from "./pretty-print"; +import { logger } from "../logger"; const printer = new PrettyPrint(); @@ -92,3 +94,77 @@ describe("prettyStringArray", () => { expect(printer.prettyStringArray([])).to.equal(""); }); }); + +describe("prettyPrintDatabase", () => { + let loggerInfoStub: sinon.SinonStub; + + const BASE_DATABASE: API.DatabaseResp = { + name: "projects/my-project/databases/(default)", + uid: "uid", + createTime: "2020-01-01T00:00:00Z", + updateTime: "2020-01-01T00:00:00Z", + locationId: "us-central1", + type: API.DatabaseType.FIRESTORE_NATIVE, + concurrencyMode: "OPTIMISTIC", + appEngineIntegrationMode: "ENABLED", + keyPrefix: "prefix", + deleteProtectionState: API.DatabaseDeleteProtectionState.DISABLED, + pointInTimeRecoveryEnablement: API.PointInTimeRecoveryEnablement.DISABLED, + etag: "etag", + versionRetentionPeriod: "1h", + earliestVersionTime: "2020-01-01T00:00:00Z", + }; + + beforeEach(() => { + loggerInfoStub = sinon.stub(logger, "info"); + }); + + afterEach(() => { + loggerInfoStub.restore(); + }); + + it("should display STANDARD edition when databaseEdition is not provided", () => { + const database: API.DatabaseResp = { ...BASE_DATABASE }; + + printer.prettyPrintDatabase(database); + + expect(loggerInfoStub.firstCall.args[0]).to.include("Edition"); + expect(loggerInfoStub.firstCall.args[0]).to.include("STANDARD"); + }); + + it("should display STANDARD edition when databaseEdition is UNSPECIFIED", () => { + const database: API.DatabaseResp = { + ...BASE_DATABASE, + databaseEdition: API.DatabaseEdition.DATABASE_EDITION_UNSPECIFIED, + }; + + printer.prettyPrintDatabase(database); + + expect(loggerInfoStub.firstCall.args[0]).to.include("Edition"); + expect(loggerInfoStub.firstCall.args[0]).to.include("STANDARD"); + }); + + it("should display ENTERPRISE edition when databaseEdition is ENTERPRISE", () => { + const database: API.DatabaseResp = { + ...BASE_DATABASE, + databaseEdition: API.DatabaseEdition.ENTERPRISE, + }; + + printer.prettyPrintDatabase(database); + + expect(loggerInfoStub.firstCall.args[0]).to.include("Edition"); + expect(loggerInfoStub.firstCall.args[0]).to.include("ENTERPRISE"); + }); + + it("should display STANDARD edition when databaseEdition is STANDARD", () => { + const database: API.DatabaseResp = { + ...BASE_DATABASE, + databaseEdition: API.DatabaseEdition.STANDARD, + }; + + printer.prettyPrintDatabase(database); + + expect(loggerInfoStub.firstCall.args[0]).to.include("Edition"); + expect(loggerInfoStub.firstCall.args[0]).to.include("STANDARD"); + }); +}); diff --git a/src/firestore/pretty-print.ts b/src/firestore/pretty-print.ts index e285a1c8b5f..f3f265ea93b 100644 --- a/src/firestore/pretty-print.ts +++ b/src/firestore/pretty-print.ts @@ -59,11 +59,17 @@ export class PrettyPrint { colWidths: [30, colValueWidth], }); + const edition = + !database.databaseEdition || + database.databaseEdition === types.DatabaseEdition.DATABASE_EDITION_UNSPECIFIED + ? types.DatabaseEdition.STANDARD + : database.databaseEdition; table.push( ["Name", clc.yellow(database.name)], ["Create Time", clc.yellow(database.createTime)], ["Last Update Time", clc.yellow(database.updateTime)], ["Type", clc.yellow(database.type)], + ["Edition", clc.yellow(edition)], ["Location", clc.yellow(database.locationId)], ["Delete Protection State", clc.yellow(database.deleteProtectionState)], ["Point In Time Recovery", clc.yellow(database.pointInTimeRecoveryEnablement)], diff --git a/src/management/studio.spec.ts b/src/management/studio.spec.ts new file mode 100644 index 00000000000..6e97005cd80 --- /dev/null +++ b/src/management/studio.spec.ts @@ -0,0 +1,195 @@ +import * as chai from "chai"; +import * as sinon from "sinon"; +import * as studio from "./studio"; +import * as prompt from "../prompt"; +import { configstore } from "../configstore"; +import { Client } from "../apiv2"; +import * as utils from "../utils"; +import { Options } from "../options"; +import { Config } from "../config"; +import { RC } from "../rc"; +import { logger } from "../logger"; + +const expect = chai.expect; + +describe("Studio Management", () => { + let sandbox: sinon.SinonSandbox; + let promptStub: sinon.SinonStub; + let clientRequestStub: sinon.SinonStub; + let utilsStub: sinon.SinonStub; + + let testOptions: Options; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + promptStub = sandbox.stub(prompt, "select"); + sandbox.stub(configstore, "get"); + sandbox.stub(configstore, "set"); + clientRequestStub = sandbox.stub(Client.prototype, "request"); + utilsStub = sandbox.stub(utils, "makeActiveProject"); + const emptyConfig = new Config("{}", {}); + testOptions = { + cwd: "", + configPath: "", + only: "", + except: "", + filteredTargets: [], + force: false, + json: false, + nonInteractive: false, + interactive: false, + debug: false, + config: emptyConfig, + rc: new RC(), + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("reconcileStudioFirebaseProject", () => { + it("should return active project from config if WORKSPACE_SLUG is not set", async () => { + process.env.WORKSPACE_SLUG = ""; + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + expect(result).to.equal("cli-project"); + expect(clientRequestStub).to.not.have.been.called; + }); + + it("should return active project from config if getStudioWorkspace fails", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.rejects(new Error("API Error")); + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + expect(result).to.equal("cli-project"); + }); + + it("should update studio with CLI project if studio has no project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub + .onFirstCall() + .resolves({ body: { name: "test-workspace", firebaseProjectId: undefined } }); + clientRequestStub.onSecondCall().resolves({ body: {} }); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + + expect(result).to.equal("cli-project"); + expect(clientRequestStub).to.have.been.calledTwice; + expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project"); + }); + + it("should update CLI with studio project if CLI has no project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, projectRoot: "/test" }, + undefined, + ); + + expect(result).to.equal("studio-project"); + expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project"); + }); + + it("should prompt user and update studio if user chooses CLI project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub + .onFirstCall() + .resolves({ body: { name: "test-workspace", firebaseProjectId: "studio-project" } }); + clientRequestStub.onSecondCall().resolves({ body: {} }); + promptStub.resolves(true); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + + expect(result).to.equal("cli-project"); + expect(promptStub).to.have.been.calledOnce; + expect(clientRequestStub).to.have.been.calledTwice; + expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project"); + }); + + it("should prompt user and update CLI if user chooses studio project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + promptStub.resolves(false); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, projectRoot: "/test" }, + "cli-project", + ); + + expect(result).to.equal("studio-project"); + expect(promptStub).to.have.been.calledOnce; + expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project"); + }); + + it("should do nothing if projects are the same", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "same-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "same-project"); + + expect(result).to.equal("same-project"); + expect(promptStub).to.not.have.been.called; + expect(utilsStub).to.not.have.been.called; + }); + + it("should do nothing if in non-interactive mode", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, nonInteractive: true }, + "cli-project", + ); + + expect(result).to.equal("studio-project"); + expect(promptStub).to.not.have.been.called; + expect(utilsStub).to.not.have.been.called; + }); + }); + + describe("updateStudioFirebaseProject", () => { + it("should not call api if WORKSPACE_SLUG is not set", async () => { + process.env.WORKSPACE_SLUG = ""; + await studio.updateStudioFirebaseProject("new-project"); + expect(clientRequestStub).to.not.have.been.called; + }); + + it("should call api to update project id", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ body: {} }); + + await studio.updateStudioFirebaseProject("new-project"); + + expect(clientRequestStub).to.have.been.calledOnceWith({ + method: "PATCH", + path: `/workspaces/test-workspace`, + responseType: "json", + body: { + firebaseProjectId: "new-project", + }, + queryParams: { + updateMask: "workspace.firebaseProjectId", + }, + timeout: 30000, + }); + }); + + it("should log error if api call fails", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.rejects(new Error("API Error")); + const errorLogSpy = sandbox.spy(logger, "warn"); + + await studio.updateStudioFirebaseProject("new-project"); + + expect(errorLogSpy).to.have.been.calledOnce; + }); + }); +}); diff --git a/src/management/studio.ts b/src/management/studio.ts new file mode 100644 index 00000000000..fb8e6d78727 --- /dev/null +++ b/src/management/studio.ts @@ -0,0 +1,158 @@ +import { Client } from "../apiv2"; +import * as prompt from "../prompt"; +import * as api from "../api"; +import { logger } from "../logger"; +import * as utils from "../utils"; +import { Options } from "../options"; +import { configstore } from "../configstore"; + +const TIMEOUT_MILLIS = 30000; + +const studioClient = new Client({ + urlPrefix: api.studioApiOrigin(), + apiVersion: "v1", +}); + +/** + * Reconciles the active project in your Studio Workspace when running the CLI + * in Firebase Studio. + * @param activeProjectFromConfig The project ID saved in configstore + * @return A promise that resolves with the reconciled active project + */ +export async function reconcileStudioFirebaseProject( + options: Options, + activeProjectFromConfig: string | undefined, +): Promise { + const studioWorkspace = await getStudioWorkspace(); + // Fail gracefully and resolve with the existing configs + if (!studioWorkspace) { + return activeProjectFromConfig; + } + // If Studio has no project, update Studio if the CLI has one + if (!studioWorkspace.firebaseProjectId) { + if (activeProjectFromConfig) { + await updateStudioFirebaseProject(activeProjectFromConfig); + } + return activeProjectFromConfig; + } + // If the CLI has no project, update the CLI with what Studio has + if (!activeProjectFromConfig) { + await writeStudioProjectToConfigStore(options, studioWorkspace.firebaseProjectId); + return studioWorkspace.firebaseProjectId; + } + // If both have an active project, allow the user to choose + if (studioWorkspace.firebaseProjectId !== activeProjectFromConfig && !options.nonInteractive) { + const choices = [ + { + name: `Set ${studioWorkspace.firebaseProjectId} from Firebase Studio as my active project in both places`, + value: false as any, + }, + { + name: `Set ${activeProjectFromConfig} from Firebase CLI as my active project in both places`, + value: true as any, + }, + ]; + const useCliProject = await prompt.select({ + message: + "Found different active Firebase Projects in the Firebase CLI and your Firebase Studio Workspace. Which project would you like to set as your active project?", + choices, + }); + if (useCliProject) { + await updateStudioFirebaseProject(activeProjectFromConfig); + return activeProjectFromConfig; + } else { + await writeStudioProjectToConfigStore(options, studioWorkspace.firebaseProjectId); + return studioWorkspace.firebaseProjectId; + } + } + // Otherwise, Studio and the CLI agree + return studioWorkspace.firebaseProjectId; +} + +export interface StudioWorkspace { + name: string; + firebaseProjectId: string | undefined; +} + +async function getStudioWorkspace(): Promise { + const workspaceId = process.env.WORKSPACE_SLUG; + if (!workspaceId) { + logger.error( + `Failed to fetch Firebase Project from Studio Workspace because WORKSPACE_SLUG environment variable is empty`, + ); + return undefined; + } + try { + const res = await studioClient.request({ + method: "GET", + path: `/workspaces/${workspaceId}`, + timeout: TIMEOUT_MILLIS, + }); + return res.body; + } catch (err: any) { + let message = err.message; + if (err.original) { + message += ` (original: ${err.original.message})`; + } + logger.error(`Failed to fetch Firebase Project from current Studio Workspace: ${message}`); + // We're going to fail gracefully so that the caller can handle the error + return undefined; + } +} + +async function writeStudioProjectToConfigStore(options: Options, studioProjectId: string) { + if (options.projectRoot) { + logger.info( + `Updating Firebase CLI active project to match Studio Workspace '${studioProjectId}'`, + ); + utils.makeActiveProject(options.projectRoot, studioProjectId); + recordStudioProjectSyncTime(); + } +} + +/** + * Sets the active project for the current Firebase Studio Workspace + * @param projectId The project ID saved in spanner + * @return A promise that resolves when complete + */ +export async function updateStudioFirebaseProject(projectId: string): Promise { + logger.info(`Updating Studio Workspace active project to match Firebase CLI '${projectId}'`); + const workspaceId = process.env.WORKSPACE_SLUG; + if (!workspaceId) { + logger.error( + `Failed to update Firebase Project for Studio Workspace because WORKSPACE_SLUG environment variable is empty`, + ); + return; + } + try { + await studioClient.request({ + method: "PATCH", + path: `/workspaces/${workspaceId}`, + responseType: "json", + body: { + firebaseProjectId: projectId, + }, + queryParams: { + updateMask: "workspace.firebaseProjectId", + }, + timeout: TIMEOUT_MILLIS, + }); + } catch (err: any) { + let message = err.message; + if (err.original) { + message += ` (original: ${err.original.message})`; + } + logger.warn( + `Failed to update active Firebase Project for current Studio Workspace: ${message}`, + ); + } + recordStudioProjectSyncTime(); +} + +/** + * Records the last time we synced the Studio project in Configstore. + * This is important to trigger a file watcher in Firebase Studio that keeps the UI in sync. + */ +function recordStudioProjectSyncTime() { + configstore.set("firebaseStudioProjectLastSynced", Date.now()); +} diff --git a/src/requireAuth.ts b/src/requireAuth.ts index e212f6c2aab..2a4e064b5db 100644 --- a/src/requireAuth.ts +++ b/src/requireAuth.ts @@ -96,7 +96,11 @@ export async function requireAuth( skipAutoAuth: boolean = false, ): Promise { lastOptions = options; - api.setScopes([scopes.CLOUD_PLATFORM, scopes.FIREBASE_PLATFORM]); + const requiredScopes = [scopes.CLOUD_PLATFORM]; + if (isFirebaseStudio()) { + requiredScopes.push(scopes.USERINFO_EMAIL); + } + api.setScopes(requiredScopes); options.authScopes = api.getScopes(); const tokens = options.tokens as Tokens | undefined; diff --git a/src/scopes.ts b/src/scopes.ts index 9bdef330e61..39c5a4d7399 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -1,6 +1,7 @@ // default scopes export const OPENID = "openid"; export const EMAIL = "email"; +export const USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email"; export const CLOUD_PROJECTS_READONLY = "https://www.googleapis.com/auth/cloudplatformprojects.readonly"; export const FIREBASE_PLATFORM = "https://www.googleapis.com/auth/firebase"; From 712c755c59ccc8574914791f03c708ab6a51e2bc Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 5 Aug 2025 09:50:17 -0700 Subject: [PATCH 5/5] pr fixes --- src/commands/apps-sdkconfig.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commands/apps-sdkconfig.spec.ts b/src/commands/apps-sdkconfig.spec.ts index 52802b0433c..44a86b93800 100644 --- a/src/commands/apps-sdkconfig.spec.ts +++ b/src/commands/apps-sdkconfig.spec.ts @@ -29,7 +29,7 @@ describe("apps:sdkconfig", () => { beforeEach(() => { sandbox = sinon.createSandbox(); sandbox.stub(auth, "requireAuth").resolves(); - needProjectIdStub = sandbox.stub(projectUtils, "needProjectId"); + needProjectIdStub = sandbox.stub(projectUtils, "needProjectId").returns("test-project-id"); getAppConfigStub = sandbox.stub(apps, "getAppConfig"); getAppConfigFileStub = sandbox.stub(apps, "getAppConfigFile"); listFirebaseAppsStub = sandbox.stub(apps, "listFirebaseApps"); @@ -61,7 +61,6 @@ describe("apps:sdkconfig", () => { }); it("should get config for the only app when no app id is provided", async () => { - needProjectIdStub.returns("test-project-id"); listFirebaseAppsStub.resolves([ { name: "n1", projectId: "p1", appId: "test-app-id", platform: AppPlatform.ANDROID }, ]); @@ -78,7 +77,6 @@ describe("apps:sdkconfig", () => { }); it("should prompt for app if multiple apps exist", async () => { - needProjectIdStub.returns("test-project-id"); const app1 = { name: "n1", projectId: "p1", @@ -105,7 +103,6 @@ describe("apps:sdkconfig", () => { }); it("should throw if multiple apps exist in non-interactive mode", async () => { - needProjectIdStub.returns("test-project-id"); const app1 = { name: "n1", projectId: "p1", appId: "app1", platform: AppPlatform.IOS }; const app2 = { name: "n2", projectId: "p2", appId: "app2", platform: AppPlatform.IOS }; listFirebaseAppsStub.resolves([app1, app2]); @@ -117,7 +114,6 @@ describe("apps:sdkconfig", () => { }); it("should throw if no apps exist", async () => { - needProjectIdStub.returns("test-project-id"); listFirebaseAppsStub.resolves([]); await expect(command.runner()("IOS", "", {})).to.be.rejectedWith(