Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
4 changes: 4 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
/**
* Loads all commands for our parser.
*/
export function load(client: any): any {

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
function loadCommand(name: string) {

Check warning on line 6 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const t0 = process.hrtime.bigint();
const { command: cmd } = require(`./${name}`);

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Require statement not part of import statement

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
cmd.register(client);

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .register on an `any` value
const t1 = process.hrtime.bigint();
const diffMS = (t1 - t0) / BigInt(1e6);
if (diffMS > 75) {
Expand All @@ -14,7 +14,7 @@
// console.error(`Loading ${name} took ${diffMS}ms`);
}

return cmd.runner();

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .runner on an `any` value

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

const t0 = process.hrtime.bigint();
Expand Down Expand Up @@ -213,6 +213,10 @@
client.remoteconfig.rollback = loadCommand("remoteconfig-rollback");
client.remoteconfig.versions = {};
client.remoteconfig.versions.list = loadCommand("remoteconfig-versions-list");
client.remoteconfig.experiments = {};
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
client.serve = loadCommand("serve");
client.setup = {};
client.setup.emulators = {};
Expand Down
33 changes: 33 additions & 0 deletions src/commands/remoteconfig-experiments-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcExperiment from "../remoteconfig/deleteexperiment";
import { getExperiment, parseExperiment } from "../remoteconfig/getexperiment";
import { confirm } from "../prompt";

export const command = new Command("remoteconfig:experiments:delete [experimentId]")
.description("delete a Remote Config experiment")
.before(requireAuth)
.before(requirePermissions, [
"firebaseabt.experiments.delete",
"firebaseanalytics.resources.googleAnalyticsEdit",
])
.action(async (experimentId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const experiment = await getExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId);
logger.info(parseExperiment(experiment));
const confirmDeletion = await confirm({
message: "Are you sure you want to delete this experiment? This cannot be undone.",
default: false,
});
if (!confirmDeletion) {
return;
}
logger.info(
await rcExperiment.deleteExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId),
);
});
23 changes: 23 additions & 0 deletions src/commands/remoteconfig-experiments-get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcExperiment from "../remoteconfig/getexperiment";

export const command = new Command("remoteconfig:experiments:get [experimentId]")
.description("retrieve a Remote Config experiment")
.before(requireAuth)
.before(requirePermissions, ["firebaseabt.experiments.get"])
.action(async (experimentId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const experiment: GetExperimentResult = await rcExperiment.getExperiment(
projectNumber,
NAMESPACE_FIREBASE,
experimentId,
);
logger.info(rcExperiment.parseExperiment(experiment));
return experiment;
});
51 changes: 51 additions & 0 deletions src/commands/remoteconfig-experiments-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as rcExperiment from "../remoteconfig/listexperiments";
import {
DEFAULT_PAGE_SIZE,
ListExperimentOptions,
ListExperimentsResult,
NAMESPACE_FIREBASE,
} from "../remoteconfig/interfaces";
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";

export const command = new Command("remoteconfig:experiments:list")
.description("get a list of Remote Config experiments")
.option(
"--pageSize <pageSize>",
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments",
)
.option(
"--pageToken <pageToken>",
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
)
.option(
"--filter <filter>",
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`",
)
.before(requireAuth)
.before(requirePermissions, [
"firebaseabt.experiments.list",
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
])
.action(async (options: Options) => {
const projectNumber = await needProjectNumber(options);
const listExperimentOptions: ListExperimentOptions = {
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE,
pageToken: options.pageToken as string,
filter: options.filter as string,
};
const { experiments, nextPageToken }: ListExperimentsResult =
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions);
logger.info(rcExperiment.parseExperimentList(experiments ?? []));
if (nextPageToken) {
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
}
return {
experiments,
nextPageToken,
};
});
46 changes: 46 additions & 0 deletions src/remoteconfig/deleteexperiment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect } from "chai";
import * as nock from "nock";
import { remoteConfigApiOrigin } from "../api";
import { FirebaseError } from "../error";
import { deleteExperiment } from "./deleteexperiment";
import { NAMESPACE_FIREBASE } from "./interfaces";
import * as clc from "colorette";

const PROJECT_ID = "12345679";
const EXPERIMENT_ID = "1";

describe("Remote Config Experiment Delete", () => {
afterEach(() => {
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
nock.cleanAll();
});

it("should delete an experiment successfully", async () => {
nock(remoteConfigApiOrigin())
.delete(
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
)
.reply(200);

await expect(
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
).to.eventually.equal(clc.bold(`Successfully deleted experiment ${clc.yellow(EXPERIMENT_ID)}`));
});

it("should throw FirebaseError if experiment is running", async () => {
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}`;
nock(remoteConfigApiOrigin())
.delete(
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
)
.reply(400, {
error: {
message: errorMessage,
},
});

await expect(
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
).to.be.rejectedWith(FirebaseError, errorMessage);
});
});
47 changes: 47 additions & 0 deletions src/remoteconfig/deleteexperiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { remoteConfigApiOrigin } from "../api";
import { Client } from "../apiv2";
import { FirebaseError, getErrMsg, getError } from "../error";
import { consoleUrl } from "../utils";
import * as clc from "colorette";

