This commit is contained in:
LaZzyMan 2026-01-19 19:40:16 +08:00
parent f8e41fb7fa
commit 8b4626a2be
12 changed files with 1339 additions and 1493 deletions

View file

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extensionsCommand } from './extensions.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import yargs from 'yargs';
describe('extensions command', () => {
it('should have correct command name', () => {
expect(extensionsCommand.command).toBe('extensions <command>');
});
it('should have a description', () => {
expect(extensionsCommand.describe).toBe('Manage Qwen Code extensions.');
});
it('should require a subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
expect(() => parser.parse('extensions')).toThrow();
});
it('should register install subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
// This should throw as 'install' requires a source argument
expect(() => parser.parse('extensions install')).toThrow(
'Not enough non-option arguments',
);
});
it('should register uninstall subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
expect(() => parser.parse('extensions uninstall')).toThrow(
'Not enough non-option arguments',
);
});
it('should register list subcommand', () => {
const parser = yargs([])
.command(extensionsCommand)
.fail(false)
.locale('en');
// list doesn't require arguments, so it should not throw
expect(() => parser.parse('extensions list')).not.toThrow();
});
it('should register update subcommand', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update')).toThrow(
'Either an extension name or --all must be provided',
);
});
it('should register disable subcommand', () => {
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
expect(() => parser.parse('disable')).toThrow(
'Not enough non-option arguments',
);
});
it('should register enable subcommand', () => {
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
expect(() => parser.parse('enable')).toThrow(
'Not enough non-option arguments',
);
});
it('should register link subcommand', () => {
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
expect(() => parser.parse('link')).toThrow(
'Not enough non-option arguments',
);
});
it('should register new subcommand', async () => {
const parser = yargs([]).command(newCommand).fail(false).locale('en');
await expect(parser.parseAsync('new')).rejects.toThrow(
'Not enough non-option arguments',
);
});
});

View file

