diff --git a/calm-plugins/vscode/src/extension/extension.test.ts b/calm-plugins/vscode/src/extension/extension.test.ts new file mode 100644 index 0000000000..a8172a43e2 --- /dev/null +++ b/calm-plugins/vscode/src/extension/extension.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { activate, disconnectFromHub, hubMenuItems } from './extension'; + +interface ConfigUpdate { + section: string; + key: string; + value: unknown; +} + +type CommandCallback = (...args: unknown[]) => unknown; + +const registeredCommands = new Map(); +const configUpdates: ConfigUpdate[] = []; + +function context(): vscode.ExtensionContext { + return { + subscriptions: [], + extensionUri: vscode.Uri.file('/tmp/extension'), + secrets: { + get: async () => undefined, + store: async () => {}, + delete: async () => {}, + onDidChange: () => ({ dispose: () => {} }), + }, + } as unknown as vscode.ExtensionContext; +} + +beforeEach(() => { + registeredCommands.clear(); + configUpdates.length = 0; + + (vscode.commands as unknown as { registerCommand: unknown }).registerCommand = ( + command: string, + callback: CommandCallback + ) => { + registeredCommands.set(command, callback); + return { dispose: () => {} }; + }; + + (vscode.workspace as unknown as { getConfiguration: unknown }).getConfiguration = ( + section?: string + ) => ({ + get: (key: string, defaultValue?: T): T | undefined => { + if (key === 'url') return 'https://calmhub.example.com' as unknown as T; + // Keep activate() from starting a real connectToHub in every test. + if (key === 'autoConnect') return false as unknown as T; + return defaultValue; + }, + update: async (key: string, value: unknown) => { + configUpdates.push({ section: section ?? '', key, value }); + }, + }); + + activate(context()); +}); + +describe('disconnecting from CALM Hub', () => { + it('keeps the saved URL so the status bar can reconnect', async () => { + await disconnectFromHub(); + + expect(configUpdates).toEqual([]); + }); + + it('keeps the saved URL when the disconnect command runs', async () => { + const disconnect = registeredCommands.get('calm.disconnectFromHub'); + expect(disconnect).toBeDefined(); + + await disconnect!(); + + expect(configUpdates).toEqual([]); + }); + + it('forgets the saved URL only for Clear URL', async () => { + await disconnectFromHub({ clearUrl: true }); + + expect(configUpdates).toEqual([ + { section: 'calm.hub', key: 'url', value: undefined }, + ]); + }); +}); + +describe('CALM Hub menu', () => { + it('offers Disconnect, and no Clear URL, while connected', () => { + expect(hubMenuItems('connected').map((item) => item.action)).toEqual([ + 'namespaces', + 'refresh', + 'change', + 'disconnect', + ]); + }); + + it('offers Clear URL, and no Disconnect, after a failed connection', () => { + expect(hubMenuItems('error').map((item) => item.action)).toEqual([ + 'change', + 'retry', + 'clearUrl', + ]); + }); +}); + +describe('the Clear URL menu item', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not connect while autoConnect is off', async () => { + const fetchStub = vi.fn(); + vi.stubGlobal('fetch', fetchStub); + + // The stub in beforeEach reports autoConnect: false, so activating the + // extension must not send a request of its own. + activate(context()); + // activate() starts auto-connect without awaiting it, so give that path + // the chance to reach the network before asserting it never did. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('forgets the saved URL when the status bar is in its error state', async () => { + // A failed connection leaves the status bar in its error state, which is + // the only state that offers "Clear URL". + const fetchStub = vi.fn().mockRejectedValue(new Error('offline')); + vi.stubGlobal('fetch', fetchStub); + + const connect = registeredCommands.get('calm.connectToHub'); + expect(connect).toBeDefined(); + + await connect!(); + expect(fetchStub).toHaveBeenCalled(); + + const placeholders: string[] = []; + (vscode.window as unknown as { showQuickPick: unknown }).showQuickPick = async ( + items: vscode.QuickPickItem[] & { action?: string }[], + options?: vscode.QuickPickOptions + ) => { + placeholders.push(options?.placeHolder ?? ''); + return (items as unknown as { action: string }[]).find( + (item) => item.action === 'clearUrl' + ); + }; + + await connect!(); + + expect(placeholders[0]).toContain('Failed to connect'); + expect(configUpdates).toEqual([ + { section: 'calm.hub', key: 'url', value: undefined }, + ]); + }); +}); diff --git a/calm-plugins/vscode/src/extension/extension.ts b/calm-plugins/vscode/src/extension/extension.ts index a57a175d96..f1fdb09606 100644 --- a/calm-plugins/vscode/src/extension/extension.ts +++ b/calm-plugins/vscode/src/extension/extension.ts @@ -139,6 +139,40 @@ export function activate(context: vscode.ExtensionContext): void { } } +export type HubMenuAction = + | 'namespaces' + | 'refresh' + | 'change' + | 'disconnect' + | 'retry' + | 'clearUrl'; + +export interface HubMenuItem { + label: string; + action: HubMenuAction; +} + +/** + * Items of the CALM Hub status bar menu. A plain disconnect keeps the saved URL, + * so "Disconnect" stays in the connected state and the tooltip's "Click to + * reconnect" works; only the failed state offers "Clear URL" (#3494). + */ +export function hubMenuItems(state: 'connected' | 'error'): HubMenuItem[] { + if (state === 'connected') { + return [ + { label: '$(list-selection) Select Namespaces', action: 'namespaces' }, + { label: '$(sync) Refresh from Hub', action: 'refresh' }, + { label: '$(pencil) Change Hub URL', action: 'change' }, + { label: '$(sign-out) Disconnect', action: 'disconnect' }, + ]; + } + return [ + { label: '$(pencil) Change Hub URL', action: 'change' }, + { label: '$(debug-restart) Retry Connection', action: 'retry' }, + { label: '$(sign-out) Clear URL', action: 'clearUrl' }, + ]; +} + async function connectToHub( context: vscode.ExtensionContext ): Promise { @@ -147,18 +181,9 @@ async function connectToHub( .get('url'); if (url && (hubStatusBar?.state === 'connected' || hubStatusBar?.state === 'error')) { - const items = hubStatusBar?.state === 'connected' - ? [ - { label: '$(list-selection) Select Namespaces', action: 'namespaces' }, - { label: '$(sync) Refresh from Hub', action: 'refresh' }, - { label: '$(pencil) Change Hub URL', action: 'change' }, - { label: '$(sign-out) Disconnect', action: 'disconnect' }, - ] - : [ - { label: '$(pencil) Change Hub URL', action: 'change' }, - { label: '$(debug-restart) Retry Connection', action: 'retry' }, - { label: '$(sign-out) Clear URL', action: 'disconnect' }, - ]; + const items = hubMenuItems( + hubStatusBar?.state === 'connected' ? 'connected' : 'error' + ); const choice = await vscode.window.showQuickPick(items, { placeHolder: hubStatusBar?.state === 'connected' @@ -167,6 +192,10 @@ async function connectToHub( }); if (!choice) return; if (choice.action === 'disconnect') { await disconnectFromHub(); return; } + if (choice.action === 'clearUrl') { + await disconnectFromHub({ clearUrl: true }); + return; + } if (choice.action === 'namespaces') { await selectNamespaces(); return; } if (choice.action === 'refresh' || choice.action === 'retry') { // Fall through to reconnect with existing URL @@ -252,7 +281,18 @@ async function connectToHub( } } -async function disconnectFromHub(): Promise { +export interface DisconnectOptions { + /** + * Also forget the saved `calm.hub.url`. Only the error menu's "Clear URL" + * asks for this; a plain disconnect keeps the URL so the status bar's + * "Click to reconnect" works (#3494). + */ + clearUrl?: boolean; +} + +export async function disconnectFromHub( + options: DisconnectOptions = {} +): Promise { if (hubAuthService) { await hubAuthService.signOut(); } @@ -260,8 +300,16 @@ async function disconnectFromHub(): Promise { hubAuthService = undefined; hubStatusBar?.setState('disconnected'); void canvasPanel?.setHubConnection(undefined); - await vscode.workspace.getConfiguration('calm.hub').update('url', undefined, vscode.ConfigurationTarget.Global); - outputChannel.appendLine('[INFO] Disconnected from CalmHub'); + if (options.clearUrl) { + await vscode.workspace + .getConfiguration('calm.hub') + .update('url', undefined, vscode.ConfigurationTarget.Global); + } + outputChannel.appendLine( + options.clearUrl + ? '[INFO] Disconnected from CalmHub and cleared the saved URL' + : '[INFO] Disconnected from CalmHub' + ); } async function refreshFromHub(): Promise {