diff --git a/src/appdistribution/client.ts b/src/appdistribution/client.ts index a286beebd0d..263fbec051a 100644 --- a/src/appdistribution/client.ts +++ b/src/appdistribution/client.ts @@ -9,6 +9,7 @@ import { appDistributionOrigin } from "../api"; import { AabInfo, + AIInstruction, BatchRemoveTestersResponse, BatchUpdateTestCasesRequest, BatchUpdateTestCasesResponse, @@ -70,7 +71,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; @@ -275,6 +276,7 @@ export class AppDistributionClient { async createReleaseTest( releaseName: string, devices: TestDevice[], + aiInstruction?: AIInstruction, loginCredential?: LoginCredential, testCaseName?: string, ): Promise { @@ -286,6 +288,7 @@ export class AppDistributionClient { deviceExecutions: devices.map((device) => ({ device })), loginCredential, testCase: testCaseName, + aiInstructions: aiInstruction, }, }); return response.body; diff --git a/src/appdistribution/distribution.ts b/src/appdistribution/distribution.ts index 3f03d7ea35b..4a006103080 100644 --- a/src/appdistribution/distribution.ts +++ b/src/appdistribution/distribution.ts @@ -1,7 +1,13 @@ 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 { UploadReleaseResult, TestDevice, ReleaseTest } from "../appdistribution/types"; +import { AppDistributionClient } from "./client"; +import { FirebaseError, getErrMsg, getErrStatus } from "../error"; + +const TEST_MAX_POLLING_RETRIES = 40; +const TEST_POLLING_INTERVAL_MILLIS = 30_000; export enum DistributionFileType { IPA = "ipa", @@ -9,6 +15,61 @@ export enum DistributionFileType { AAB = "aab", } +/** Upload a distribution */ +export async function upload( + requests: AppDistributionClient, + appName: string, + distribution: Distribution, +): Promise { + utils.logBullet("uploading binary..."); + 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}`, + ); + return 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 }); + } +} + /** * Object representing an APK, AAB or IPA file. Used for uploading app distributions. */ @@ -58,3 +119,63 @@ export class Distribution { return this.fileName; } } + +/** Wait for release tests to complete */ +export 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) { + const device = deviceToString(execution.device); + switch (execution.state) { + case "PASSED": + case "IN_PROGRESS": + continue; + case "FAILED": + throw new FirebaseError( + `Automated test failed for ${device}: ${execution.failedReason}`, + { exit: 1 }, + ); + case "INCONCLUSIVE": + throw new FirebaseError( + `Automated test inconclusive for ${device}: ${execution.inconclusiveReason}`, + { exit: 1 }, + ); + default: + throw new FirebaseError( + `Unsupported automated test state for ${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/appdistribution/types.ts b/src/appdistribution/types.ts index 4c442d1c928..fd8d1c1e25f 100644 --- a/src/appdistribution/types.ts +++ b/src/appdistribution/types.ts @@ -116,6 +116,17 @@ export interface ReleaseTest { deviceExecutions: DeviceExecution[]; loginCredential?: LoginCredential; testCase?: string; + aiInstructions?: AIInstruction; +} + +export interface AIInstruction { + steps: AIStep[]; +} + +export interface AIStep { + goal: string; + hint?: string; + successCriteria?: string; } export interface AiStep { diff --git a/src/commands/appdistribution-distribute.ts b/src/commands/appdistribution-distribute.ts index 61fb435fb2c..d13da419856 100644 --- a/src/commands/appdistribution-distribute.ts +++ b/src/commands/appdistribution-distribute.ts @@ -1,18 +1,14 @@ 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 { + awaitTestResults, + Distribution, + DistributionFileType, + upload, +} from "../appdistribution/distribution"; import { ensureFileExists, getAppName, @@ -20,9 +16,15 @@ import { parseTestDevices, parseIntoStringArray, } from "../appdistribution/options-parser-util"; - -const TEST_MAX_POLLING_RETRIES = 40; -const TEST_POLLING_INTERVAL_MILLIS = 30_000; +import { + AabInfo, + IntegrationState, + LoginCredential, + ReleaseTest, + TestDevice, +} from "../appdistribution/types"; +import { AppDistributionClient } from "../appdistribution/client"; +import * as utils from "../utils"; function getReleaseNotes(releaseNotes: string, releaseNotesFile: string): string { if (releaseNotes) { @@ -104,206 +106,129 @@ export const command = new Command("appdistribution:distribute [] = []; - if (!testCases.length) { - // fallback to basic automated test + // 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, undefined, loginCredential), + ); + } else { + for (const testCaseId of testCases) { releaseTestPromises.push( - requests.createReleaseTest(releaseName, testDevices, loginCredential), + requests.createReleaseTest( + releaseName, + testDevices, + undefined, + loginCredential, + `${appName}/testCases/${testCaseId}`, + ), ); - } 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 }, - ); - } - } + const releaseTests = await Promise.all(releaseTestPromises); + utils.logSuccess(`${releaseTests.length} release test(s) started successfully`); + if (!testNonBlocking) { + await awaitTestResults(releaseTests, requests); } } - 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/mcp/prompts/apptesting/index.ts b/src/mcp/prompts/apptesting/index.ts new file mode 100644 index 00000000000..5dc7acbb9b3 --- /dev/null +++ b/src/mcp/prompts/apptesting/index.ts @@ -0,0 +1,9 @@ +import { isEnabled } from "../../../experiments"; +import { runTest } from "./run_test"; +import type { ServerPrompt } from "../../prompt"; + +export const apptestingPrompts: ServerPrompt[] = []; + +if (isEnabled("mcpalpha")) { + apptestingPrompts.push(runTest); +} diff --git a/src/mcp/prompts/apptesting/run_test.ts b/src/mcp/prompts/apptesting/run_test.ts new file mode 100644 index 00000000000..767da9e2cb4 --- /dev/null +++ b/src/mcp/prompts/apptesting/run_test.ts @@ -0,0 +1,130 @@ +import { prompt } from "../../prompt"; + +export const runTest = prompt( + "apptesting", + { + name: "run_test", + description: "Run a test with the Firebase App Testing agent", + omitPrefix: false, + arguments: [ + { + name: "testDescription", + description: + "Description of the test you want to run. The agent will use the description to generate a test case that will be used as input for the AI-guided test", + required: false, + }, + ], + annotations: { + title: "Run an App Testing AI-guided test", + }, + }, + async ({ testDescription }, { accountEmail, projectId }) => { + return [ + { + role: "user" as const, + content: { + type: "text", + text: ` +You are going to help a developer run a test for their mobile app +using the Firebase App Testing agent. + +Active user: ${accountEmail || ""} +Project ID: ${projectId || ""} + +## Prerequisites + +Here are a list of prerequisite steps that must be completed before running a test. + +1. **Make sure this is an Android app**. The App Testing agent only works with Android apps. If + this is not an Android app, instruct the user that this command can't be used with this app. +2. **Make sure the user is logged in. No App Testing tools will work if the user is not logged in.** + a. Use the \`firebase_get_environment\` tool to verify that the user is logged in. + b. If the Firebase 'Active user' is set to , instruct the user to run \`firebase login\` + before continuing. Ignore other fields that are set to . We are just making sure the + user is logged in. +3. **Get the Firebase app ID.** + The \`firebase_get_environment\` tool should return a list of detected app IDs, where the app + ID contains four colon (":") delimited parts: a version number (typically "1"), a project + number, a platform type ("android", "ios", or "web"). Ask the user confirm if there is only + a single app ID, or to choose one if there are multiple app IDs. + + If the tool does not return a list of detected apps, just ask the user for it. + +4. **Confirm that the application ID of the app matches the bundle ID of the Firebase app** + + The \`firebase_get_environment\` tool returns a list of detected app IDs mapped to their corresponding + bundle IDs. If the developer selected an app ID from the the list of detected app IDs, this already + confirms that the bundle ID matches the app ID. If not, get the application IDs of all the variants of + the app. Then get the bundle ID of the Firebase app by calling the \`firebase_list_apps\` tool and + confirming that the \`namespace\` field of the app with the selected app ID matches one of the application + IDs of the variants. + +## Test Case Generation + + Once you have completed the required steps, you need the help the user generate a "test case", which is the input to the + app testing agent. A test case consists of multiple steps where each step contains the following fields: + + * Goal (required): In one sentence or less, describe what you want the agent to do in this step. + * Hint (optional): Provide additional information to help Gemini understand and navigate your app. + * Success Criteria (optional): Your success criteria should be phrased as an observation, such as 'The screen shows a + success message' or 'The checkout page is visible'. + + The developer has optionally specified the following description for their test: + * ${testDescription} + + Sometimes, test descriptions that developers provide tend to be too vague and lack the necessary details for the + app testing agent to be able to reliably re-run the tests with consistent results. Test cases should follow these + guidelines to ensure that they are structured in a way to make the agent more reliable. + + * Prefer multiple steps with smaller, detailed goals. Broader, more open-ended goals can lead to unreliable tests + since the app testing agent can more easily veer of course. It should only take a few actions to accomplish a goal. + For example, if a step has a list in it, it should probably be broken up into multiple steps. Steps do not need + to be too small though. The test case should provide a good balance between strict guidance and flexibility. As a + rule of thumb, each step should require between 2-5 actions. + * Include a hint and success criteria whenever possible. Specifically, try to always include a success criteria to help + the agent determine when the goal has been completed. + * Avoid functionality that the app testing agent struggles with. The app testing agent struggles with the following: + * Journeys that require specific timing (like observing that something should be visible for a certain number of + seconds), interacting with moving or transient elements, etc. + * Playing games or generally interacting with drawn visuals that would require pixel input + * Complex swipe interactions, multi-finger gestures, etc., which aren't supported + + First, analyze the code to get an understanding of how the app works. Get all the available screens in the app and the + different actions for each screen. Understand what functionality is and isn't available to the app testing agent. + Only include specific details in the test case if you are certain they will be available to the agent, otherwise the + agent will likely fail if it tries to follow specific guidance that doesn't work (e.g. click the 'Play' button but the + button isn't visible to the app testing agent). Do not include Android resource ids in the test case. Include + explanations that prove that each step includes between 2-5 actions. Using that information as context and the guidelines + above, convert the test description provided by the user to make it easier for the agent to follow so that the tests can + be re-run reliably. If there is no test description, generate a test case that you think will be useful given the functionality + of the app. Generate an explanation on why you generated the new test case the way you did, and then generate the + new test case, which again is an array of steps where each step contains a goal, hint, and success criteria. Show this + to the user and have them confirm before moving forward. + +## Run Test + + Use the \`apptesting_run_test\` tool to run an automated test with the following as input: + * The generated test case that as been confirmed by the user + * An APK. If there is no APK present, build the app to produce one. Make sure to build the variant of the app + with the same bundle ID as the Firebase app. + * The devices to test on. If the user doesn't specify any devices in the test description, you can leave this + blank and the test will run on a the default virtual device. If the user does specify a device, + Use the \`apptesting_check_status\` tool with \`getAvailableDevices\` set to true to get a list of available + devices. + + Once the test has started, provide the developer a link to see the results of the test in the Firebase Console. + You should already know the value of \`appId\' and \`projectId\` from earlier (if you only know \`projectNumber\', + use the \`firebase_get_project\` tool to get \`projectId\`). \`packageName\` is the package name of the app we tested. + The \`apptesting_run_test\` tool returns a response with field \`name\` in the form + projects/{projectNumber}/apps/{appId}/releases/{releaseId}/tests/{releaseTestId}. Extract the values for \'releaseId\' + and \`releaseTestId\` and use provide a link to the results in the Firebase Console in the format: + \`https://console.firebase.google.com/u/0/project/{projectId}/apptesting/app/android:{packageName}/releases/{releaseId}/tests/{releaseTestId}\`. + + You can check the status of the test using the \`apptesting_check_status\` tool with \`release_test_name\' set to + the name of the release test returned by the \`run_test\` tool. +`.trim(), + }, + }, + ]; + }, +); diff --git a/src/mcp/prompts/index.ts b/src/mcp/prompts/index.ts index c5dca11001e..7ea672dc63f 100644 --- a/src/mcp/prompts/index.ts +++ b/src/mcp/prompts/index.ts @@ -3,6 +3,7 @@ import { ServerPrompt } from "../prompt"; import { corePrompts } from "./core"; import { dataconnectPrompts } from "./dataconnect"; import { crashlyticsPrompts } from "./crashlytics"; +import { apptestingPrompts } from "./apptesting"; const prompts: Record = { core: namespacePrompts(corePrompts, "core"), @@ -14,6 +15,7 @@ const prompts: Record = { functions: [], remoteconfig: [], crashlytics: namespacePrompts(crashlyticsPrompts, "crashlytics"), + apptesting: namespacePrompts(apptestingPrompts, "apptesting"), apphosting: [], database: [], }; diff --git a/src/mcp/tools/apptesting/index.ts b/src/mcp/tools/apptesting/index.ts new file mode 100644 index 00000000000..e2fb94dc5b6 --- /dev/null +++ b/src/mcp/tools/apptesting/index.ts @@ -0,0 +1,9 @@ +import { isEnabled } from "../../../experiments"; +import type { ServerTool } from "../../tool"; +import { check_status, run_tests } from "./tests"; + +export const apptestingTools: ServerTool[] = []; + +if (isEnabled("mcpalpha")) { + apptestingTools.push(...[run_tests, check_status]); +} diff --git a/src/mcp/tools/apptesting/tests.ts b/src/mcp/tools/apptesting/tests.ts new file mode 100644 index 00000000000..57548b2a0e7 --- /dev/null +++ b/src/mcp/tools/apptesting/tests.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +import { ApplicationIdSchema } from "../../../crashlytics/filters"; +import { upload, Distribution } from "../../../appdistribution/distribution"; + +import { tool } from "../../tool"; +import { toContent } from "../../util"; +import { toAppName } from "../../../appdistribution/options-parser-util"; +import { AppDistributionClient } from "../../../appdistribution/client"; +import { google } from "googleapis"; +import { getAccessToken } from "../../../apiv2"; + +const TestDeviceSchema = z + .object({ + model: z.string(), + version: z.string(), + locale: z.string(), + orientation: z.string(), + }) + .describe( + `Device to run automated test on. Can run 'gcloud firebase test android|ios models list' to see available devices.`, + ); + +const AIStepSchema = z + .object({ + goal: z.string().describe("A goal to be accomplished during the test."), + hint: z + .string() + .optional() + .describe("Hint text containing suggestions to help the agent accomplish the goal."), + successCriteria: z + .string() + .optional() + .describe( + "A description of criteria the agent should use to determine if the goal has been successfully completed.", + ), + }) + .describe("Step within a test case; run during the execution of the test."); + +const defaultDevices = [ + { + model: "MediumPhone.arm", + version: "30", + locale: "en_US", + orientation: "portrait", + }, +]; + +export const run_tests = tool( + "apptesting", + { + name: "run_test", + description: `Run a remote test.`, + inputSchema: z.object({ + appId: ApplicationIdSchema, + releaseBinaryFile: z.string().describe("Path to the binary release (APK)."), + testDevices: z.array(TestDeviceSchema).default(defaultDevices), + testCase: z.object({ + steps: z + .array(AIStepSchema) + .describe("Test case containing the steps that are run during its execution."), + }), + }), + annotations: { + title: "Run a Remote Test", + readOnlyHint: false, + }, + }, + async ({ appId, releaseBinaryFile, testDevices, testCase }) => { + // For some reason, testDevices can still be + const devices = testDevices || defaultDevices; + const client = new AppDistributionClient(); + const releaseName = await upload(client, toAppName(appId), new Distribution(releaseBinaryFile)); + return toContent(await client.createReleaseTest(releaseName, devices, testCase)); + }, +); + +export const check_status = tool( + "apptesting", + { + name: "check_status", + description: + "Check the status of an apptesting release test and/or get available devices that can be used for automated tests ", + inputSchema: z.object({ + release_test_name: z + .string() + .optional() + .describe( + "The name of the release test returned by the run_test tool. If set, the tool will fetch the release test", + ), + getAvailableDevices: z + .boolean() + .optional() + .describe( + "If set to true, the tool will get the available devices that can be used for automated tests using the app testing agent", + ), + }), + annotations: { + title: "Check Remote Test", + readOnlyHint: true, + }, + }, + async ({ release_test_name, getAvailableDevices }) => { + let devices = undefined; + let releaseTest = undefined; + if (release_test_name) { + const client = new AppDistributionClient(); + releaseTest = await client.getReleaseTest(release_test_name); + } + if (getAvailableDevices) { + const testing = google.testing("v1"); + devices = await testing.testEnvironmentCatalog.get({ + oauth_token: await getAccessToken(), + environmentType: "ANDROID", + }); + } + + return toContent({ + devices, + releaseTest, + }); + }, +); diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 5b3ffcb7b70..10ab364c305 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 { apptestingTools } from "./apptesting/index"; import { realtimeDatabaseTools } from "./realtime_database/index"; import { functionsTools } from "./functions/index"; @@ -53,6 +54,7 @@ const tools: Record = { functions: addFeaturePrefix("functions", functionsTools), remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools), + apptesting: addFeaturePrefix("apptesting", apptestingTools), apphosting: addFeaturePrefix("apphosting", appHostingTools), database: addFeaturePrefix("realtimedatabase", realtimeDatabaseTools), }; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 753b6dcb443..19dbcd1577d 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -12,6 +12,7 @@ export const SERVER_FEATURES = [ "functions", "remoteconfig", "crashlytics", + "apptesting", "apphosting", "database", ] as const; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 48712cb023a..f7a11b88d2a 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(), + apptesting: appDistributionOrigin(), apphosting: apphostingOrigin(), database: realtimeOrigin(), }; @@ -87,6 +89,7 @@ const DETECTED_API_FEATURES: Record = { functions: undefined, remoteconfig: undefined, crashlytics: undefined, + apptesting: undefined, apphosting: undefined, database: undefined, }; diff --git a/src/mcp/util/apptesting/availability.spec.ts b/src/mcp/util/apptesting/availability.spec.ts new file mode 100644 index 00000000000..9485eac2af9 --- /dev/null +++ b/src/mcp/util/apptesting/availability.spec.ts @@ -0,0 +1,102 @@ +import * as mockfs from "mock-fs"; +import * as sinon from "sinon"; +import { FirebaseMcpServer } from "../../index"; +import { RC } from "../../../rc"; +import { Config } from "../../../config"; +import { McpContext } from "../../types"; +import { isAppTestingAvailable } from "./availability"; +import { expect } from "chai"; +import * as ensureApiEnabled from "../../../ensureApiEnabled"; + +describe("isAppTestingAvailable", () => { + let sandbox: sinon.SinonSandbox; + let checkStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + checkStub = sandbox.stub(ensureApiEnabled, "check"); + }); + + afterEach(() => { + sandbox.restore(); + mockfs.restore(); + }); + + const mockContext = (projectDir: string): McpContext => ({ + projectId: "test-project", + accountEmail: null, + config: { + projectDir: projectDir, + } as Config, + host: new FirebaseMcpServer({}), + rc: {} as RC, + firebaseCliCommand: "firebase", + }); + + it("returns false for non mobile project", async () => { + checkStub.resolves(true); + mockfs({ + "/test-dir": { + "package.json": '{ "name": "web-app" }', + "index.html": "", + }, + }); + const result = await isAppTestingAvailable(mockContext("/test-dir")); + expect(result).to.be.false; + }); + + it("returns false if App Distribution API isn't enabled", async () => { + checkStub.resolves(false); + mockfs({ + "/test-dir": { + android: { + "build.gradle": "", + src: { main: {} }, + }, + }, + }); + const result = await isAppTestingAvailable(mockContext("/test-dir")); + expect(result).to.be.false; + }); + + it("returns true for an Android project with API enabled", async () => { + checkStub.resolves(true); + mockfs({ + "/test-dir": { + android: { + "build.gradle": "", + src: { main: {} }, + }, + }, + }); + const result = await isAppTestingAvailable(mockContext("/test-dir")); + expect(result).to.be.true; + }); + + it("returns true for an iOS project with API enabled", async () => { + checkStub.resolves(true); + mockfs({ + "/test-dir": { + ios: { + Podfile: "", + "Project.xcodeproj": {}, + }, + }, + }); + const result = await isAppTestingAvailable(mockContext("/test-dir")); + expect(result).to.be.true; + }); + + it("returns true for an Flutter project with API enabled", async () => { + checkStub.resolves(true); + mockfs({ + "/test-dir": { + "pubspec.yaml": "", + ios: { "Runner.xcodeproj": {} }, + android: { src: { main: {} } }, + }, + }); + const result = await isAppTestingAvailable(mockContext("/test-dir")); + expect(result).to.be.true; + }); +}); diff --git a/src/mcp/util/apptesting/availability.ts b/src/mcp/util/apptesting/availability.ts new file mode 100644 index 00000000000..62475bf534a --- /dev/null +++ b/src/mcp/util/apptesting/availability.ts @@ -0,0 +1,33 @@ +import { appDistributionOrigin } from "../../../api"; +import { getPlatformsFromFolder, Platform } from "../../../appUtils"; +import { check } from "../../../ensureApiEnabled"; +import { timeoutFallback } from "../../../timeout"; +import { McpContext } from "../../types"; + +/** + * Returns whether or not App Testing should be enabled + */ +export async function isAppTestingAvailable(ctx: McpContext): Promise { + const host = ctx.host; + const projectDir = ctx.config.projectDir; + const platforms = await getPlatformsFromFolder(projectDir); + + const supportedPlatforms = [Platform.FLUTTER, Platform.ANDROID, Platform.IOS]; + + if (!platforms.some((p) => supportedPlatforms.includes(p))) { + host.log("debug", `Found no supported App Testing platforms.`); + return false; + } + + // Checkf if App Distribution API is active + try { + return await timeoutFallback( + check(ctx.projectId, appDistributionOrigin(), "", true), + true, + 3000, + ); + } catch (e) { + // If there was a network error, default to enabling the feature + return true; + } +} diff --git a/src/mcp/util/availability.spec.ts b/src/mcp/util/availability.spec.ts index 4b43a92a0be..2f42118117c 100644 --- a/src/mcp/util/availability.spec.ts +++ b/src/mcp/util/availability.spec.ts @@ -42,7 +42,7 @@ describe("getDefaultFeatureAvailabilityCheck", () => { // Test all other features that rely on checkFeatureActive const featuresThatUseCheckActive = SERVER_FEATURES.filter( - (f) => f !== "core" && f !== "crashlytics", + (f) => f !== "core" && f !== "crashlytics" && f !== "apptesting", ); for (const feature of featuresThatUseCheckActive) { diff --git a/src/mcp/util/availability.ts b/src/mcp/util/availability.ts index ce96e5f8e14..f5f487fb812 100644 --- a/src/mcp/util/availability.ts +++ b/src/mcp/util/availability.ts @@ -1,6 +1,7 @@ import { McpContext, ServerFeature } from "../types"; import { checkFeatureActive } from "../util"; import { isCrashlyticsAvailable } from "./crashlytics/availability"; +import { isAppTestingAvailable } from "./apptesting/availability"; const DEFAULT_AVAILABILITY_CHECKS: Record Promise> = { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -22,6 +23,7 @@ const DEFAULT_AVAILABILITY_CHECKS: Record Pr crashlytics: isCrashlyticsAvailable, apphosting: (ctx: McpContext): Promise => checkFeatureActive("apphosting", ctx.projectId, { config: ctx.config }), + apptesting: isAppTestingAvailable, database: (ctx: McpContext): Promise => checkFeatureActive("database", ctx.projectId, { config: ctx.config }), };