Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for automated tests in App Distribution #6730

Merged
merged 16 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Added rudimentary email enumeration protection for auth emulator. (#6702)
- 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)
rebehe marked this conversation as resolved.
Show resolved Hide resolved
124 changes: 49 additions & 75 deletions src/appdistribution/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,83 +6,32 @@
import { FirebaseError } from "../error";
import { Client } from "../apiv2";
import { appDistributionOrigin } from "../api";

/**
* Helper interface for an app that is provisioned with App Distribution
*/
export interface AabInfo {
name: string;
integrationState: IntegrationState;
testCertificate: TestCertificate | null;
}

export interface TestCertificate {
hashSha1: string;
hashSha256: string;
hashMd5: string;
}

/** Enum representing the App Bundles state for the App */
export enum IntegrationState {
AAB_INTEGRATION_STATE_UNSPECIFIED = "AAB_INTEGRATION_STATE_UNSPECIFIED",
INTEGRATED = "INTEGRATED",
PLAY_ACCOUNT_NOT_LINKED = "PLAY_ACCOUNT_NOT_LINKED",
NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT = "NO_APP_WITH_GIVEN_BUNDLE_ID_IN_PLAY_ACCOUNT",
APP_NOT_PUBLISHED = "APP_NOT_PUBLISHED",
AAB_STATE_UNAVAILABLE = "AAB_STATE_UNAVAILABLE",
PLAY_IAS_TERMS_NOT_ACCEPTED = "PLAY_IAS_TERMS_NOT_ACCEPTED",
}

export enum UploadReleaseResult {
UPLOAD_RELEASE_RESULT_UNSPECIFIED = "UPLOAD_RELEASE_RESULT_UNSPECIFIED",
RELEASE_CREATED = "RELEASE_CREATED",
RELEASE_UPDATED = "RELEASE_UPDATED",
RELEASE_UNMODIFIED = "RELEASE_UNMODIFIED",
}

export interface Release {
name: string;
releaseNotes: ReleaseNotes;
displayVersion: string;
buildVersion: string;
createTime: Date;
firebaseConsoleUri: string;
testingUri: string;
binaryDownloadUri: string;
}

export interface ReleaseNotes {
text: string;
}

export interface UploadReleaseResponse {
result: UploadReleaseResult;
release: Release;
}

export interface BatchRemoveTestersResponse {
emails: string[];
}

export interface Group {
name: string;
displayName: string;
testerCount?: number;
releaseCount?: number;
inviteLinkCount?: number;
}
import {
AabInfo,
BatchRemoveTestersResponse,
Group,
LoginCredential,
mapDeviceToExecution,
ReleaseTest,
TestDevice,
UploadReleaseResponse,
} from "./types";

