Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions Composer/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ module.exports = {
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-object-literal-type-assertion': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-explicit-any': 'off',

'security/detect-buffer-noassert': 'off',
'security/detect-child-process': 'off',
Expand Down
2 changes: 1 addition & 1 deletion Composer/packages/electron-server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async function createAppDataDir() {
const azurePublishPath: string = join(composerAppDataPath, 'publishBots');
process.env.COMPOSER_APP_DATA = join(composerAppDataPath, 'data.json'); // path to the actual data file
process.env.COMPOSER_EXTENSION_DATA = join(composerAppDataPath, 'extensions.json');
process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = join(composerAppDataPath, '.composer');
process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = join(composerAppDataPath, 'extensions');

log('creating composer app data path at: ', composerAppDataPath);

Expand Down
6 changes: 5 additions & 1 deletion Composer/packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@types/fs-extra": "^9.0.1",
"@types/passport": "^1.0.3",
"@types/path-to-regexp": "^1.7.0",
"@types/tar": "^4.0.3",
"json-schema": "^0.2.5",
"rimraf": "^3.0.2",
Comment thread
a-b-r-o-w-n marked this conversation as resolved.
"typescript": "^3.8.3"
Expand All @@ -26,7 +27,10 @@
"debug": "^4.1.1",
"fs-extra": "^9.0.1",
"globby": "^11.0.0",
"node-fetch": "^2.6.1",
"passport": "^0.4.1",
"path-to-regexp": "^6.1.0"
"path-to-regexp": "^6.1.0",
"rimraf": "^3.0.2",
"tar": "^6.0.5"
}
}
2 changes: 1 addition & 1 deletion Composer/packages/extension/src/extensionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class ExtensionContext {
const packageJSON = fs.readFileSync(packageJsonPath, 'utf8');
const json = JSON.parse(packageJSON);

if (json.extendsComposer) {
if (json.composer?.enabled !== false) {
const modulePath = path.dirname(packageJsonPath);
try {
// eslint-disable-next-line security/detect-non-literal-require, @typescript-eslint/no-var-requires
Expand Down
268 changes: 248 additions & 20 deletions Composer/packages/extension/src/manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import path from 'path';

import { writeJsonSync } from 'fs-extra';
import { readJson, ensureDir } from 'fs-extra';
import glob from 'globby';
import rimraf from 'rimraf';

import { ExtensionManager } from '../manager';
import { search, downloadPackage } from '../../utils/npm';
import { ExtensionManifestStore, ExtensionManifest } from '../../storage/extensionManifestStore';
import { ExtensionManagerImp } from '../manager';

const mockManifest = {
const mockManifest = ({
extension1: {
id: 'extension1',
builtIn: true,
Expand All @@ -19,16 +24,32 @@ const mockManifest = {
id: 'extension3',
enabled: false,
},
};
} as unknown) as ExtensionManifest;

jest.mock('../../storage/extensionManifestStore');

jest.mock('globby', () => jest.fn());
jest.mock('rimraf', () => jest.fn().mockImplementation((path, cb) => cb()));

jest.mock('fs-extra', () => ({
ensureDir: jest.fn(),
readJson: jest.fn(),
}));

jest.mock('../../utils/npm');

let manager: ExtensionManagerImp;
let manifest: ExtensionManifestStore;

beforeEach(() => {
writeJsonSync(process.env.COMPOSER_EXTENSION_DATA as string, mockManifest);
ExtensionManager.reloadManifest();
manifest = new ExtensionManifestStore('/some/path');
});

describe('#getAll', () => {
it('return an array of all extensions', () => {
expect(ExtensionManager.getAll()).toEqual([
(manifest.getExtensions as jest.Mock).mockReturnValue(mockManifest);
manager = new ExtensionManagerImp(manifest);
expect(manager.getAll()).toEqual([
{
id: 'extension1',
builtIn: true,
Expand All @@ -48,32 +69,239 @@ describe('#getAll', () => {

describe('#find', () => {
it('returns extension metadata for id', () => {
expect(ExtensionManager.find('extension1')).toEqual({ id: 'extension1', builtIn: true, enabled: true });
expect(ExtensionManager.find('does-not-exist')).toBeUndefined();
(manifest.getExtensionConfig as jest.Mock).mockImplementation((id) => {
return mockManifest[id];
});
manager = new ExtensionManagerImp(manifest);
expect(manager.find('extension1')).toEqual({ id: 'extension1', builtIn: true, enabled: true });
expect(manager.find('does-not-exist')).toBeUndefined();
});
});

describe('#loadAll', () => {
it('loads built-in extensions and remote extensions that are enabled', async () => {
const loadSpy = jest.spyOn(ExtensionManager, 'load');
let loadSpy: jest.SpyInstance;

beforeEach(() => {
manager = new ExtensionManagerImp(manifest);
loadSpy = jest.spyOn(manager, 'loadFromDir');

loadSpy.mockReturnValue(Promise.resolve());
});

await ExtensionManager.loadAll();
it('ensures remote dir is created', async () => {
await manager.loadAll();

expect(ensureDir).toHaveBeenCalledWith(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
});

it('loads built-in extensions and remote extensions that are enabled', async () => {
await manager.loadAll();

expect(loadSpy).toHaveBeenCalledTimes(2);
expect(loadSpy).toHaveBeenNthCalledWith(1, process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR, true);
expect(loadSpy).toHaveBeenNthCalledWith(2, process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
});
});

describe('#loadFromDir', () => {
it('finds all package.json files in dir', async () => {
((glob as unknown) as jest.Mock).mockReturnValue([]);
manager = new ExtensionManagerImp(manifest);

await manager.loadFromDir('/some/dir');
expect(glob).toHaveBeenCalledWith('*/package.json', { cwd: '/some/dir' });
});

it('updates the extension manifest and loads each extension found', async () => {
((glob as unknown) as jest.Mock).mockReturnValue(['extension1/package.json', 'extension2/package.json']);
(readJson as jest.Mock).mockImplementation((path) => {
if (path.includes('extension1')) {
return {
name: 'extension1',
};
} else if (path.includes('extension2')) {
return {
name: 'extension2',
};
}

return {};
});

manager = new ExtensionManagerImp(manifest);
const loadSpy = jest.spyOn(manager, 'load');
loadSpy.mockResolvedValue(undefined);

await manager.loadFromDir('/some/dir');

expect(readJson).toHaveBeenCalledWith('/some/dir/extension1/package.json');
expect(readJson).toHaveBeenCalledWith('/some/dir/extension2/package.json');

expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
'extension1',
expect.objectContaining({ id: 'extension1' })
);
expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
'extension2',
expect.objectContaining({ id: 'extension2' })
);

expect(loadSpy).toHaveBeenCalledWith('extension1');
expect(loadSpy).toHaveBeenCalledWith('extension2');
});

it('removes the extension from the manifest if not enabled', async () => {
((glob as unknown) as jest.Mock).mockReturnValue(['extension1/package.json']);
(readJson as jest.Mock).mockResolvedValue({
name: 'extension1',
composer: {
enabled: false,
},
});
(manifest.getExtensionConfig as jest.Mock).mockReturnValueOnce('extension1');

manager = new ExtensionManagerImp(manifest);
const loadSpy = jest.spyOn(manager, 'load');
loadSpy.mockResolvedValue(undefined);

await manager.loadFromDir('/some/dir');

expect(manifest.updateExtensionConfig).not.toHaveBeenCalled();
expect(loadSpy).not.toHaveBeenCalled();
expect(manifest.removeExtension).toHaveBeenCalledTimes(1);
expect(manifest.removeExtension).toHaveBeenCalledWith('extension1');
});
});

describe('#installRemote', () => {
it('ensures remote dir exists', async () => {
manager = new ExtensionManagerImp(manifest);
await manager.installRemote('extension1');

expect(ensureDir).toHaveBeenLastCalledWith(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR);
});

it('downloads package and updates the manifest', async () => {
(readJson as jest.Mock).mockResolvedValue({
name: 'extension1',
});

manager = new ExtensionManagerImp(manifest);
await manager.installRemote('extension1');

expect(downloadPackage).toHaveBeenCalledWith(
'extension1',
'latest',
path.join(process.env.COMPOSER_REMOTE_EXTENSIONS_DIR as string, 'extension1')
);

expect(manifest.updateExtensionConfig).toHaveBeenCalledWith(
'extension1',
expect.objectContaining({ id: 'extension1' })
);
});

it('throws an error if problem downloading', () => {
(downloadPackage as jest.Mock).mockRejectedValue(undefined);
manager = new ExtensionManagerImp(manifest);

expect(manager.installRemote('extension1', '2.0.0')).rejects.toThrow(/Unable to install/);
});
});

// describe('#installRemote', () => {});
// describe('#loadBuiltinExtensions', () => {});
// describe('#loadRemotePlugins', () => {});
// describe('#load', () => {});
// describe('#enable', () => {});
// describe('#disable', () => {});
// describe('#remove', () => {});
// describe('#search', () => {});
// describe('#getAllBundles', () => {});

describe('#enable', () => {
it('updates the manifest and reloads the extension', async () => {
manager = new ExtensionManagerImp(manifest);
const loadSpy = jest.spyOn(manager, 'load');
(loadSpy as jest.Mock).mockResolvedValue(undefined);

await manager.enable('extension1');

expect(manifest.updateExtensionConfig).toHaveBeenCalledWith('extension1', { enabled: true });
expect(loadSpy).toHaveBeenCalledWith('extension1');
});
});

describe('#disable', () => {
it('updates the manifest', async () => {
manager = new ExtensionManagerImp(manifest);

await manager.disable('extension1');

expect(manifest.updateExtensionConfig).toHaveBeenCalledWith('extension1', { enabled: false });
});
});

describe('#remove', () => {
it('throws an error if extension not found', () => {
(manifest.getExtensionConfig as jest.Mock).mockReturnValue(undefined);
manager = new ExtensionManagerImp(manifest);

expect(manager.remove('extension1')).rejects.toThrow(/Unable to remove extension/);
});

it('throws an error if extension is builtin', () => {
(manifest.getExtensionConfig as jest.Mock).mockReturnValue({ builtIn: true });
manager = new ExtensionManagerImp(manifest);

expect(manager.remove('extension1')).rejects.toThrow(/Cannot remove builtin extension./);
});

it('removes the extension from the manifest and cleans up', async () => {
(manifest.getExtensionConfig as jest.Mock).mockReturnValue({ builtIn: false, path: '/some/path' });
manager = new ExtensionManagerImp(manifest);

await manager.remove('extension1');

expect(rimraf).toHaveBeenCalledWith('/some/path', expect.any(Function));
expect(manifest.removeExtension).toHaveBeenCalledWith('extension1');
});
});

describe('#search', () => {
beforeEach(() => {
(search as jest.Mock).mockResolvedValue([
{
id: 'extension1',
keywords: ['botframework-composer', 'extension', 'foo', 'bar'],
description: 'LOREM ipsum',
version: '1.0.0',
url: 'extension1 npm link',
},
{
id: 'extension-2',
keywords: ['botframework-composer', 'extension', 'bar'],
description: 'foo',
version: '1.0.0',
url: 'extension-2 npm link',
},
{
id: 'foo',
keywords: ['botframework-composer'],
description: 'lorem ipsum',
version: '1.0.0',
url: 'foo npm link',
},
{
id: 'bar',
keywords: ['botframework-composer'],
description: '',
version: '1.0.0',
url: 'bar npm link',
},
]);
});

it('filters the search results by the extension keyword', async () => {
manager = new ExtensionManagerImp(manifest);

const results = await manager.search('foo');

expect(search).toHaveBeenCalledWith('foo');
expect(results).toHaveLength(2);
});
});

// describe('#getBundle', () => {});
Loading