-
Notifications
You must be signed in to change notification settings - Fork 1.1k
CLI for Remote Config Experiments #9052
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
Merged
Merged
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
d6d09e1
chore: add initial implementation of GetExperiment
Namyalg 101027c
chore: delete experiment; minor improvements for get experiment
Namyalg 66075cd
chore: implementation of list experiments, minor refactors for get & …
Namyalg f2ca4bb
chore: improve error handling
Namyalg 4d2d951
chore: add implementation of GetExperiment
Namyalg f924c8a
chore: minor refactors to tests; review docstrings
Namyalg dd1197e
chore: address review comments around test cases
Namyalg 5a912fd
Refactor: Standardize casing for remoteconfig experiment files and im…
Namyalg 533132b
Update CHANGELOG.md
rathovarun1032 f9455be
fix merge conflict
a96b7c2
Fix camelcase
acd945d
revert package.json
1570369
change camelCase
1212be1
fix lint error
0605e7d
fix lint in CHANGELOG.md
08ca533
chore: revert change to RC get
Namyalg 24864af
chore: fix space
Namyalg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.