@ -0,0 +1,273 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { extensionConsentString, requestConsentOrFail } from './consent.js';
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
vi.mock('../../i18n/index.js', () => ({
t: vi.fn((str: string, params?: Record<string, string>) => {
if (params) {
return Object.entries(params).reduce(
(acc, [key, value]) => acc.replace(`{{${key}}}`, value),
str,
);
}
return str;
}),
}));
describe('extensionConsentString', () => {
it('should include extension name', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config);
expect(result).toContain('Installing extension "test-extension".');
});
it('should include warning message', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config);
expect(result).toContain('Extensions may introduce unexpected behavior');
});
it('should include MCP servers when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
};
const result = extensionConsentString(config);
expect(result).toContain(
'This extension will run the following MCP servers',
);
expect(result).toContain('test-server');
expect(result).toContain('local');
expect(result).toContain('node server.js');
});
it('should include remote MCP servers', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'remote-server': {
httpUrl: 'https://example.com/mcp',
},
},
};
const result = extensionConsentString(config);
expect(result).toContain('remote');
expect(result).toContain('https://example.com/mcp');
});
it('should include commands when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(config, ['command1', 'command2']);
expect(result).toContain('This extension will add the following commands');
expect(result).toContain('command1, command2');
});
it('should include context file name when present (string)', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
contextFileName: 'CUSTOM.md',
};
const result = extensionConsentString(config);
expect(result).toContain('CUSTOM.md');
});
it('should include context file name when present (array)', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
contextFileName: ['FILE1.md', 'FILE2.md'],
};
const result = extensionConsentString(config);
expect(result).toContain('FILE1.md, FILE2.md');
});
it('should include excluded tools when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
};
const result = extensionConsentString(config);
expect(result).toContain(
'This extension will exclude the following core tools',
);
expect(result).toContain('tool1, tool2');
});
it('should include skills when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(
config,
[],
[
{
name: 'skill1',
description: 'Skill 1 description',
level: 'extension',
filePath: '/test/skill1',
body: 'skill body',
},
{
name: 'skill2',
description: 'Skill 2 description',
level: 'extension',
filePath: '/test/skill2',
body: 'skill body',
},
],
);
expect(result).toContain(
'This extension will install the following skills',
);
expect(result).toContain('skill1');
expect(result).toContain('Skill 1 description');
});
it('should include subagents when present', () => {
const config: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = extensionConsentString(
config,
[],
[],
[
{
name: 'agent1',
description: 'Agent 1 description',
systemPrompt: 'You are agent1',
level: 'extension',
},
],
);
expect(result).toContain(
'This extension will install the following subagents',
);
expect(result).toContain('agent1');
expect(result).toContain('Agent 1 description');
});
});
describe('requestConsentOrFail', () => {
let mockRequestConsent: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockRequestConsent = vi.fn();
vi.clearAllMocks();
});
it('should do nothing when options is undefined', async () => {
await requestConsentOrFail(mockRequestConsent, undefined);
expect(mockRequestConsent).not.toHaveBeenCalled();
});
it('should request consent for new extension', async () => {
mockRequestConsent.mockResolvedValueOnce(true);
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
});
expect(mockRequestConsent).toHaveBeenCalled();
});
it('should throw error when user declines consent', async () => {
mockRequestConsent.mockResolvedValueOnce(false);
await expect(
requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
}),
).rejects.toThrow('Installation cancelled for "test-extension".');
});
it('should skip consent when consent string is unchanged', async () => {
const extensionConfig: ExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
await requestConsentOrFail(mockRequestConsent, {
extensionConfig,
previousExtensionConfig: extensionConfig,
});
expect(mockRequestConsent).not.toHaveBeenCalled();
});
it('should request consent when consent string changes', async () => {
mockRequestConsent.mockResolvedValueOnce(true);
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: {
name: 'test-extension',
version: '1.0.0',
excludeTools: ['tool1'],
},
previousExtensionConfig: { name: 'test-extension', version: '1.0.0' },
});
expect(mockRequestConsent).toHaveBeenCalled();
});
it('should request consent when commands change', async () => {
mockRequestConsent.mockResolvedValueOnce(true);
await requestConsentOrFail(mockRequestConsent, {
extensionConfig: { name: 'test-extension', version: '1.0.0' },
commands: ['command1'],
previousExtensionConfig: { name: 'test-extension', version: '1.0.0' },
previousCommands: [],
});
expect(mockRequestConsent).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { disableCommand, handleDisable } from './disable.js';
import yargs from 'yargs';
import { SettingScope } from '../../config/settings.js';
const mockDisableExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
disableExtension: mockDisableExtension,
}),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions disable command', () => {
it('should fail if no name is provided', () => {
const validationParser = yargs([])
.command(disableCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('disable')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should fail if invalid scope is provided', () => {
const validationParser = yargs([])
.command(disableCommand)
.fail(false)
.locale('en');
expect(() =>
validationParser.parse('disable test-extension --scope=invalid'),
).toThrow(/Invalid scope: invalid/);
});
it('should accept valid scope values', () => {
const parser = yargs([]).command(disableCommand).fail(false).locale('en');
// Just check that the scope option is recognized, actual execution needs name first
expect(() =>
parser.parse('disable my-extension --scope=user'),
).not.toThrow();
});
});
describe('handleDisable', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should disable an extension with user scope', async () => {
await handleDisable({
name: 'test-extension',
scope: 'user',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully disabled for scope "user".',
);
});
it('should disable an extension with workspace scope', async () => {
await handleDisable({
name: 'test-extension',
scope: 'workspace',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.Workspace,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully disabled for scope "workspace".',
);
});
it('should default to user scope when no scope is provided', async () => {
await handleDisable({
name: 'test-extension',
});
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
});
it('should handle errors and exit with code 1', async () => {
mockDisableExtension.mockImplementationOnce(() => {
throw new Error('Disable failed');
});
await handleDisable({
name: 'test-extension',
scope: 'user',
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Disable failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View file

@ -0,0 +1,136 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { enableCommand, handleEnable } from './enable.js';
import yargs from 'yargs';
import { SettingScope } from '../../config/settings.js';
const mockEnableExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
enableExtension: mockEnableExtension,
}),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
FatalConfigError: class FatalConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalConfigError';
}
},
getErrorMessage: (error: Error) => error.message,
};
});
describe('extensions enable command', () => {
it('should fail if no name is provided', () => {
const validationParser = yargs([])
.command(enableCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('enable')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should fail if invalid scope is provided', () => {
const validationParser = yargs([])
.command(enableCommand)
.fail(false)
.locale('en');
expect(() =>
validationParser.parse('enable test-extension --scope=invalid'),
).toThrow(/Invalid scope: invalid/);
});
it('should accept valid scope values', () => {
const parser = yargs([]).command(enableCommand).fail(false).locale('en');
// Just check that the scope option is recognized, actual execution needs name first
expect(() =>
parser.parse('enable my-extension --scope=user'),
).not.toThrow();
});
});
describe('handleEnable', () => {
let consoleLogSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.clearAllMocks();
});
it('should enable an extension with user scope', async () => {
await handleEnable({
name: 'test-extension',
scope: 'user',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled for scope "user".',
);
});
it('should enable an extension with workspace scope', async () => {
await handleEnable({
name: 'test-extension',
scope: 'workspace',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.Workspace,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled for scope "workspace".',
);
});
it('should default to user scope when no scope is provided', async () => {
await handleEnable({
name: 'test-extension',
});
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
SettingScope.User,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully enabled in all scopes.',
);
});
it('should throw FatalConfigError when enable fails', async () => {
mockEnableExtension.mockImplementationOnce(() => {
throw new Error('Enable failed');
});
await expect(
handleEnable({
name: 'test-extension',
scope: 'user',
}),
).rejects.toThrow('Enable failed');
});
});

