diff --git a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts index 64781abcc82..3f5f8dca829 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts @@ -1,10 +1,32 @@ -import { getBundleVersionNumber, getExtensionBundleFolder } from '../bundleFeed'; +import { + getBundleVersionNumber, + getExtensionBundleFolder, + getLatestVersionRange, + addDefaultBundle, + downloadExtensionBundle, +} from '../bundleFeed'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as cp from 'child_process'; -import { extensionBundleId } from '../../../constants'; +import { extensionBundleId, defaultVersionRange, defaultExtensionBundlePathValue } from '../../../constants'; +import type { IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; import * as cpUtils from '../funcCoreTools/cpUtils'; +import * as feedModule from '../feed'; +import * as binariesModule from '../binaries'; + +// Mock fs-extra +vi.mock('fs-extra', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + readdir: vi.fn(), + stat: vi.fn(), + pathExists: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + }; +}); // Mock localize vi.mock('../../localize', () => ({ @@ -47,6 +69,21 @@ vi.mock('vscode', () => ({ }, })); +// Mock feed module +vi.mock('../feed', () => ({ + getJsonFeed: vi.fn(), +})); + +// Mock binaries module +vi.mock('../binaries', () => ({ + downloadAndExtractDependency: vi.fn(), +})); + +// Mock localSettings +vi.mock('../appSettings/localSettings', () => ({ + getLocalSettingsJson: vi.fn().mockResolvedValue({}), +})); + const mockedFse = vi.mocked(fse); const mockedExecSync = vi.mocked(cp.execSync); const mockedExecuteCommand = vi.mocked(cpUtils.executeCommand); @@ -379,3 +416,183 @@ describe('getBundleVersionNumber', () => { expect(mockedExecuteCommand).toHaveBeenCalledWith(expect.anything(), '/mock/workspace', 'func', 'GetExtensionBundlePath'); }); }); + +describe('getLatestVersionRange', () => { + it('should return the default version range constant', () => { + const result = getLatestVersionRange(); + expect(result).toBe(defaultVersionRange); + }); + + it('should return a valid semver range string', () => { + const result = getLatestVersionRange(); + expect(result).toMatch(/^\[.*\)$/); + }); +}); + +describe('addDefaultBundle', () => { + it('should add extension bundle configuration to host.json', () => { + const hostJson: IHostJsonV2 = { + version: '2.0', + }; + + addDefaultBundle(hostJson); + + expect(hostJson.extensionBundle).toBeDefined(); + expect(hostJson.extensionBundle?.id).toBe(extensionBundleId); + expect(hostJson.extensionBundle?.version).toBe(defaultVersionRange); + }); + + it('should overwrite existing extension bundle configuration', () => { + const hostJson: IHostJsonV2 = { + version: '2.0', + extensionBundle: { + id: 'old-bundle-id', + version: '[1.0.0, 2.0.0)', + }, + }; + + addDefaultBundle(hostJson); + + expect(hostJson.extensionBundle?.id).toBe(extensionBundleId); + expect(hostJson.extensionBundle?.version).toBe(defaultVersionRange); + }); + + it('should preserve other host.json properties', () => { + const hostJson: IHostJsonV2 = { + version: '2.0', + logging: { + logLevel: { + default: 'Information', + }, + }, + }; + + addDefaultBundle(hostJson); + + expect(hostJson.version).toBe('2.0'); + expect(hostJson.logging).toBeDefined(); + expect(hostJson.extensionBundle).toBeDefined(); + }); +}); + +describe('downloadExtensionBundle', () => { + const mockedGetJsonFeed = vi.mocked(feedModule.getJsonFeed); + const mockedDownloadAndExtract = vi.mocked(binariesModule.downloadAndExtractDependency); + + const createMockContext = () => ({ + telemetry: { + properties: {} as Record, + measurements: {} as Record, + }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset environment variables + delete process.env.AzureFunctionsJobHost_extensionBundle_version; + delete process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI; + }); + + it('should download newer version when feed has higher version than local', async () => { + // Feed versions (simulating index.json format) + const feedVersions = ['1.0.0', '1.1.0', '1.2.0', '1.3.0', '1.95.0']; + + // Local version is 1.75.0 + mockedFse.pathExists.mockResolvedValue(true as never); + mockedFse.readdirSync.mockReturnValue(['1.75.0'] as any); + mockedFse.statSync.mockReturnValue({ isDirectory: () => true } as any); + + // Mock the feed to return the versions array + mockedGetJsonFeed.mockResolvedValue(feedVersions as any); + + // Mock download to succeed + mockedDownloadAndExtract.mockResolvedValue(undefined); + + const context = createMockContext(); + const result = await downloadExtensionBundle(context as any); + + // Should have downloaded + expect(result).toBe(true); + expect(context.telemetry.properties.didUpdateExtensionBundle).toBe('true'); + + // Should download version 1.95.0 (the highest from feed) + expect(mockedDownloadAndExtract).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('1.95.0'), + defaultExtensionBundlePathValue, + extensionBundleId, + '1.95.0' + ); + }); + + it('should not download when local version is higher than feed versions', async () => { + // Feed only has older versions + const feedVersions = ['1.0.0', '1.1.0', '1.2.0']; + + // Local version is already 1.75.0 + mockedFse.pathExists.mockResolvedValue(true as never); + mockedFse.readdirSync.mockReturnValue(['1.75.0'] as any); + mockedFse.statSync.mockReturnValue({ isDirectory: () => true } as any); + + mockedGetJsonFeed.mockResolvedValue(feedVersions as any); + + const context = createMockContext(); + const result = await downloadExtensionBundle(context as any); + + // Should not download + expect(result).toBe(false); + expect(context.telemetry.properties.didUpdateExtensionBundle).toBe('false'); + expect(mockedDownloadAndExtract).not.toHaveBeenCalled(); + }); + + it('should correctly identify the latest version from an unordered feed list', async () => { + // Feed versions in random order + const feedVersions = ['1.3.0', '1.95.0', '1.0.0', '1.50.0', '1.1.0']; + + // No local versions + mockedFse.pathExists.mockResolvedValue(true as never); + mockedFse.readdirSync.mockReturnValue([] as any); + + mockedGetJsonFeed.mockResolvedValue(feedVersions as any); + mockedDownloadAndExtract.mockResolvedValue(undefined); + + const context = createMockContext(); + const result = await downloadExtensionBundle(context as any); + + expect(result).toBe(true); + // Should download 1.95.0 (the actual highest version) + expect(mockedDownloadAndExtract).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('1.95.0'), + defaultExtensionBundlePathValue, + extensionBundleId, + '1.95.0' + ); + }); + + it('should handle multiple local versions and compare against highest', async () => { + // Feed has 1.95.0 + const feedVersions = ['1.0.0', '1.95.0']; + + // Multiple local versions, highest is 1.75.0 + mockedFse.pathExists.mockResolvedValue(true as never); + mockedFse.readdirSync.mockReturnValue(['1.50.0', '1.75.0', '1.60.0'] as any); + mockedFse.statSync.mockReturnValue({ isDirectory: () => true } as any); + + mockedGetJsonFeed.mockResolvedValue(feedVersions as any); + mockedDownloadAndExtract.mockResolvedValue(undefined); + + const context = createMockContext(); + const result = await downloadExtensionBundle(context as any); + + // Should download since 1.95.0 > 1.75.0 + expect(result).toBe(true); + expect(mockedDownloadAndExtract).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('1.95.0'), + defaultExtensionBundlePathValue, + extensionBundleId, + '1.95.0' + ); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/bundleFeed.ts b/apps/vs-code-designer/src/app/utils/bundleFeed.ts index 0f082252458..05a95f9eae9 100644 --- a/apps/vs-code-designer/src/app/utils/bundleFeed.ts +++ b/apps/vs-code-designer/src/app/utils/bundleFeed.ts @@ -7,7 +7,7 @@ import { getLocalSettingsJson } from './appSettings/localSettings'; import { downloadAndExtractDependency } from './binaries'; import { getJsonFeed } from './feed'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IBundleDependencyFeed, IBundleFeed, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; +import type { IBundleDependencyFeed, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; import * as path from 'path'; import * as semver from 'semver'; import * as vscode from 'vscode'; @@ -16,38 +16,16 @@ import { ext } from '../../extensionVariables'; import { getFunctionsCommand } from './funcCoreTools/funcVersion'; import * as fse from 'fs-extra'; import { executeCommand } from './funcCoreTools/cpUtils'; -/** - * Gets bundle extension feed. - * @param {IActionContext} context - Command context. - * @param {IBundleMetadata | undefined} bundleMetadata - Bundle meta data. - * @returns {Promise} Returns bundle extension object. - */ -async function getBundleFeed(context: IActionContext, bundleMetadata: IBundleMetadata | undefined): Promise { - const bundleId: string = (bundleMetadata && bundleMetadata.id) || extensionBundleId; - - const envVarUri: string | undefined = process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI; - // Only use an aka.ms link for the most common case, otherwise we will dynamically construct the url - let url: string; - if (!envVarUri && bundleId === extensionBundleId) { - url = 'https://aka.ms/AAqvc78'; - } else { - const baseUrl: string = envVarUri || 'https://cdn.functions.azure.com/public'; - url = `${baseUrl}/ExtensionBundles/${bundleId}/index-v2.json`; - } - - return getJsonFeed(context, url); -} /** * Gets Workflow bundle extension feed. * @param {IActionContext} context - Command context. - * @param {IBundleMetadata | undefined} bundleMetadata - Bundle meta data. - * @returns {Promise} Returns bundle extension object. + * @returns {Promise} Returns array of available bundle versions. */ -async function getWorkflowBundleFeed(context: IActionContext): Promise { +async function getWorkflowBundleFeed(context: IActionContext): Promise { const envVarUri: string | undefined = process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI; const baseUrl: string = envVarUri || 'https://cdn.functions.azure.com/public'; - const url = `${baseUrl}/ExtensionBundles/${extensionBundleId}/index-v2.json`; + const url = `${baseUrl}/ExtensionBundles/${extensionBundleId}/index.json`; return getJsonFeed(context, url); } @@ -56,7 +34,7 @@ async function getWorkflowBundleFeed(context: IActionContext): Promise} Returns bundle extension object. + * @returns {Promise} Returns bundle extension object. */ async function getBundleDependencyFeed( context: IActionContext, @@ -77,12 +55,10 @@ async function getBundleDependencyFeed( /** * Gets latest bundle extension version range. - * @param {IActionContext} context - Command context. - * @returns {Promise} Returns lates version range. + * @returns {string} Returns latest version range. */ -export async function getLatestVersionRange(context: IActionContext): Promise { - const feed: IBundleFeed = await getBundleFeed(context, undefined); - return feed.defaultVersionRange; +export function getLatestVersionRange(): string { + return defaultVersionRange; } /** @@ -97,20 +73,12 @@ export async function getDependenciesVersion(context: IActionContext): Promise { - let versionRange: string; - try { - versionRange = await getLatestVersionRange(context); - } catch { - versionRange = defaultVersionRange; - } - +export function addDefaultBundle(hostJson: IHostJsonV2): void { hostJson.extensionBundle = { id: extensionBundleId, - version: versionRange, + version: defaultVersionRange, }; } @@ -191,8 +159,8 @@ export async function downloadExtensionBundle(context: IActionContext): Promise< // Check the latest from feed. let latestFeedBundleVersion = '1.0.0'; - const feed: IBundleFeed = await getWorkflowBundleFeed(context); - for (const bundleVersion in feed.bundleVersions) { + const feedVersions: string[] = await getWorkflowBundleFeed(context); + for (const bundleVersion of feedVersions) { latestFeedBundleVersion = semver.gt(latestFeedBundleVersion, bundleVersion) ? latestFeedBundleVersion : bundleVersion; }