Skip to content

Commit c95059d

Browse files
lfkelloggrebehe
andauthored
Add support for automated tests in App Distribution (#6730)
* Add support for automated tests in App Distribution * Add tests * Fix username and password resource fields, and implement async test flag * Fix formatting and undo temporary changes * Fix test * Change test timeout to 20 minutes * Add test for options parser util * Lint fixes * Add CHANGELOG entry * Add 'beta' messaging * Address feedback from @joehan and go/atad-cli-api-review * Move mapDeviceToExecution to types.ts * Add tests for reading password from file * Change --test-async to --test-non-blocking * Support password files with trailing newlines * Use args object for getLoginCredential() --------- Co-authored-by: Rebecca He <rebeccahe@google.com>
1 parent 8e388c1 commit c95059d

File tree

7 files changed

+667
-83
lines changed

7 files changed

+667
-83
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Added rudimentary email enumeration protection for auth emulator. (#6702)
2+
- You can now run customized automated tests on your Android apps in App Distribution, with the Automated Tester feature (beta). This feature automatically runs tests on your Android apps on virtual and physical devices at different API levels. To learn how to run an automated test, see [Run an automated test for Android apps](https://firebase.google.com/docs/app-distribution/android-automated-tester). (#6730)

src/appdistribution/client.ts

Lines changed: 49 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,83 +6,32 @@ import { Distribution } from "./distribution";
66
import { FirebaseError } from "../error";
77
import { Client } from "../apiv2";
88
import { appDistributionOrigin } from "../api";
9-
10-
/**
11-
* Helper interface for an app that is provisioned with App Distribution
12-
*/
13-
export interface AabInfo {
14-
name: string;
15-
integrationState: IntegrationState;
16-
testCertificate: TestCertificate | null;
17-
}
18-
19-
export interface TestCertificate {
20-
hashSha1: string;
21-
hashSha256: string;
22-
hashMd5: string;
23-
}
24-
25-
/** Enum representing the App Bundles state for the App */
26-
export enum IntegrationState {
27-
AAB_INTEGRATION_STATE_UNSPECIFIED = "AAB_INTEGRATION_STATE_UNSPECIFIED",
28-
INTEGRATED = "INTEGRATED",
29-
PLAY_ACCOUNT_NOT_LINKED = "PLAY_ACCOUNT_NOT_LINKED",
30-
NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT = "NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT",
31-
APP_NOT_PUBLISHED = "APP_NOT_PUBLISHED",
32-
AAB_STATE_UNAVAILABLE = "AAB_STATE_UNAVAILABLE",
33-
PLAY_IAS_TERMS_NOT_ACCEPTED = "PLAY_IAS_TERMS_NOT_ACCEPTED",
34-
}
35-
36-
export enum UploadReleaseResult {
37-
UPLOAD_RELEASE_RESULT_UNSPECIFIED = "UPLOAD_RELEASE_RESULT_UNSPECIFIED",
38-
RELEASE_CREATED = "RELEASE_CREATED",
39-
RELEASE_UPDATED = "RELEASE_UPDATED",
40-
RELEASE_UNMODIFIED = "RELEASE_UNMODIFIED",
41-
}
42-
43-
export interface Release {
44-
name: string;
45-
releaseNotes: ReleaseNotes;
46-
displayVersion: string;
47-
buildVersion: string;
48-
createTime: Date;
49-
firebaseConsoleUri: string;
50-
testingUri: string;
51-
binaryDownloadUri: string;
52-
}
53-
54-
export interface ReleaseNotes {
55-
text: string;
56-
}
57-
58-
export interface UploadReleaseResponse {
59-
result: UploadReleaseResult;
60-
release: Release;
61-
}
62-
63-
export interface BatchRemoveTestersResponse {
64-
emails: string[];
65-
}
66-
67-
export interface Group {
68-
name: string;
69-
displayName: string;
70-
testerCount?: number;
71-
releaseCount?: number;
72-
inviteLinkCount?: number;
73-
}
9+
import {
10+
AabInfo,
11+
BatchRemoveTestersResponse,
12+
Group,
13+
LoginCredential,
14+
mapDeviceToExecution,
15+
ReleaseTest,
16+
TestDevice,
17+
UploadReleaseResponse,
18+
} from "./types";
7419

7520
/**
7621
* Makes RPCs to the App Distribution server backend.
7722
*/
7823
export class AppDistributionClient {
79-
appDistroV2Client = new Client({
24+
appDistroV1Client = new Client({
8025
urlPrefix: appDistributionOrigin,
8126
apiVersion: "v1",
8227
});
28+
appDistroV1AlphaClient = new Client({
29+
urlPrefix: appDistributionOrigin,
30+
apiVersion: "v1alpha",
31+
});
8332

8433
async getAabInfo(appName: string): Promise<AabInfo> {
85-
const apiResponse = await this.appDistroV2Client.get<AabInfo>(`/${appName}/aabInfo`);
34+
const apiResponse = await this.appDistroV1Client.get<AabInfo>(`/${appName}/aabInfo`);
8635
return apiResponse.body;
8736
}
8837

@@ -131,7 +80,7 @@ export class AppDistributionClient {
13180
const queryParams = { updateMask: "release_notes.text" };
13281

13382
try {
134-
await this.appDistroV2Client.patch(`/${releaseName}`, data, { queryParams });
83+
await this.appDistroV1Client.patch(`/${releaseName}`, data, { queryParams });
13584
} catch (err: any) {
13685
throw new FirebaseError(`failed to update release notes with ${err?.message}`);
13786
}
@@ -157,7 +106,7 @@ export class AppDistributionClient {
157106
};
158107

159108
try {
160-
await this.appDistroV2Client.post(`/${releaseName}:distribute`, data);
109+
await this.appDistroV1Client.post(`/${releaseName}:distribute`, data);
161110
} catch (err: any) {
162111
let errorMessage = err.message;
163112
const errorStatus = err?.context?.body?.error?.status;
@@ -176,7 +125,7 @@ export class AppDistributionClient {
176125

177126
async addTesters(projectName: string, emails: string[]): Promise<void> {
178127
try {
179-
await this.appDistroV2Client.request({
128+
await this.appDistroV1Client.request({
180129
method: "POST",
181130
path: `${projectName}/testers:batchAdd`,
182131
body: { emails: emails },
@@ -191,7 +140,7 @@ export class AppDistributionClient {
191140
async removeTesters(projectName: string, emails: string[]): Promise<BatchRemoveTestersResponse> {
192141
let apiResponse;
193142
try {
194-
apiResponse = await this.appDistroV2Client.request<
143+
apiResponse = await this.appDistroV1Client.request<
195144
{ emails: string[] },
196145
BatchRemoveTestersResponse
197146
>({
@@ -208,7 +157,7 @@ export class AppDistributionClient {
208157
async createGroup(projectName: string, displayName: string, alias?: string): Promise<Group> {
209158
let apiResponse;
210159
try {
211-
apiResponse = await this.appDistroV2Client.request<{ displayName: string }, Group>({
160+
apiResponse = await this.appDistroV1Client.request<{ displayName: string }, Group>({
212161
method: "POST",
213162
path:
214163
alias === undefined ? `${projectName}/groups` : `${projectName}/groups?groupId=${alias}`,
@@ -222,7 +171,7 @@ export class AppDistributionClient {
222171

223172
async deleteGroup(groupName: string): Promise<void> {
224173
try {
225-
await this.appDistroV2Client.request({
174+
await this.appDistroV1Client.request({
226175
method: "DELETE",
227176
path: groupName,
228177
});
@@ -235,7 +184,7 @@ export class AppDistributionClient {
235184

236185
async addTestersToGroup(groupName: string, emails: string[]): Promise<void> {
237186
try {
238-
await this.appDistroV2Client.request({
187+
await this.appDistroV1Client.request({
239188
method: "POST",
240189
path: `${groupName}:batchJoin`,
241190
body: { emails: emails },
@@ -249,7 +198,7 @@ export class AppDistributionClient {
249198

250199
async removeTestersFromGroup(groupName: string, emails: string[]): Promise<void> {
251200
try {
252-
await this.appDistroV2Client.request({
201+
await this.appDistroV1Client.request({
253202
method: "POST",
254203
path: `${groupName}:batchLeave`,
255204
body: { emails: emails },
@@ -260,4 +209,29 @@ export class AppDistributionClient {
260209

261210
utils.logSuccess(`Testers removed from group successfully`);
262211
}
212+
213+
async createReleaseTest(
214+
releaseName: string,
215+
devices: TestDevice[],
216+
loginCredential?: LoginCredential,
217+
): Promise<ReleaseTest> {
218+
try {
219+
const response = await this.appDistroV1AlphaClient.request<ReleaseTest, ReleaseTest>({
220+
method: "POST",
221+
path: `${releaseName}/tests`,
222+
body: {
223+
deviceExecutions: devices.map(mapDeviceToExecution),
224+
loginCredential,
225+
},
226+
});
227+
return response.body;
228+
} catch (err: any) {
229+
throw new FirebaseError(`Failed to create release test ${err}`);
230+
}
231+
}
232+
233+
async getReleaseTest(releaseTestName: string): Promise<ReleaseTest> {
234+
const response = await this.appDistroV1AlphaClient.get<ReleaseTest>(releaseTestName);
235+
return response.body;
236+
}
263237
}

src/appdistribution/options-parser-util.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "fs-extra";
22
import { FirebaseError } from "../error";
33
import { needProjectNumber } from "../projectUtils";
4+
import { FieldHints, LoginCredential, TestDevice } from "./types";
45

56
/**
67
* Takes in comma separated string or a path to a comma/new line separated file
@@ -48,6 +49,7 @@ function splitter(value: string): string[] {
4849
.map((entry) => entry.trim())
4950
.filter((entry) => !!entry);
5051
}
52+
5153
// Gets project name from project number
5254
export async function getProjectName(options: any): Promise<string> {
5355
const projectNumber = await needProjectNumber(options);
@@ -62,3 +64,114 @@ export function getAppName(options: any): string {
6264
const appId = options.app;
6365
return `projects/${appId.split(":")[1]}/apps/${appId}`;
6466
}
67+
68+
/**
69+
* Takes in comma separated string or a path to a comma/new line separated file
70+
* and converts the input into a string[] of test device strings. Value takes precedent
71+
* over file.
72+
*/
73+
export function getTestDevices(value: string, file: string): TestDevice[] {
74+
// If there is no value then the file gets parsed into a string to be split
75+
if (!value && file) {
76+
ensureFileExists(file);
77+
value = fs.readFileSync(file, "utf8");
78+
}
79+
80+
if (!value) {
81+
return [];
82+
}
83+
84+
return value
85+
.split(/[;\n]/)
86+
.map((entry) => entry.trim())
87+
.filter((entry) => !!entry)
88+
.map((str) => parseTestDevice(str));
89+
}
90+
91+
function parseTestDevice(testDeviceString: string): TestDevice {
92+
const entries = testDeviceString.split(",");
93+
const allowedKeys = new Set(["model", "version", "orientation", "locale"]);
94+
let model: string | undefined;
95+
let version: string | undefined;
96+
let orientation: string | undefined;
97+
let locale: string | undefined;
98+
for (const entry of entries) {
99+
const keyAndValue = entry.split("=");
100+
switch (keyAndValue[0]) {
101+
case "model":
102+
model = keyAndValue[1];
103+
break;
104+
case "version":
105+
version = keyAndValue[1];
106+
break;
107+
case "orientation":
108+
orientation = keyAndValue[1];
109+
break;
110+
case "locale":
111+
locale = keyAndValue[1];
112+
break;
113+
default:
114+
throw new FirebaseError(
115+
`Unrecognized key in test devices. Can only contain ${Array.from(allowedKeys).join(", ")}`,
116+
);
117+
}
118+
}
119+
120+
if (!model || !version || !orientation || !locale) {
121+
throw new FirebaseError(
122+
"Test devices must be in the format 'model=<model-id>,version=<os-version-id>,locale=<locale>,orientation=<orientation>'",
123+
);
124+
}
125+
return { model, version, locale, orientation };
126+
}
127+
128+
/**
129+
* Takes option values for username and password related options and returns a LoginCredential
130+
* object that can be passed to the API.
131+
*/
132+
export function getLoginCredential(args: {
133+
username?: string;
134+
password?: string;
135+
passwordFile?: string;
136+
usernameResourceName?: string;
137+
passwordResourceName?: string;
138+
}): LoginCredential | undefined {
139+
const { username, passwordFile, usernameResourceName, passwordResourceName } = args;
140+
let password = args.password;
141+
if (!password && passwordFile) {
142+
ensureFileExists(passwordFile);
143+
password = fs.readFileSync(passwordFile, "utf8").trim();
144+
}
145+
146+
if (isPresenceMismatched(usernameResourceName, passwordResourceName)) {
147+
throw new FirebaseError(
148+
"Username and password resource names for automated tests need to be specified together.",
149+
);
150+
}
151+
let fieldHints: FieldHints | undefined;
152+
if (usernameResourceName && passwordResourceName) {
153+
fieldHints = {
154+
usernameResourceName: usernameResourceName,
155+
passwordResourceName: passwordResourceName,
156+
};
157+
}
158+
159+
if (isPresenceMismatched(username, password)) {
160+
throw new FirebaseError(
161+
"Username and password for automated tests need to be specified together.",
162+
);
163+
}
164+
let loginCredential: LoginCredential | undefined;
165+
if (username && password) {
166+
loginCredential = { username, password, fieldHints };
167+
} else if (fieldHints) {
168+
throw new FirebaseError(
169+
"Must specify username and password for automated tests if resource names are set",
170+
);
171+
}
172+
return loginCredential;
173+
}
174+
175+
function isPresenceMismatched(value1?: string, value2?: string) {
176+
return (value1 && !value2) || (!value1 && value2);
177+
}

0 commit comments

Comments
 (0)