View file

@ -0,0 +1,95 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { linkCommand, handleLink } from './link.js';
import yargs from 'yargs';
const mockInstallExtension = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
installExtension: mockInstallExtension,
}),
}));
vi.mock('./consent.js', () => ({
requestConsentNonInteractive: vi.fn().mockResolvedValue(true),
requestConsentOrFail: vi.fn(),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions link command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([])
.command(linkCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('link')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should accept a path argument', () => {
const parser = yargs([]).command(linkCommand).fail(false).locale('en');
expect(() => parser.parse('link /some/path')).not.toThrow();
});
});
describe('handleLink', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should link an extension from a local path', async () => {
mockInstallExtension.mockResolvedValueOnce({ name: 'linked-extension' });
await handleLink({
path: '/some/local/path',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: '/some/local/path',
type: 'link',
},
expect.any(Function),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "linked-extension" linked successfully and enabled.',
);
});
it('should handle errors and exit with code 1', async () => {
mockInstallExtension.mockRejectedValueOnce(new Error('Link failed'));
await handleLink({
path: '/some/local/path',
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Link failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View file

@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { listCommand, handleList } from './list.js';
import yargs from 'yargs';
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockToOutputString = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
getLoadedExtensions: mockGetLoadedExtensions,
toOutputString: mockToOutputString,
}),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
describe('extensions list command', () => {
it('should parse the list command', () => {
const parser = yargs([]).command(listCommand).fail(false).locale('en');
expect(() => parser.parse('list')).not.toThrow();
});
});
describe('handleList', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.clearAllMocks();
});
it('should display message when no extensions are installed', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
await handleList();
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions installed.');
});
it('should list installed extensions', async () => {
const mockExtensions = [
{ name: 'extension-1', version: '1.0.0' },
{ name: 'extension-2', version: '2.0.0' },
];
mockGetLoadedExtensions.mockReturnValueOnce(mockExtensions);
mockToOutputString.mockImplementation(
(ext) => `${ext.name} (${ext.version})`,
);
await handleList();
expect(mockGetLoadedExtensions).toHaveBeenCalled();
expect(mockToOutputString).toHaveBeenCalledTimes(2);
expect(consoleLogSpy).toHaveBeenCalledWith(
'extension-1 (1.0.0)\n\nextension-2 (2.0.0)',
);
});
it('should handle errors and exit with code 1', async () => {
mockGetLoadedExtensions.mockImplementationOnce(() => {
throw new Error('List failed');
});
await handleList();
expect(consoleErrorSpy).toHaveBeenCalledWith('List failed');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});

