diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index e6f57ba7..cf913de5 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Unreleased +* Add `focusResourceGroup` function to the resources API. + ## [3.0.0] - 2025-10-06 * Package is now a combined CJS+ESM package. diff --git a/api/docs/vscode-azureresources-api.d.ts b/api/docs/vscode-azureresources-api.d.ts index 0c28dd44..fb4b0def 100644 --- a/api/docs/vscode-azureresources-api.d.ts +++ b/api/docs/vscode-azureresources-api.d.ts @@ -124,12 +124,12 @@ export declare interface AzureAuthentication { /** * Gets a VS Code authentication session for an Azure subscription. * - * @param scopes - The scopes for which the authentication is needed. Use AuthenticationWwwAuthenticateRequest for supporting challenge requests. - * Note: use of AuthenticationWwwAuthenticateRequest requires VS Code v1.104 + * @param scopeListOrRequest - The scopes for which the authentication is needed. Use AuthenticationWwwAuthenticateRequest for supporting challenge requests. + * Note: use of AuthenticationWwwAuthenticateRequest requires VS Code v1.105.0 * * @returns A VS Code authentication session or undefined, if none could be obtained. */ - getSessionWithScopes(scopes: string[] | vscode.AuthenticationWwwAuthenticateRequest): vscode.ProviderResult; + getSessionWithScopes(scopeListOrRequest: string[] | vscode.AuthenticationWwwAuthenticateRequest): vscode.ProviderResult; } export declare interface AzureExtensionApi { @@ -174,7 +174,7 @@ export declare interface AzureResource extends ResourceBase { /** * A copy of the raw resource. */ - readonly raw: {}; + readonly raw: unknown; } /** @@ -401,6 +401,13 @@ export declare interface ResourcesApi { * @param options - Options for revealing the resource. See {@link vscode.TreeView.reveal} */ revealWorkspaceResource(id: string, options?: VSCodeRevealOptions): Promise; + /** + * Focus on a resource group in the Focused Resources view. + * This opens the Focused Resources view and filters it to show only resources from the specified resource group. + * + * @param resourceGroupId - The Azure Resource Group ID to focus on. + */ + focusResourceGroup(resourceGroupId: string): Promise; /** * Gets a list of node IDs for nodes recently used/interacted with in the Azure tree view. * @@ -428,14 +435,14 @@ export declare interface ViewPropertiesModelAsync { /** * Async function to get the raw data associated with the resource to populate the properties file. */ - getData: () => Promise<{}>; + getData: () => Promise; } export declare interface ViewPropertiesModelSync { /** * Raw data associated with the resource to populate the properties file. */ - data: {}; + data: unknown; } export declare type VSCodeRevealOptions = Parameters['reveal']>['1']; @@ -495,4 +502,4 @@ export declare interface Wrapper { unwrap(): T; } -export { }; +export { } diff --git a/api/package-lock.json b/api/package-lock.json index 79b607ea..43605471 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,15 +1,15 @@ { "name": "@microsoft/vscode-azureresources-api", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/vscode-azureresources-api", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "devDependencies": { - "@types/node": "^16.0.0", + "@types/node": "22.x", "@types/vscode": "1.105.0" }, "engines": { @@ -26,10 +26,14 @@ "peer": true }, "node_modules/@types/node": { - "version": "16.18.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.24.tgz", - "integrity": "sha512-zvSN2Esek1aeLdKDYuntKAYjti9Z2oT4I8bfkLLhIxHlv3dwZ5vvATxOc31820iYm4hQRCwjUgDpwSMFjfTUnw==", - "dev": true + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/vscode": { "version": "1.105.0", @@ -37,6 +41,13 @@ "integrity": "sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==", "dev": true, "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/api/package.json b/api/package.json index e570a1b2..ce875705 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/vscode-azureresources-api", - "version": "3.0.0", + "version": "3.1.0", "description": "Type declarations and client library for the Azure Resources extension API", "repository": { "type": "git", @@ -23,7 +23,7 @@ "vscode": "^1.105.0" }, "devDependencies": { - "@types/node": "^16.0.0", + "@types/node": "22.x", "@types/vscode": "1.105.0" }, "peerDependencies": { diff --git a/api/src/resources/resourcesApi.ts b/api/src/resources/resourcesApi.ts index 470f4852..1c841d75 100644 --- a/api/src/resources/resourcesApi.ts +++ b/api/src/resources/resourcesApi.ts @@ -69,6 +69,16 @@ export interface ResourcesApi { */ revealWorkspaceResource(id: string, options?: VSCodeRevealOptions): Promise; + /** + * Focus on a resource group in the Focused Resources view. + * This opens the Focused Resources view and filters it to show only resources from the specified resource group. + * + * @param resourceGroupId - The Azure Resource Group ID to focus on, in the form + * `/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}`. + * The promise is rejected with an error if the ID is not in the expected format. + */ + focusResourceGroup(resourceGroupId: string): Promise; + /** * Gets a list of node IDs for nodes recently used/interacted with in the Azure tree view. * diff --git a/package-lock.json b/package-lock.json index db955f13..caac4acd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -483,8 +483,7 @@ "node_modules/@azure/ms-rest-azure-env": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@azure/ms-rest-azure-env/-/ms-rest-azure-env-2.0.0.tgz", - "integrity": "sha512-dG76W7ElfLi+fbTjnZVGj+M9e0BIEJmRxU6fHaUQ12bZBe8EJKYb2GV50YWNaP2uJiVQ5+7nXEVj1VN1UQtaEw==", - "peer": true + "integrity": "sha512-dG76W7ElfLi+fbTjnZVGj+M9e0BIEJmRxU6fHaUQ12bZBe8EJKYb2GV50YWNaP2uJiVQ5+7nXEVj1VN1UQtaEw==" }, "node_modules/@azure/msal-browser": { "version": "3.13.0", @@ -2226,7 +2225,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2320,7 +2318,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -2874,7 +2871,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3296,7 +3292,6 @@ "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4024,7 +4019,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4143,7 +4137,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7785,7 +7778,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/api/createAzureResourcesHostApi.ts b/src/api/createAzureResourcesHostApi.ts index 1c87ed6c..dab25e66 100644 --- a/src/api/createAzureResourcesHostApi.ts +++ b/src/api/createAzureResourcesHostApi.ts @@ -63,6 +63,15 @@ export function createAzureResourcesHostApi( }); }, + focusResourceGroup: (resourceGroupId: string) => { + return callWithTelemetryAndErrorHandling('internalFocusResourceGroup', context => { + context.errorHandling.rethrow = true; + context.errorHandling.suppressDisplay = true; + context.errorHandling.suppressReportIssue = true; + return vscode.commands.executeCommand('azureResourceGroups.focusGroup', resourceGroupId); + }); + }, + getRecentlyUsedAzureNodes: () => { return getRecentlyUsedAzureNodes(); }, diff --git a/src/api/createWrappedAzureResourcesExtensionApi.ts b/src/api/createWrappedAzureResourcesExtensionApi.ts index 2255ec1c..c25c996f 100644 --- a/src/api/createWrappedAzureResourcesExtensionApi.ts +++ b/src/api/createWrappedAzureResourcesExtensionApi.ts @@ -27,6 +27,7 @@ export function createWrappedAzureResourcesExtensionApi(api: AzureResourcesApiIn ...wrapFunctionsInTelemetry({ revealAzureResource: api.resources.revealAzureResource.bind(api) as typeof api.resources.revealAzureResource, revealWorkspaceResource: api.resources.revealWorkspaceResource.bind(api) as typeof api.resources.revealWorkspaceResource, + focusResourceGroup: api.resources.focusResourceGroup.bind(api) as typeof api.resources.focusResourceGroup, }, wrapOptions), ...wrapFunctionsInTelemetrySync({ registerAzureResourceBranchDataProvider: api.resources.registerAzureResourceBranchDataProvider.bind(api) as typeof api.resources.registerAzureResourceBranchDataProvider, diff --git a/src/commands/focus/focusGroup.ts b/src/commands/focus/focusGroup.ts index f05c0ba0..7731e1de 100644 --- a/src/commands/focus/focusGroup.ts +++ b/src/commands/focus/focusGroup.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { contextValueExperience, IActionContext } from "@microsoft/vscode-azext-utils"; -import { AzExtResourceType } from "api/src/AzExtResourceType"; +import { AzExtResourceType } from "api/src"; import * as vscode from 'vscode'; import { canFocusContextValue, hasFocusedGroupContextKey } from "../../constants"; import { ext } from "../../extensionVariables"; @@ -12,9 +12,34 @@ import { GroupingItem } from "../../tree/azure/grouping/GroupingItem"; import { isLocationGroupingItem } from "../../tree/azure/grouping/LocationGroupingItem"; import { isResourceGroupGroupingItem } from "../../tree/azure/grouping/ResourceGroupGroupingItem"; import { isResourceTypeGroupingItem } from "../../tree/azure/grouping/ResourceTypeGroupingItem"; +import { validateResourceGroupId } from "../../utils/azureUtils"; -export async function focusGroup(context: IActionContext, item?: GroupingItem): Promise { - item ??= await contextValueExperience(context, ext.v2.api.resources.azureResourceTreeDataProvider, { +export async function focusGroup(context: IActionContext, itemOrId?: GroupingItem | string): Promise { + if (typeof itemOrId === 'string') { + // When called with a resource group ID string, validate it and set focus state directly + // This works regardless of the current tree grouping mode + context.errorHandling.rethrow = true; + context.errorHandling.suppressDisplay = true; + context.errorHandling.suppressReportIssue = true; + + validateResourceGroupId(itemOrId); + + ext.focusedGroup = { + kind: 'resourceGroup', + id: itemOrId.toLowerCase(), + }; + + context.telemetry.properties.calledWithId = 'true'; + context.telemetry.properties.groupKind = 'resourceGroup'; + + await vscode.commands.executeCommand('setContext', hasFocusedGroupContextKey, true); + ext.actions.refreshFocusTree(); + await ext.focusView.reveal(undefined); + return; + } + + // When called with a tree item or no arguments, use the tree-based approach + const item = itemOrId ?? await contextValueExperience(context, ext.v2.api.resources.azureResourceTreeDataProvider, { include: canFocusContextValue, }); diff --git a/src/extension.ts b/src/extension.ts index c348a607..6afdb591 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -271,6 +271,7 @@ export async function activate(context: vscode.ExtensionContext, perfStats: { lo }, extensionVariables: { getOutputChannel: () => ext.outputChannel, + getFocusedGroup: () => ext.focusedGroup, }, testing: { setOverrideAzureServiceFactory: (factory) => { diff --git a/src/testApi.ts b/src/testApi.ts index dcaeba4d..0ed2048f 100644 --- a/src/testApi.ts +++ b/src/testApi.ts @@ -6,6 +6,7 @@ import { AzureSubscriptionProvider } from "@microsoft/vscode-azext-azureauth"; import type { AzExtLocation } from "@microsoft/vscode-azext-azureutils"; import { AzExtTreeDataProvider, IActionContext, IAzExtLogOutputChannel, ISubscriptionActionContext } from "@microsoft/vscode-azext-utils"; +import { GroupingKind } from "./extensionVariables"; import { AzureResourcesApiInternal } from "./hostapi.v2.internal"; import { AzureResourcesServiceFactory } from "./services/AzureResourcesService"; import { SubscriptionItem } from "./tree/azure/SubscriptionItem"; @@ -44,6 +45,11 @@ export interface TestApi { * Get the output channel */ getOutputChannel(): IAzExtLogOutputChannel; + + /** + * Get the focused group + */ + getFocusedGroup(): GroupingKind | undefined; }; /** diff --git a/src/utils/azureUtils.ts b/src/utils/azureUtils.ts index 98c73ac8..1048a9da 100644 --- a/src/utils/azureUtils.ts +++ b/src/utils/azureUtils.ts @@ -13,6 +13,13 @@ import { IAzExtMetadata, legacyTypeMap } from '../azureExtensions'; import { localize } from './localize'; import { treeUtils } from './treeUtils'; +export function validateResourceGroupId(resourceGroupId: string): void { + const match = resourceGroupId.match(/^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+$/i); + if (!match) { + throw new Error(`Invalid resource group ID format: ${resourceGroupId}. Expected format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}`); + } +} + export function createGroupConfigFromResource(resource: AppResource, subscriptionId: string | undefined): GroupingConfig { const id = nonNullProp(resource, 'id'); const unknown = localize('unknown', 'Unknown'); diff --git a/test/api/focusResourceGroup.test.ts b/test/api/focusResourceGroup.test.ts new file mode 100644 index 00000000..da4d08a0 --- /dev/null +++ b/test/api/focusResourceGroup.test.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "assert"; +import { commands } from "vscode"; +import { getCachedTestApi } from "../utils/testApiAccess"; +import { createMockSubscriptionWithFunctions } from "./mockServiceFactory"; + +const api = () => { + return getCachedTestApi().getApi().resources; +}; + +suite('focusResourceGroup API tests', () => { + test("focusResourceGroup should focus on a resource group by ID", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + const resourceGroupId = mockResources.sub1.rg1.id; + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = api().azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Call the API method + await api().focusResourceGroup(resourceGroupId); + + // Verify that the focused group was set correctly + const focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + assert.strictEqual(focusedGroup.kind, 'resourceGroup', 'Focused group kind should be resourceGroup'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, resourceGroupId.toLowerCase(), 'Focused group ID should match the resource group ID'); + } + }); + + test("focusResourceGroup command should accept resource group ID string", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + const resourceGroupId = mockResources.sub1.rg1.id; + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = api().azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Call the command directly with a string ID + await commands.executeCommand('azureResourceGroups.focusGroup', resourceGroupId); + + // Verify that the focused group was set correctly + const focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + assert.strictEqual(focusedGroup.kind, 'resourceGroup', 'Focused group kind should be resourceGroup'); + if (focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, resourceGroupId.toLowerCase(), 'Focused group ID should match the resource group ID'); + } + }); + + test("focusResourceGroup should be callable multiple times with different resource groups", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = api().azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Add another resource group + const rg2Id = `${mockResources.sub1.id}/resourceGroups/test-rg-2`; + mockResources.sub1.resourceGroups.push({ + type: 'microsoft.resources/resourcegroups', + name: 'test-rg-2', + location: 'eastus', + id: rg2Id, + resources: [] + } as any); + + const rg1Id = mockResources.sub1.rg1.id; + + // Focus on first resource group + await api().focusResourceGroup(rg1Id); + let focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, rg1Id.toLowerCase(), 'First focused group ID should match'); + } + + // Refresh tree to include the new resource group + await commands.executeCommand('azureResourceGroups.refreshTree'); + await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Focus on second resource group + await api().focusResourceGroup(rg2Id); + focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should still be set'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, rg2Id.toLowerCase(), 'Second focused group ID should match'); + } + }); + + test("focusResourceGroup should handle resource group IDs with different casing", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + const resourceGroupId = mockResources.sub1.rg1.id; + const upperCaseId = resourceGroupId.toUpperCase(); + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = api().azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Call with uppercase ID + await api().focusResourceGroup(upperCaseId); + + // Verify that the focused group was set with normalized (lowercase) ID + const focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + assert.strictEqual(focusedGroup.kind, 'resourceGroup', 'Focused group kind should be resourceGroup'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, resourceGroupId.toLowerCase(), 'Focused group ID should be normalized to lowercase'); + } + }); + + test("focusResourceGroup should reject invalid resource group IDs", async () => { + const invalidIds = [ + 'not-a-valid-id', + '/subscriptions/sub-id', + '/subscriptions/sub-id/resourceGroups', + '/subscriptions//resourceGroups/rg-name', + '/subscriptions/sub-id/resourceGroups/', + '/subscriptions/sub-id/resourcegroups/rg-name/extra', + 'subscriptions/sub-id/resourceGroups/rg-name', + '', + ]; + + for (const invalidId of invalidIds) { + await assert.rejects( + async () => api().focusResourceGroup(invalidId), + (error: Error) => { + assert.ok(error.message.includes('Invalid resource group ID format'), + `Expected error message to mention invalid format for ID: ${invalidId}`); + assert.ok(error.message.includes('/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}'), + `Expected error message to include expected format for ID: ${invalidId}`); + return true; + }, + `Should reject invalid resource group ID: ${invalidId}` + ); + } + }); +}); diff --git a/test/focusGroup.test.ts b/test/focusGroup.test.ts new file mode 100644 index 00000000..1f9728c2 --- /dev/null +++ b/test/focusGroup.test.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "assert"; +import { commands } from "vscode"; +import { createMockSubscriptionWithFunctions } from "./api/mockServiceFactory"; +import { getCachedTestApi } from "./utils/testApiAccess"; + +suite('focusGroup command tests', () => { + test("focusGroup command should accept GroupingItem (backward compatibility)", async () => { + createMockSubscriptionWithFunctions(); + + // Group by resource group and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + + const tdp = getCachedTestApi().getApi().resources.azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + const resourceGroups = await tdp.getChildren(subscriptions![0]); + + assert.ok(resourceGroups && resourceGroups.length > 0, 'Should have resource groups'); + + // Call the command with a GroupingItem (existing behavior) + await commands.executeCommand('azureResourceGroups.focusGroup', resourceGroups[0]); + + // Verify that the focused group was set + const focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + assert.strictEqual(focusedGroup.kind, 'resourceGroup', 'Focused group kind should be resourceGroup'); + }); + + test("focusGroup command should accept string ID (new behavior)", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + const resourceGroupId = mockResources.sub1.rg1.id; + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = getCachedTestApi().getApi().resources.azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Call the command with a string ID (new behavior) + await commands.executeCommand('azureResourceGroups.focusGroup', resourceGroupId); + + // Verify that the focused group was set + const focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set'); + assert.strictEqual(focusedGroup.kind, 'resourceGroup', 'Focused group kind should be resourceGroup'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, resourceGroupId.toLowerCase(), 'Focused group ID should match'); + } + }); + + test("focusGroup command should clear previous focus when focusing new group", async () => { + const mockResources = createMockSubscriptionWithFunctions(); + const resourceGroupId = mockResources.sub1.rg1.id; + + // Set grouping mode and populate tree + await commands.executeCommand('azureResourceGroups.groupBy.resourceGroup'); + const tdp = getCachedTestApi().getApi().resources.azureResourceTreeDataProvider; + const subscriptions = await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + // Set initial focus + await commands.executeCommand('azureResourceGroups.focusGroup', resourceGroupId); + let focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should be set initially'); + + // Focus on a different group + const rg2Id = `${mockResources.sub1.id}/resourceGroups/test-rg-2`; + mockResources.sub1.resourceGroups.push({ + type: 'microsoft.resources/resourcegroups', + name: 'test-rg-2', + location: 'eastus', + id: rg2Id, + resources: [] + } as any); + + // Refresh tree to include the new resource group + await commands.executeCommand('azureResourceGroups.refreshTree'); + await tdp.getChildren(); + await tdp.getChildren(subscriptions![0]); + + await commands.executeCommand('azureResourceGroups.focusGroup', rg2Id); + + // Verify the focus changed + focusedGroup = getCachedTestApi().extensionVariables.getFocusedGroup(); + assert.ok(focusedGroup, 'Focused group should still be set'); + if (focusedGroup && focusedGroup.kind === 'resourceGroup') { + assert.strictEqual(focusedGroup.id, rg2Id.toLowerCase(), 'Focused group should be the new group'); + } + }); +});