Skip to content

Commit 4d2d951

Browse files
committed
chore: add implementation of GetExperiment
1 parent e175bb6 commit 4d2d951

11 files changed

+823
-0
lines changed

src/commands/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ export function load(client: any): any {
213213
client.remoteconfig.rollback = loadCommand("remoteconfig-rollback");
214214
client.remoteconfig.versions = {};
215215
client.remoteconfig.versions.list = loadCommand("remoteconfig-versions-list");
216+
client.remoteconfig.experiments = {};
217+
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
218+
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
219+
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
216220
client.serve = loadCommand("serve");
217221
client.setup = {};
218222
client.setup.emulators = {};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Command } from "../command";
2+
import { Options } from "../options";
3+
import { requireAuth } from "../requireAuth";
4+
import { requirePermissions } from "../requirePermissions";
5+
import * as clc from "colorette";
6+
import { logger } from "../logger";
7+
import { needProjectNumber } from "../projectUtils";
8+
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
9+
import * as rcExperiment from "../remoteconfig/deleteexperiment";
10+
import { getExperiment, parseExperiment } from "../remoteconfig/getexperiment";
11+
import { confirm } from "../prompt";
12+
13+
export const command = new Command("remoteconfig:experiments:delete [experimentId]")
14+
.description("delete a Remote Config experiment")
15+
.before(requireAuth)
16+
.before(requirePermissions, [
17+
"firebaseabt.experiments.delete",
18+
"firebaseanalytics.resources.googleAnalyticsEdit",
19+
])
20+
.action(async (experimentId: string, options: Options) => {
21+
const projectNumber: string = await needProjectNumber(options);
22+
const experiment = await getExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId);
23+
logger.info(parseExperiment(experiment));
24+
const confirmDeletion = await confirm(
25+
"Are you sure you want to delete this experiment? This cannot be undone.",
26+
);
27+
if (!confirmDeletion) {
28+
return;
29+
}
30+
await rcExperiment.deleteExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId);
31+
logger.info(clc.bold(`Successfully deleted experiment ${clc.yellow(experimentId)}`));
32+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Command } from "../command";
2+
import { Options } from "../options";
3+
import { requireAuth } from "../requireAuth";
4+
import { requirePermissions } from "../requirePermissions";
5+
import { logger } from "../logger";
6+
import { needProjectNumber } from "../projectUtils";
7+
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
8+
import * as rcExperiment from "../remoteconfig/getexperiment";
9+
10+
export const command = new Command("remoteconfig:experiments:get [experimentId]")
11+
.description("retrieve a Remote Config experiment")
12+
.before(requireAuth)
13+
.before(requirePermissions, ["firebaseabt.experiments.get"])
14+
.action(async (experimentId: string, options: Options) => {
15+
const projectNumber: string = await needProjectNumber(options);
16+
const experiment: GetExperimentResult = await rcExperiment.getExperiment(
17+
projectNumber,
18+
NAMESPACE_FIREBASE,
19+
experimentId,
20+
);
21+
logger.info(rcExperiment.parseExperiment(experiment));
22+
return experiment;
23+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as rcExperiment from "../remoteconfig/listexperiments";
2+
import {
3+
DEFAULT_PAGE_SIZE,
4+
ListExperimentOptions,
5+
ListExperimentsResult,
6+
NAMESPACE_FIREBASE,
7+
} from "../remoteconfig/interfaces";
8+
import { Command } from "../command";
9+
import { Options } from "../options";
10+
import { requireAuth } from "../requireAuth";
11+
import { requirePermissions } from "../requirePermissions";
12+
import { logger } from "../logger";
13+
import { needProjectNumber } from "../projectUtils";
14+
15+
export const command = new Command("remoteconfig:experiments:list")
16+
.description("get a list of Remote Config experiments")
17+
.option(
18+
"--pageSize <pageSize>",
19+
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments",
20+
)
21+
.option(
22+
"--pageToken <pageToken>",
23+
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
24+
)
25+
.option(
26+
"--filter <filter>",
27+
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`",
28+
)
29+
.before(requireAuth)
30+
.before(requirePermissions, [
31+
"firebaseabt.experiments.list",
32+
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
33+
])
34+
.action(async (options: Options) => {
35+
const projectNumber = await needProjectNumber(options);
36+
const listExperimentOptions: ListExperimentOptions = {
37+
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE,
38+
pageToken: options.pageToken as string,
39+
filter: options.filter as string,
40+
};
41+
const { experiments, nextPageToken }: ListExperimentsResult =
42+
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions);
43+
logger.info(rcExperiment.parseExperimentList(experiments ?? []));
44+
if (nextPageToken) {
45+
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
46+
}
47+
return {
48+
experiments,
49+
nextPageToken,
50+
};
51+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect } from "chai";
2+
import * as nock from "nock";
3+
import { remoteConfigApiOrigin } from "../api";
4+
import { FirebaseError } from "../error";
5+
import { deleteExperiment } from "./deleteexperiment";
6+
import { NAMESPACE_FIREBASE } from "./interfaces";
7+
8+
const PROJECT_ID = "12345679";
9+
const EXPERIMENT_ID = "1";
10+
11+
describe("remoteconfig: deleteExperiment", () => {
12+
afterEach(() => {
13+
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
14+
nock.cleanAll();
15+
});
16+
17+
it("should delete an experiment successfully", async () => {
18+
nock(remoteConfigApiOrigin())
19+
.delete(
20+
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
21+
)
22+
.reply(200);
23+
24+
await expect(deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID)).to.be.fulfilled;
25+
});
26+
27+
it("should throw FirebaseError if experiment is running", async () => {
28+
const errorMessage = `Experiment ${EXPERIMENT_ID} is currently running and cannot be deleted. If you want to delete this experiment, stop it at https://console.firebase.google.com/project/${PROJECT_ID}/config/experiment/results/${EXPERIMENT_ID}`;
29+
nock(remoteConfigApiOrigin())
30+
.delete(
31+
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
32+
)
33+
.reply(400, {
34+
error: {
35+
message: errorMessage,
36+
},
37+
});
38+
39+
await expect(
40+
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
41+
).to.be.rejectedWith(FirebaseError, errorMessage);
42+
});
43+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { remoteConfigApiOrigin } from "../api";
2+
import { Client } from "../apiv2";
3+
import { FirebaseError, getErrMsg, getError } from "../error";
4+
import { consoleUrl } from "../utils";
5+
6+
const TIMEOUT = 30000;
7+
8+
const apiClient = new Client({
9+
urlPrefix: remoteConfigApiOrigin(),
10+
apiVersion: "v1",
11+
});
12+
13+
/**
14+
* Deletes a Remote Config experiment.
15+
* @param projectId The ID of the project.
16+
* @param namespace The namespace under which the experiment is created.
17+
* @param experimentId The ID of the experiment to retrieve.
18+
* @return A promise that resolves to the experiment object.
19+
*/
20+
export async function deleteExperiment(
21+
projectId: string,
22+
namespace: string,
23+
experimentId: string,
24+
): Promise<void> {
25+
try {
26+
await apiClient.request<void, void>({
27+
method: "DELETE",
28+
path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`,
29+
timeout: TIMEOUT,
30+
});
31+
} catch (err: unknown) {
32+
const error: Error = getError(err);
33+
if (error.message.includes("is running and cannot be deleted")) {
34+
const rcConsoleUrl = consoleUrl(projectId, `/config/experiment/results/${experimentId}`);
35+
throw new FirebaseError(
36+
`Experiment ${experimentId} is currently running and cannot be deleted. If you want to delete this experiment, stop it at ${rcConsoleUrl}`,
37+
{ original: error },
38+
);
39+
}
40+
throw new FirebaseError(
41+
`Failed to delete Remote Config experiment with ID ${experimentId} for project ${projectId}. Error: ${getErrMsg(err)}`,
42+
{ original: error },
43+
);
44+
}
45+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { expect } from "chai";
2+
import { remoteConfigApiOrigin } from "../api";
3+
import * as nock from "nock";
4+
import * as Table from "cli-table3";
5+
import * as util from "util";
6+
7+
import * as rcExperiment from "./getexperiment";
8+
import { GetExperimentResult, NAMESPACE_FIREBASE } from "./interfaces";
9+
import { FirebaseError } from "../error";
10+
11+
const PROJECT_ID = "1234567890";
12+
const EXPERIMENT_ID_1 = "1";
13+
const EXPERIMENT_ID_2 = "2";
14+
15+
// Test sample experiment
16+
const expectedExperimentResult: GetExperimentResult = {
17+
name: "projects/1234567890/namespaces/firebase/experiments/1",
18+
definition: {
19+
displayName: "param_one",
20+
service: "EXPERIMENT_SERVICE_REMOTE_CONFIG",
21+
objectives: {
22+
activationEvent: {},
23+
eventObjectives: [
24+
{
25+
isPrimary: true,
26+
systemObjectiveDetails: {
27+
objective: "total_revenue",
28+
},
29+
},
30+
{
31+
systemObjectiveDetails: {
32+
objective: "retention_7",
33+
},
34+
},
35+
{
36+
customObjectiveDetails: {
37+
event: "app_exception",
38+
countType: "NO_EVENT_USERS",
39+
},
40+
},
41+
],
42+
},
43+
variants: [
44+
{
45+
name: "Baseline",
46+
weight: 1,
47+
},
48+
{
49+
name: "Variant A",
50+
weight: 1,
51+
},
52+
],
53+
},
54+
state: "PENDING",
55+
startTime: "1970-01-01T00:00:00Z",
56+
endTime: "1970-01-01T00:00:00Z",
57+
lastUpdateTime: "2025-07-25T08:24:30.682Z",
58+
etag: "e1",
59+
};
60+
61+
describe("Remote Config Experiment", () => {
62+
describe("getExperiment", () => {
63+
afterEach(() => {
64+
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
65+
nock.cleanAll();
66+
});
67+
68+
it("should successfully retrieve a Remote Config experiment by ID", async () => {
69+
nock(remoteConfigApiOrigin())
70+
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_1}`)
71+
.reply(200, expectedExperimentResult);
72+
73+
const experimentOne = await rcExperiment.getExperiment(
74+
PROJECT_ID,
75+
NAMESPACE_FIREBASE,
76+
EXPERIMENT_ID_1,
77+
);
78+
expect(experimentOne).to.deep.equal(expectedExperimentResult);
79+
});
80+
81+
it("should reject with a FirebaseError if the API call fails", async () => {
82+
nock(remoteConfigApiOrigin())
83+
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_2}`)
84+
.reply(404, {});
85+
86+
await expect(
87+
rcExperiment.getExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID_2),
88+
).to.eventually.be.rejectedWith(
89+
FirebaseError,
90+
`Failed to get Remote Config experiment with ID 2 for project 1234567890.`,
91+
);
92+
});
93+
});
94+
95+
describe("parseExperiment", () => {
96+
it("should correctly parse and format an experiment result into a tabular format", () => {
97+
const resultTable = rcExperiment.parseExperiment(expectedExperimentResult);
98+
99+
const expectedTable = [
100+
["Name", expectedExperimentResult.name],
101+
["Display Name", expectedExperimentResult.definition.displayName],
102+
["Service", expectedExperimentResult.definition.service],
103+
[
104+
"Objectives",
105+
util.inspect(expectedExperimentResult.definition.objectives, {
106+
showHidden: false,
107+
depth: null,
108+
}),
109+
],
110+
[
111+
"Variants",
112+
util.inspect(expectedExperimentResult.definition.variants, {
113+
showHidden: false,
114+
depth: null,
115+
}),
116+
],
117+
["State", expectedExperimentResult.state],
118+
["Start Time", expectedExperimentResult.startTime],
119+
["End Time", expectedExperimentResult.endTime],
120+
["Last Update Time", expectedExperimentResult.lastUpdateTime],
121+
["etag", expectedExperimentResult.etag],
122+
];
123+
124+
const expectedTableString = new Table({
125+
head: ["Entry Name", "Value"],
126+
style: { head: ["green"] },
127+
});
128+
expectedTableString.push(...expectedTable);
129+
130+
expect(resultTable).to.equal(expectedTableString.toString());
131+
});
132+
});
133+
});

0 commit comments

Comments
 (0)