View file

@ -0,0 +1,262 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type MockInstance,
} from 'vitest';
import { updateCommand, handleUpdate } from './update.js';
import yargs from 'yargs';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockUpdateExtension = vi.hoisted(() => vi.fn());
const mockCheckForAllExtensionUpdates = vi.hoisted(() => vi.fn());
const mockUpdateAllUpdatableExtensions = vi.hoisted(() => vi.fn());
const mockCheckForExtensionUpdate = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
getLoadedExtensions: mockGetLoadedExtensions,
updateExtension: mockUpdateExtension,
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
}),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
checkForExtensionUpdate: mockCheckForExtensionUpdate,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
vi.mock('../../ui/state/extensions.js', () => ({
ExtensionUpdateState: {
UPDATE_AVAILABLE: 'update available',
UP_TO_DATE: 'up to date',
ERROR: 'error',
},
}));
describe('extensions update command', () => {
it('should fail if neither name nor --all is provided', () => {
const validationParser = yargs([])
.command(updateCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('update')).toThrow(
'Either an extension name or --all must be provided',
);
});
it('should fail if both name and --all are provided', () => {
const validationParser = yargs([])
.command(updateCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('update test-extension --all')).toThrow(
/Arguments .* are mutually exclusive/,
);
});
it('should accept --all flag', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update --all')).not.toThrow();
});
it('should accept an extension name', () => {
const parser = yargs([]).command(updateCommand).fail(false).locale('en');
expect(() => parser.parse('update test-extension')).not.toThrow();
});
});
describe('handleUpdate', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.clearAllMocks();
});
describe('update by name', () => {
it('should show message when extension is not found', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([]);
await handleUpdate({ name: 'non-existent-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "non-existent-extension" not found.',
);
});
it('should show message when extension has no install metadata', async () => {
mockGetLoadedExtensions.mockReturnValueOnce([
{ name: 'test-extension', installMetadata: undefined },
]);
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Unable to install extension "test-extension" due to missing install metadata',
);
});
it('should show message when extension is already up to date', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UP_TO_DATE,
);
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" is already up to date.',
);
});
it('should update extension when update is available', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValueOnce({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
});
await handleUpdate({ name: 'test-extension' });
expect(mockUpdateExtension).toHaveBeenCalledWith(
mockExtension,
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" successfully updated: 1.0.0 → 2.0.0.',
);
});
it('should show up to date message when versions are the same after update', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockResolvedValueOnce(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValueOnce({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '1.0.0',
});
await handleUpdate({ name: 'test-extension' });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "test-extension" is already up to date.',
);
});
it('should handle errors during update', async () => {
const mockExtension = {
name: 'test-extension',
installMetadata: { source: 'test' },
};
mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]);
mockCheckForExtensionUpdate.mockRejectedValueOnce(
new Error('Update check failed'),
);
await handleUpdate({ name: 'test-extension' });
expect(consoleErrorSpy).toHaveBeenCalledWith('Update check failed');
});
});
describe('update all', () => {
it('should show message when no extensions to update', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith('No extensions to update.');
});
it('should update all extensions with updates available', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
{
name: 'extension-1',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
},
{
name: 'extension-2',
originalVersion: '1.0.0',
updatedVersion: '1.5.0',
},
]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.\n' +
'Extension "extension-2" successfully updated: 1.0.0 → 1.5.0.',
);
});
it('should filter out extensions with same version after update', async () => {
mockCheckForAllExtensionUpdates.mockResolvedValueOnce(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValueOnce([
{
name: 'extension-1',
originalVersion: '1.0.0',
updatedVersion: '2.0.0',
},
{
name: 'extension-2',
originalVersion: '1.0.0',
updatedVersion: '1.0.0',
},
]);
await handleUpdate({ all: true });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "extension-1" successfully updated: 1.0.0 → 2.0.0.',
);
});
it('should handle errors during update all', async () => {
mockCheckForAllExtensionUpdates.mockRejectedValueOnce(
new Error('Update all failed'),
);
await handleUpdate({ all: true });
expect(consoleErrorSpy).toHaveBeenCalledWith('Update all failed');
});
});
});

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getExtensionManager } from './utils.js';
const mockRefreshCache = vi.fn();
const mockExtensionManagerInstance = {
refreshCache: mockRefreshCache,
};
vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi
.fn()
.mockImplementation(() => mockExtensionManagerInstance),
}));
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({
merged: {},
}),
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }),
}));
vi.mock('./consent.js', () => ({
requestConsentOrFail: vi.fn(),
requestConsentNonInteractive: vi.fn(),
}));
describe('getExtensionManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRefreshCache.mockResolvedValue(undefined);
});
it('should return an ExtensionManager instance', async () => {
const manager = await getExtensionManager();
expect(manager).toBeDefined();
expect(manager).toBe(mockExtensionManagerInstance);
});
it('should call refreshCache on the ExtensionManager', async () => {
await getExtensionManager();
expect(mockRefreshCache).toHaveBeenCalled();
});
it('should use current working directory as workspace', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
await getExtensionManager();
expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: process.cwd(),
}),
);
});
});

