From eb65237acb8b016b6a1249b7d525023d7f4f81a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 17 Sep 2026 19:48:16 +0200 Subject: [PATCH] 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 --- .../CommandBarComponentButtonFactory.test.ts | 59 ++++++++++++++++++- .../ShellTypes/CosmosDBShellHandler.test.tsx | 33 ++++++++++- .../ShellTypes/CosmosDBShellHandler.tsx | 24 ++++++-- src/Localization/en/Resources.json | 6 ++ src/Platform/Hosted/extractFeatures.test.ts | 12 ++++ src/Platform/Hosted/extractFeatures.ts | 2 +- test/sql/indexAdvisor.spec.ts | 2 +- 7 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts index 0008f99a3..afb7f6ba4 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts @@ -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(["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); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.test.tsx index 99d9e75fe..21b043faf 100644 --- a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.test.tsx +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.test.tsx @@ -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"); }); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx index ed1b9658d..5ef137260 100644 --- a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CosmosDBShellHandler.tsx @@ -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[] { diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index f0301eb47..e9e85e210 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -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", diff --git a/src/Platform/Hosted/extractFeatures.test.ts b/src/Platform/Hosted/extractFeatures.test.ts index 98fc8ebd7..5117329f9 100644 --- a/src/Platform/Hosted/extractFeatures.test.ts +++ b/src/Platform/Hosted/extractFeatures.test.ts @@ -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"; diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index d8ccecae0..dbeffe906 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -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"), }; } diff --git a/test/sql/indexAdvisor.spec.ts b/test/sql/indexAdvisor.spec.ts index dc6ee978c..a52d1dc23 100644 --- a/test/sql/indexAdvisor.spec.ts +++ b/test/sql/indexAdvisor.spec.ts @@ -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); }