Enable Cosmos DB Shell by default and clarify setup progress (#2593)

* Enable Cosmos DB Shell by default and clarify setup progress

Default enableCosmosDBShell to true while retaining explicit opt-out support. Add localized progress for SDK installation, shell installation, update checks, and connection with safe Bash quoting. Cover default visibility, overrides, and progress messages with regression tests.

Validation: npm run build:ci and 83 focused unit tests passed.

* Fix Index Advisor Playwright tooltip interference
This commit is contained in:
Mike Krüger
2026-09-17 19:48:16 +02:00
committed by GitHub
parent 746a218000
commit eb65237acb
7 changed files with 126 additions and 12 deletions
@@ -1,8 +1,9 @@
import * as ko from "knockout";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { AuthType } from "../../../AuthType";
import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext";
import { CollectionBase, TerminalKind } from "../../../Contracts/ViewModels";
import { ApiType, updateUserContext, userContext } from "../../../UserContext";
import Explorer from "../../Explorer";
import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode";
@@ -165,6 +166,60 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
});
describe("Open Cosmos DB Shell button", () => {
let originalUserContext: typeof userContext;
beforeEach(() => {
originalUserContext = { ...userContext };
mockExplorer = { openNotebookTerminal: jest.fn() } as unknown as Explorer;
updateUserContext({
authType: AuthType.AAD,
databaseAccount: { kind: "DocumentDB", properties: { capabilities: [] } } as DatabaseAccount,
features: extractFeatures(new URLSearchParams()),
});
});
afterEach(() => updateUserContext(originalUserContext));
const getShellButton = () =>
CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, useSelectedNode.getState()).find(
(button) => button.commandButtonLabel === "Open Cosmos DB Shell",
);
it("shows and opens the shell with default features", () => {
const button = getShellButton();
expect(button).toBeDefined();
expect(button.disabled).toBe(false);
button.onCommandClick(new KeyboardEvent("keydown", { key: "Enter" }));
expect(mockExplorer.openNotebookTerminal).toHaveBeenCalledWith(TerminalKind.CosmosDB);
});
it("hides the shell when explicitly disabled by the feature flag", () => {
updateUserContext({
features: extractFeatures(new URLSearchParams({ "feature.enableCosmosDBShell": "false" })),
});
expect(getShellButton()).toBeUndefined();
});
it("hides the shell when Cloud Shell is unavailable", () => {
updateUserContext({ features: { ...userContext.features, enableCloudShell: false } });
expect(getShellButton()).toBeUndefined();
});
it.each<ApiType>(["Mongo", "Cassandra", "Gremlin", "Tables", "Postgres", "VCoreMongo"])(
"hides the shell for the %s API",
(apiType) => {
updateUserContext({ apiType });
expect(getShellButton()).toBeUndefined();
},
);
it("hides the shell for resource token authentication", () => {
updateUserContext({ authType: AuthType.ResourceToken });
expect(getShellButton()).toBeUndefined();
});
});
describe("Resource token", () => {
const mockCollection = { id: ko.observable("test") } as CollectionBase;
useSelectedNode.getState().setSelectedNode(mockCollection);
@@ -1,3 +1,4 @@
import * as Localization from "Localization/t";
import { CosmosDBShellHandler } from "./CosmosDBShellHandler";
// Mock dependencies
@@ -14,8 +15,9 @@ jest.mock("../../../../UserContext", () => ({
describe("CosmosDBShellHandler", () => {
const mockKey = "testKey";
const endpoint = "https://test-account.documents.azure.com:443/";
const tokenConnectionCommand = `export COSMOSDB_SHELL_TOKEN='aadToken123'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
const keyConnectionCommand = `export COSMOSDB_SHELL_ACCOUNT_KEY='${mockKey}'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
const connectionProgress = "printf '%s\\n' 'Connecting to your Cosmos DB account...'";
const tokenConnectionCommand = `${connectionProgress}; export COSMOSDB_SHELL_TOKEN='aadToken123'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
const keyConnectionCommand = `${connectionProgress}; export COSMOSDB_SHELL_ACCOUNT_KEY='${mockKey}'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
let cosmosDBShellHandler: CosmosDBShellHandler;
beforeEach(() => {
@@ -54,6 +56,32 @@ describe("CosmosDBShellHandler", () => {
expect(commands.some((c) => c === "export DOTNET_ROOT=$HOME/.dotnet")).toBe(true);
});
it("should describe each setup step only in the corresponding command branch", () => {
const commands = cosmosDBShellHandler.getSetUpCommands();
const sdkCommand = commands.find((command) => command.includes("dotnet-install.sh"));
const shellCommand = commands.find((command) => command.includes("dotnet tool install"));
expect(sdkCommand).toContain(
"then printf '%s\\n' 'Downloading and installing .NET SDK 10. First-time setup may take a few minutes.'; curl",
);
expect(shellCommand).toContain("then printf '%s\\n' 'Installing Cosmos DB Shell...'; dotnet tool install");
expect(shellCommand).toContain(
"else printf '%s\\n' 'Checking for Cosmos DB Shell updates...'; dotnet tool update",
);
expect(commands).toHaveLength(6);
});
it("should quote localized progress messages safely for Bash", () => {
const translation = jest.spyOn(Localization, "t").mockReturnValue("Account's $HOME `command` %s");
try {
expect(cosmosDBShellHandler.getConnectionCommand()).toContain(
"printf '%s\\n' 'Account'\\''s $HOME `command` %s'; export",
);
} finally {
translation.mockRestore();
}
});
it("should not export any credential env var in setup commands for a key credential", () => {
const commands = cosmosDBShellHandler.getSetUpCommands();
@@ -107,6 +135,7 @@ describe("CosmosDBShellHandler", () => {
const connectionCommand = handler.getConnectionCommand();
expect(connectionCommand).not.toContain("cosmosdbshell --connect");
expect(connectionCommand).not.toContain("Connecting to your Cosmos DB account");
expect(connectionCommand).toContain("Unable to acquire a Cosmos DB credential");
expect(connectionCommand).toContain("Login for Entra ID");
});
@@ -1,3 +1,4 @@
import { ResourceKey, t } from "Localization/t";
import { userContext } from "../../../../UserContext";
import { AbstractShellHandler } from "./AbstractShellHandler";
@@ -65,14 +66,23 @@ export class CosmosDBShellHandler extends AbstractShellHandler {
return [
"export DOTNET_ROOT=$HOME/.dotnet",
"export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH",
"if ! command -v cosmosdbshell &> /dev/null; then echo '⚠️ cosmosdbshell not found. Installing .NET SDK 10 and CosmosDBShell...'; fi",
"if ! command -v cosmosdbshell &> /dev/null && ! dotnet --list-sdks 2>/dev/null | grep -q '^10\\.'; then curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir $HOME/.dotnet; fi",
"if ! command -v cosmosdbshell &> /dev/null; then dotnet tool install --global CosmosDBShell --prerelease; else dotnet tool update --global CosmosDBShell --prerelease; fi",
`if ! command -v cosmosdbshell &> /dev/null && ! dotnet --list-sdks 2>/dev/null | grep -q '^10\\.'; then ${this._getProgressCommand(
"cosmosDBShell.installingSdk",
)}; curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir $HOME/.dotnet; fi`,
`if ! command -v cosmosdbshell &> /dev/null; then ${this._getProgressCommand(
"cosmosDBShell.installingShell",
)}; dotnet tool install --global CosmosDBShell --prerelease; else ${this._getProgressCommand(
"cosmosDBShell.updatingShell",
)}; dotnet tool update --global CosmosDBShell --prerelease; fi`,
"grep -qxF 'export DOTNET_ROOT=$HOME/.dotnet' ~/.bashrc || echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc",
"grep -qxF 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' ~/.bashrc || echo 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' >> ~/.bashrc",
];
}
private _getProgressCommand(key: ResourceKey): string {
return `printf '%s\\n' '${t(key).replace(/'/g, "'\\''")}'`;
}
private _getKeyConnectionCommand(key: string): string {
// Export the key immediately before invoking the tool, on the same command line, so it
// never appears as a --connect flag value or lands in a separate setup step. The tool
@@ -118,9 +128,11 @@ export class CosmosDBShellHandler extends AbstractShellHandler {
endpoint: this._endpoint,
});
return this.credential.kind === "key"
? this._getKeyConnectionCommand(this.credential.value)
: this._getTokenConnectionCommand(this.credential.value);
const connectionCommand =
this.credential.kind === "key"
? this._getKeyConnectionCommand(this.credential.value)
: this._getTokenConnectionCommand(this.credential.value);
return `${this._getProgressCommand("cosmosDBShell.connecting")}; ${connectionCommand}`;
}
public getTerminalSuppressedData(): string[] {
+6
View File
@@ -38,6 +38,12 @@
"off": "Off",
"preview": "Preview"
},
"cosmosDBShell": {
"installingSdk": "Downloading and installing .NET SDK 10. First-time setup may take a few minutes.",
"installingShell": "Installing Cosmos DB Shell...",
"updatingShell": "Checking for Cosmos DB Shell updates...",
"connecting": "Connecting to your Cosmos DB account..."
},
"splashScreen": {
"title": {
"default": "Welcome to Azure Cosmos DB",
@@ -1,6 +1,18 @@
import { extractFeatures, hasFlag } from "./extractFeatures";
describe("extractFeatures", () => {
it("enables Cosmos DB Shell by default", () => {
expect(extractFeatures(new URLSearchParams()).enableCosmosDBShell).toBe(true);
});
it.each(["feature.enableCosmosDBShell", "enableCosmosDBShell"])(
"respects explicit Cosmos DB Shell overrides via %s",
(key) => {
expect(extractFeatures(new URLSearchParams({ [key]: "false" })).enableCosmosDBShell).toBe(false);
expect(extractFeatures(new URLSearchParams({ [key]: "true" })).enableCosmosDBShell).toBe(true);
},
);
it("correctly detects feature flags in a case insensitive manner", () => {
const url = "https://localhost:10001/12345/notebook";
const token = "super secret";
+1 -1
View File
@@ -105,7 +105,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enableContainerCopy: "true" === get("enablecontainercopy"),
enableRestoreContainer: "true" === get("enablerestorecontainer"),
enableCloudShell: true,
enableCosmosDBShell: "true" === get("enablecosmosdbshell"),
enableCosmosDBShell: "true" === get("enablecosmosdbshell", "true"),
mongoDisableNativeAuth: "true" === get("mongodisablenativeauth"),
};
}
+1 -1
View File
@@ -38,9 +38,9 @@ async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
const queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
await queryEditor.locator.click();
if (customQuery) {
await queryEditor.locator.click();
await queryEditor.setText(customQuery);
}