Skip to content

Commit 18c29f8

Browse files
jrothfederJamie Rothfedergemini-code-assist[bot]
authored
New MCP tool for running mobile tests (via app distribution). (#9250)
* Scaffolding for new appdistribution MCP tool. * Refactor business logic out of the appdistribution CLI so that it can be used by an MCP tool. * Wire new appdistribution tool up to the business logic. * Fix linting errors. * Update src/appdistribution/distribution.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Jamie Rothfeder <[email protected]> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 11b53b8 commit 18c29f8

File tree

10 files changed

+299
-220
lines changed

10 files changed

+299
-220
lines changed

src/appdistribution/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class AppDistributionClient {
6666
});
6767
}
6868

69-
async updateReleaseNotes(releaseName: string, releaseNotes: string): Promise<void> {
69+
async updateReleaseNotes(releaseName: string, releaseNotes?: string): Promise<void> {
7070
if (!releaseNotes) {
7171
utils.logWarning("no release notes specified, skipping");
7272
return;

src/appdistribution/distribution.ts

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,186 @@
11
import * as fs from "fs-extra";
2-
import { FirebaseError, getErrMsg } from "../error";
32
import { logger } from "../logger";
43
import * as pathUtil from "path";
4+
import * as utils from "../utils";
5+
import {
6+
AabInfo,
7+
IntegrationState,
8+
UploadReleaseResult,
9+
TestDevice,
10+
ReleaseTest,
11+
LoginCredential,
12+
} from "../appdistribution/types";
13+
import { AppDistributionClient } from "./client";
14+
import { FirebaseError, getErrMsg, getErrStatus } from "../error";
515

616
export enum DistributionFileType {
717
IPA = "ipa",
818
APK = "apk",
919
AAB = "aab",
1020
}
1121

22+
const TEST_MAX_POLLING_RETRIES = 40;
23+
const TEST_POLLING_INTERVAL_MILLIS = 30_000;
24+
25+
/**
26+
* Execute an app distribution action
27+
*/
28+
export async function distribute(
29+
appName: string,
30+
distribution: Distribution,
31+
testCases: string[],
32+
testDevices: TestDevice[],
33+
releaseNotes?: string,
34+
testers?: string[],
35+
groups?: string[],
36+
testNonBlocking?: boolean,
37+
loginCredential?: LoginCredential,
38+
) {
39+
const requests = new AppDistributionClient();
40+
let aabInfo: AabInfo | undefined;
41+
if (distribution.distributionFileType() === DistributionFileType.AAB) {
42+
try {
43+
aabInfo = await requests.getAabInfo(appName);
44+
} catch (err: unknown) {
45+
if (getErrStatus(err) === 404) {
46+
throw new FirebaseError(
47+
`App Distribution could not find your app ${appName}. ` +
48+
`Make sure to onboard your app by pressing the "Get started" ` +
49+
"button on the App Distribution page in the Firebase console: " +
50+
"https://console.firebase.google.com/project/_/appdistribution",
51+
{ exit: 1 },
52+
);
53+
}
54+
throw new FirebaseError(`failed to determine AAB info. ${getErrMsg(err)}`, { exit: 1 });
55+
}
56+
57+
if (
58+
aabInfo.integrationState !== IntegrationState.INTEGRATED &&
59+
aabInfo.integrationState !== IntegrationState.AAB_STATE_UNAVAILABLE
60+
) {
61+
switch (aabInfo.integrationState) {
62+
case IntegrationState.PLAY_ACCOUNT_NOT_LINKED: {
63+
throw new FirebaseError("This project is not linked to a Google Play account.");
64+
}
65+
case IntegrationState.APP_NOT_PUBLISHED: {
66+
throw new FirebaseError('"This app is not published in the Google Play console.');
67+
}
68+
case IntegrationState.NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT: {
69+
throw new FirebaseError("App with matching package name does not exist in Google Play.");
70+
}
71+
case IntegrationState.PLAY_IAS_TERMS_NOT_ACCEPTED: {
72+
throw new FirebaseError(
73+
"You must accept the Play Internal App Sharing (IAS) terms to upload AABs.",
74+
);
75+
}
76+
default: {
77+
throw new FirebaseError(
78+
"App Distribution failed to process the AAB: " + aabInfo.integrationState,
79+
);
80+
}
81+
}
82+
}
83+
}
84+
85+
utils.logBullet("uploading binary...");
86+
let releaseName;
87+
try {
88+
const operationName = await requests.uploadRelease(appName, distribution);
89+
90+
// The upload process is asynchronous, so poll to figure out when the upload has finished successfully
91+
const uploadResponse = await requests.pollUploadStatus(operationName);
92+
93+
const release = uploadResponse.release;
94+
switch (uploadResponse.result) {
95+
case UploadReleaseResult.RELEASE_CREATED:
96+
utils.logSuccess(
97+
`uploaded new release ${release.displayVersion} (${release.buildVersion}) successfully!`,
98+
);
99+
break;
100+
case UploadReleaseResult.RELEASE_UPDATED:
101+
utils.logSuccess(
102+
`uploaded update to existing release ${release.displayVersion} (${release.buildVersion}) successfully!`,
103+
);
104+
break;
105+
case UploadReleaseResult.RELEASE_UNMODIFIED:
106+
utils.logSuccess(
107+
`re-uploaded already existing release ${release.displayVersion} (${release.buildVersion}) successfully!`,
108+
);
109+
break;
110+
default:
111+
utils.logSuccess(
112+
`uploaded release ${release.displayVersion} (${release.buildVersion}) successfully!`,
113+
);
114+
}
115+
utils.logSuccess(`View this release in the Firebase console: ${release.firebaseConsoleUri}`);
116+
utils.logSuccess(`Share this release with testers who have access: ${release.testingUri}`);
117+
utils.logSuccess(
118+
`Download the release binary (link expires in 1 hour): ${release.binaryDownloadUri}`,
119+
);
120+
releaseName = uploadResponse.release.name;
121+
} catch (err: unknown) {
122+
if (getErrStatus(err) === 404) {
123+
throw new FirebaseError(
124+
`App Distribution could not find your app ${appName}. ` +
125+
`Make sure to onboard your app by pressing the "Get started" ` +
126+
"button on the App Distribution page in the Firebase console: " +
127+
"https://console.firebase.google.com/project/_/appdistribution",
128+
{ exit: 1 },
129+
);
130+
}
131+
throw new FirebaseError(`Failed to upload release. ${getErrMsg(err)}`, { exit: 1 });
132+
}
133+
134+
// If this is an app bundle and the certificate was originally blank fetch the updated
135+
// certificate and print
136+
if (aabInfo && !aabInfo.testCertificate) {
137+
aabInfo = await requests.getAabInfo(appName);
138+
if (aabInfo.testCertificate) {
139+
utils.logBullet(
140+
"After you upload an AAB for the first time, App Distribution " +
141+
"generates a new test certificate. All AAB uploads are re-signed with this test " +
142+
"certificate. Use the certificate fingerprints below to register your app " +
143+
"signing key with API providers, such as Google Sign-In and Google Maps.\n" +
144+
`MD-1 certificate fingerprint: ${aabInfo.testCertificate.hashMd5}\n` +
145+
`SHA-1 certificate fingerprint: ${aabInfo.testCertificate.hashSha1}\n` +
146+
`SHA-256 certificate fingerprint: ${aabInfo.testCertificate.hashSha256}`,
147+
);
148+
}
149+
}
150+
151+
// Add release notes and distribute to testers/groups
152+
await requests.updateReleaseNotes(releaseName, releaseNotes);
153+
await requests.distribute(releaseName, testers, groups);
154+
155+
// Run automated tests
156+
if (testDevices.length) {
157+
utils.logBullet("starting automated test (note: this feature is in beta)");
158+
const releaseTestPromises: Promise<ReleaseTest>[] = [];
159+
if (!testCases.length) {
160+
// fallback to basic automated test
161+
releaseTestPromises.push(
162+
requests.createReleaseTest(releaseName, testDevices, loginCredential),
163+
);
164+
} else {
165+
for (const testCaseId of testCases) {
166+
releaseTestPromises.push(
167+
requests.createReleaseTest(
168+
releaseName,
169+
testDevices,
170+
loginCredential,
171+
`${appName}/testCases/${testCaseId}`,
172+
),
173+
);
174+
}
175+
}
176+
const releaseTests = await Promise.all(releaseTestPromises);
177+
utils.logSuccess(`${releaseTests.length} release test(s) started successfully`);
178+
if (!testNonBlocking) {
179+
await awaitTestResults(releaseTests, requests);
180+
}
181+
}
182+
}
183+
12184
/**
13185
* Object representing an APK, AAB or IPA file. Used for uploading app distributions.
14186
*/
@@ -58,3 +230,59 @@ export class Distribution {
58230
return this.fileName;
59231
}
60232
}
233+
234+
async function awaitTestResults(
235+
releaseTests: ReleaseTest[],
236+
requests: AppDistributionClient,
237+
): Promise<void> {
238+
const releaseTestNames = new Set(releaseTests.map((rt) => rt.name).filter((n): n is string => !!n));
239+
for (let i = 0; i < TEST_MAX_POLLING_RETRIES; i++) {
240+
utils.logBullet(`${releaseTestNames.size} automated test results are pending...`);
241+
await delay(TEST_POLLING_INTERVAL_MILLIS);
242+
for (const releaseTestName of releaseTestNames) {
243+
const releaseTest = await requests.getReleaseTest(releaseTestName);
244+
if (releaseTest.deviceExecutions.every((e) => e.state === "PASSED")) {
245+
releaseTestNames.delete(releaseTestName);
246+
if (releaseTestNames.size === 0) {
247+
utils.logSuccess("Automated test(s) passed!");
248+
return;
249+
} else {
250+
continue;
251+
}
252+
}
253+
for (const execution of releaseTest.deviceExecutions) {
254+
switch (execution.state) {
255+
case "PASSED":
256+
case "IN_PROGRESS":
257+
continue;
258+
case "FAILED":
259+
throw new FirebaseError(
260+
`Automated test failed for ${deviceToString(execution.device)}: ${execution.failedReason}`,
261+
{ exit: 1 },
262+
);
263+
case "INCONCLUSIVE":
264+
throw new FirebaseError(
265+
`Automated test inconclusive for ${deviceToString(execution.device)}: ${execution.inconclusiveReason}`,
266+
{ exit: 1 },
267+
);
268+
default:
269+
throw new FirebaseError(
270+
`Unsupported automated test state for ${deviceToString(execution.device)}: ${execution.state}`,
271+
{ exit: 1 },
272+
);
273+
}
274+
}
275+
}
276+
}
277+
throw new FirebaseError("It took longer than expected to run your test(s), please try again.", {
278+
exit: 1,
279+
});
280+
}
281+
282+
function delay(ms: number): Promise<number> {
283+
return new Promise((resolve) => setTimeout(resolve, ms));
284+
}
285+
286+
function deviceToString(device: TestDevice): string {
287+
return `${device.model} (${device.version}/${device.orientation}/${device.locale})`;
288+
}

src/appdistribution/options-parser-util.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { FieldHints, LoginCredential, TestDevice } from "./types";
88
* file and converts the input into an string[].
99
* Value takes precedent over file.
1010
*/
11-
export function parseIntoStringArray(value: string, file: string): string[] {
11+
export function parseIntoStringArray(value: string, file = ""): string[] {
1212
// If there is no value then the file gets parsed into a string to be split
1313
if (!value && file) {
1414
ensureFileExists(file);
@@ -61,7 +61,10 @@ export function getAppName(options: any): string {
6161
if (!options.app) {
6262
throw new FirebaseError("set the --app option to a valid Firebase app id and try again");
6363
}
64-
const appId = options.app;
64+
return toAppName(options.app);
65+
}
66+
67+
export function toAppName(appId: string) {
6568
return `projects/${appId.split(":")[1]}/apps/${appId}`;
6669
}
6770

@@ -70,7 +73,7 @@ export function getAppName(options: any): string {
7073
* and converts the input into a string[] of test device strings.
7174
* Value takes precedent over file.
7275
*/
73-
export function parseTestDevices(value: string, file: string): TestDevice[] {
76+
export function parseTestDevices(value: string, file = ""): TestDevice[] {
7477
// If there is no value then the file gets parsed into a string to be split
7578
if (!value && file) {
7679
ensureFileExists(file);

0 commit comments

Comments
 (0)