Skip to content

Commit 68f23cb

Browse files
ehsannasjoehan
andauthored
feat: Add firestore:operations:* comamnds (#8982)
* feat: Add firestore:operations:list command * feat: Add firestore:operations:describe command. * feat: Add firestore:operations:cancel command. * Fix typos. * Better 'list' view. * Address feedback (1). * address feedback (2). --------- Co-authored-by: Joe Hanley <joehanley@google.com>
1 parent 2a57d7b commit 68f23cb

12 files changed

Lines changed: 559 additions & 1 deletion
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import { command } from "./firestore-operations-cancel";
4+
import * as fsi from "../firestore/api";
5+
import * as prompt from "../prompt";
6+
import * as utils from "../utils";
7+
import { logger } from "../logger";
8+
9+
describe("firestore:operations:cancel", () => {
10+
const sandbox = sinon.createSandbox();
11+
let firestoreApiStub: sinon.SinonStubbedInstance<fsi.FirestoreApi>;
12+
let confirmStub: sinon.SinonStub;
13+
let logSuccessStub: sinon.SinonStub;
14+
let logWarningStub: sinon.SinonStub;
15+
let loggerInfoStub: sinon.SinonStub;
16+
17+
beforeEach(() => {
18+
firestoreApiStub = sandbox.createStubInstance(fsi.FirestoreApi);
19+
sandbox.stub(fsi, "FirestoreApi").returns(firestoreApiStub);
20+
confirmStub = sandbox.stub(prompt, "confirm");
21+
logSuccessStub = sandbox.stub(utils, "logSuccess");
22+
logWarningStub = sandbox.stub(utils, "logWarning");
23+
loggerInfoStub = sandbox.stub(logger, "info");
24+
});
25+
26+
afterEach(() => {
27+
sandbox.restore();
28+
});
29+
30+
it("should call the Firestore API with the correct parameters with --force", async () => {
31+
const options = { project: "test-project", database: "test-db", force: true };
32+
const operationName = "test-operation";
33+
firestoreApiStub.cancelOperation.resolves({ success: true });
34+
35+
await command.runner()(operationName, options);
36+
37+
expect(firestoreApiStub.cancelOperation).to.be.calledOnceWith(
38+
"test-project",
39+
"test-db",
40+
operationName,
41+
);
42+
expect(confirmStub).to.not.be.called;
43+
});
44+
45+
it("should prompt for confirmation and continue if confirmed", async () => {
46+
const options = { project: "test-project", database: "test-db", force: false };
47+
const operationName = "test-operation";
48+
confirmStub.resolves(true);
49+
firestoreApiStub.cancelOperation.resolves({ success: true });
50+
51+
await command.runner()(operationName, options);
52+
53+
expect(confirmStub).to.be.calledOnce;
54+
expect(firestoreApiStub.cancelOperation).to.be.calledOnceWith(
55+
"test-project",
56+
"test-db",
57+
operationName,
58+
);
59+
expect(logSuccessStub).to.be.calledOnceWith("Operation cancelled successfully.");
60+
});
61+
62+
it("should not cancel the operation if not confirmed", async () => {
63+
const options = { project: "test-project", database: "test-db", force: false };
64+
const operationName = "test-operation";
65+
confirmStub.resolves(false);
66+
67+
await expect(command.runner()(operationName, options)).to.be.rejectedWith("Command aborted.");
68+
69+
expect(confirmStub).to.be.calledOnce;
70+
expect(firestoreApiStub.cancelOperation).to.not.be.called;
71+
});
72+
73+
it("should log a warning if operation cancellation fails", async () => {
74+
const options = { project: "test-project", database: "test-db", force: true };
75+
const operationName = "test-operation";
76+
firestoreApiStub.cancelOperation.resolves({ success: false });
77+
78+
await command.runner()(operationName, options);
79+
80+
expect(firestoreApiStub.cancelOperation).to.be.calledOnce;
81+
expect(logWarningStub).to.be.calledOnceWith("Canceling the operation failed.");
82+
});
83+
84+
it("should print status in JSON format when --json is specified", async () => {
85+
const options = { project: "test-project", database: "test-db", force: true, json: true };
86+
const operationName = "test-operation";
87+
const status = { success: true };
88+
firestoreApiStub.cancelOperation.resolves(status);
89+
90+
await command.runner()(operationName, options);
91+
92+
expect(loggerInfoStub).to.be.calledOnceWith(JSON.stringify(status, undefined, 2));
93+
expect(logSuccessStub).to.not.be.called;
94+
expect(logWarningStub).to.not.be.called;
95+
});
96+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Command } from "../command";
2+
import * as fsi from "../firestore/api";
3+
import { Emulators } from "../emulator/types";
4+
import { errorMissingProject, warnEmulatorNotSupported } from "../emulator/commandUtils";
5+
import { FirestoreOptions } from "../firestore/options";
6+
import { getShortOperationName } from "./firestore-utils";
7+
import { confirm } from "../prompt";
8+
import * as clc from "colorette";
9+
import * as utils from "../utils";
10+
import { logger } from "../logger";
11+
12+
export const command = new Command("firestore:operations:cancel <operationName>")
13+
.description("cancels a long-running Cloud Firestore admin operation")
14+
.option(
15+
"--database <databaseName>",
16+
'Database ID for which the operation is running. "(default)" if none is provided.',
17+
)
18+
.option("--force", "Forces the operation cancellation without asking for confirmation")
19+
.before(errorMissingProject)
20+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
21+
.action(async (operationName: string, options: FirestoreOptions) => {
22+
const databaseId = options.database || "(default)";
23+
operationName = getShortOperationName(operationName);
24+
25+
if (!options.force) {
26+
const fullName = `/projects/${options.project}/databases/${databaseId}/operations/${operationName}`;
27+
const confirmMessage = `You are about to cancel the operation: ${clc.bold(clc.yellow(clc.underline(fullName)))}. Do you wish to continue?`;
28+
const consent = await confirm(confirmMessage);
29+
if (!consent) {
30+
return utils.reject("Command aborted.", { exit: 1 });
31+
}
32+
}
33+
34+
const api = new fsi.FirestoreApi();
35+
const status = await api.cancelOperation(options.project, databaseId, operationName);
36+
37+
if (options.json) {
38+
logger.info(JSON.stringify(status, undefined, 2));
39+
} else {
40+
if (status.success) {
41+
utils.logSuccess("Operation cancelled successfully.");
42+
} else {
43+
utils.logWarning("Canceling the operation failed.");
44+
}
45+
}
46+
47+
return status;
48+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import { command } from "./firestore-operations-describe";
4+
import * as fsi from "../firestore/api";
5+
import { logger } from "../logger";
6+
import { PrettyPrint } from "../firestore/pretty-print";
7+
import { FirebaseError } from "../error";
8+
9+
describe("firestore:operations:describe", () => {
10+
const sandbox = sinon.createSandbox();
11+
let firestoreApiStub: sinon.SinonStubbedInstance<fsi.FirestoreApi>;
12+
let loggerInfoStub: sinon.SinonStub;
13+
let prettyPrintStub: sinon.SinonStub;
14+
15+
beforeEach(() => {
16+
firestoreApiStub = sandbox.createStubInstance(fsi.FirestoreApi);
17+
sandbox.stub(fsi, "FirestoreApi").returns(firestoreApiStub);
18+
loggerInfoStub = sandbox.stub(logger, "info");
19+
prettyPrintStub = sandbox.stub(PrettyPrint.prototype, "prettyPrintOperation");
20+
});
21+
22+
afterEach(() => {
23+
sandbox.restore();
24+
});
25+
26+
it("should call the Firestore API with the correct parameters", async () => {
27+
const options = { project: "test-project", database: "test-db" };
28+
const operationName = "test-operation";
29+
firestoreApiStub.describeOperation.resolves({ name: "op1", done: false, metadata: {} });
30+
31+
await command.runner()(operationName, options);
32+
33+
expect(firestoreApiStub.describeOperation).to.be.calledOnceWith(
34+
"test-project",
35+
"test-db",
36+
operationName,
37+
);
38+
});
39+
40+
it("should use default values for database if not provided", async () => {
41+
const options = { project: "test-project" };
42+
const operationName = "test-operation";
43+
firestoreApiStub.describeOperation.resolves({ name: "op1", done: false, metadata: {} });
44+
45+
await command.runner()(operationName, options);
46+
47+
expect(firestoreApiStub.describeOperation).to.be.calledOnceWith(
48+
"test-project",
49+
"(default)",
50+
operationName,
51+
);
52+
});
53+
54+
it("should print the operation in JSON format when --json is specified", async () => {
55+
const options = { project: "test-project", json: true };
56+
const operationName = "test-operation";
57+
const operation = { name: "op1", done: false, metadata: {} };
58+
firestoreApiStub.describeOperation.resolves(operation);
59+
60+
await command.runner()(operationName, options);
61+
62+
expect(loggerInfoStub).to.be.calledOnceWith(JSON.stringify(operation, undefined, 2));
63+
expect(prettyPrintStub).to.not.be.called;
64+
});
65+
66+
it("should pretty-print the operation when --json is not specified", async () => {
67+
const options = { project: "test-project" };
68+
const operationName = "test-operation";
69+
const operation = { name: "op1", done: false, metadata: {} };
70+
firestoreApiStub.describeOperation.resolves(operation);
71+
72+
await command.runner()(operationName, options);
73+
74+
expect(prettyPrintStub).to.be.calledOnceWith(operation);
75+
expect(loggerInfoStub).to.not.be.called;
76+
});
77+
78+
it("should throw a FirebaseError if project is not defined", async () => {
79+
const options = {};
80+
const operationName = "test-operation";
81+
await expect(command.runner()(operationName, options)).to.be.rejectedWith(
82+
FirebaseError,
83+
"Project is not defined. Either use `--project` or use `firebase use` to set your active project.",
84+
);
85+
});
86+
87+
it("should throw a FirebaseError if operation name is invalid", async () => {
88+
const options = { project: "test-project" };
89+
await expect(command.runner()("", options)).to.be.rejectedWith(
90+
FirebaseError,
91+
'"" is not a valid operation name.',
92+
);
93+
await expect(command.runner()("projects/p/databases/d", options)).to.be.rejectedWith(
94+
FirebaseError,
95+
'"projects/p/databases/d" is not a valid operation name.',
96+
);
97+
await expect(
98+
command.runner()("projects/p/databases/d/operations/", options),
99+
).to.be.rejectedWith(
100+
FirebaseError,
101+
'"projects/p/databases/d/operations/" is not a valid operation name.',
102+
);
103+
});
104+
});
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 * as fsi from "../firestore/api";
3+
import { logger } from "../logger";
4+
import { Emulators } from "../emulator/types";
5+
import { errorMissingProject, warnEmulatorNotSupported } from "../emulator/commandUtils";
6+
import { FirestoreOptions } from "../firestore/options";
7+
import { PrettyPrint } from "../firestore/pretty-print";
8+
import { getShortOperationName } from "./firestore-utils";
9+
10+
export const command = new Command("firestore:operations:describe <operationName>")
11+
.description("retrieves information about a Cloud Firestore admin operation")
12+
.option(
13+
"--database <databaseName>",
14+
'Database ID for which the operation is running. "(default)" if none is provided.',
15+
)
16+
.before(errorMissingProject)
17+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
18+
.action(async (operationName: string, options: FirestoreOptions) => {
19+
const databaseId = options.database || "(default)";
20+
operationName = getShortOperationName(operationName);
21+
const api = new fsi.FirestoreApi();
22+
const operation = await api.describeOperation(options.project, databaseId, operationName);
23+
24+
if (options.json) {
25+
logger.info(JSON.stringify(operation, undefined, 2));
26+
} else {
27+
const printer = new PrettyPrint();
28+
printer.prettyPrintOperation(operation);
29+
}
30+
31+
return operation;
32+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import { command } from "./firestore-operations-list";
4+
import * as fsi from "../firestore/api";
5+
import { logger } from "../logger";
6+
import { PrettyPrint } from "../firestore/pretty-print";
7+
import { FirebaseError } from "../error";
8+
9+
describe("firestore:operations:list", () => {
10+
const sandbox = sinon.createSandbox();
11+
let firestoreApiStub: sinon.SinonStubbedInstance<fsi.FirestoreApi>;
12+
let loggerInfoStub: sinon.SinonStub;
13+
let prettyPrintStub: sinon.SinonStub;
14+
15+
beforeEach(() => {
16+
firestoreApiStub = sandbox.createStubInstance(fsi.FirestoreApi);
17+
sandbox.stub(fsi, "FirestoreApi").returns(firestoreApiStub);
18+
loggerInfoStub = sandbox.stub(logger, "info");
19+
prettyPrintStub = sandbox.stub(PrettyPrint.prototype, "prettyPrintOperations");
20+
});
21+
22+
afterEach(() => {
23+
sandbox.restore();
24+
});
25+
26+
it("should call the Firestore API with the correct parameters", async () => {
27+
const options = { project: "test-project", database: "test-db", limit: 50 };
28+
firestoreApiStub.listOperations.resolves({ operations: [] });
29+
30+
await command.runner()(options);
31+
32+
expect(firestoreApiStub.listOperations).to.be.calledOnceWith("test-project", "test-db", 50);
33+
});
34+
35+
it("should use default values for database and limit if not provided", async () => {
36+
const options = { project: "test-project" };
37+
firestoreApiStub.listOperations.resolves({ operations: [] });
38+
39+
await command.runner()(options);
40+
41+
expect(firestoreApiStub.listOperations).to.be.calledOnceWith("test-project", "(default)", 100);
42+
});
43+
44+
it("should print operations in JSON format when --json is specified", async () => {
45+
const options = { project: "test-project", json: true };
46+
const operations = [
47+
{ name: "op1", done: false, metadata: {} },
48+
{ name: "op2", done: true, metadata: {} },
49+
];
50+
firestoreApiStub.listOperations.resolves({ operations });
51+
52+
await command.runner()(options);
53+
54+
expect(loggerInfoStub).to.be.calledOnceWith(JSON.stringify(operations, undefined, 2));
55+
expect(prettyPrintStub).to.not.be.called;
56+
});
57+
58+
it("should pretty-print operations when --json is not specified", async () => {
59+
const options = { project: "test-project" };
60+
const operations = [
61+
{ name: "op1", done: false, metadata: {} },
62+
{ name: "op2", done: true, metadata: {} },
63+
];
64+
firestoreApiStub.listOperations.resolves({ operations });
65+
66+
await command.runner()(options);
67+
68+
expect(prettyPrintStub).to.be.calledOnceWith(operations);
69+
expect(loggerInfoStub).to.not.be.called;
70+
});
71+
72+
it("should throw a FirebaseError if project is not defined", async () => {
73+
const options = {};
74+
await expect(command.runner()(options)).to.be.rejectedWith(
75+
FirebaseError,
76+
"Project is not defined. Either use `--project` or use `firebase use` to set your active project.",
77+
);
78+
});
79+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Command } from "../command";
2+
import * as fsi from "../firestore/api";
3+
import { logger } from "../logger";
4+
import { Emulators } from "../emulator/types";
5+
import { errorMissingProject, warnEmulatorNotSupported } from "../emulator/commandUtils";
6+
import { FirestoreOptions } from "../firestore/options";
7+
import { PrettyPrint } from "../firestore/pretty-print";
8+
9+
export const command = new Command("firestore:operations:list")
10+
.description("list pending Cloud Firestore admin operations and their status")
11+
.option(
12+
"--database <databaseName>",
13+
'Database ID for database to list operations for. "(default)" if none is provided.',
14+
)
15+
.option("--limit <number>", "The maximum number of operations to list. Uses 100 by default.")
16+
.before(errorMissingProject)
17+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
18+
.action(async (options: FirestoreOptions) => {
19+
const databaseId = options.database || "(default)";
20+
const limit = options.limit === undefined ? 100 : Number(options.limit);
21+
22+
const api = new fsi.FirestoreApi();
23+
const { operations } = await api.listOperations(options.project, databaseId, limit);
24+
25+
if (options.json) {
26+
logger.info(JSON.stringify(operations, undefined, 2));
27+
} else {
28+
const printer = new PrettyPrint();
29+
printer.prettyPrintOperations(operations);
30+
}
31+
32+
return operations;
33+
});

0 commit comments

Comments
 (0)