Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
11b53b8
creation of feature branch
Oct 6, 2025
18c29f8
New MCP tool for running mobile tests (via app distribution). (#9250)
jrothfeder Oct 6, 2025
7d8e857
Merge branch 'master' into feature-branch/mcp/mobile-testing
Oct 6, 2025
455d6d3
Rename appdistribution directory to apptesting (#9268)
jrothfeder Oct 7, 2025
b26c98b
Merge branch 'master' into feature-branch/mcp/mobile-testing
Oct 7, 2025
1744e5e
Use a datastructure to represent test devices rather than a string. (…
jrothfeder Oct 8, 2025
62e3171
Merge branch 'master' into feature-branch/mcp/mobile-testing
Oct 8, 2025
26dd4f3
Merge branch 'master' into feature-branch/mcp/mobile-testing
tagboola Oct 8, 2025
c318b06
Merge branch 'master' into feature-branch/mcp/mobile-testing
tagboola Oct 9, 2025
81c1a61
Add run_test prompt (#9292)
tagboola Oct 9, 2025
75d279f
Merge branch 'master' into feature-branch/mcp/mobile-testing
Oct 14, 2025
b309807
MCP tool `apptesting_run_test` can create and run a on-off test. (#9321)
jrothfeder Oct 16, 2025
9bbf17a
Merge branch 'master' into feature-branch/mcp/mobile-testing
Oct 16, 2025
973e8d7
Use the same default device that's used in the Console (#9320)
tagboola Oct 16, 2025
82e1724
Update prompt to support generating a test case when there is no test…
tagboola Oct 16, 2025
c081d6d
Merge branch 'master' into feature-branch/mcp/mobile-testing
tagboola Oct 23, 2025
380b001
Add custom auto-enablement for app testing (#9373)
tagboola Oct 23, 2025
8a95353
Add get devices tool (#9387)
tagboola Oct 28, 2025
7356c41
Display link to results in the Firebase Console (#9406)
tagboola Oct 29, 2025
32c5a93
Merge branch 'master' into feature-branch/mcp/mobile-testing
tagboola Oct 30, 2025
7f94828
Place app testing tools behind an experiment
tagboola Oct 30, 2025
cb9c42b
Address GCA comments
tagboola Oct 30, 2025
81a2734
Explicitly set default devices
tagboola Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/appdistribution/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import {
AabInfo,
AIInstruction,
BatchRemoveTestersResponse,
Group,
ListGroupsResponse,
Expand Down Expand Up @@ -67,7 +68,7 @@
});
}

async updateReleaseNotes(releaseName: string, releaseNotes: string): Promise<void> {
async updateReleaseNotes(releaseName: string, releaseNotes?: string): Promise<void> {
if (!releaseNotes) {
utils.logWarning("no release notes specified, skipping");
return;
Expand Down Expand Up @@ -114,9 +115,9 @@
} catch (err: any) {
let errorMessage = err.message;
const errorStatus = err?.context?.body?.error?.status;
if (errorStatus === "FAILED_PRECONDITION") {

Check warning on line 118 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
errorMessage = "invalid testers";
} else if (errorStatus === "INVALID_ARGUMENT") {

Check warning on line 120 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .context on an `any` value

Check warning on line 120 in src/appdistribution/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
errorMessage = "invalid groups";
}
throw new FirebaseError(`failed to distribute to testers/groups: ${errorMessage}`, {
Expand Down Expand Up @@ -272,6 +273,7 @@
async createReleaseTest(
releaseName: string,
devices: TestDevice[],
aiInstruction?: AIInstruction,
loginCredential?: LoginCredential,
testCaseName?: string,
): Promise<ReleaseTest> {
Expand All @@ -283,6 +285,7 @@
deviceExecutions: devices.map(mapDeviceToExecution),
loginCredential,
testCase: testCaseName,
aiInstructions: aiInstruction,
},
Comment on lines +288 to 289
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The aiInstructions parameter is being passed directly from the request to the backend. Ensure that this data is properly validated and sanitized to prevent potential injection attacks or unexpected behavior on the backend. Consider adding validation logic to the client to enforce a specific structure or allowed values for the AI instructions.

});
return response.body;
Expand Down
121 changes: 120 additions & 1 deletion src/appdistribution/distribution.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,74 @@
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",
APK = "apk",
AAB = "aab",
}

export async function upload(

Check warning on line 18 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
requests: AppDistributionClient,
appName: string,
distribution: Distribution,
): Promise<string> {
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.
*/
Expand Down Expand Up @@ -58,3 +118,62 @@
return this.fileName;
}
}

export async function awaitTestResults(

Check warning on line 122 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
releaseTests: ReleaseTest[],
requests: AppDistributionClient,
): Promise<void> {
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}`,

Check warning on line 151 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression
{ exit: 1 },
);
case "INCONCLUSIVE":
throw new FirebaseError(
`Automated test inconclusive for ${device}: ${execution.inconclusiveReason}`,

Check warning on line 156 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "string | undefined" of template literal expression
{ exit: 1 },
);
default:
throw new FirebaseError(
`Unsupported automated test state for ${device}: ${execution.state}`,

Check warning on line 161 in src/appdistribution/distribution.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "undefined" of template literal expression
{ 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<number> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function deviceToString(device: TestDevice): string {
return `${device.model} (${device.version}/${device.orientation}/${device.locale})`;
}
9 changes: 6 additions & 3 deletions src/appdistribution/options-parser-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* 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);
Expand Down Expand Up @@ -36,7 +36,7 @@
}

// Ensures a the file path that the user input is valid
export function ensureFileExists(file: string, message = ""): void {

Check warning on line 39 in src/appdistribution/options-parser-util.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
if (!fs.existsSync(file)) {
throw new FirebaseError(`File ${file} does not exist: ${message}`);
}
Expand All @@ -51,7 +51,7 @@
}

// Gets project name from project number
export async function getProjectName(options: any): Promise<string> {

Check warning on line 54 in src/appdistribution/options-parser-util.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const projectNumber = await needProjectNumber(options);
return `projects/${projectNumber}`;
}
Expand All @@ -61,7 +61,10 @@
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}`;
}

Expand All @@ -70,7 +73,7 @@
* 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);
Expand Down
11 changes: 11 additions & 0 deletions src/appdistribution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,15 @@ 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;
}
Loading
Loading