Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions calm-plugins/vscode/src/extension/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, CommandCallback>();
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: <T>(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 },
]);
});
});
78 changes: 63 additions & 15 deletions calm-plugins/vscode/src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -147,18 +181,9 @@ async function connectToHub(
.get<string>('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'
Expand All @@ -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;
}
Comment thread
markscott-ms marked this conversation as resolved.
if (choice.action === 'namespaces') { await selectNamespaces(); return; }
if (choice.action === 'refresh' || choice.action === 'retry') {
// Fall through to reconnect with existing URL
Expand Down Expand Up @@ -252,16 +281,35 @@ async function connectToHub(
}
}

async function disconnectFromHub(): Promise<void> {
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<void> {
if (hubAuthService) {
await hubAuthService.signOut();
}
hubClient = undefined;
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<void> {
Expand Down
Loading