Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 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
0bc8208
Address PR comments
tagboola Nov 7, 2025
d642542
Merge branch 'master' into feature-branch/mcp/mobile-testing
jrothfeder Nov 7, 2025
9877ee5
Fix the status URL. (#9438)
jrothfeder Nov 7, 2025
1c4631d
Add a tool to export tests to a file.
Nov 7, 2025
f34e992
Merge branch 'master' into serizlize
Nov 7, 2025
a1de3cd
Remove merge conflict markers.
Nov 10, 2025
6d6b1a9
Simplify conversion of testcases to yaml.
Nov 10, 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 { appDistributionOrigin } from "../api";

import {
AabInfo,
AIInstruction,
BatchRemoveTestersResponse,
BatchUpdateTestCasesRequest,
BatchUpdateTestCasesResponse,
Expand Down Expand Up @@ -70,7 +71,7 @@ export class AppDistributionClient {
});
}

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 @@ -275,6 +276,7 @@ export class AppDistributionClient {
async createReleaseTest(
releaseName: string,
devices: TestDevice[],
aiInstruction?: AIInstruction,
loginCredential?: LoginCredential,
testCaseName?: string,
): Promise<ReleaseTest> {
Expand All @@ -286,6 +288,7 @@ export class AppDistributionClient {
deviceExecutions: devices.map((device) => ({ device })),
loginCredential,
testCase: testCaseName,
aiInstructions: aiInstruction,
},
});
return response.body;
Expand Down
123 changes: 122 additions & 1 deletion src/appdistribution/distribution.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,75 @@
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",
}

/** Upload a distribution */
export async function upload(
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 +119,63 @@ export class Distribution {
return this.fileName;
}
}

/** Wait for release tests to complete */
export async function awaitTestResults(
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;
}
Comment on lines 138 to 143
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The else { continue; } block is redundant. The for...of loop will naturally continue to the next iteration if the preceding if condition is not met. Removing this else block will make the code slightly cleaner.

        if (releaseTestNames.size === 0) {
          utils.logSuccess("Automated test(s) passed!");
          return;
        }

}
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<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 @@ 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);
Expand Down Expand Up @@ -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}`;
}

Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/appdistribution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/appdistribution/yaml_helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ function extractIdFromResourceName(name: string): string {
function toYamlTestCases(testCases: TestCase[]): YamlTestCase[] {
return testCases.map((testCase) => ({
displayName: testCase.displayName,
id: extractIdFromResourceName(testCase.name!), // resource name is retured by server
...(testCase.name && {
id: extractIdFromResourceName(testCase.name!), // resource name is retured by server
}),
...(testCase.prerequisiteTestCase && {
prerequisiteTestCaseId: extractIdFromResourceName(testCase.prerequisiteTestCase),
}),
Expand Down
Loading
Loading