View file

@ -231,18 +231,21 @@ describe('Configuration Integration Tests', () => {
expect(config.getExtensionContextFilePaths()).toEqual([]);
});
it('should correctly store and return extension context file paths', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
it('should correctly store and return extension context file paths with outputLanguageFilePath', () => {
const outputLanguageFilePath = '/path/to/language.txt';
const configParams: ConfigParameters = {
cwd: '/tmp',
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
outputLanguageFilePath,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
// outputLanguageFilePath should be included in extension context file paths
expect(config.getExtensionContextFilePaths()).toContain(
outputLanguageFilePath,
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -862,14 +862,19 @@ export async function loadCliConfig(
argv.excludeTools,
);
const allowedMcpServers = argv.allowedMcpServerNames
? new Set(argv.allowedMcpServerNames.filter(Boolean))
: settings.mcp?.allowed
let allowedMcpServers: Set<string> | undefined;
let excludedMcpServers: Set<string> | undefined;
if (argv.allowedMcpServerNames) {
allowedMcpServers = new Set(argv.allowedMcpServerNames.filter(Boolean));
excludedMcpServers = undefined;
} else {
allowedMcpServers = settings.mcp?.allowed
? new Set(settings.mcp.allowed.filter(Boolean))
: undefined;
const excludedMcpServers = settings.mcp?.excluded
? new Set(settings.mcp.excluded.filter(Boolean))
: undefined;
excludedMcpServers = settings.mcp?.excluded
? new Set(settings.mcp.excluded.filter(Boolean))
: undefined;
}
const selectedAuthType =
(argv.authType as AuthType | undefined) ||

View file

@ -100,7 +100,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions installed. Run `/extensions explore` to check out the gallery.',
text: 'No extensions installed.',
},
expect.any(Number),
);
@ -241,7 +241,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension ext-one not found.',
text: 'Extension "ext-one" not found.',
},
expect.any(Number),
);
@ -645,7 +645,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for the scope "User"',
text: 'Extension "test-extension" disabled for scope "User"',
},
expect.any(Number),
);
@ -663,7 +663,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for the scope "Workspace"',
text: 'Extension "test-extension" disabled for scope "Workspace"',
},
expect.any(Number),
);
@ -675,7 +675,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope invalid, should be one of "user" or "workspace"',
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);
@ -741,7 +741,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for the scope "User"',
text: 'Extension "test-extension" enabled for scope "User"',
},
expect.any(Number),
);
@ -759,7 +759,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for the scope "Workspace"',
text: 'Extension "test-extension" enabled for scope "Workspace"',
},
expect.any(Number),
);
@ -771,7 +771,7 @@ describe('extensionsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope invalid, should be one of "user" or "workspace"',
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);