Skip to content
Open
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
221 changes: 219 additions & 2 deletions apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Comment on lines +464 to +465
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the "should preserve other host.json properties" test, hostJson is typed as IHostJsonV2 but the logging object you provide uses logLevel.default, which doesn’t match the IHostJsonV2['logging'] shape (it expects applicationInsights.samplingSettings...). This will fail type-checking; either use a property that exists on IHostJsonV2 (e.g., managedDependency, extensions, or the supported logging.applicationInsights structure) or relax the test’s typing for that specific case.

Suggested change
logLevel: {
default: 'Information',
applicationInsights: {
samplingSettings: {
isEnabled: true,
},

Copilot uses AI. Check for mistakes.
},
},
};

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<string, string>,
measurements: {} as Record<string, number>,
},
});

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'
);
});
});
56 changes: 12 additions & 44 deletions apps/vs-code-designer/src/app/utils/bundleFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<IBundleFeed>} Returns bundle extension object.
*/
async function getBundleFeed(context: IActionContext, bundleMetadata: IBundleMetadata | undefined): Promise<IBundleFeed> {
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<IBundleFeed>} Returns bundle extension object.
* @returns {Promise<string[]>} Returns array of available bundle versions.
*/
async function getWorkflowBundleFeed(context: IActionContext): Promise<IBundleFeed> {
async function getWorkflowBundleFeed(context: IActionContext): Promise<string[]> {
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);
}
Comment on lines +25 to 31
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getWorkflowBundleFeed is typed to return Promise<string[]>, but it directly returns getJsonFeed(context, url). getJsonFeed is constrained to T extends Record<string, any> (i.e., JSON object), so this is very likely a type error and/or a runtime mismatch if index.json is an object (as the existing IBundleFeed model suggests). Consider fetching index.json into a typed object (e.g., IBundleFeed or a dedicated IIndexJsonFeed), then explicitly deriving the version list from a known property (and keep the function return type aligned with what index.json actually contains).

Copilot uses AI. Check for mistakes.
Expand All @@ -56,7 +34,7 @@ async function getWorkflowBundleFeed(context: IActionContext): Promise<IBundleFe
* Gets extension bundle dependency feed.
* @param {IActionContext} context - Command context.
* @param {IBundleMetadata | undefined} bundleMetadata - Bundle meta data.
* @returns {Promise<IBundleFeed>} Returns bundle extension object.
* @returns {Promise<IBundleDependencyFeed>} Returns bundle extension object.
*/
async function getBundleDependencyFeed(
context: IActionContext,
Expand All @@ -77,12 +55,10 @@ async function getBundleDependencyFeed(

/**
* Gets latest bundle extension version range.
* @param {IActionContext} context - Command context.
* @returns {Promise<string>} Returns lates version range.
* @returns {string} Returns latest version range.
*/
export async function getLatestVersionRange(context: IActionContext): Promise<string> {
const feed: IBundleFeed = await getBundleFeed(context, undefined);
return feed.defaultVersionRange;
export function getLatestVersionRange(): string {
return defaultVersionRange;
}
Comment on lines 56 to 62
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLatestVersionRange now returns the defaultVersionRange constant unconditionally, but its name/JSDoc still reads like it computes the latest value. This is misleading for callers and makes it easy to assume it’s dynamically sourced. Consider renaming it (e.g., to reflect that it returns the default/fallback range) and/or updating the doc comment to explicitly state it no longer queries the feed.

Copilot uses AI. Check for mistakes.

/**
Expand All @@ -97,20 +73,12 @@ export async function getDependenciesVersion(context: IActionContext): Promise<I

/**
* Add bundle extension version to host.json configuration.
* @param {IActionContext} context - Command context.
* @param {IHostJsonV2} hostJson - Host.json configuration.
*/
export async function addDefaultBundle(context: IActionContext, hostJson: IHostJsonV2): Promise<void> {
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,
};
}

Expand Down Expand Up @@ -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;
}
Comment on lines +162 to 165
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadExtensionBundle assumes every entry returned from the feed is a valid semver and passes it directly to semver.gt. If any non-semver value makes it into the returned list (e.g., because the feed shape changes or contains metadata), semver.gt can throw and the whole download path will fall into the catch and silently skip updating. Consider filtering/validating feed entries with semver.valid (and/or extracting versions from a well-defined feed object) before comparing.

Copilot uses AI. Check for mistakes.

Expand Down
Loading