/**
* Makes RPCs to the App Distribution server backend.
*/
export class AppDistributionClient {
appDistroV2Client = new Client({
lfkellogg marked this conversation as resolved.
Show resolved Hide resolved
appDistroV1Client = new Client({
urlPrefix: appDistributionOrigin,
apiVersion: "v1",
});
appDistroV1AlphaClient = new Client({
urlPrefix: appDistributionOrigin,
apiVersion: "v1alpha",
});

async getAabInfo(appName: string): Promise<AabInfo> {
const apiResponse = await this.appDistroV2Client.get<AabInfo>(`/${appName}/aabInfo`);
const apiResponse = await this.appDistroV1Client.get<AabInfo>(`/${appName}/aabInfo`);
return apiResponse.body;
}

Expand Down Expand Up @@ -131,9 +80,9 @@
const queryParams = { updateMask: "release_notes.text" };

try {
await this.appDistroV2Client.patch(`/${releaseName}`, data, { queryParams });
await this.appDistroV1Client.patch(`/${releaseName}`, data, { queryParams });
} catch (err: any) {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(`failed to update release notes with ${err?.message}`);

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

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "any" of template literal expression

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value
}

utils.logSuccess("added release notes successfully");
Expand All @@ -157,16 +106,16 @@
};

try {
await this.appDistroV2Client.post(`/${releaseName}:distribute`, data);
await this.appDistroV1Client.post(`/${releaseName}:distribute`, data);
} catch (err: any) {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
let errorMessage = err.message;

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value
const errorStatus = err?.context?.body?.error?.status;

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .context on an `any` value
if (errorStatus === "FAILED_PRECONDITION") {
errorMessage = "invalid testers";
} else if (errorStatus === "INVALID_ARGUMENT") {
errorMessage = "invalid groups";
}
throw new FirebaseError(`failed to distribute to testers/groups: ${errorMessage}`, {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "any" of template literal expression
exit: 1,
});
}
Expand All @@ -176,12 +125,12 @@

async addTesters(projectName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
await this.appDistroV1Client.request({
method: "POST",
path: `${projectName}/testers:batchAdd`,
body: { emails: emails },
});
} catch (err: any) {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
throw new FirebaseError(`Failed to add testers ${err}`);
}

Expand All @@ -191,7 +140,7 @@
async removeTesters(projectName: string, emails: string[]): Promise<BatchRemoveTestersResponse> {
let apiResponse;
try {
apiResponse = await this.appDistroV2Client.request<
apiResponse = await this.appDistroV1Client.request<
{ emails: string[] },
BatchRemoveTestersResponse
>({
Expand All @@ -208,7 +157,7 @@
async createGroup(projectName: string, displayName: string, alias?: string): Promise<Group> {
let apiResponse;
try {
apiResponse = await this.appDistroV2Client.request<{ displayName: string }, Group>({
apiResponse = await this.appDistroV1Client.request<{ displayName: string }, Group>({
method: "POST",
path:
alias === undefined ? `${projectName}/groups` : `${projectName}/groups?groupId=${alias}`,
Expand All @@ -222,7 +171,7 @@

async deleteGroup(groupName: string): Promise<void> {
try {
await this.appDistroV2Client.request({
await this.appDistroV1Client.request({
method: "DELETE",
path: groupName,
});
Expand All @@ -235,7 +184,7 @@

async addTestersToGroup(groupName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
await this.appDistroV1Client.request({
method: "POST",
path: `${groupName}:batchJoin`,
body: { emails: emails },
Expand All @@ -249,7 +198,7 @@

async removeTestersFromGroup(groupName: string, emails: string[]): Promise<void> {
try {
await this.appDistroV2Client.request({
await this.appDistroV1Client.request({
method: "POST",
path: `${groupName}:batchLeave`,
body: { emails: emails },
Expand All @@ -260,4 +209,29 @@

utils.logSuccess(`Testers removed from group successfully`);
}

async createReleaseTest(
releaseName: string,
devices: TestDevice[],
loginCredential?: LoginCredential,
): Promise<ReleaseTest> {
try {
const response = await this.appDistroV1AlphaClient.request<ReleaseTest, ReleaseTest>({
method: "POST",
path: `${releaseName}/tests`,
body: {
deviceExecutions: devices.map(mapDeviceToExecution),
loginCredential,
},
});
return response.body;
} catch (err: any) {
throw new FirebaseError(`Failed to create release test ${err}`);
}
}

async getReleaseTest(releaseTestName: string): Promise<ReleaseTest> {
const response = await this.appDistroV1AlphaClient.get<ReleaseTest>(releaseTestName);
return response.body;
}
}
113 changes: 113 additions & 0 deletions src/appdistribution/options-parser-util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "fs-extra";
import { FirebaseError } from "../error";
import { needProjectNumber } from "../projectUtils";
import { FieldHints, LoginCredential, TestDevice } from "./types";

/**
* Takes in comma separated string or a path to a comma/new line separated file
Expand Down Expand Up @@ -48,6 +49,7 @@ function splitter(value: string): string[] {
.map((entry) => entry.trim())
.filter((entry) => !!entry);
}

// Gets project name from project number
export async function getProjectName(options: any): Promise<string> {
const projectNumber = await needProjectNumber(options);
Expand All @@ -62,3 +64,114 @@ export function getAppName(options: any): string {
const appId = options.app;
return `projects/${appId.split(":")[1]}/apps/${appId}`;
}

/**
* Takes in comma separated string or a path to a comma/new line separated file
* and converts the input into a string[] of test device strings. Value takes precedent
* over file.
*/
export function getTestDevices(value: string, file: string): TestDevice[] {
// If there is no value then the file gets parsed into a string to be split
if (!value && file) {
ensureFileExists(file);
value = fs.readFileSync(file, "utf8");
}

if (!value) {
return [];
}

return value
.split(/[;\n]/)
.map((entry) => entry.trim())
.filter((entry) => !!entry)
.map((str) => parseTestDevice(str));
}

function parseTestDevice(testDeviceString: string): TestDevice {
const entries = testDeviceString.split(",");
const allowedKeys = new Set(["model", "version", "orientation", "locale"]);
let model: string | undefined;
let version: string | undefined;
let orientation: string | undefined;
let locale: string | undefined;
for (const entry of entries) {
const keyAndValue = entry.split("=");
switch (keyAndValue[0]) {
case "model":
model = keyAndValue[1];
break;
case "version":
version = keyAndValue[1];
break;
case "orientation":
orientation = keyAndValue[1];
break;
case "locale":
locale = keyAndValue[1];
break;
default:
throw new FirebaseError(
`Unrecognized key in test devices. Can only contain ${Array.from(allowedKeys).join(", ")}`,
);
}
}

if (!model || !version || !orientation || !locale) {
throw new FirebaseError(
"Test devices must be in the format 'model=<model-id>,version=<os-version-id>,locale=<locale>,orientation=<orientation>'",
);
}
return { model, version, locale, orientation };
}

/**
* Takes option values for username and password related options and returns a LoginCredential
* object that can be passed to the API.
*/
export function getLoginCredential(args: {
username?: string;
password?: string;
passwordFile?: string;
usernameResourceName?: string;
passwordResourceName?: string;
}): LoginCredential | undefined {
const { username, passwordFile, usernameResourceName, passwordResourceName } = args;
let password = args.password;
if (!password && passwordFile) {
ensureFileExists(passwordFile);
password = fs.readFileSync(passwordFile, "utf8").trim();
}

if (isPresenceMismatched(usernameResourceName, passwordResourceName)) {
throw new FirebaseError(
"Username and password resource names for automated tests need to be specified together.",
);
}
let fieldHints: FieldHints | undefined;
if (usernameResourceName && passwordResourceName) {
fieldHints = {
usernameResourceName: usernameResourceName,
passwordResourceName: passwordResourceName,
};
}

if (isPresenceMismatched(username, password)) {
throw new FirebaseError(
"Username and password for automated tests need to be specified together.",
);
}
let loginCredential: LoginCredential | undefined;
if (username && password) {
loginCredential = { username, password, fieldHints };
} else if (fieldHints) {
throw new FirebaseError(
"Must specify username and password for automated tests if resource names are set",
);
}
return loginCredential;
}

function isPresenceMismatched(value1?: string, value2?: string) {
return (value1 && !value2) || (!value1 && value2);
}