const TIMEOUT = 30000;

const apiClient = new Client({
urlPrefix: remoteConfigApiOrigin(),
apiVersion: "v1",
});

/**
* Deletes a Remote Config experiment.
* @param projectId The ID of the project.
* @param namespace The namespace under which the experiment is created.
* @param experimentId The ID of the experiment to retrieve.
* @return A promise that resolves to the experiment object.
*/
export async function deleteExperiment(
projectId: string,
namespace: string,
experimentId: string,
): Promise<string> {
try {
await apiClient.request<void, void>({
method: "DELETE",
path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`,
timeout: TIMEOUT,
});
return clc.bold(`Successfully deleted experiment ${clc.yellow(experimentId)}`);
} catch (err: unknown) {
const error: Error = getError(err);
if (error.message.includes("is running and cannot be deleted")) {
const rcConsoleUrl = consoleUrl(projectId, `/config/experiment/results/${experimentId}`);
throw new FirebaseError(
`Experiment ${experimentId} is currently running and cannot be deleted. If you want to delete this experiment, stop it at ${rcConsoleUrl}`,
{ original: error },
);
}
throw new FirebaseError(
`Failed to delete Remote Config experiment with ID ${experimentId} for project ${projectId}. Error: ${getErrMsg(err)}`,
{ original: error },
);
}
}
132 changes: 132 additions & 0 deletions src/remoteconfig/getexperiment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect } from "chai";
import { remoteConfigApiOrigin } from "../api";
import * as nock from "nock";
import * as Table from "cli-table3";
import * as util from "util";

import * as rcExperiment from "./getexperiment";
import { GetExperimentResult, NAMESPACE_FIREBASE } from "./interfaces";
import { FirebaseError } from "../error";

const PROJECT_ID = "1234567890";
const EXPERIMENT_ID_1 = "1";
const EXPERIMENT_ID_2 = "2";

const expectedExperimentResult: GetExperimentResult = {
name: "projects/1234567890/namespaces/firebase/experiments/1",
definition: {
displayName: "param_one",
service: "EXPERIMENT_SERVICE_REMOTE_CONFIG",
objectives: {
activationEvent: {},
eventObjectives: [
{
isPrimary: true,
systemObjectiveDetails: {
objective: "total_revenue",
},
},
{
systemObjectiveDetails: {
objective: "retention_7",
},
},
{
customObjectiveDetails: {
event: "app_exception",
countType: "NO_EVENT_USERS",
},
},
],
},
variants: [
{
name: "Baseline",
weight: 1,
},
{
name: "Variant A",
weight: 1,
},
],
},
state: "PENDING",
startTime: "1970-01-01T00:00:00Z",
endTime: "1970-01-01T00:00:00Z",
lastUpdateTime: "2025-07-25T08:24:30.682Z",
etag: "e1",
};

describe("Remote Config Experiment Get", () => {
describe("getExperiment", () => {
afterEach(() => {
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
nock.cleanAll();
});

it("should successfully retrieve a Remote Config experiment by ID", async () => {
nock(remoteConfigApiOrigin())
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_1}`)
.reply(200, expectedExperimentResult);

const experimentOne = await rcExperiment.getExperiment(
PROJECT_ID,
NAMESPACE_FIREBASE,
EXPERIMENT_ID_1,
);
expect(experimentOne).to.deep.equal(expectedExperimentResult);
});

it("should reject with a FirebaseError if the API call fails", async () => {
nock(remoteConfigApiOrigin())
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_2}`)
.reply(404, {});

await expect(
rcExperiment.getExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID_2),
).to.eventually.be.rejectedWith(
FirebaseError,
`Failed to get Remote Config experiment with ID 2 for project 1234567890.`,
);
});
});

describe("parseExperiment", () => {
it("should correctly parse and format an experiment result into a tabular format", () => {
const resultTable = rcExperiment.parseExperiment(expectedExperimentResult);

const expectedTable = [
["Name", expectedExperimentResult.name],
["Display Name", expectedExperimentResult.definition.displayName],
["Service", expectedExperimentResult.definition.service],
[
"Objectives",
util.inspect(expectedExperimentResult.definition.objectives, {
showHidden: false,
depth: null,
}),
],
[
"Variants",
util.inspect(expectedExperimentResult.definition.variants, {
showHidden: false,
depth: null,
}),
],
["State", expectedExperimentResult.state],
["Start Time", expectedExperimentResult.startTime],
["End Time", expectedExperimentResult.endTime],
["Last Update Time", expectedExperimentResult.lastUpdateTime],
["etag", expectedExperimentResult.etag],
];

const expectedTableString = new Table({
head: ["Entry Name", "Value"],
style: { head: ["green"] },
});
expectedTableString.push(...expectedTable);

expect(resultTable).to.equal(expectedTableString.toString());
});
});
});
Loading
Loading