podman-desktop/packages/main/src/plugin/extension/extension-loader.spec.ts
Florent BENOIT 8d30d8a4e5
chore: add a property to store folders used as extension folders (#10712)
* chore: add a property to store folders used as extension folders

no UI for now it's just adding backend way on adding/editing/removing
and the store keeping the values

related to https://github.com/podman-desktop/podman-desktop/issues/8616
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
2025-01-20 08:18:07 +00:00

2804 lines
85 KiB
TypeScript

/**********************************************************************
* Copyright (C) 2023-2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as fs from 'node:fs';
import { readFile, realpath } from 'node:fs/promises';
import * as path from 'node:path';
import type * as containerDesktopAPI from '@podman-desktop/api';
import { app } from 'electron';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Certificates } from '/@/plugin/certificates.js';
import type { ContributionManager } from '/@/plugin/contribution-manager.js';
import type { KubeGeneratorRegistry } from '/@/plugin/kubernetes/kube-generator-registry.js';
import { NavigationManager } from '/@/plugin/navigation/navigation-manager.js';
import type { WebviewRegistry } from '/@/plugin/webview/webview-registry.js';
import type { ContributionInfo } from '/@api/contribution-info.js';
import { ExtensionLoaderSettings } from '/@api/extension-loader-settings.js';
import { NavigationPage } from '/@api/navigation-page.js';
import type { OnboardingInfo } from '/@api/onboarding.js';
import type { WebviewInfo } from '/@api/webview-info.js';
import { getBase64Image } from '../../util.js';
import type { ApiSenderType } from '../api.js';
import type { AuthenticationImpl } from '../authentication.js';
import type { CliToolRegistry } from '../cli-tool-registry.js';
import type { ColorRegistry } from '../color-registry.js';
import type { CommandRegistry } from '../command-registry.js';
import type { ConfigurationRegistry } from '../configuration-registry.js';
import type { ContainerProviderRegistry } from '../container-registry.js';
import { Context } from '../context/context.js';
import type { CustomPickRegistry } from '../custompick/custompick-registry.js';
import type { DialogRegistry } from '../dialog-registry.js';
import type { Directories } from '../directories.js';
import type { FilesystemMonitoring } from '../filesystem-monitoring.js';
import type { IconRegistry } from '../icon-registry.js';
import type { ImageCheckerImpl } from '../image-checker.js';
import type { ImageFilesRegistry } from '../image-files-registry.js';
import type { ImageRegistry } from '../image-registry.js';
import type { InputQuickPickRegistry } from '../input-quickpick/input-quickpick-registry.js';
import type { KubernetesClient } from '../kubernetes/kubernetes-client.js';
import type { MenuRegistry } from '../menu-registry.js';
import type { MessageBox } from '../message-box.js';
import type { OnboardingRegistry } from '../onboarding-registry.js';
import type { ProviderRegistry } from '../provider-registry.js';
import type { Proxy } from '../proxy.js';
import type { ExtensionSecretStorage, SafeStorageRegistry } from '../safe-storage/safe-storage-registry.js';
import type { StatusBarRegistry } from '../statusbar/statusbar-registry.js';
import type { NotificationRegistry } from '../tasks/notification-registry.js';
import { type ProgressImpl, ProgressLocation } from '../tasks/progress-impl.js';
import type { Telemetry } from '../telemetry/telemetry.js';
import type { TrayMenuRegistry } from '../tray-menu-registry.js';
import type { IDisposable } from '../types/disposable.js';
import { Disposable } from '../types/disposable.js';
import { Uri } from '../types/uri.js';
import { Exec } from '../util/exec.js';
import type { ViewRegistry } from '../view-registry.js';
import type { ExtensionDevelopmentFolders } from './extension-development-folders.js';
import type { ActivatedExtension, AnalyzedExtension, RequireCacheDict } from './extension-loader.js';
import { ExtensionLoader } from './extension-loader.js';
import type { ExtensionWatcher } from './extension-watcher.js';
class TestExtensionLoader extends ExtensionLoader {
public override async setupScanningDirectory(): Promise<void> {
return super.setupScanningDirectory();
}
setPluginsScanDirectory(path: string): void {
this.pluginsScanDirectory = path;
}
setWatchTimeout(timeout: number): void {
this.watchTimeout = timeout;
}
getExtensionState(): Map<string, string> {
return this.extensionState;
}
getActivatedExtensions(): Map<string, ActivatedExtension> {
return this.activatedExtensions;
}
getExtensionStateErrors(): Map<string, unknown> {
return this.extensionStateErrors;
}
override doRequire(module: string): NodeRequire {
return super.doRequire(module);
}
getRequireCache(): RequireCacheDict {
return super.requireCache;
}
setActivatedExtension(extensionId: string, activatedExtension: ActivatedExtension): void {
this.activatedExtensions.set(extensionId, activatedExtension);
}
setAnalyzedExtension(extensionId: string, analyzedExtension: AnalyzedExtension): void {
this.analyzedExtensions.set(extensionId, analyzedExtension);
}
override reloadExtension(extension: AnalyzedExtension, removable: boolean): Promise<void> {
return super.reloadExtension(extension, removable);
}
override loadDevelopmentFolderExtensions(analyzedExtensions: AnalyzedExtension[]): Promise<void> {
return super.loadDevelopmentFolderExtensions(analyzedExtensions);
}
}
let extensionLoader: TestExtensionLoader;
const commandRegistry: CommandRegistry = {} as unknown as CommandRegistry;
const menuRegistry: MenuRegistry = {} as unknown as MenuRegistry;
const kubernetesGeneratorRegistry: KubeGeneratorRegistry = {} as unknown as KubeGeneratorRegistry;
const providerRegistry: ProviderRegistry = {} as unknown as ProviderRegistry;
const configurationRegistryGetConfigurationMock = vi.fn();
const configurationRegistryUpdateConfigurationMock = vi.fn();
const configurationRegistry: ConfigurationRegistry = {
getConfiguration: configurationRegistryGetConfigurationMock,
registerConfigurations: vi.fn(),
updateConfigurationValue: configurationRegistryUpdateConfigurationMock,
} as unknown as ConfigurationRegistry;
const imageRegistry: ImageRegistry = {
registerRegistry: vi.fn(),
} as unknown as ImageRegistry;
const apiSender: ApiSenderType = { send: vi.fn() } as unknown as ApiSenderType;
const trayMenuRegistry: TrayMenuRegistry = {} as unknown as TrayMenuRegistry;
const messageBox: MessageBox = {} as MessageBox;
const progress: ProgressImpl = {
withProgress: vi.fn(),
} as unknown as ProgressImpl;
const statusBarRegistry: StatusBarRegistry = {} as unknown as StatusBarRegistry;
const kubernetesClient: KubernetesClient = {} as unknown as KubernetesClient;
const fileSystemMonitoring: FilesystemMonitoring = {} as unknown as FilesystemMonitoring;
const proxy: Proxy = {} as unknown as Proxy;
const containerProviderRegistry: ContainerProviderRegistry = {
containerExist: vi.fn(),
imageExist: vi.fn(),
volumeExist: vi.fn(),
podExist: vi.fn(),
listPods: vi.fn(),
stopPod: vi.fn(),
removePod: vi.fn(),
getContainerStats: vi.fn(),
stopContainerStats: vi.fn(),
listImages: vi.fn(),
podmanListImages: vi.fn(),
listInfos: vi.fn(),
} as unknown as ContainerProviderRegistry;
const inputQuickPickRegistry: InputQuickPickRegistry = {} as unknown as InputQuickPickRegistry;
const customPickRegistry: CustomPickRegistry = {} as unknown as CustomPickRegistry;
const authenticationProviderRegistry: AuthenticationImpl = {
registerAuthenticationProvider: vi.fn(),
} as unknown as AuthenticationImpl;
const iconRegistry: IconRegistry = {} as unknown as IconRegistry;
const onboardingRegistry: OnboardingRegistry = {
getOnboarding: vi.fn(),
} as unknown as OnboardingRegistry;
const telemetryTrackMock = vi.fn();
const telemetry: Telemetry = { track: telemetryTrackMock } as unknown as Telemetry;
const viewRegistry: ViewRegistry = {} as unknown as ViewRegistry;
const context: Context = new Context(apiSender);
const cliToolRegistry: CliToolRegistry = {
createCliTool: vi.fn(),
} as unknown as CliToolRegistry;
const safeStorageRegistry: SafeStorageRegistry = {
getExtensionStorage: vi.fn(),
} as unknown as SafeStorageRegistry;
const directories = {
getPluginsDirectory: () => '/fake-plugins-directory',
getPluginsScanDirectory: () => '/fake-plugins-scanning-directory',
getExtensionsStorageDirectory: () => '/fake-extensions-storage-directory',
getSafeStorageDirectory: () => '/fake-safe-storage-directory',
} as unknown as Directories;
const exec = new Exec(proxy);
const notificationRegistry: NotificationRegistry = {
registerExtension: vi.fn(),
addNotification: vi.fn(),
} as unknown as NotificationRegistry;
const imageCheckerImpl: ImageCheckerImpl = {
registerImageCheckerProvider: vi.fn(),
} as unknown as ImageCheckerImpl;
const imageFilesImpl: ImageFilesRegistry = {
registerImageFilesProvider: vi.fn(),
} as unknown as ImageFilesRegistry;
const contributionManager: ContributionManager = {
listContributions: vi.fn(),
} as unknown as ContributionManager;
const webviewRegistry: WebviewRegistry = {
listSimpleWebviews: vi.fn(),
listWebviews: vi.fn(),
} as unknown as WebviewRegistry;
const navigationManager: NavigationManager = new NavigationManager(
apiSender,
containerProviderRegistry,
contributionManager,
providerRegistry,
webviewRegistry,
commandRegistry,
onboardingRegistry,
);
const colorRegistry = {
registerExtensionThemes: vi.fn(),
} as unknown as ColorRegistry;
const openDialogMock = vi.fn();
const saveDialogMock = vi.fn();
const dialogRegistry: DialogRegistry = {
openDialog: openDialogMock,
saveDialog: saveDialogMock,
} as unknown as DialogRegistry;
const certificates: Certificates = {} as unknown as Certificates;
const extensionWatcher = {
monitor: vi.fn(),
untrack: vi.fn(),
stop: vi.fn(),
reloadExtension: vi.fn(),
} as unknown as ExtensionWatcher;
const extensionDevelopmentFolder = {
getDevelopmentFolders: vi.fn(),
} as unknown as ExtensionDevelopmentFolders;
vi.mock('electron', () => {
return {
app: {
getVersion: vi.fn(),
},
};
});
vi.mock('../../util.js', async () => {
return {
getBase64Image: vi.fn(),
};
});
/* eslint-disable @typescript-eslint/no-empty-function */
beforeAll(() => {
extensionLoader = new TestExtensionLoader(
commandRegistry,
menuRegistry,
providerRegistry,
configurationRegistry,
imageRegistry,
apiSender,
trayMenuRegistry,
messageBox,
progress,
statusBarRegistry,
kubernetesClient,
fileSystemMonitoring,
proxy,
containerProviderRegistry,
inputQuickPickRegistry,
customPickRegistry,
authenticationProviderRegistry,
iconRegistry,
onboardingRegistry,
telemetry,
viewRegistry,
context,
directories,
exec,
kubernetesGeneratorRegistry,
cliToolRegistry,
notificationRegistry,
imageCheckerImpl,
imageFilesImpl,
navigationManager,
webviewRegistry,
colorRegistry,
dialogRegistry,
safeStorageRegistry,
certificates,
extensionWatcher,
extensionDevelopmentFolder,
);
});
vi.mock('node:fs');
beforeEach(() => {
telemetryTrackMock.mockImplementation(() => Promise.resolve());
vi.clearAllMocks();
});
test('Should watch for files and load them at startup', async () => {
const fakeDirectory = '/fake/path/scanning';
// fake scanning property
extensionLoader.setPluginsScanDirectory(fakeDirectory);
// mock fs.watch
const fsWatchMock = vi.spyOn(fs, 'watch');
fsWatchMock.mockReturnValue({} as fs.FSWatcher);
// mock fs.existsSync
const fsExistsSyncMock = vi.spyOn(fs, 'existsSync');
fsExistsSyncMock.mockReturnValue(true);
// mock fs.promises.readdir
const readdirMock = vi.spyOn(fs.promises, 'readdir');
const ent1 = {
isFile: () => true,
isDirectory: () => false,
name: 'foo.cdix',
} as unknown as fs.Dirent;
const ent2 = {
isFile: () => true,
isDirectory: () => false,
name: 'bar.foo',
} as unknown as fs.Dirent;
const ent3 = {
isFile: () => false,
isDirectory: () => true,
name: 'baz',
} as unknown as fs.Dirent;
readdirMock.mockResolvedValue([ent1, ent2, ent3]);
// mock loadPackagedFile
const loadPackagedFileMock = vi.spyOn(extensionLoader, 'loadPackagedFile');
loadPackagedFileMock.mockResolvedValue();
await extensionLoader.setupScanningDirectory();
// expect to load only one file (other are invalid files/folder)
expect(loadPackagedFileMock).toBeCalledWith(path.join(fakeDirectory, 'foo.cdix'));
// expect watcher is setup
expect(fsWatchMock).toBeCalledWith(fakeDirectory, expect.anything());
});
test('Should load file from watching scanning folder', async () => {
const fakeDirectory = '/fake/path/scanning';
const rootedFakeDirectory = path.resolve(fakeDirectory);
// fake scanning property
extensionLoader.setPluginsScanDirectory(fakeDirectory);
let watchFilename: fs.PathLike | undefined = undefined;
let watchListener: fs.WatchListener<string> = {} as unknown as fs.WatchListener<string>;
// reduce timeout delay for tests
extensionLoader.setWatchTimeout(50);
vi.mock('node:fs');
// mock fs.watch
const fsWatchMock = vi.spyOn(fs, 'watch');
fsWatchMock.mockImplementation((filename: fs.PathLike, listener?: fs.WatchListener<string>): fs.FSWatcher => {
watchFilename = filename;
if (listener) {
watchListener = listener;
}
return {} as fs.FSWatcher;
});
// mock fs.existsSync
const fsExistsSyncMock = vi.spyOn(fs, 'existsSync');
fsExistsSyncMock.mockReturnValue(true);
// mock fs.promises.readdir
const readdirMock = vi.spyOn(fs.promises, 'readdir');
readdirMock.mockResolvedValue([]);
// mock loadPackagedFile
const loadPackagedFileMock = vi.spyOn(extensionLoader, 'loadPackagedFile');
loadPackagedFileMock.mockResolvedValue();
await extensionLoader.setupScanningDirectory();
// no loading for now as no files in the folder
expect(loadPackagedFileMock).not.toBeCalled();
// expect watcher is setup
expect(fsWatchMock).toBeCalledWith(fakeDirectory, expect.anything());
expect(watchFilename).toBeDefined();
expect(watchListener).toBeDefined();
expect(watchFilename).toBe(fakeDirectory);
// call the watcher callback
if (watchListener) {
watchListener('rename', 'watch.cdix');
}
// wait more than the watchListener timeout
await new Promise(resolve => setTimeout(resolve, 100));
// expect to load only one file (other are invalid files/folder)
expect(loadPackagedFileMock).toBeCalledWith(path.resolve(rootedFakeDirectory, 'watch.cdix'));
});
test('Verify extension error leads to failed state', async () => {
const id = 'extension.id';
await extensionLoader.activateExtension(
{
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {},
subscriptions: [],
readme: '',
dispose: vi.fn(),
},
{
activate: () => {
throw Error('Failed');
},
},
);
expect(extensionLoader.getExtensionState().get(id)).toBe('failed');
});
test('Verify extension subscriptions are disposed when failed state reached', async () => {
const id = 'extension.id';
const disposableMock: containerDesktopAPI.Disposable = {
dispose: vi.fn(),
};
configurationRegistryGetConfigurationMock.mockReturnValue({
get: vi.fn().mockReturnValue(5000),
});
await extensionLoader.activateExtension(
{
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {},
subscriptions: [],
readme: '',
dispose: vi.fn(),
},
{
activate: (extensionContext: containerDesktopAPI.ExtensionContext) => {
extensionContext.subscriptions.push(disposableMock);
throw Error('Failed');
},
},
);
expect(extensionLoader.getExtensionState().get(id)).toBe('failed');
expect(disposableMock.dispose).toHaveBeenCalledOnce();
});
test('Verify extension activate with a long timeout is flagged as error', async () => {
const id = 'extension.id';
// mock getConfiguration
const getMock = vi.fn();
configurationRegistryGetConfigurationMock.mockReturnValue({
get: getMock,
});
getMock.mockReturnValue(1);
await extensionLoader.activateExtension(
{
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {},
subscriptions: [],
readme: '',
dispose: vi.fn(),
},
{
activate: () => {
// wait for 20 seconds
return new Promise(resolve => setTimeout(resolve, 20000));
},
},
);
expect(extensionLoader.getExtensionStateErrors().get(id)).toBeDefined();
expect(extensionLoader.getExtensionStateErrors().get(id)?.toString()).toContain(
'Extension extension.id activation timed out after 1 seconds',
);
expect(extensionLoader.getExtensionState().get(id)).toBe('failed');
});
test('Verify extension load', async () => {
const id = 'extension.foo';
await extensionLoader.loadExtension({
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {
version: '1.1',
},
subscriptions: [],
readme: '',
dispose: vi.fn(),
});
expect(telemetry.track).toBeCalledWith(
'loadExtension.error',
expect.objectContaining({ extensionId: id, extensionVersion: '1.1' }),
);
});
test('Verify extension do not add configuration to subscriptions', async () => {
const id = 'extension.foo';
const disposable = {
dispose: vi.fn(),
} as unknown as Disposable;
vi.mocked(configurationRegistry.registerConfigurations).mockReturnValue(disposable);
const subscriptions: Disposable[] = [];
await extensionLoader.loadExtension({
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {
version: '1.1',
contributes: {
configuration: {
title: 'dummy-configuration-title',
},
},
},
subscriptions: subscriptions,
readme: '',
dispose: vi.fn(),
});
expect(configurationRegistry.registerConfigurations).toHaveBeenCalled();
expect(subscriptions).not.toContain(disposable);
await extensionLoader.deactivateExtension(id);
expect(disposable.dispose).not.toHaveBeenCalled();
});
test('Verify disable extension updates configuration', async () => {
const ids = ['extension.foo'];
configurationRegistryUpdateConfigurationMock.mockResolvedValue(Promise.resolve);
extensionLoader.setDisabledExtensionIds(ids);
expect(configurationRegistryUpdateConfigurationMock).toHaveBeenCalledWith('extensions.disabled', ids);
});
test('Verify enable extension updates configuration', async () => {
const id = 'extension.no.foo';
const before = ['a', id, 'b'];
const after = ['a', 'b'];
configurationRegistryGetConfigurationMock.mockReturnValue({
get: () => before,
});
configurationRegistryUpdateConfigurationMock.mockResolvedValue(Promise.resolve);
extensionLoader.ensureExtensionIsEnabled(id);
expect(configurationRegistryUpdateConfigurationMock).toHaveBeenCalledWith('extensions.disabled', after);
});
test('Verify stopping extension disables it', async () => {
const id = 'extension.no.foo';
configurationRegistryGetConfigurationMock.mockReturnValue({
get: () => [],
});
await extensionLoader.stopExtension(id);
expect(configurationRegistryUpdateConfigurationMock).toHaveBeenCalledWith('extensions.disabled', [id]);
});
test('Verify starting extension enables it', async () => {
const id = 'extension.no.foo';
configurationRegistryGetConfigurationMock.mockReturnValue({
get: () => ['extension.no.foo'],
});
await extensionLoader.startExtension(id);
expect(configurationRegistryUpdateConfigurationMock).toHaveBeenCalledWith('extensions.disabled', []);
});
test('Verify setExtensionsUpdates', async () => {
// set the private field analyzedExtensions of extensionLoader
const analyzedExtensions = new Map<string, AnalyzedExtension>();
const extensionId = 'my.foo.extension';
const analyzedExtension: AnalyzedExtension = {
id: extensionId,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
analyzedExtensions.set(extensionId, analyzedExtension);
extensionLoader['analyzedExtensions'] = analyzedExtensions;
// get list of extensions
const extensions = await extensionLoader.listExtensions();
// check we have our extension
expect(extensions.length).toBe(1);
expect(extensions[0]?.id).toBe(extensionId);
// check that update field is empty
expect(extensions[0]?.update).toBeUndefined();
// now call the update
const ociUri = 'quay.io/extension';
const newVersion = '2.0.0';
extensionLoader.setExtensionsUpdates([
{
id: extensionId,
version: newVersion,
ociUri,
},
]);
// get list of extensions
const extensionsAfterUpdate = await extensionLoader.listExtensions();
// check we have our extension
expect(extensionsAfterUpdate.length).toBe(1);
expect(extensionsAfterUpdate[0]?.id).toBe(extensionId);
// check that update field is set
expect(extensionsAfterUpdate[0]?.update).toStrictEqual({
ociUri: 'quay.io/extension',
version: newVersion,
});
expect(apiSender.send).toBeCalledWith('extensions-updated');
});
test('Verify searchForCircularDependencies(analyzedExtensions);', async () => {
// Check if missing dependencies are found
const extensionId1 = 'foo.extension1';
const extensionId2 = 'foo.extension2';
const extensionId3 = 'foo.extension3';
// extension1 has no dependencies
const analyzedExtension1: AnalyzedExtension = {
id: extensionId1,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension2 depends on extension 1 and extension 3
const analyzedExtension2: AnalyzedExtension = {
id: extensionId2,
manifest: {
extensionDependencies: [extensionId1, extensionId3],
name: 'hello',
},
} as AnalyzedExtension;
// extension3 depends on extension 2 (circular dependency)
const analyzedExtension3: AnalyzedExtension = {
id: extensionId3,
manifest: {
extensionDependencies: [extensionId2],
name: 'hello',
},
} as AnalyzedExtension;
expect(analyzedExtension1.circularDependencies).toBeUndefined();
expect(analyzedExtension2.circularDependencies).toBeUndefined();
expect(analyzedExtension3.circularDependencies).toBeUndefined();
const analyzedExtensions = [analyzedExtension1, analyzedExtension2, analyzedExtension3];
extensionLoader.searchForCircularDependencies(analyzedExtensions);
// do we have missingDependencies field for extension 3 as it's missing
expect(analyzedExtension1.circularDependencies).toStrictEqual([]);
expect(analyzedExtension2.circularDependencies).toStrictEqual([extensionId3]);
expect(analyzedExtension3.circularDependencies).toStrictEqual([extensionId2]);
});
test('Verify searchForMissingDependencies(analyzedExtensions);', async () => {
// Check if missing dependencies are found
const extensionId1 = 'foo.extension1';
const extensionId2 = 'foo.extension2';
const extensionId3 = 'foo.extension3';
const unknownExtensionId = 'foo.unknown';
// extension1 has no dependencies
const analyzedExtension1: AnalyzedExtension = {
id: extensionId1,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension2 depends on extension 1
const analyzedExtension2: AnalyzedExtension = {
id: extensionId2,
manifest: {
extensionDependencies: [extensionId1],
name: 'hello',
},
} as AnalyzedExtension;
// extension3 depends on unknown extension unknown
const analyzedExtension3: AnalyzedExtension = {
id: extensionId3,
manifest: {
extensionDependencies: [unknownExtensionId],
name: 'hello',
},
} as AnalyzedExtension;
expect(analyzedExtension1.missingDependencies).toBeUndefined();
expect(analyzedExtension2.missingDependencies).toBeUndefined();
expect(analyzedExtension3.missingDependencies).toBeUndefined();
const analyzedExtensions = [analyzedExtension1, analyzedExtension2, analyzedExtension3];
extensionLoader.searchForMissingDependencies(analyzedExtensions);
// do we have missingDependencies field for extension 3 as it's missing
expect(analyzedExtension1.missingDependencies).toStrictEqual([]);
expect(analyzedExtension2.missingDependencies).toStrictEqual([]);
expect(analyzedExtension3.missingDependencies).toStrictEqual([unknownExtensionId]);
});
test('Verify searchForMissingDependencies(analyzedExtensions); with already loaded extensions', async () => {
// Check if missing dependencies are found
const extensionId1 = 'foo.extension1';
const extensionId2 = 'foo.extension2';
const extensionId3 = 'foo.extension3';
const extensionId4 = 'foo.extension4';
const unknownExtensionId = 'foo.unknown';
// extension1 has no dependencies and has already been loaded
const analyzedExtension1: AnalyzedExtension = {
id: extensionId1,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension2 has no dependencies and has already been loaded
const analyzedExtension2: AnalyzedExtension = {
id: extensionId2,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension3 depends on unknown extension unknown
const analyzedExtension3: AnalyzedExtension = {
id: extensionId3,
manifest: {
extensionDependencies: [unknownExtensionId],
name: 'hello',
},
} as AnalyzedExtension;
// extension4 depends on extension1
const analyzedExtension4: AnalyzedExtension = {
id: extensionId4,
manifest: {
extensionDependencies: [extensionId1],
name: 'hello',
},
} as AnalyzedExtension;
const analyzedExtensions = new Map<string, AnalyzedExtension>();
analyzedExtensions.set(extensionId1, analyzedExtension1);
analyzedExtensions.set(extensionId2, analyzedExtension2);
extensionLoader['analyzedExtensions'] = analyzedExtensions;
expect(analyzedExtension1.missingDependencies).toBeUndefined();
expect(analyzedExtension2.missingDependencies).toBeUndefined();
expect(analyzedExtension3.missingDependencies).toBeUndefined();
expect(analyzedExtension4.missingDependencies).toBeUndefined();
extensionLoader.searchForMissingDependencies([analyzedExtension3, analyzedExtension4]);
// do we have missingDependencies field for extension 3 as it's missing
expect(analyzedExtension4.missingDependencies).toStrictEqual([]);
expect(analyzedExtension3.missingDependencies).toStrictEqual([unknownExtensionId]);
});
test('Verify sortExtensionsByDependencies(analyzedExtensions);', async () => {
const extensionId1 = 'foo.extension1';
const extensionId2 = 'foo.extension2';
const extensionId3 = 'foo.extension3';
const extensionId4 = 'foo.extension4';
const extensionId5 = 'foo.extension5';
// extension1 has no dependency
const analyzedExtension1: AnalyzedExtension = {
id: extensionId1,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension2 depends on extension 1
const analyzedExtension2: AnalyzedExtension = {
id: extensionId2,
manifest: {
extensionDependencies: [extensionId1],
name: 'hello',
},
} as AnalyzedExtension;
// extension3 depends on extension1 and extension2
const analyzedExtension3: AnalyzedExtension = {
id: extensionId3,
manifest: {
extensionDependencies: [extensionId1, extensionId2],
name: 'hello',
},
} as AnalyzedExtension;
// extension4 depends on extension3
const analyzedExtension4: AnalyzedExtension = {
id: extensionId4,
manifest: {
extensionDependencies: [extensionId3],
name: 'hello',
},
} as AnalyzedExtension;
// extension5 depends on extension2
const analyzedExtension5: AnalyzedExtension = {
id: extensionId5,
manifest: {
extensionDependencies: [extensionId2, extensionId3, extensionId4],
name: 'hello',
},
} as AnalyzedExtension;
expect(analyzedExtension1.missingDependencies).toBeUndefined();
expect(analyzedExtension2.missingDependencies).toBeUndefined();
expect(analyzedExtension3.missingDependencies).toBeUndefined();
// 1 -> nothing
// 2 -> 1
// 3 -> 1, 2
// 4 -> 3
// 5 -> 2 & 3 & 4
// order of loading is
// 1 then 2 as it depends on it
// then 3
// then 5 as it depends on 2
// and then 4
// no matter of the initial order, they should always be in the same order
const analyzedExtensions1 = [
analyzedExtension5,
analyzedExtension2,
analyzedExtension1,
analyzedExtension4,
analyzedExtension3,
];
const sortedElements1 = extensionLoader.sortExtensionsByDependencies(analyzedExtensions1);
const analyzedExtensions2 = [
analyzedExtension5,
analyzedExtension4,
analyzedExtension3,
analyzedExtension2,
analyzedExtension1,
];
const sortedElements2 = extensionLoader.sortExtensionsByDependencies(analyzedExtensions2);
const analyzedExtensions3 = [
analyzedExtension1,
analyzedExtension2,
analyzedExtension3,
analyzedExtension4,
analyzedExtension5,
];
const sortedElements3 = extensionLoader.sortExtensionsByDependencies(analyzedExtensions3);
expect(sortedElements1.map(analyzedExtension => analyzedExtension.id)).toStrictEqual([
extensionId1,
extensionId2,
extensionId3,
extensionId4,
extensionId5,
]);
expect(sortedElements2.map(analyzedExtension => analyzedExtension.id)).toStrictEqual([
extensionId1,
extensionId2,
extensionId3,
extensionId4,
extensionId5,
]);
expect(sortedElements3.map(analyzedExtension => analyzedExtension.id)).toStrictEqual([
extensionId1,
extensionId2,
extensionId3,
extensionId4,
extensionId5,
]);
});
describe('check loadRuntime', async () => {
test('check for extension with main entry', async () => {
// override doRequire method
const doRequireMock = vi.spyOn(extensionLoader, 'doRequire');
doRequireMock.mockResolvedValue({} as NodeRequire);
const fakeExtension = {
mainPath: '/fake/path',
} as unknown as AnalyzedExtension;
extensionLoader.loadRuntime(fakeExtension);
// expect require to be called with the mainPath
expect(doRequireMock).toHaveBeenCalledWith(fakeExtension.mainPath);
});
test('check for extension without main entry', async () => {
// override doRequire method
const doRequireMock = vi.spyOn(extensionLoader, 'doRequire');
doRequireMock.mockResolvedValue({} as NodeRequire);
const fakeExtension = {
mainPath: undefined,
} as unknown as AnalyzedExtension;
extensionLoader.loadRuntime(fakeExtension);
// expect require to be called with the mainPath
expect(doRequireMock).not.toBeCalled();
});
test('check cache entry without id and children', async () => {
// override doRequire method
const doRequireMock = vi.spyOn(extensionLoader, 'doRequire');
doRequireMock.mockResolvedValue({} as NodeRequire);
const getRequireCacheMock = vi.spyOn(extensionLoader, 'getRequireCache');
getRequireCacheMock.mockReturnValue({
foo: {
// no id and no children
} as unknown as NodeModule,
});
const fakeExtension = {
mainPath: '/fake/path',
} as unknown as AnalyzedExtension;
extensionLoader.loadRuntime(fakeExtension);
// expect require to be called with the mainPath and no exception
expect(doRequireMock).toHaveBeenCalledWith(fakeExtension.mainPath);
});
});
describe('analyze extension and main', async () => {
beforeEach(() => {
vi.resetAllMocks();
});
test('check for extension with main entry', async () => {
vi.mock('node:fs');
vi.mock('node:fs/promises');
// mock fs.existsSync
const fsExistsSyncMock = vi.spyOn(fs, 'existsSync');
fsExistsSyncMock.mockReturnValue(true);
const readmeContent = 'This is my custom README';
vi.mocked(realpath).mockResolvedValue('/fake/path');
// mock readFile
vi.mocked(readFile).mockResolvedValue(readmeContent);
const fakeManifest = {
publisher: 'fooPublisher',
name: 'fooName',
main: 'main-entry.js',
};
// mock loadManifest
const loadManifestMock = vi.spyOn(extensionLoader, 'loadManifest');
loadManifestMock.mockResolvedValue(fakeManifest);
const extension = await extensionLoader.analyzeExtension(path.resolve('/', 'fake', 'path'), false);
expect(extension).toBeDefined();
expect(extension?.error).toBeDefined();
expect(extension?.mainPath).toBe(path.resolve('/', 'fake', 'path', 'main-entry.js'));
expect(extension.readme).toBe(readmeContent);
expect(extension?.id).toBe('fooPublisher.fooName');
});
test('check for extension with linked folder', async () => {
vi.mock('node:fs');
vi.mock('node:fs/promises');
// mock fs.existsSync
const fsExistsSyncMock = vi.spyOn(fs, 'existsSync');
fsExistsSyncMock.mockReturnValue(true);
const readmeContent = 'This is my custom README';
vi.mocked(realpath).mockResolvedValue('/fake/path');
// mock readFile
vi.mocked(readFile).mockResolvedValue(readmeContent);
const fakeManifest = {
publisher: 'fooPublisher',
name: 'fooName',
main: 'main-entry.js',
};
// mock loadManifest
const loadManifestMock = vi.spyOn(extensionLoader, 'loadManifest');
loadManifestMock.mockResolvedValue(fakeManifest);
const extension = await extensionLoader.analyzeExtension(path.resolve('/', 'linked', 'path'), false);
expect(extension).toBeDefined();
expect(extension?.error).toBeDefined();
expect(extension?.mainPath).toBe(path.resolve('/', 'fake', 'path', 'main-entry.js'));
expect(extension.readme).toBe(readmeContent);
expect(extension?.id).toBe('fooPublisher.fooName');
});
test('check for extension without main entry', async () => {
vi.mock('node:fs');
// mock fs.existsSync
const fsExistsSyncMock = vi.spyOn(fs, 'existsSync');
fsExistsSyncMock.mockReturnValue(true);
vi.mocked(realpath).mockResolvedValue('/fake/path');
vi.mocked(readFile).mockResolvedValue('empty');
const fakeManifest = {
publisher: 'fooPublisher',
name: 'fooName',
// no main entry
};
// mock loadManifest
const loadManifestMock = vi.spyOn(extensionLoader, 'loadManifest');
loadManifestMock.mockResolvedValue(fakeManifest);
const extension = await extensionLoader.analyzeExtension('/fake/path', false);
expect(extension).toBeDefined();
expect(extension?.error).toBeDefined();
// not set
expect(extension?.mainPath).toBeUndefined();
expect(extension?.id).toBe('fooPublisher.fooName');
});
});
describe('setContextValue', async () => {
test('without scope the setValue is called with original value', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
const setValueSpy = vi.spyOn(context, 'setValue');
api.context.setValue('key', 'value');
expect(setValueSpy).toBeCalledWith('key', 'value');
});
test('with onboarding scope the key is prefixed before calling setValue', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
const setValueSpy = vi.spyOn(context, 'setValue');
api.context.setValue('key', 'value', 'onboarding');
expect(setValueSpy).toBeCalledWith('publisher.name.onboarding.key', 'value');
});
test('with DockerCompatibility scope the key is prefixed before calling setValue', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
const setValueSpy = vi.spyOn(context, 'setValue');
api.context.setValue('key', 'value', 'DockerCompatibility');
expect(setValueSpy).toBeCalledWith('publisher.name.DockerCompatibility.key', 'value');
});
});
describe('Removing extension by user', async () => {
const ExtID = 'company.ext-id';
test('sends telemetry w/o error when whens succeeds', async () => {
configurationRegistryGetConfigurationMock.mockReturnValue({
get: () => [],
});
extensionLoader.removeExtension = vi.fn();
await extensionLoader.removeExtensionPerUserRequest(ExtID);
expect(extensionLoader.removeExtension).toBeCalledWith(ExtID);
expect(telemetry.track).toBeCalledWith('removeExtension', { extensionId: ExtID });
});
test('sends telemetry w/ error when fails', async () => {
const RemoveError = 'Error';
extensionLoader.removeExtension = vi.fn().mockRejectedValue(RemoveError);
await extensionLoader.removeExtensionPerUserRequest(ExtID).catch(() => undefined);
expect(extensionLoader.removeExtension).toBeCalledWith(ExtID);
expect(telemetry.track).toBeCalledWith('removeExtension', { extensionId: ExtID, error: RemoveError });
});
});
test('check dispose when deactivating', async () => {
vi.mock('node:fs');
const extensionId = 'fooPublisher.fooName';
extensionLoader.setActivatedExtension(extensionId, {
id: extensionId,
} as ActivatedExtension);
const analyzedExtension: AnalyzedExtension = {
id: extensionId,
dispose: vi.fn(),
} as unknown as AnalyzedExtension;
extensionLoader.setAnalyzedExtension(extensionId, analyzedExtension);
// should have call the dispose method
await extensionLoader.deactivateExtension(extensionId);
expect(analyzedExtension.dispose).toBeCalled();
expect(extensionWatcher.untrack).toBeCalled();
expect(telemetry.track).toBeCalledWith('deactivateExtension', { extensionId });
});
test('Verify extension uri', async () => {
const id = 'extension.id';
const activateMethod = vi.fn();
configurationRegistryGetConfigurationMock.mockReturnValue({ get: vi.fn().mockReturnValue(1) });
await extensionLoader.activateExtension(
{
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {},
subscriptions: [],
readme: '',
dispose: vi.fn(),
},
{ activate: activateMethod },
);
expect(activateMethod).toBeCalled();
// check extensionUri
const grabUri: containerDesktopAPI.Uri = activateMethod.mock.calls[0]?.[0].extensionUri;
expect(grabUri).toBeDefined();
expect(grabUri.fsPath).toBe('dummy');
});
test('Verify exports and packageJSON', async () => {
const id = 'extension.id';
const activateMethod = vi.fn();
activateMethod.mockResolvedValue({
hello: () => 'world',
});
configurationRegistryGetConfigurationMock.mockReturnValue({ get: vi.fn().mockReturnValue(1) });
await extensionLoader.activateExtension(
{
id: id,
name: 'id',
path: 'dummy',
api: {} as typeof containerDesktopAPI,
mainPath: '',
removable: false,
manifest: {
foo: 'bar',
},
subscriptions: [],
readme: '',
dispose: vi.fn(),
},
{ activate: activateMethod },
);
expect(activateMethod).toBeCalled();
const myActivatedExtension = extensionLoader.getActivatedExtensions().get(id);
expect(myActivatedExtension).toBeDefined();
expect(myActivatedExtension?.exports).toBeDefined();
expect(myActivatedExtension?.exports.hello()).toBe('world');
expect(myActivatedExtension?.packageJSON).toBeDefined();
expect((myActivatedExtension?.packageJSON as any)?.foo).toBe('bar');
const exposed = extensionLoader.getExposedExtension(id);
expect(exposed).toBeDefined();
expect(exposed?.exports.hello()).toBe('world');
expect((exposed as any).packageJSON.foo).toBe('bar');
const allExtensions = extensionLoader.getAllExposedExtensions();
expect(allExtensions).toBeDefined();
// 1 item
expect(allExtensions.length).toBe(1);
expect(allExtensions[0]?.exports.hello()).toBe('world');
expect((allExtensions[0] as any).packageJSON.foo).toBe('bar');
});
describe('Navigation', async () => {
test('navigateToContainers', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
await api.navigation.navigateToContainers();
expect(sendMock).toBeCalledWith('navigate', { page: NavigationPage.CONTAINERS });
});
test.each([
{
name: 'navigateToContainer valid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) => api.navigateToContainer,
expected: {
page: NavigationPage.CONTAINER,
parameters: {
id: 'valid',
},
},
},
{
name: 'navigateToContainerLogs valid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerLogs,
expected: {
page: NavigationPage.CONTAINER_LOGS,
parameters: {
id: 'valid',
},
},
},
{
name: 'navigateToContainerInspect valid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerInspect,
expected: {
page: NavigationPage.CONTAINER_INSPECT,
parameters: {
id: 'valid',
},
},
},
{
name: 'navigateToContainerTerminal valid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerTerminal,
expected: {
page: NavigationPage.CONTAINER_TERMINAL,
parameters: {
id: 'valid',
},
},
},
])('$name', async ({ method, expected }) => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const containerExistSpy = vi.spyOn(containerProviderRegistry, 'containerExist');
containerExistSpy.mockImplementation(() => Promise.resolve(true));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
await method(api.navigation)('valid');
// Ensure the send method is called properly
expect(sendMock).toBeCalledWith('navigate', expected);
// Valid we listed the contains properly
expect(containerExistSpy).toHaveBeenCalledOnce();
});
test.each([
{
name: 'navigateToContainer invalid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) => api.navigateToContainer,
},
{
name: 'navigateToContainerLogs invalid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerLogs,
},
{
name: 'navigateToContainerInspect invalid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerInspect,
},
{
name: 'navigateToContainerTerminal invalid',
method: (api: typeof containerDesktopAPI.navigation): ((id: string) => Promise<void>) =>
api.navigateToContainerTerminal,
},
])('$name', async ({ method }) => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const containerExistSpy = vi.spyOn(containerProviderRegistry, 'containerExist');
containerExistSpy.mockImplementation(() => Promise.resolve(false));
// Call the method provided
let error = undefined;
try {
await method(api.navigation)('invalid');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Valid we listed the contains properly
expect(containerExistSpy).toHaveBeenCalledOnce();
});
test('navigateToImages', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
await api.navigation.navigateToImages();
expect(sendMock).toBeCalledWith('navigate', { page: NavigationPage.IMAGES });
});
test('navigateToImage existing image', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const imageExistSpy = vi.spyOn(containerProviderRegistry, 'imageExist');
imageExistSpy.mockImplementation(() => Promise.resolve(true));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
await api.navigation.navigateToImage('valid-id', 'valid-engine', 'valid-tag');
// Ensure the send method is called properly
expect(sendMock).toBeCalledWith('navigate', {
page: NavigationPage.IMAGE,
parameters: {
id: 'valid-id',
engineId: 'valid-engine',
tag: 'valid-tag',
},
});
// Valid we listed the contains properly each time
expect(imageExistSpy).toHaveBeenCalledOnce();
});
test('navigateToImage non-existent image', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const imageExistSpy = vi.spyOn(containerProviderRegistry, 'imageExist');
imageExistSpy.mockImplementation(() => Promise.resolve(false));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
let error = undefined;
try {
await api.navigation.navigateToImage('non-valid-id', 'non-valid-engine', 'non-valid-tag');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Ensure the send method is never called
expect(sendMock).toHaveBeenCalledTimes(0);
// Valid we listed the contains properly each time
expect(imageExistSpy).toHaveBeenCalledOnce();
});
test('navigateToVolumes', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
await api.navigation.navigateToVolumes();
expect(sendMock).toBeCalledWith('navigate', { page: NavigationPage.VOLUMES });
});
test('navigateToVolume existing volume', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const volumeExistSpy = vi.spyOn(containerProviderRegistry, 'volumeExist');
volumeExistSpy.mockImplementation(() => Promise.resolve(true));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
await api.navigation.navigateToVolume('valid-name', 'valid-engine');
// Ensure the send method is called properly
expect(sendMock).toBeCalledWith('navigate', {
page: NavigationPage.VOLUME,
parameters: {
name: 'valid-name',
},
});
// Valid we listed the contains properly each time
expect(volumeExistSpy).toHaveBeenCalledOnce();
});
test('navigateToVolume non-existent volume', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const volumeExistSpy = vi.spyOn(containerProviderRegistry, 'volumeExist');
volumeExistSpy.mockImplementation(() => Promise.resolve(false));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
let error = undefined;
try {
await api.navigation.navigateToVolume('non-valid-name', 'non-valid-engine');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Ensure the send method is called properly
expect(sendMock).toHaveBeenCalledTimes(0);
// Valid we listed the contains properly each time
expect(volumeExistSpy).toHaveBeenCalledOnce();
});
test('navigateToPods', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
await api.navigation.navigateToPods();
expect(sendMock).toBeCalledWith('navigate', { page: NavigationPage.PODS });
});
test('navigateToPod existing pod', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const podExistSpy = vi.spyOn(containerProviderRegistry, 'podExist');
podExistSpy.mockImplementation(() => Promise.resolve(true));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
await api.navigation.navigateToPod('valid-kind', 'valid-name', 'valid-engine');
// Ensure the send method is called properly
expect(sendMock).toBeCalledWith('navigate', {
page: NavigationPage.POD,
parameters: {
kind: 'valid-kind',
name: 'valid-name',
engineId: 'valid-engine',
},
});
// Valid we listed the contains properly each time
expect(podExistSpy).toHaveBeenCalledOnce();
});
test('navigateToPod non-existent volume', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const podExistSpy = vi.spyOn(containerProviderRegistry, 'podExist');
podExistSpy.mockImplementation(() => Promise.resolve(false));
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
let error = undefined;
try {
await api.navigation.navigateToPod('non-valid-kind', 'non-valid-name', 'non-valid-engine');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Ensure the send method is called properly
expect(sendMock).toHaveBeenCalledTimes(0);
// Valid we listed the contains properly each time
expect(podExistSpy).toHaveBeenCalledOnce();
});
test('navigateToContribution existing contribution', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleContainer implementation
const listContributionsSpy = vi.spyOn(contributionManager, 'listContributions');
listContributionsSpy.mockImplementation(() => [
{
name: 'valid-name',
} as unknown as ContributionInfo,
]);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
await api.navigation.navigateToContribution('valid-name');
// Ensure the send method is called properly
expect(sendMock).toBeCalledWith('navigate', {
page: NavigationPage.CONTRIBUTION,
parameters: {
name: 'valid-name',
},
});
// Valid we listed the contains properly each time
expect(listContributionsSpy).toHaveBeenCalledOnce();
});
test('navigateToContribution non-existent contribution', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listContributions implementation
const listContributionsSpy = vi.spyOn(contributionManager, 'listContributions');
listContributionsSpy.mockImplementation(() => []);
// Spy send method
const sendMock = vi.spyOn(apiSender, 'send');
// Call the method provided
let error = undefined;
try {
await api.navigation.navigateToContribution('non-valid-name');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Ensure the send method is called properly
expect(sendMock).toHaveBeenCalledTimes(0);
// Valid we listed the contains properly each time
expect(listContributionsSpy).toHaveBeenCalledOnce();
});
test('navigateToWebview', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
vi.mocked(webviewRegistry.listWebviews).mockReturnValue([
{
id: 'myWebviewId',
} as unknown as WebviewInfo,
]);
await api.navigation.navigateToWebview('myWebviewId');
// Ensure the send method is called properly
expect(vi.mocked(apiSender).send).toBeCalledWith('navigate', {
page: NavigationPage.WEBVIEW,
parameters: {
id: 'myWebviewId',
},
});
expect(vi.mocked(webviewRegistry.listWebviews)).toHaveBeenCalled();
});
test('navigateToOnboarding without parameter', async () => {
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
[],
);
vi.mocked(onboardingRegistry.getOnboarding).mockReturnValue({
extension: 'foo',
} as OnboardingInfo);
await api.navigation.navigateToOnboarding();
expect(vi.mocked(apiSender.send)).toBeCalledWith('navigate', {
page: NavigationPage.ONBOARDING,
parameters: {
extensionId: 'publisher.name',
},
});
// checked on onboarding registry
expect(vi.mocked(onboardingRegistry.getOnboarding)).toHaveBeenCalledWith('publisher.name');
});
test('navigateToOnboarding with parameter', async () => {
vi.mocked(onboardingRegistry.getOnboarding).mockReturnValue({
extension: 'foo',
} as OnboardingInfo);
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
[],
);
// Call the method provided
await api.navigation.navigateToOnboarding('my.extension');
// Ensure the send method is called properly
expect(vi.mocked(apiSender.send)).toBeCalledWith('navigate', {
page: NavigationPage.ONBOARDING,
parameters: {
extensionId: 'my.extension',
},
});
// checked on onboarding registry
expect(vi.mocked(onboardingRegistry.getOnboarding)).toHaveBeenCalledWith('my.extension');
});
test('navigateToOnboarding but no onboarding available', async () => {
vi.mocked(onboardingRegistry.getOnboarding).mockReturnValue(undefined);
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
[],
);
// Call the method provided
let error = undefined;
try {
await api.navigation.navigateToOnboarding('do.not-exists');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
// Ensure the send method is never called
expect(vi.mocked(apiSender.send)).not.toHaveBeenCalled();
// checked on onboarding registry
expect(vi.mocked(onboardingRegistry.getOnboarding)).toHaveBeenCalledWith('do.not-exists');
});
});
test('check listWebviews', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
// Mock listSimpleWebviews implementation
const listSimpleWebviewsSpy = vi.spyOn(webviewRegistry, 'listSimpleWebviews');
listSimpleWebviewsSpy.mockImplementation(() =>
Promise.resolve([
{
id: '123',
viewType: 'customView',
title: 'customTitle1',
},
{
id: '456',
viewType: 'anotherView',
title: 'customTitle2',
},
]),
);
// Call the method provided
const result = await api.window.listWebviews();
// check we called method
expect(listSimpleWebviewsSpy).toHaveBeenCalledOnce();
// esnure we got result
expect(result).toBeDefined();
expect(result.length).toBe(2);
expect(result[0]?.id).toBe('123');
expect(result[0]?.viewType).toBe('customView');
expect(result[0]?.title).toBe('customTitle1');
expect(result[1]?.id).toBe('456');
expect(result[1]?.viewType).toBe('anotherView');
expect(result[1]?.title).toBe('customTitle2');
});
test('check version', async () => {
const fakeVersion = '1.2.3.4';
// mock electron.app.getVersion
vi.mocked(app.getVersion).mockReturnValue(fakeVersion);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
const readPodmanVersion = api.version;
// check we called method
expect(readPodmanVersion).toBe(fakeVersion);
});
test('listPods', async () => {
const listPodsSpy = vi.spyOn(containerProviderRegistry, 'listPods');
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
await api.containerEngine.listPods();
expect(listPodsSpy).toHaveBeenCalledOnce();
});
test('stopPod', async () => {
const stopPodSpy = vi.spyOn(containerProviderRegistry, 'stopPod');
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
await api.containerEngine.stopPod('engine1', 'pod1');
expect(stopPodSpy).toHaveBeenCalledWith('engine1', 'pod1');
});
test('removePod', async () => {
const removePodSpy = vi.spyOn(containerProviderRegistry, 'removePod');
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'path',
{
name: 'name',
publisher: 'publisher',
version: '1',
displayName: 'dname',
},
disposables,
);
await api.containerEngine.removePod('engine1', 'pod1');
expect(removePodSpy).toHaveBeenCalledWith('engine1', 'pod1');
});
describe('authentication Provider', async () => {
const BASE64ENCODEDIMAGE = 'BASE64ENCODEDIMAGE';
const providerMock = {
onDidChangeSessions: vi.fn(),
getSessions: vi.fn().mockResolvedValue([]),
createSession: vi.fn(),
removeSession: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
});
test('basic registerAuthenticationProvider ', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
// size is 0 for disposables
expect(disposables.length).toBe(0);
api.authentication.registerAuthenticationProvider('provider1.id', 'Provider1 Label', providerMock, {
supportsMultipleAccounts: true,
});
// one disposable
expect(disposables.length).toBe(1);
expect(authenticationProviderRegistry.registerAuthenticationProvider).toBeCalledWith(
'provider1.id',
'Provider1 Label',
providerMock,
{ supportsMultipleAccounts: true },
);
});
test('allows images option to be undefined or empty', async () => {
vi.mocked(getBase64Image).mockReturnValue(BASE64ENCODEDIMAGE);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
api.authentication.registerAuthenticationProvider('provider1.id', 'Provider1 Label', providerMock, {});
// grab the call to authenticationProviderRegistry.registerAuthenticationProvider
const call = vi.mocked(authenticationProviderRegistry.registerAuthenticationProvider).mock.calls[0];
// get options from the call
const options = call?.[3];
expect(options?.images?.logo).toBeUndefined();
expect(options?.images?.icon).toBeUndefined();
});
test('allows images option to be single image', async () => {
vi.mocked(getBase64Image).mockReturnValue(BASE64ENCODEDIMAGE);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
api.authentication.registerAuthenticationProvider('provider1.id', 'Provider1 Label', providerMock, {
images: {
icon: './image.png',
logo: './image.png',
},
});
// grab the call to authenticationProviderRegistry.registerAuthenticationProvider
const call = vi.mocked(authenticationProviderRegistry.registerAuthenticationProvider).mock.calls[0];
// get options from the call
const options = call?.[3];
expect(options?.images?.logo).equals(BASE64ENCODEDIMAGE);
expect(options?.images?.icon).equals(BASE64ENCODEDIMAGE);
});
test('allows images option to be light/dark image', async () => {
vi.mocked(getBase64Image).mockReturnValue(BASE64ENCODEDIMAGE);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
api.authentication.registerAuthenticationProvider('provider1.id', 'Provider1 Label', providerMock, {
images: {
icon: {
light: './image.png',
dark: './image.png',
},
logo: {
light: './image.png',
dark: './image.png',
},
},
});
// grab the call to authenticationProviderRegistry.registerAuthenticationProvider
const call = vi.mocked(authenticationProviderRegistry.registerAuthenticationProvider).mock.calls[0];
// get options from the call
const options = call?.[3];
const themeIcon = typeof options?.images?.icon === 'string' ? undefined : options?.images?.icon;
expect(themeIcon).toBeDefined();
expect(themeIcon?.light).equals(BASE64ENCODEDIMAGE);
expect(themeIcon?.dark).equals(BASE64ENCODEDIMAGE);
const themeLogo = typeof options?.images?.logo === 'string' ? undefined : options?.images?.logo;
expect(themeLogo).toBeDefined();
expect(themeLogo?.light).equals(BASE64ENCODEDIMAGE);
expect(themeLogo?.dark).equals(BASE64ENCODEDIMAGE);
});
});
test('createCliTool ', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
expect(disposables.length).toBe(0);
const options: containerDesktopAPI.CliToolOptions = {
name: 'tool-name',
displayName: 'tool-display-name',
markdownDescription: 'markdown description',
images: {},
version: '1.0.1',
path: 'path/to/tool-name',
};
vi.mocked(cliToolRegistry.createCliTool).mockReturnValue({ id: 'created' } as containerDesktopAPI.CliTool);
const newCliTool = api.cli.createCliTool(options);
expect(disposables.length).toBe(1);
expect(cliToolRegistry.createCliTool).toBeCalledWith(expect.objectContaining({ extensionPath: '/path' }), options);
expect(newCliTool).toStrictEqual({ id: 'created' });
});
test('registerImageCheckerProvider ', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const provider = {
check: (
_image: containerDesktopAPI.ImageInfo,
_token?: containerDesktopAPI.CancellationToken,
): containerDesktopAPI.ProviderResult<containerDesktopAPI.ImageChecks> => {
return {
checks: [
{
name: 'check1',
status: 'failed',
},
],
};
},
};
vi.mocked(imageCheckerImpl.registerImageCheckerProvider).mockReturnValue(Disposable.create(() => {}));
expect(disposables.length).toBe(0);
api.imageChecker.registerImageCheckerProvider(provider, { label: 'dummyLabel' });
expect(disposables.length).toBe(1);
expect(imageCheckerImpl.registerImageCheckerProvider).toBeCalledWith(
expect.objectContaining({ extensionPath: '/path' }),
provider,
{ label: 'dummyLabel' },
);
});
test('loadExtension with themes', async () => {
const manifest = {
name: 'hello',
contributes: {
themes: [
{
id: 'custom-dark',
name: 'Custom dark theme',
parent: 'dark',
colors: {
TitlebarBg: 'red',
},
},
],
},
};
const fakeExtension = {
manifest,
subscriptions: [],
} as unknown as AnalyzedExtension;
await extensionLoader.loadExtension(fakeExtension);
expect(colorRegistry.registerExtensionThemes).toBeCalledWith(fakeExtension, manifest.contributes.themes);
});
describe('window', async () => {
test('showOpenDialog ', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const filePaths = ['/path-to-file1', '/path-to-file2'];
vi.mocked(dialogRegistry.openDialog).mockResolvedValue(filePaths);
const uris = await api.window.showOpenDialog();
expect(uris?.length).toBe(2);
const urisArray = uris as containerDesktopAPI.Uri[];
expect(dialogRegistry.openDialog).toBeCalled();
expect(urisArray[0]?.fsPath).toContain('path-to-file1');
expect(urisArray[1]?.fsPath).toContain('path-to-file2');
});
test('showSaveDialog ', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const filePath = '/path-to-file1';
vi.mocked(dialogRegistry.saveDialog).mockResolvedValue(Uri.file(filePath));
const uri = await api.window.showSaveDialog();
expect(dialogRegistry.saveDialog).toBeCalled();
expect(uri?.fsPath).toContain('path-to-file1');
});
});
describe('containerEngine', async () => {
test('statsContainer ', async () => {
vi.mocked(containerProviderRegistry.getContainerStats).mockResolvedValue(99);
vi.mocked(containerProviderRegistry.stopContainerStats).mockResolvedValue(undefined);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const disposable = await api.containerEngine.statsContainer('dummyEngineId', 'dummyContainerId', () => {});
expect(disposable).toBeDefined();
expect(disposable instanceof Disposable).toBeTruthy();
expect(containerProviderRegistry.getContainerStats).toHaveBeenCalledWith(
'dummyEngineId',
'dummyContainerId',
expect.anything(),
);
disposable.dispose();
await vi.waitUntil(() => {
expect(containerProviderRegistry.stopContainerStats).toHaveBeenCalledWith(99);
return true;
});
});
test('listImages without option ', async () => {
vi.mocked(containerProviderRegistry.podmanListImages).mockResolvedValue([]);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const images = await api.containerEngine.listImages();
expect(images.length).toBe(0);
expect(containerProviderRegistry.podmanListImages).toHaveBeenCalledWith(undefined);
});
test('listImages with provider option', async () => {
vi.mocked(containerProviderRegistry.podmanListImages).mockResolvedValue([]);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const images = await api.containerEngine.listImages({
provider: {
name: 'dummyProvider',
} as unknown as containerDesktopAPI.ContainerProviderConnection,
});
expect(images.length).toBe(0);
expect(containerProviderRegistry.podmanListImages).toHaveBeenCalledWith({
provider: {
name: 'dummyProvider',
},
});
});
test('listInfos without option', async () => {
vi.mocked(containerProviderRegistry.listInfos).mockResolvedValue([]);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const infos = await api.containerEngine.listInfos();
expect(infos.length).toBe(0);
expect(containerProviderRegistry.listInfos).toHaveBeenCalledWith(undefined);
});
test('listInfos with provider option', async () => {
vi.mocked(containerProviderRegistry.listInfos).mockResolvedValue([]);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const infos = await api.containerEngine.listInfos({
provider: {
name: 'dummyProvider',
} as unknown as containerDesktopAPI.ContainerProviderConnection,
});
expect(infos.length).toBe(0);
expect(containerProviderRegistry.listInfos).toHaveBeenCalledWith({
provider: {
name: 'dummyProvider',
},
});
});
});
describe('extensionContext', async () => {
test('secrets', async () => {
vi.mock('node:fs');
vi.mocked(fs.existsSync).mockReturnValue(true);
const extension: AnalyzedExtension = {
subscriptions: [],
id: 'fooPublisher.fooName',
name: 'fooName',
manifest: {
version: '1.0',
},
} as unknown as AnalyzedExtension;
vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({
get: vi.fn().mockReturnValue(5),
} as unknown as containerDesktopAPI.Configuration);
const getMock = vi.fn();
const storeMock = vi.fn();
const deleteMock = vi.fn();
vi.mocked(safeStorageRegistry.getExtensionStorage).mockReturnValue({
get: getMock,
store: storeMock,
delete: deleteMock,
} as unknown as ExtensionSecretStorage);
let extensionContext: containerDesktopAPI.ExtensionContext | undefined;
const activateMethod = (context: containerDesktopAPI.ExtensionContext): void => {
extensionContext = context;
};
const extensionMain = {
activate: activateMethod,
};
await extensionLoader.activateExtension(extension, extensionMain);
expect(extensionContext).toBeDefined();
expect(extensionContext?.secrets).toBeDefined();
expect(telemetry.track).toBeCalledWith('activateExtension', {
extensionId: 'fooPublisher.fooName',
extensionVersion: '1.0',
duration: expect.any(Number),
});
expect(safeStorageRegistry.getExtensionStorage).toBeCalledWith('fooPublisher.fooName');
await extensionContext?.secrets.store('key', 'value');
expect(storeMock).toBeCalledWith('key', 'value');
await extensionContext?.secrets.get('key');
expect(getMock).toBeCalledWith('key');
await extensionContext?.secrets.delete('key');
expect(deleteMock).toBeCalledWith('key');
});
});
test('load extensions sequentially', async () => {
// Check if missing dependencies are found
const extensionId1 = 'foo.extension1';
const extensionId2 = 'foo.extension2';
const extensionId3 = 'foo.extension3';
const unknownExtensionId = 'foo.unknown';
// extension1 has no dependencies
const analyzedExtension1: AnalyzedExtension = {
id: extensionId1,
manifest: {
name: 'hello',
},
} as AnalyzedExtension;
// extension2 depends on extension 1
const analyzedExtension2: AnalyzedExtension = {
id: extensionId2,
manifest: {
extensionDependencies: [extensionId1],
name: 'hello',
},
} as AnalyzedExtension;
// extension3 depends on unknown extension unknown
const analyzedExtension3: AnalyzedExtension = {
id: extensionId3,
manifest: {
extensionDependencies: [unknownExtensionId],
name: 'hello',
},
} as AnalyzedExtension;
// mock loadExtension
const loadExtensionMock = vi.spyOn(extensionLoader, 'loadExtension');
loadExtensionMock.mockImplementation(extension => {
if (extension.id === extensionId1) {
// extension 1 takes 1s to load
return new Promise(resolve => setTimeout(resolve, 1000));
} else if (extension.id === extensionId2) {
return new Promise(resolve => setTimeout(resolve, 100));
} else if (extension.id === extensionId3) {
return new Promise(resolve => setTimeout(resolve, 1000));
}
return Promise.resolve();
});
const start = performance.now();
await extensionLoader.loadExtensions([analyzedExtension1, analyzedExtension2, analyzedExtension3]);
const end = performance.now();
const delta = end - start;
// delta should be greater than 2s as it's sequential (so 1s + 1s + 100ms) > 2s
expect(delta).toBeGreaterThan(2000);
// check if loadExtension is called in order
expect(loadExtensionMock).toBeCalledTimes(3);
expect(loadExtensionMock.mock.calls[0]?.[0]).toBe(analyzedExtension1);
expect(loadExtensionMock.mock.calls[1]?.[0]).toBe(analyzedExtension2);
expect(loadExtensionMock.mock.calls[2]?.[0]).toBe(analyzedExtension3);
});
test('when loading registry registerRegistry, do not push to disposables', async () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
const fakeRegistry = {
source: 'fake',
serverUrl: 'http://fake',
username: 'foo',
// eslint-disable-next-line sonarjs/no-hardcoded-passwords
password: 'bar',
secret: 'baz',
};
api.registry.registerRegistry(fakeRegistry);
expect(disposables.length).toBe(0);
});
test('when registering a navigation route, should be pushed to disposables', () => {
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi('/path', {}, disposables);
expect(api).toBeDefined();
expect(disposables.length).toBe(0);
api.navigation.register('dummy-route-id', 'dummy-command-id');
expect(disposables.length).toBe(1);
});
test('withProgress should add the extension id to the routeId', async () => {
vi.mocked(progress.withProgress).mockResolvedValue(undefined);
const disposables: IDisposable[] = [];
const api = extensionLoader.createApi(
'/path',
{
publisher: 'pub',
name: 'dummy',
},
disposables,
);
expect(api).toBeDefined();
await api.window.withProgress<void>(
{
location: ProgressLocation.TASK_WIDGET,
title: 'Dummy title',
details: {
routeId: 'dummy-route-id',
routeArgs: ['hello', 'world'],
},
},
async () => {},
);
expect(progress.withProgress).toHaveBeenCalledWith(
{
location: ProgressLocation.TASK_WIDGET,
title: 'Dummy title',
details: {
routeId: 'pub.dummy.dummy-route-id',
routeArgs: ['hello', 'world'],
},
},
expect.any(Function),
);
});
describe('loading extension folders', () => {
const fileEntry = {
isDirectory: () => false,
} as fs.Dirent;
const nodeModulesEntry = {
isDirectory: () => true,
name: 'node_modules',
} as fs.Dirent;
const dirEntry = {
isDirectory: () => true,
name: 'extension1',
} as fs.Dirent;
const dirEntry2 = {
isDirectory: () => true,
name: 'extension2',
} as fs.Dirent;
const dirEntry3 = {
isDirectory: () => true,
name: 'extension3',
} as fs.Dirent;
const dirEntry4 = {
isDirectory: () => true,
name: 'extension4',
} as fs.Dirent;
describe('in dev mode', () => {
test('ignores files', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([fileEntry]);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(0);
});
test('ignores node_modules folders', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([nodeModulesEntry]);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(0);
});
test('ignores folders without package.json', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(0);
});
test('recognizes a plain extension when only ext/package.json is present', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false).mockReturnValueOnce(true);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(1);
expect(folders[0]).toBe(path.join('path', 'extension1'));
});
test('recognizes as an api extension when only ext/packages/extension/package.json is present', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true).mockReturnValueOnce(true);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(1);
expect(folders[0]).toBe(path.join('path', 'extension1', 'packages', 'extension'));
});
test('recognizes as an api extension when ext/package.json and ext/packages/extension/package.json are present', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true).mockReturnValueOnce(false);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(1);
expect(folders[0]).toBe(path.join('path', 'extension1', 'packages', 'extension'));
});
test('works correctly for multiple different extensions, files and empty folders', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([fileEntry, dirEntry, dirEntry2, dirEntry3, dirEntry4]);
vi.spyOn(fs, 'existsSync')
// an api extension
.mockReturnValueOnce(true)
// an plain extension
.mockReturnValueOnce(false) // plain extension
// priority to an api extension
.mockReturnValueOnce(true)
.mockReturnValueOnce(true) // priority to api extension
// ignore no package.json folders
.mockReturnValueOnce(false)
.mockReturnValueOnce(false);
const folders = await extensionLoader.readDevelopmentFolders('path');
expect(folders).length(3);
expect(folders[0]).toBe(path.join('path', 'extension1', 'packages', 'extension'));
expect(folders[1]).toBe(path.join('path', 'extension2'));
expect(folders[2]).toBe(path.join('path', 'extension3', 'packages', 'extension'));
});
});
describe('in prod mode', () => {
test('ignores files', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([fileEntry]);
const folders = await extensionLoader.readProductionFolders('path');
expect(folders).length(0);
});
test('ignores node_modules folders', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([nodeModulesEntry]);
const folders = await extensionLoader.readProductionFolders('path');
expect(folders).length(0);
});
test('recognizes a plain extension when only ext/package.json is present', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValueOnce(true);
const folders = await extensionLoader.readProductionFolders('path');
expect(folders).length(1);
expect(folders[0]).toBe(path.join('path', 'extension1', 'builtin', 'extension1.cdix'));
});
test('recognizes an api extension when ext/package.json is not present', async () => {
vi.spyOn(fs.promises, 'readdir').mockResolvedValue([dirEntry]);
vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false);
const folders = await extensionLoader.readProductionFolders('path');
expect(folders).length(1);
expect(folders[0]).toBe(path.join('path', 'extension1', 'packages', 'extension', 'builtin', `extension1.cdix`));
});
});
});
test('reload extensions', async () => {
const extension = {
path: 'fakePath',
manifest: {
displayName: 'My Extension Display Name',
},
id: 'my.extensionId',
} as unknown as AnalyzedExtension;
// override deactivateExtension
const deactivateSpy = vi.spyOn(extensionLoader, 'deactivateExtension');
const analyzeExtensionSpy = vi.spyOn(extensionLoader, 'analyzeExtension');
const loadExtensionSpy = vi.spyOn(extensionLoader, 'loadExtension');
const analyzedExtension = {} as unknown as AnalyzedExtension;
analyzeExtensionSpy.mockResolvedValue(analyzedExtension);
const fakeDisposableObject = {
dispose: vi.fn(),
} as unknown as Disposable;
vi.mocked(notificationRegistry.addNotification).mockReturnValue(fakeDisposableObject);
// reload the extension
await extensionLoader.reloadExtension(extension, false);
expect(deactivateSpy).toBeCalledWith(extension.id);
expect(analyzeExtensionSpy).toBeCalledWith(extension.path, false);
expect(loadExtensionSpy).toBeCalledWith(analyzedExtension, true);
expect(vi.mocked(notificationRegistry.addNotification)).toBeCalledWith({
extensionId: extension.id,
title: 'Extension My Extension Display Name has been updated',
type: 'info',
});
// restore the spy
deactivateSpy.mockRestore();
analyzeExtensionSpy.mockRestore();
loadExtensionSpy.mockRestore();
// wait the notification is disposed
await vi.waitFor(() => expect(fakeDisposableObject.dispose).toBeCalled(), { timeout: 5_000 });
});
describe('init', () => {
test('check configuration being registered', async () => {
await extensionLoader.init();
expect(configurationRegistry.registerConfigurations).toBeCalled();
// get the object being called
const call = vi.mocked(configurationRegistry.registerConfigurations).mock.calls[0];
expect(call).toBeDefined();
// check the developmentMode property is passed
const configurations = call?.[0];
expect(configurations).toBeDefined();
expect(configurations?.length).toBeGreaterThanOrEqual(3);
expect(configurations?.[2]?.id).toBe('preferences.extensions');
const property =
configurations?.[2]?.properties?.[
`${ExtensionLoaderSettings.SectionName}.${ExtensionLoaderSettings.DevelopmentMode}`
];
expect(property).toBeDefined();
expect(property?.type).toBe('boolean');
expect(property?.default).toBeTruthy();
});
});
describe('loadDevelopmentFolderExtensions', () => {
test('check loading', async () => {
vi.mocked(extensionDevelopmentFolder).getDevelopmentFolders.mockReturnValue([
{ path: 'foo' },
{ path: 'bar' },
{ path: 'baz' },
]);
// spy console.error
const consoleErrorSpy = vi.spyOn(console, 'error');
// 1st is non existing
vi.mocked(fs.existsSync).mockReturnValueOnce(false);
// 2nd exists
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
// 3rd exists
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
const analyzeExtensionSpy = vi.spyOn(extensionLoader, 'analyzeExtension');
// bar is working
const barAnalyzedExtension = {} as AnalyzedExtension;
analyzeExtensionSpy.mockResolvedValueOnce(barAnalyzedExtension);
// baz has error
const bazAnalyzedExtension = { error: 'dummy error' } as AnalyzedExtension;
analyzeExtensionSpy.mockResolvedValueOnce(bazAnalyzedExtension);
const analyzedExtensions: AnalyzedExtension[] = [];
await extensionLoader.loadDevelopmentFolderExtensions(analyzedExtensions);
// check only bar has been added
expect(analyzedExtensions.length).toBe(1);
expect(analyzedExtensions[0]).toBe(barAnalyzedExtension);
// expect we got a console error for baz
expect(consoleErrorSpy).toBeCalledWith('Error while analyzing extension baz', bazAnalyzedExtension.error);
consoleErrorSpy.mockRestore();
});
});