diff --git a/src/appdistribution/client.ts b/src/appdistribution/client.ts index 07af4b456c8..839e3d8bdcc 100644 --- a/src/appdistribution/client.ts +++ b/src/appdistribution/client.ts @@ -66,7 +66,7 @@ export class AppDistributionClient { }); } - async updateReleaseNotes(releaseName: string, releaseNotes: string): Promise { + async updateReleaseNotes(releaseName: string, releaseNotes?: string): Promise { if (!releaseNotes) { utils.logWarning("no release notes specified, skipping"); return; diff --git a/src/appdistribution/distribution.ts b/src/appdistribution/distribution.ts index 3f03d7ea35b..30c46620965 100644 --- a/src/appdistribution/distribution.ts +++ b/src/appdistribution/distribution.ts @@ -1,7 +1,17 @@ import * as fs from "fs-extra"; -import { FirebaseError, getErrMsg } from "../error"; import { logger } from "../logger"; import * as pathUtil from "path"; +import * as utils from "../utils"; +import { + AabInfo, + IntegrationState, + UploadReleaseResult, + TestDevice, + ReleaseTest, + LoginCredential, +} from "../appdistribution/types"; +import { AppDistributionClient } from "./client"; +import { FirebaseError, getErrMsg, getErrStatus } from "../error"; export enum DistributionFileType { IPA = "ipa", @@ -9,6 +19,168 @@ export enum DistributionFileType { AAB = "aab", } +const TEST_MAX_POLLING_RETRIES = 40; +const TEST_POLLING_INTERVAL_MILLIS = 30_000; + +/** + * Execute an app distribution action + */ +export async function distribute( + appName: string, + distribution: Distribution, + testCases: string[], + testDevices: TestDevice[], + releaseNotes?: string, + testers?: string[], + groups?: string[], + testNonBlocking?: boolean, + loginCredential?: LoginCredential, +) { + const requests = new AppDistributionClient(); + let aabInfo: AabInfo | undefined; + if (distribution.distributionFileType() === DistributionFileType.AAB) { + try { + aabInfo = await requests.getAabInfo(appName); + } catch (err: unknown) { + if (getErrStatus(err) === 404) { + throw new FirebaseError( + `App Distribution could not find your app ${appName}. ` + + `Make sure to onboard your app by pressing the "Get started" ` + + "button on the App Distribution page in the Firebase console: " + + "https://console.firebase.google.com/project/_/appdistribution", + { exit: 1 }, + ); + } + throw new FirebaseError(`failed to determine AAB info. ${getErrMsg(err)}`, { exit: 1 }); + } + + if ( + aabInfo.integrationState !== IntegrationState.INTEGRATED && + aabInfo.integrationState !== IntegrationState.AAB_STATE_UNAVAILABLE + ) { + switch (aabInfo.integrationState) { + case IntegrationState.PLAY_ACCOUNT_NOT_LINKED: { + throw new FirebaseError("This project is not linked to a Google Play account."); + } + case IntegrationState.APP_NOT_PUBLISHED: { + throw new FirebaseError('"This app is not published in the Google Play console.'); + } + case IntegrationState.NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT: { + throw new FirebaseError("App with matching package name does not exist in Google Play."); + } + case IntegrationState.PLAY_IAS_TERMS_NOT_ACCEPTED: { + throw new FirebaseError( + "You must accept the Play Internal App Sharing (IAS) terms to upload AABs.", + ); + } + default: { + throw new FirebaseError( + "App Distribution failed to process the AAB: " + aabInfo.integrationState, + ); + } + } + } + } + + utils.logBullet("uploading binary..."); + let releaseName; + try { + const operationName = await requests.uploadRelease(appName, distribution); + + // The upload process is asynchronous, so poll to figure out when the upload has finished successfully + const uploadResponse = await requests.pollUploadStatus(operationName); + + const release = uploadResponse.release; + switch (uploadResponse.result) { + case UploadReleaseResult.RELEASE_CREATED: + utils.logSuccess( + `uploaded new release ${release.displayVersion} (${release.buildVersion}) successfully!`, + ); + break; + case UploadReleaseResult.RELEASE_UPDATED: + utils.logSuccess( + `uploaded update to existing release ${release.displayVersion} (${release.buildVersion}) successfully!`, + ); + break; + case UploadReleaseResult.RELEASE_UNMODIFIED: + utils.logSuccess( + `re-uploaded already existing release ${release.displayVersion} (${release.buildVersion}) successfully!`, + ); + break; + default: + utils.logSuccess( + `uploaded release ${release.displayVersion} (${release.buildVersion}) successfully!`, + ); + } + utils.logSuccess(`View this release in the Firebase console: ${release.firebaseConsoleUri}`); + utils.logSuccess(`Share this release with testers who have access: ${release.testingUri}`); + utils.logSuccess( + `Download the release binary (link expires in 1 hour): ${release.binaryDownloadUri}`, + ); + releaseName = uploadResponse.release.name; + } catch (err: unknown) { + if (getErrStatus(err) === 404) { + throw new FirebaseError( + `App Distribution could not find your app ${appName}. ` + + `Make sure to onboard your app by pressing the "Get started" ` + + "button on the App Distribution page in the Firebase console: " + + "https://console.firebase.google.com/project/_/appdistribution", + { exit: 1 }, + ); + } + throw new FirebaseError(`Failed to upload release. ${getErrMsg(err)}`, { exit: 1 }); + } + + // If this is an app bundle and the certificate was originally blank fetch the updated + // certificate and print + if (aabInfo && !aabInfo.testCertificate) { + aabInfo = await requests.getAabInfo(appName); + if (aabInfo.testCertificate) { + utils.logBullet( + "After you upload an AAB for the first time, App Distribution " + + "generates a new test certificate. All AAB uploads are re-signed with this test " + + "certificate. Use the certificate fingerprints below to register your app " + + "signing key with API providers, such as Google Sign-In and Google Maps.\n" + + `MD-1 certificate fingerprint: ${aabInfo.testCertificate.hashMd5}\n` + + `SHA-1 certificate fingerprint: ${aabInfo.testCertificate.hashSha1}\n` + + `SHA-256 certificate fingerprint: ${aabInfo.testCertificate.hashSha256}`, + ); + } + } + + // Add release notes and distribute to testers/groups + await requests.updateReleaseNotes(releaseName, releaseNotes); + await requests.distribute(releaseName, testers, groups); + + // Run automated tests + if (testDevices.length) { + utils.logBullet("starting automated test (note: this feature is in beta)"); + const releaseTestPromises: Promise[] = []; + if (!testCases.length) { + // fallback to basic automated test + releaseTestPromises.push( + requests.createReleaseTest(releaseName, testDevices, loginCredential), + ); + } else { + for (const testCaseId of testCases) { + releaseTestPromises.push( + requests.createReleaseTest( + releaseName, + testDevices, + loginCredential, + `${appName}/testCases/${testCaseId}`, + ), + ); + } + } + const releaseTests = await Promise.all(releaseTestPromises); + utils.logSuccess(`${releaseTests.length} release test(s) started successfully`); + if (!testNonBlocking) { + await awaitTestResults(releaseTests, requests); + } + } +} + /** * Object representing an APK, AAB or IPA file. Used for uploading app distributions. */ @@ -58,3 +230,59 @@ export class Distribution { return this.fileName; } } + +async function awaitTestResults( + releaseTests: ReleaseTest[], + requests: AppDistributionClient, +): Promise { + const releaseTestNames = new Set(releaseTests.map((rt) => rt.name).filter((n): n is string => !!n)); + for (let i = 0; i < TEST_MAX_POLLING_RETRIES; i++) { + utils.logBullet(`${releaseTestNames.size} automated test results are pending...`); + await delay(TEST_POLLING_INTERVAL_MILLIS); + for (const releaseTestName of releaseTestNames) { + const releaseTest = await requests.getReleaseTest(releaseTestName); + if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) { + releaseTestNames.delete(releaseTestName); + if (releaseTestNames.size === 0) { + utils.logSuccess("Automated test(s) passed!"); + return; + } else { + continue; + } + } + for (const execution of releaseTest.deviceExecutions) { + switch (execution.state) { + case "PASSED": + case "IN_PROGRESS": + continue; + case "FAILED": + throw new FirebaseError( + `Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`, + { exit: 1 }, + ); + case "INCONCLUSIVE": + throw new FirebaseError( + `Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`, + { exit: 1 }, + ); + default: + throw new FirebaseError( + `Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`, + { exit: 1 }, + ); + } + } + } + } + throw new FirebaseError("It took longer than expected to run your test(s), please try again.", { + exit: 1, + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function deviceToString(device: TestDevice): string { + return `${device.model} (${device.version}/${device.orientation}/${device.locale})`; +} diff --git a/src/appdistribution/options-parser-util.ts b/src/appdistribution/options-parser-util.ts index 1d8d83e5fcf..a4de257cb80 100644 --- a/src/appdistribution/options-parser-util.ts +++ b/src/appdistribution/options-parser-util.ts @@ -8,7 +8,7 @@ import { FieldHints, LoginCredential, TestDevice } from "./types"; * file and converts the input into an string[]. * Value takes precedent over file. */ -export function parseIntoStringArray(value: string, file: string): string[] { +export function parseIntoStringArray(value: string, file = ""): string[] { // If there is no value then the file gets parsed into a string to be split if (!value && file) { ensureFileExists(file); @@ -61,7 +61,10 @@ export function getAppName(options: any): string { if (!options.app) { throw new FirebaseError("set the --app option to a valid Firebase app id and try again"); } - const appId = options.app; + return toAppName(options.app); +} + +export function toAppName(appId: string) { return `projects/${appId.split(":")[1]}/apps/${appId}`; } @@ -70,7 +73,7 @@ export function getAppName(options: any): string { * and converts the input into a string[] of test device strings. * Value takes precedent over file. */ -export function parseTestDevices(value: string, file: string): TestDevice[] { +export function parseTestDevices(value: string, file = ""): TestDevice[] { // If there is no value then the file gets parsed into a string to be split if (!value && file) { ensureFileExists(file); diff --git a/src/commands/appdistribution-distribute.ts b/src/commands/appdistribution-distribute.ts index 61fb435fb2c..5d0346d2d0a 100644 --- a/src/commands/appdistribution-distribute.ts +++ b/src/commands/appdistribution-distribute.ts @@ -1,18 +1,9 @@ import * as fs from "fs-extra"; import { Command } from "../command"; -import * as utils from "../utils"; import { requireAuth } from "../requireAuth"; -import { AppDistributionClient } from "../appdistribution/client"; -import { - AabInfo, - IntegrationState, - UploadReleaseResult, - TestDevice, - ReleaseTest, -} from "../appdistribution/types"; -import { FirebaseError, getErrMsg, getErrStatus } from "../error"; -import { Distribution, DistributionFileType } from "../appdistribution/distribution"; +import { FirebaseError } from "../error"; +import { distribute, Distribution } from "../appdistribution/distribution"; import { ensureFileExists, getAppName, @@ -21,9 +12,6 @@ import { parseIntoStringArray, } from "../appdistribution/options-parser-util"; -const TEST_MAX_POLLING_RETRIES = 40; -const TEST_POLLING_INTERVAL_MILLIS = 30_000; - function getReleaseNotes(releaseNotes: string, releaseNotesFile: string): string { if (releaseNotes) { // Un-escape new lines from argument string @@ -104,206 +92,16 @@ export const command = new Command("appdistribution:distribute [] = []; - if (!testCases.length) { - // fallback to basic automated test - releaseTestPromises.push( - requests.createReleaseTest(releaseName, testDevices, loginCredential), - ); - } else { - for (const testCaseId of testCases) { - releaseTestPromises.push( - requests.createReleaseTest( - releaseName, - testDevices, - loginCredential, - `${appName}/testCases/${testCaseId}`, - ), - ); - } - } - const releaseTests = await Promise.all(releaseTestPromises); - utils.logSuccess(`${releaseTests.length} release test(s) started successfully`); - if (!options.testNonBlocking) { - await awaitTestResults(releaseTests, requests); - } - } - }); - -async function awaitTestResults( - releaseTests: ReleaseTest[], - requests: AppDistributionClient, -): Promise { - const releaseTestNames = new Set(releaseTests.map((rt) => rt.name!)); - for (let i = 0; i < TEST_MAX_POLLING_RETRIES; i++) { - utils.logBullet(`${releaseTestNames.size} automated test results are pending...`); - await delay(TEST_POLLING_INTERVAL_MILLIS); - for (const releaseTestName of releaseTestNames) { - const releaseTest = await requests.getReleaseTest(releaseTestName); - if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) { - releaseTestNames.delete(releaseTestName); - if (releaseTestNames.size === 0) { - utils.logSuccess("Automated test(s) passed!"); - return; - } else { - continue; - } - } - for (const execution of releaseTest.deviceExecutions) { - switch (execution.state) { - case "PASSED": - case "IN_PROGRESS": - continue; - case "FAILED": - throw new FirebaseError( - `Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`, - { exit: 1 }, - ); - case "INCONCLUSIVE": - throw new FirebaseError( - `Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`, - { exit: 1 }, - ); - default: - throw new FirebaseError( - `Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`, - { exit: 1 }, - ); - } - } - } - } - throw new FirebaseError("It took longer than expected to run your test(s), please try again.", { - exit: 1, + await distribute( + appName, + distribution, + testCases, + testDevices, + releaseNotes, + testers, + groups, + options.testNonBlocking, + loginCredential, + ); }); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function deviceToString(device: TestDevice): string { - return `${device.model} (${device.version}/${device.orientation}/${device.locale})`; -} diff --git a/src/mcp/prompts/index.ts b/src/mcp/prompts/index.ts index cb3f7a1a4f4..05d2ee8584e 100644 --- a/src/mcp/prompts/index.ts +++ b/src/mcp/prompts/index.ts @@ -14,6 +14,7 @@ const prompts: Record = { functions: [], remoteconfig: [], crashlytics: crashlyticsPrompts, + appdistribution: [], apphosting: [], database: [], }; diff --git a/src/mcp/tools/appdistribution/index.ts b/src/mcp/tools/appdistribution/index.ts new file mode 100644 index 00000000000..46d78af393f --- /dev/null +++ b/src/mcp/tools/appdistribution/index.ts @@ -0,0 +1,4 @@ +import type { ServerTool } from "../../tool"; +import { run_tests } from "./tests"; + +export const appdistributionTools: ServerTool[] = [run_tests]; diff --git a/src/mcp/tools/appdistribution/tests.ts b/src/mcp/tools/appdistribution/tests.ts new file mode 100644 index 00000000000..1fbfbc4942a --- /dev/null +++ b/src/mcp/tools/appdistribution/tests.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { ApplicationIdSchema } from "../../../crashlytics/filters"; +import { distribute, Distribution } from "../../../appdistribution/distribution"; +import { tool } from "../../tool"; +import { mcpError, toContent } from "../../util"; +import { + parseIntoStringArray, + parseTestDevices, + toAppName, +} from "../../../appdistribution/options-parser-util"; + +export const run_tests = tool( + { + name: "run_test", + description: `Upload an APK and run an existing test against it.`, + inputSchema: z.object({ + appId: ApplicationIdSchema, + releaseBinaryFile: z.string().describe("Path to the binary release (APK)."), + testDevices: z.string().describe( + `Semicolon-separated list of devices to run automated tests on, in the format + 'model=,version=,locale=,orientation='. Run 'gcloud firebase test android|ios models list' to see + available devices.`, + ), + testCaseIds: z.string().describe(`A comma-separated list of test case IDs.`), + }), + }, + async ({ appId, releaseBinaryFile, testDevices, testCaseIds }) => { + if (!appId) return mcpError(`Must specify 'appId' parameter.`); + if (!releaseBinaryFile) return mcpError(`Must specify 'releaseBinaryFile' parameter.`); + if (!testDevices) return mcpError(`Must specify 'testDevices' parameter.`); + if (!testCaseIds) return mcpError(`Must specify 'testCaseIds' parmeter.`); + + const appName = toAppName(appId); + const distribution = new Distribution(releaseBinaryFile); + const testCases = parseIntoStringArray(testCaseIds); + const devices = parseTestDevices(testDevices); + + return toContent(await distribute(appName, distribution, testCases, devices)); + }, +); diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index a6133d6c224..4c7db7a3852 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -9,6 +9,7 @@ import { messagingTools } from "./messaging/index"; import { remoteConfigTools } from "./remoteconfig/index"; import { crashlyticsTools } from "./crashlytics/index"; import { appHostingTools } from "./apphosting/index"; +import { appdistributionTools } from "./appdistribution/index"; import { realtimeDatabaseTools } from "./realtime_database/index"; import { functionsTools } from "./functions/index"; @@ -37,6 +38,7 @@ const tools: Record = { functions: addFeaturePrefix("functions", functionsTools), remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools), + appdistribution: addFeaturePrefix("appdistribution", appdistributionTools), apphosting: addFeaturePrefix("apphosting", appHostingTools), database: addFeaturePrefix("realtimedatabase", realtimeDatabaseTools), }; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 04381a744f8..5ae9e6daa83 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -12,6 +12,7 @@ export const SERVER_FEATURES = [ "functions", "remoteconfig", "crashlytics", + "appdistribution", "apphosting", "database", ] as const; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 92c3f382cdd..68e484b63c3 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -11,6 +11,7 @@ import { remoteConfigApiOrigin, storageOrigin, crashlyticsApiOrigin, + appDistributionOrigin, realtimeOrigin, } from "../api"; import { check } from "../ensureApiEnabled"; @@ -73,6 +74,7 @@ const SERVER_FEATURE_APIS: Record = { functions: functionsOrigin(), remoteconfig: remoteConfigApiOrigin(), crashlytics: crashlyticsApiOrigin(), + appdistribution: appDistributionOrigin(), apphosting: apphostingOrigin(), database: realtimeOrigin(), };