diff --git a/packages/cli/src/services/FileCommandLoader-extension.test.ts b/packages/cli/src/services/FileCommandLoader-extension.test.ts index c579bf20a..38af0865d 100644 --- a/packages/cli/src/services/FileCommandLoader-extension.test.ts +++ b/packages/cli/src/services/FileCommandLoader-extension.test.ts @@ -62,10 +62,13 @@ describe('FileCommandLoader - Extension Commands Support', () => { // Mock config to return the extension mockConfig.getExtensions = () => [ { + id: 'test-ext', + config: extensionConfig, name: 'test-ext', version: '1.0.0', isActive: true, path: extensionDir, + contextFiles: [], }, ]; @@ -111,6 +114,9 @@ describe('FileCommandLoader - Extension Commands Support', () => { // Mock config to return the extension mockConfig.getExtensions = () => [ { + id: 'multi-ext', + config: extensionConfig, + contextFiles: [], name: 'multi-ext', version: '1.0.0', isActive: true, @@ -156,6 +162,9 @@ describe('FileCommandLoader - Extension Commands Support', () => { // Mock config to return the extension mockConfig.getExtensions = () => [ { + id: 'default-ext', + config: extensionConfig, + contextFiles: [], name: 'default-ext', version: '1.0.0', isActive: true, @@ -193,6 +202,9 @@ describe('FileCommandLoader - Extension Commands Support', () => { // Mock config to return the extension mockConfig.getExtensions = () => [ { + id: 'no-cmds-ext', + config: extensionConfig, + contextFiles: [], name: 'no-cmds-ext', version: '1.0.0', isActive: true, @@ -234,6 +246,9 @@ describe('FileCommandLoader - Extension Commands Support', () => { mockConfig.getExtensions = () => [ { + id: 'prefix-ext', + config: extensionConfig, + contextFiles: [], name: 'prefix-ext', version: '1.0.0', isActive: true, @@ -281,8 +296,24 @@ describe('FileCommandLoader - Extension Commands Support', () => { ); mockConfig.getExtensions = () => [ - { name: 'ext-b', version: '1.0.0', isActive: true, path: ext1Dir }, - { name: 'ext-a', version: '1.0.0', isActive: true, path: ext2Dir }, + { + id: 'ext-b', + config: { name: 'ext-b', version: '1.0.0' }, + contextFiles: [], + name: 'ext-b', + version: '1.0.0', + isActive: true, + path: ext1Dir, + }, + { + id: 'ext-a', + config: { name: 'ext-a', version: '1.0.0' }, + contextFiles: [], + name: 'ext-a', + version: '1.0.0', + isActive: true, + path: ext2Dir, + }, ]; const loader = new FileCommandLoader(mockConfig as Config); diff --git a/packages/cli/src/services/command-migration-tool.ts b/packages/cli/src/services/command-migration-tool.ts index 670632522..e8f32b613 100644 --- a/packages/cli/src/services/command-migration-tool.ts +++ b/packages/cli/src/services/command-migration-tool.ts @@ -11,7 +11,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { glob } from 'glob'; -import { convertTomlToMarkdown } from '@qwen-code/qwen-code-core/src/utils/toml-to-markdown-converter.js'; +import { convertTomlToMarkdown } from '@qwen-code/qwen-code-core'; export interface MigrationResult { success: boolean; diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index 92e0d9573..5b6ed38bf 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -5,7 +5,7 @@ */ import { z } from 'zod'; -import { parse as parseYaml } from '@qwen-code/qwen-code-core/src/utils/yaml-parser.js'; +import { parse as parseYaml } from '@qwen-code/qwen-code-core'; /** * Defines the Zod schema for a Markdown command definition file. diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts deleted file mode 100644 index 7366c3cf6..000000000 --- a/packages/cli/src/test-utils/createExtension.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { - type MCPServerConfig, - type ExtensionInstallMetadata, -} from '@qwen-code/qwen-code-core'; -import { - EXTENSIONS_CONFIG_FILENAME, - INSTALL_METADATA_FILENAME, -} from '../config/extensions/variables.js'; - -export function createExtension({ - extensionsDir = 'extensions-dir', - name = 'my-extension', - version = '1.0.0', - addContextFile = false, - contextFileName = undefined as string | undefined, - mcpServers = {} as Record, - installMetadata = undefined as ExtensionInstallMetadata | undefined, -} = {}): string { - const extDir = path.join(extensionsDir, name); - fs.mkdirSync(extDir, { recursive: true }); - fs.writeFileSync( - path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers }), - ); - - if (addContextFile) { - fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context'); - } - - if (contextFileName) { - fs.writeFileSync(path.join(extDir, contextFileName), 'context'); - } - - if (installMetadata) { - fs.writeFileSync( - path.join(extDir, INSTALL_METADATA_FILENAME), - JSON.stringify(installMetadata), - ); - } - return extDir; -} diff --git a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx index 9067e3473..fe67396ad 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx @@ -18,7 +18,6 @@ const mockUseUIState = vi.mocked(useUIState); const mockExtensions = [ { name: 'ext-one', version: '1.0.0', isActive: true }, { name: 'ext-two', version: '2.1.0', isActive: true }, - { name: 'ext-disabled', version: '3.0.0', isActive: false }, ]; describe('', () => { @@ -29,7 +28,6 @@ describe('', () => { const mockUIState = ( extensions: unknown[], extensionsUpdateState: Map, - disabledExtensions: string[] = [], ) => { mockUseUIState.mockReturnValue({ commandContext: createMockCommandContext({ @@ -37,13 +35,6 @@ describe('', () => { config: { getExtensions: () => extensions, }, - settings: { - merged: { - extensions: { - disabled: disabledExtensions, - }, - }, - }, }, }), extensionsUpdateState, @@ -58,12 +49,11 @@ describe('', () => { }); it('should render a list of extensions with their version and status', () => { - mockUIState(mockExtensions, new Map(), ['ext-disabled']); + mockUIState(mockExtensions, new Map()); const { lastFrame } = render(); const output = lastFrame(); expect(output).toContain('ext-one (v1.0.0) - active'); expect(output).toContain('ext-two (v2.1.0) - active'); - expect(output).toContain('ext-disabled (v3.0.0) - disabled'); }); it('should display "unknown state" if an extension has no update state', () => { diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index 3f21c019c..0475b0bad 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -4,27 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - annotateActiveExtensions, - loadExtension, - ExtensionManager, -} from '../../config/extension.js'; -import { createExtension } from '../../test-utils/createExtension.js'; + import { useExtensionUpdates } from './useExtensionUpdates.js'; -import { QWEN_DIR, type GeminiCLIExtension } from '@qwen-code/qwen-code-core'; +import { + QWEN_DIR, + type ExtensionManager, + type Extension, + type ExtensionUpdateInfo, + ExtensionUpdateState, +} from '@qwen-code/qwen-code-core'; import { renderHook, waitFor } from '@testing-library/react'; import { MessageType } from '../types.js'; -import { - checkForAllExtensionUpdates, - updateExtension, -} from '../../config/extensions/update.js'; -import { ExtensionUpdateState } from '../state/extensions.js'; - vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { @@ -33,63 +28,85 @@ vi.mock('os', async (importOriginal) => { }; }); -vi.mock('../../config/extensions/update.js', () => ({ - checkForAllExtensionUpdates: vi.fn(), - updateExtension: vi.fn(), -})); +function createMockExtension(overrides: Partial = {}): Extension { + return { + id: 'test-extension-id', + name: 'test-extension', + version: '1.0.0', + path: '/some/path', + isActive: true, + config: { + name: 'test-extension', + version: '1.0.0', + }, + contextFiles: [], + installMetadata: { + type: 'git', + source: 'https://some/repo', + autoUpdate: false, + }, + ...overrides, + }; +} + +function createMockExtensionManager( + extensions: Extension[], + checkCallback?: ( + callback: (extensionName: string, state: ExtensionUpdateState) => void, + ) => Promise, + updateResult?: ExtensionUpdateInfo | undefined, +): ExtensionManager { + return { + getLoadedExtensions: vi.fn(() => extensions), + checkForAllExtensionUpdates: vi.fn( + async ( + callback: (extensionName: string, state: ExtensionUpdateState) => void, + ) => { + if (checkCallback) { + await checkCallback(callback); + } + }, + ), + updateExtension: vi.fn(async () => updateResult), + } as unknown as ExtensionManager; +} describe('useExtensionUpdates', () => { let tempHomeDir: string; let userExtensionsDir: string; beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-cli-test-home-')); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions'); fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(checkForAllExtensionUpdates).mockReset(); - vi.mocked(updateExtension).mockReset(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.clearAllMocks(); }); it('should check for updates and log a message if an update is available', async () => { - const extensions = [ - { - name: 'test-extension', + const extension = createMockExtension({ + name: 'test-extension', + installMetadata: { type: 'git', - version: '1.0.0', - path: '/some/path', - isActive: true, - installMetadata: { - type: 'git', - source: 'https://some/repo', - autoUpdate: false, - }, + source: 'https://some/repo', + autoUpdate: false, }, - ]; + }); const addItem = vi.fn(); const cwd = '/test/cwd'; - vi.mocked(checkForAllExtensionUpdates).mockImplementation( - async (extensions, dispatch) => { - dispatch({ - type: 'SET_STATE', - payload: { - name: 'test-extension', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); + const extensionManager = createMockExtensionManager( + [extension], + async (callback) => { + callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE); }, ); - renderHook(() => - useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd), - ); + renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd)); await waitFor(() => { expect(addItem).toHaveBeenCalledWith( @@ -103,43 +120,32 @@ describe('useExtensionUpdates', () => { }); it('should check for updates and automatically update if autoUpdate is true', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, + const extension = createMockExtension({ name: 'test-extension', - version: '1.0.0', installMetadata: { - source: 'https://some.git/repo', type: 'git', + source: 'https://some.git/repo', autoUpdate: true, }, }); - const extension = annotateActiveExtensions( - [loadExtension({ extensionDir, workspaceDir: tempHomeDir })!], - tempHomeDir, - new ExtensionManager(), - )[0]; const addItem = vi.fn(); - vi.mocked(checkForAllExtensionUpdates).mockImplementation( - async (extensions, dispatch) => { - dispatch({ - type: 'SET_STATE', - payload: { - name: 'test-extension', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); + const extensionManager = createMockExtensionManager( + [extension], + async (callback) => { + callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE); + }, + { + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + name: 'test-extension', }, ); - vi.mocked(updateExtension).mockResolvedValue({ - originalVersion: '1.0.0', - updatedVersion: '1.1.0', - name: '', - }); - - renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir)); + renderHook(() => + useExtensionUpdates(extensionManager, addItem, tempHomeDir), + ); await waitFor( () => { @@ -156,77 +162,64 @@ describe('useExtensionUpdates', () => { }); it('should batch update notifications for multiple extensions', async () => { - const extensionDir1 = createExtension({ - extensionsDir: userExtensionsDir, + const extension1 = createMockExtension({ + id: 'test-extension-1-id', name: 'test-extension-1', version: '1.0.0', installMetadata: { - source: 'https://some.git/repo1', type: 'git', + source: 'https://some.git/repo1', autoUpdate: true, }, }); - const extensionDir2 = createExtension({ - extensionsDir: userExtensionsDir, + const extension2 = createMockExtension({ + id: 'test-extension-2-id', name: 'test-extension-2', version: '2.0.0', installMetadata: { - source: 'https://some.git/repo2', type: 'git', + source: 'https://some.git/repo2', autoUpdate: true, }, }); - const extensions = annotateActiveExtensions( - [ - loadExtension({ - extensionDir: extensionDir1, - workspaceDir: tempHomeDir, - })!, - loadExtension({ - extensionDir: extensionDir2, - workspaceDir: tempHomeDir, - })!, - ], - tempHomeDir, - new ExtensionManager(), - ); - const addItem = vi.fn(); + let updateCallCount = 0; - vi.mocked(checkForAllExtensionUpdates).mockImplementation( - async (extensions, dispatch) => { - dispatch({ - type: 'SET_STATE', - payload: { + const extensionManager = { + getLoadedExtensions: vi.fn(() => [extension1, extension2]), + checkForAllExtensionUpdates: vi.fn( + async ( + callback: ( + extensionName: string, + state: ExtensionUpdateState, + ) => void, + ) => { + callback('test-extension-1', ExtensionUpdateState.UPDATE_AVAILABLE); + callback('test-extension-2', ExtensionUpdateState.UPDATE_AVAILABLE); + }, + ), + updateExtension: vi.fn(async () => { + updateCallCount++; + if (updateCallCount === 1) { + return { + originalVersion: '1.0.0', + updatedVersion: '1.1.0', name: 'test-extension-1', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); - dispatch({ - type: 'SET_STATE', - payload: { - name: 'test-extension-2', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); - }, + }; + } + return { + originalVersion: '2.0.0', + updatedVersion: '2.1.0', + name: 'test-extension-2', + }; + }), + } as unknown as ExtensionManager; + + renderHook(() => + useExtensionUpdates(extensionManager, addItem, tempHomeDir), ); - vi.mocked(updateExtension) - .mockResolvedValueOnce({ - originalVersion: '1.0.0', - updatedVersion: '1.1.0', - name: '', - }) - .mockResolvedValueOnce({ - originalVersion: '2.0.0', - updatedVersion: '2.1.0', - name: '', - }); - - renderHook(() => useExtensionUpdates(extensions, addItem, tempHomeDir)); - await waitFor( () => { expect(addItem).toHaveBeenCalledTimes(2); @@ -250,60 +243,40 @@ describe('useExtensionUpdates', () => { }); it('should batch update notifications for multiple extensions with autoUpdate: false', async () => { - const extensions = [ - { - name: 'test-extension-1', + const extension1 = createMockExtension({ + id: 'test-extension-1-id', + name: 'test-extension-1', + version: '1.0.0', + installMetadata: { type: 'git', - version: '1.0.0', - path: '/some/path1', - isActive: true, - installMetadata: { - type: 'git', - source: 'https://some/repo1', - autoUpdate: false, - }, + source: 'https://some/repo1', + autoUpdate: false, }, - { - name: 'test-extension-2', + }); + const extension2 = createMockExtension({ + id: 'test-extension-2-id', + name: 'test-extension-2', + version: '2.0.0', + installMetadata: { type: 'git', - version: '2.0.0', - path: '/some/path2', - isActive: true, - installMetadata: { - type: 'git', - source: 'https://some/repo2', - autoUpdate: false, - }, + source: 'https://some/repo2', + autoUpdate: false, }, - ]; + }); + const addItem = vi.fn(); const cwd = '/test/cwd'; - vi.mocked(checkForAllExtensionUpdates).mockImplementation( - async (extensions, dispatch) => { - dispatch({ type: 'BATCH_CHECK_START' }); - dispatch({ - type: 'SET_STATE', - payload: { - name: 'test-extension-1', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); + const extensionManager = createMockExtensionManager( + [extension1, extension2], + async (callback) => { + callback('test-extension-1', ExtensionUpdateState.UPDATE_AVAILABLE); await new Promise((r) => setTimeout(r, 50)); - dispatch({ - type: 'SET_STATE', - payload: { - name: 'test-extension-2', - state: ExtensionUpdateState.UPDATE_AVAILABLE, - }, - }); - dispatch({ type: 'BATCH_CHECK_END' }); + callback('test-extension-2', ExtensionUpdateState.UPDATE_AVAILABLE); }, ); - renderHook(() => - useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd), - ); + renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd)); await waitFor(() => { expect(addItem).toHaveBeenCalledTimes(1); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 9e6cead54..c5833286c 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -11,8 +11,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { glob } from 'glob'; import type { ExtensionConfig } from './extensionManager.js'; -import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import { ExtensionStorage } from './storage.js'; +import type { MCPServerConfig } from '../config/config.js'; export interface ClaudePluginConfig { name: string; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index a3a85ee40..1bebae39d 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -4,40 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - annotateActiveExtensions, - disableExtension, - enableExtension, - installExtension, - loadExtension, - loadExtensionConfig, - loadExtensions, - performWorkspaceExtensionMigration, - requestConsentNonInteractive, - uninstallExtension, - type Extension, -} from './extension.js'; import { INSTALL_METADATA_FILENAME, EXTENSIONS_CONFIG_FILENAME, -} from './extensions/variables.js'; -import { ExtensionStorage } from './extensions/storage.js'; +} from './variables.js'; +import { QWEN_DIR } from '../config/storage.js'; import { - QWEN_DIR, - type GeminiCLIExtension, - ExtensionUninstallEvent, - ExtensionDisableEvent, - ExtensionEnableEvent, -} from '@qwen-code/qwen-code-core'; -import { execSync } from 'node:child_process'; -import { SettingScope } from './settings.js'; -import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createExtension } from '../test-utils/createExtension.js'; -import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; + ExtensionManager, + SettingScope, + type ExtensionManagerOptions, +} from './extensionManager.js'; +import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; const mockGit = { clone: vi.fn(), @@ -46,8 +27,6 @@ const mockGit = { checkout: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), - // Not a part of the actual API, but we need to use this to do the correct - // file system interactions. path: vi.fn(), }; @@ -58,9 +37,8 @@ vi.mock('simple-git', () => ({ }), })); -vi.mock('./extensions/github.js', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('./github.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, downloadFromGitHubRelease: vi @@ -69,19 +47,12 @@ vi.mock('./extensions/github.js', async (importOriginal) => { }; }); +const mockHomedir = vi.hoisted(() => vi.fn()); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, - homedir: vi.fn(), - }; -}); - -vi.mock('./trustedFolders.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isWorkspaceTrusted: vi.fn(), + homedir: mockHomedir, }; }); @@ -89,41 +60,58 @@ const mockLogExtensionEnable = vi.hoisted(() => vi.fn()); const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); +const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); +vi.mock('../telemetry/loggers.js', () => ({ + logExtensionEnable: mockLogExtensionEnable, + logExtensionUpdateEvent: mockLogExtensionUpdateEvent, +})); + +vi.mock('../index.js', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, logExtensionEnable: mockLogExtensionEnable, logExtensionInstallEvent: mockLogExtensionInstallEvent, logExtensionUninstall: mockLogExtensionUninstall, logExtensionDisable: mockLogExtensionDisable, - ExtensionEnableEvent: vi.fn(), - ExtensionInstallEvent: vi.fn(), - ExtensionUninstallEvent: vi.fn(), - ExtensionDisableEvent: vi.fn(), }; }); -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - execSync: vi.fn(), - }; -}); - -const mockQuestion = vi.hoisted(() => vi.fn()); -const mockClose = vi.hoisted(() => vi.fn()); -vi.mock('node:readline', () => ({ - createInterface: vi.fn(() => ({ - question: mockQuestion, - close: mockClose, - })), -})); - const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions'); +function createExtension({ + extensionsDir = 'extensions-dir', + name = 'my-extension', + version = '1.0.0', + addContextFile = false, + contextFileName = undefined as string | undefined, + mcpServers = {} as Record, + installMetadata = undefined as ExtensionInstallMetadata | undefined, +} = {}): string { + const extDir = path.join(extensionsDir, name); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name, version, contextFileName, mcpServers }), + ); + + if (addContextFile) { + fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context'); + } + + if (contextFileName) { + fs.writeFileSync(path.join(extDir, contextFileName), 'context'); + } + + if (installMetadata) { + fs.writeFileSync( + path.join(extDir, INSTALL_METADATA_FILENAME), + JSON.stringify(installMetadata), + ); + } + return extDir; +} + describe('extension tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; @@ -139,24 +127,28 @@ describe('extension tests', () => { userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + mockHomedir.mockReturnValue(tempHomeDir); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); - mockQuestion.mockImplementation((_query, callback) => callback('y')); - vi.mocked(execSync).mockClear(); Object.values(mockGit).forEach((fn) => fn.mockReset()); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); - mockQuestion.mockClear(); - mockClose.mockClear(); }); - describe('loadExtensions', () => { - it('should include extension path in loaded extension', () => { + function createExtensionManager( + options: Partial = {}, + ): ExtensionManager { + return new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + isWorkspaceTrusted: true, + ...options, + }); + } + + describe('loadExtension', () => { + it('should include extension path in loaded extension', async () => { const extensionDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extensionDir, { recursive: true }); @@ -166,15 +158,16 @@ describe('extension tests', () => { version: '1.0.0', }); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + expect(extensions).toHaveLength(1); expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].config.name).toBe('test-extension'); }); - it('should load context file path when QWEN.md is present', () => { + it('should load context file path when QWEN.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -187,9 +180,9 @@ describe('extension tests', () => { version: '2.0.0', }); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); expect(extensions).toHaveLength(2); const ext1 = extensions.find((e) => e.config.name === 'ext1'); @@ -200,7 +193,7 @@ describe('extension tests', () => { expect(ext2?.contextFiles).toEqual([]); }); - it('should load context file path from the extension config', () => { + it('should load context file path from the extension config', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -209,9 +202,9 @@ describe('extension tests', () => { contextFileName: 'my-context-file.md', }); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); expect(extensions).toHaveLength(1); const ext1 = extensions.find((e) => e.config.name === 'ext1'); @@ -220,114 +213,314 @@ describe('extension tests', () => { ]); }); - it('should filter out disabled extensions', () => { + it('should skip extensions with invalid JSON and log a warning', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension createExtension({ extensionsDir: userExtensionsDir, - name: 'disabled-extension', + name: 'good-ext', version: '1.0.0', }); - createExtension({ - extensionsDir: userExtensionsDir, - name: 'enabled-extension', - version: '2.0.0', - }); - disableExtension( - 'disabled-extension', - SettingScope.User, - tempWorkspaceDir, + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext'); + fs.mkdirSync(badExtDir); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`Warning: Skipping extension in ${badExtDir}`), ); - const manager = new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ); - const extensions = loadExtensions(manager); - const activeExtensions = annotateActiveExtensions( - extensions, - tempWorkspaceDir, - manager, - ).filter((e) => e.isActive); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('enabled-extension'); + + consoleSpy.mockRestore(); }); - it('should hydrate variables', () => { + it('should skip extensions with missing name and log a warning', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Good extension + createExtension({ + extensionsDir: userExtensionsDir, + name: 'good-ext', + version: '1.0.0', + }); + + // Bad extension + const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); + fs.mkdirSync(badExtDir); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`Warning: Skipping extension in ${badExtDir}`), + ); + + consoleSpy.mockRestore(); + }); + + it('should filter trust out of mcp servers', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', - addContextFile: false, - contextFileName: undefined, mcpServers: { 'test-server': { - cwd: '${extensionPath}${/}server', - }, + command: 'node', + args: ['server.js'], + trust: true, + } as MCPServerConfig, }, }); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - expect(extensions).toHaveLength(1); - const loadedConfig = extensions[0].config; - const expectedCwd = path.join( - userExtensionsDir, - 'test-extension', - 'server', - ); - expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); - }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); - it('should load a linked extension correctly', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempWorkspaceDir, - name: 'my-linked-extension', + expect(extensions).toHaveLength(1); + // trust should be filtered from extension.mcpServers (not config.mcpServers) + expect(extensions[0].mcpServers?.['test-server']?.trust).toBeUndefined(); + // config.mcpServers should still have trust (original config) + expect(extensions[0].config.mcpServers?.['test-server']?.trust).toBe( + true, + ); + }); + }); + + describe('enableExtension / disableExtension', () => { + it('should disable an extension at the user scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', version: '1.0.0', - contextFileName: 'context.md', }); - fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - const extensionName = await installExtension( - { - source: sourceExtDir, - type: 'link', - }, - async (_) => true, - ); + const manager = createExtensionManager(); + await manager.refreshCache(); - expect(extensionName).toEqual('my-linked-extension'); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - expect(extensions).toHaveLength(1); - - const linkedExt = extensions[0]; - expect(linkedExt.config.name).toBe('my-linked-extension'); - - expect(linkedExt.path).toBe(sourceExtDir); - expect(linkedExt.installMetadata).toEqual({ - source: sourceExtDir, - type: 'link', - }); - expect(linkedExt.contextFiles).toEqual([ - path.join(sourceExtDir, 'context.md'), - ]); + manager.disableExtension('my-extension', SettingScope.User); + expect(manager.isEnabled('my-extension', tempWorkspaceDir)).toBe(false); }); - it('should resolve environment variables in extension configuration', () => { - process.env.TEST_API_KEY = 'test-api-key-123'; - process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; + it('should disable an extension at the workspace scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + manager.disableExtension( + 'my-extension', + SettingScope.Workspace, + tempWorkspaceDir, + ); + + expect(manager.isEnabled('my-extension', tempHomeDir)).toBe(true); + expect(manager.isEnabled('my-extension', tempWorkspaceDir)).toBe(false); + }); + + it('should handle disabling the same extension twice', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + manager.disableExtension('my-extension', SettingScope.User); + manager.disableExtension('my-extension', SettingScope.User); + expect(manager.isEnabled('my-extension', tempWorkspaceDir)).toBe(false); + }); + + it('should throw an error if you request system scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + expect(() => + manager.disableExtension('my-extension', SettingScope.System), + ).toThrow('System and SystemDefaults scopes are not supported.'); + }); + + it('should enable an extension at the user scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + manager.disableExtension('ext1', SettingScope.User); + expect(manager.isEnabled('ext1')).toBe(false); + + manager.enableExtension('ext1', SettingScope.User); + expect(manager.isEnabled('ext1')).toBe(true); + }); + + it('should enable an extension at the workspace scope', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + manager.disableExtension('ext1', SettingScope.Workspace); + expect(manager.isEnabled('ext1', tempWorkspaceDir)).toBe(false); + + manager.enableExtension('ext1', SettingScope.Workspace); + expect(manager.isEnabled('ext1', tempWorkspaceDir)).toBe(true); + }); + }); + + describe('validateExtensionOverrides', () => { + it('should mark all extensions as active if no enabled extensions are provided', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(2); + expect(extensions.every((e) => e.isActive)).toBe(true); + }); + + it('should mark only the enabled extensions as active', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext3', + version: '1.0.0', + }); + + const manager = createExtensionManager({ + enabledExtensionOverrides: ['ext1', 'ext3'], + }); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions.find((e) => e.name === 'ext1')?.isActive).toBe(true); + expect(extensions.find((e) => e.name === 'ext2')?.isActive).toBe(false); + expect(extensions.find((e) => e.name === 'ext3')?.isActive).toBe(true); + }); + + it('should mark all extensions as inactive when "none" is provided', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + + const manager = createExtensionManager({ + enabledExtensionOverrides: ['none'], + }); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions.every((e) => !e.isActive)).toBe(true); + }); + + it('should handle case-insensitivity', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const manager = createExtensionManager({ + enabledExtensionOverrides: ['EXT1'], + }); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions.find((e) => e.name === 'ext1')?.isActive).toBe(true); + }); + + it('should log an error for unknown extensions', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const manager = createExtensionManager({ + enabledExtensionOverrides: ['ext4'], + }); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + manager.validateExtensionOverrides(extensions); + + expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); + consoleSpy.mockRestore(); + }); + }); + + describe('loadExtensionConfig', () => { + it('should resolve environment variables in extension configuration', async () => { + process.env['TEST_API_KEY'] = 'test-api-key-123'; + process.env['TEST_DB_URL'] = 'postgresql://localhost:5432/testdb'; try { - const userExtensionsDir = path.join( - tempHomeDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); - // Write config to a separate file for clarity and good practices - const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME); const extensionConfig = { name: 'test-extension', version: '1.0.0', @@ -343,14 +536,15 @@ describe('extension tests', () => { }, }, }; - fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); - - const extensions = loadExtensions( - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(extensionConfig), ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + expect(extensions).toHaveLength(1); const extension = extensions[0]; expect(extension.config.name).toBe('test-extension'); @@ -359,24 +553,18 @@ describe('extension tests', () => { const serverConfig = extension.config.mcpServers?.['test-server']; expect(serverConfig).toBeDefined(); expect(serverConfig?.env).toBeDefined(); - expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123'); - expect(serverConfig?.env?.DATABASE_URL).toBe( + expect(serverConfig?.env?.['API_KEY']).toBe('test-api-key-123'); + expect(serverConfig?.env?.['DATABASE_URL']).toBe( 'postgresql://localhost:5432/testdb', ); - expect(serverConfig?.env?.STATIC_VALUE).toBe('no-substitution'); + expect(serverConfig?.env?.['STATIC_VALUE']).toBe('no-substitution'); } finally { - delete process.env.TEST_API_KEY; - delete process.env.TEST_DB_URL; + delete process.env['TEST_API_KEY']; + delete process.env['TEST_DB_URL']; } }); - it('should handle missing environment variables gracefully', () => { - const userExtensionsDir = path.join( - tempHomeDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - + it('should handle missing environment variables gracefully', async () => { const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); @@ -400,1066 +588,16 @@ describe('extension tests', () => { JSON.stringify(extensionConfig), ); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; const serverConfig = extension.config.mcpServers!['test-server']; expect(serverConfig.env).toBeDefined(); - expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR'); - expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}'); - }); - - it('should skip extensions with invalid JSON and log a warning', () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - // Good extension - createExtension({ - extensionsDir: userExtensionsDir, - name: 'good-ext', - version: '1.0.0', - }); - - // Bad extension - const badExtDir = path.join(userExtensionsDir, 'bad-ext'); - fs.mkdirSync(badExtDir); - const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); - fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed - - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - - expect(extensions).toHaveLength(1); - expect(extensions[0].config.name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, - ), - ); - - consoleSpy.mockRestore(); - }); - - it('should skip extensions with missing name and log a warning', () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - // Good extension - createExtension({ - extensionsDir: userExtensionsDir, - name: 'good-ext', - version: '1.0.0', - }); - - // Bad extension - const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); - fs.mkdirSync(badExtDir); - const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); - fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); - - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - - expect(extensions).toHaveLength(1); - expect(extensions[0].config.name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, - ), - ); - - consoleSpy.mockRestore(); - }); - - it('should filter trust out of mcp servers', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - trust: true, - }, - }, - }); - - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - expect(extensions).toHaveLength(1); - const loadedConfig = extensions[0].config; - expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined(); - }); - - it('should throw an error for invalid extension names', () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const badExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'bad_name', - version: '1.0.0', - }); - - const extension = loadExtension({ - extensionDir: badExtDir, - workspaceDir: tempWorkspaceDir, - }); - - expect(extension).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Invalid extension name: "bad_name"'), - ); - consoleSpy.mockRestore(); - }); - }); - - describe('annotateActiveExtensions', () => { - const extensions: Extension[] = [ - { - path: '/path/to/ext1', - config: { name: 'ext1', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext2', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext3', - config: { name: 'ext3', version: '1.0.0' }, - contextFiles: [], - }, - ]; - - it('should mark all extensions as active if no enabled extensions are provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - '/path/to/workspace', - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => e.isActive)).toBe(true); - }); - - it('should mark only the enabled extensions as active', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - '/path/to/workspace', - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ['ext1', 'ext3'], - ), - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe( - false, - ); - expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe( - true, - ); - }); - - it('should mark all extensions as inactive when "none" is provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - '/path/to/workspace', - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ['none'], - ), - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => !e.isActive)).toBe(true); - }); - - it('should handle case-insensitivity', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - '/path/to/workspace', - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ['EXT1'], - ), - ); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - }); - - it('should log an error for unknown extensions', () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - annotateActiveExtensions( - extensions, - '/path/to/workspace', - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ['ext4'], - ), - ); - expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); - consoleSpy.mockRestore(); - }); - - describe('autoUpdate', () => { - it('should be false if autoUpdate is not set in install metadata', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - tempHomeDir, - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), - ); - expect( - activeExtensions.every( - (e) => e.installMetadata?.autoUpdate === false, - ), - ).toBe(false); - }); - - it('should be true if autoUpdate is true in install metadata', () => { - const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({ - ...e, - installMetadata: { - ...e.installMetadata!, - autoUpdate: true, - }, - })); - const activeExtensions = annotateActiveExtensions( - extensionsWithAutoUpdate, - tempHomeDir, - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), - ); - expect( - activeExtensions.every((e) => e.installMetadata?.autoUpdate === true), - ).toBe(true); - }); - - it('should respect the per-extension settings from install metadata', () => { - const extensionsWithAutoUpdate: Extension[] = [ - { - path: '/path/to/ext1', - config: { name: 'ext1', version: '1.0.0' }, - contextFiles: [], - installMetadata: { - source: 'test', - type: 'local', - autoUpdate: true, - }, - }, - { - path: '/path/to/ext2', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - installMetadata: { - source: 'test', - type: 'local', - autoUpdate: false, - }, - }, - { - path: '/path/to/ext3', - config: { name: 'ext3', version: '1.0.0' }, - contextFiles: [], - }, - ]; - const activeExtensions = annotateActiveExtensions( - extensionsWithAutoUpdate, - tempHomeDir, - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), - ); - expect( - activeExtensions.find((e) => e.name === 'ext1')?.installMetadata - ?.autoUpdate, - ).toBe(true); - expect( - activeExtensions.find((e) => e.name === 'ext2')?.installMetadata - ?.autoUpdate, - ).toBe(false); - expect( - activeExtensions.find((e) => e.name === 'ext3')?.installMetadata - ?.autoUpdate, - ).toBe(undefined); - }); - }); - }); - - describe('installExtension', () => { - it('should install an extension from a local path', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - await installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: sourceExtDir, - type: 'local', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should throw an error if the extension already exists', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - await installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), - ).rejects.toThrow( - 'Extension "my-local-extension" is already installed. Please uninstall it first.', - ); - }); - - it('should throw an error and cleanup if qwen-extension.json is missing', async () => { - const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); - fs.mkdirSync(sourceExtDir, { recursive: true }); - const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), - ).rejects.toThrow(`Configuration file not found at ${configPath}`); - - const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); - expect(fs.existsSync(targetExtDir)).toBe(false); - }); - - it('should throw an error for invalid JSON in qwen-extension.json', async () => { - const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext'); - fs.mkdirSync(sourceExtDir, { recursive: true }); - const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); - fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), - ).rejects.toThrow( - new RegExp( - `^Failed to load extension config from ${configPath.replace( - /\\/g, - '\\\\', - )}`, - ), - ); - }); - - it('should throw an error for missing name in qwen-extension.json', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'missing-name-ext', - version: '1.0.0', - }); - const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); - // Overwrite with invalid config - fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ), - ).rejects.toThrow( - `Invalid configuration in ${configPath}: missing "name"`, - ); - }); - - it('should install an extension from a git URL', async () => { - const gitUrl = 'https://github.com/google/gemini-extensions.git'; - const extensionName = 'qwen-extensions'; - const targetExtDir = path.join(userExtensionsDir, extensionName); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { - recursive: true, - }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.0.0' }), - ); - }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - await installExtension( - { source: gitUrl, type: 'git' }, - async (_) => true, - ); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: gitUrl, - type: 'git', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should install a linked extension', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-linked-extension', - version: '1.0.0', - }); - const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); - - await installExtension( - { source: sourceExtDir, type: 'link' }, - async (_) => true, - ); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - - expect(fs.existsSync(configPath)).toBe(false); - - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: sourceExtDir, - type: 'link', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should log to clearcut on successful install', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - - await installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); - - expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); - }); - - it('should show users information on their mcp server when installing', async () => { - const consoleInfoSpy = vi.spyOn(console, 'info'); - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - description: 'a local mcp server', - }, - 'test-server-2': { - description: 'a remote mcp server', - httpUrl: 'https://google.com', - }, - }, - }); - - mockQuestion.mockImplementation((_query, callback) => callback('y')); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - requestConsentNonInteractive, - ), - ).resolves.toBe('my-local-extension'); - - expect(consoleInfoSpy).toHaveBeenCalledWith( - `Installing extension "my-local-extension". -**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.** -This extension will run the following MCP servers: - * test-server (local): node server.js - * test-server-2 (remote): https://google.com`, - ); - }); - - it('should continue installation if user accepts prompt for local extension with mcp servers', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - }, - }, - }); - - mockQuestion.mockImplementation((_query, callback) => callback('y')); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - requestConsentNonInteractive, - ), - ).resolves.toBe('my-local-extension'); - - expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? [Y/n]: '), - expect.any(Function), - ); - }); - - it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - }, - }, - }); - - mockQuestion.mockImplementation((_query, callback) => callback('n')); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - requestConsentNonInteractive, - ), - ).rejects.toThrow('Installation cancelled for "my-local-extension".'); - - expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? [Y/n]: '), - expect.any(Function), - ); - }); - - it('should save the autoUpdate flag to the install metadata', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - await installExtension( - { - source: sourceExtDir, - type: 'local', - autoUpdate: true, - }, - async (_) => true, - ); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: sourceExtDir, - type: 'local', - autoUpdate: true, - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should ignore consent flow if not required', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - }, - }, - }); - - const mockRequestConsent = vi.fn(); - - await expect( - installExtension( - { source: sourceExtDir, type: 'local' }, - mockRequestConsent, - process.cwd(), - // Provide its own existing config as the previous config. - await loadExtensionConfig({ - extensionDir: sourceExtDir, - workspaceDir: process.cwd(), - }), - ), - ).resolves.toBe('my-local-extension'); - - expect(mockRequestConsent).not.toHaveBeenCalled(); - }); - - it('should throw an error for invalid extension names', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'bad_name', - version: '1.0.0', - }); - - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).rejects.toThrow('Invalid extension name: "bad_name"'); - }); - }); - - describe('uninstallExtension', () => { - it('should uninstall an extension by name', async () => { - const sourceExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', - }); - - await uninstallExtension('my-local-extension'); - - expect(fs.existsSync(sourceExtDir)).toBe(false); - }); - - it('should uninstall an extension by name and retain existing extensions', async () => { - const sourceExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', - }); - const otherExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'other-extension', - version: '1.0.0', - }); - - await uninstallExtension('my-local-extension'); - - expect(fs.existsSync(sourceExtDir)).toBe(false); - expect( - loadExtensions( - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), - ), - ).toHaveLength(1); - expect(fs.existsSync(otherExtDir)).toBe(true); - }); - - it('should throw an error if the extension does not exist', async () => { - await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Extension not found.', - ); - }); - - it('should log uninstall event', async () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', - }); - - await uninstallExtension('my-local-extension'); - - expect(mockLogExtensionUninstall).toHaveBeenCalled(); - expect(ExtensionUninstallEvent).toHaveBeenCalledWith( - 'my-local-extension', - 'success', - ); - }); - - it('should uninstall an extension by its source URL', async () => { - const gitUrl = 'https://github.com/google/gemini-sql-extension.git'; - const sourceExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'gemini-sql-extension', - version: '1.0.0', - installMetadata: { - source: gitUrl, - type: 'git', - }, - }); - - await uninstallExtension(gitUrl); - - expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(mockLogExtensionUninstall).toHaveBeenCalled(); - expect(ExtensionUninstallEvent).toHaveBeenCalledWith( - 'gemini-sql-extension', - 'success', - ); - }); - - it('should fail to uninstall by URL if an extension has no install metadata', async () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'no-metadata-extension', - version: '1.0.0', - // No installMetadata provided - }); - - await expect( - uninstallExtension('https://github.com/google/no-metadata-extension'), - ).rejects.toThrow('Extension not found.'); - }); - }); - - describe('performWorkspaceExtensionMigration', () => { - let workspaceExtensionsDir: string; - - beforeEach(() => { - workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(workspaceExtensionsDir, { recursive: true, force: true }); - }); - - describe('folder trust', () => { - it('refuses to install extensions from untrusted folders', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - const failed = await performWorkspaceExtensionMigration([ - loadExtension({ - extensionDir: ext1Path, - workspaceDir: tempWorkspaceDir, - })!, - ]); - - expect(failed).toEqual(['ext1']); - }); - - it('does not copy extensions to the user dir', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - await performWorkspaceExtensionMigration( - [ - loadExtension({ - extensionDir: ext1Path, - workspaceDir: tempWorkspaceDir, - })!, - ], - async (_) => true, - ); - - const userExtensionsDir = path.join( - tempHomeDir, - QWEN_DIR, - 'extensions', - ); - expect(fs.readdirSync(userExtensionsDir).length).toBe(0); - }); - - it('does not load any extensions in the workspace config', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - await performWorkspaceExtensionMigration( - [ - loadExtension({ - extensionDir: ext1Path, - workspaceDir: tempWorkspaceDir, - })!, - ], - async (_) => true, - ); - const extensions = loadExtensions( - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ), - ); - - expect(extensions).toEqual([]); - }); - }); - - it('should install the extensions in the user directory', async () => { - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - const ext2Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext2', - version: '1.0.0', - }); - const extensionsToMigrate: Extension[] = [ - loadExtension({ - extensionDir: ext1Path, - workspaceDir: tempWorkspaceDir, - })!, - loadExtension({ - extensionDir: ext2Path, - workspaceDir: tempWorkspaceDir, - })!, - ]; - const failed = await performWorkspaceExtensionMigration( - extensionsToMigrate, - async (_) => true, - ); - - expect(failed).toEqual([]); - - const userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions'); - const userExt1Path = path.join(userExtensionsDir, 'ext1'); - const extensions = loadExtensions( - new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), - ); - - expect(extensions).toHaveLength(2); - const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: ext1Path, - type: 'local', - }); - }); - - it('should return the names of failed installations', async () => { - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - const extensions: Extension[] = [ - loadExtension({ - extensionDir: ext1Path, - workspaceDir: tempWorkspaceDir, - })!, - { - path: '/ext/path/1', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - }, - ]; - - const failed = await performWorkspaceExtensionMigration( - extensions, - async (_) => true, - ); - expect(failed).toEqual(['ext2']); - }); - }); - - describe('disableExtension', () => { - it('should disable an extension at the user scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-extension', - version: '1.0.0', - }); - - disableExtension('my-extension', SettingScope.User); - expect( - isEnabled({ - name: 'my-extension', - configDir: userExtensionsDir, - enabledForPath: tempWorkspaceDir, - }), - ).toBe(false); - }); - - it('should disable an extension at the workspace scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-extension', - version: '1.0.0', - }); - - disableExtension( - 'my-extension', - SettingScope.Workspace, - tempWorkspaceDir, - ); - expect( - isEnabled({ - name: 'my-extension', - configDir: userExtensionsDir, - enabledForPath: tempHomeDir, - }), - ).toBe(true); - expect( - isEnabled({ - name: 'my-extension', - configDir: userExtensionsDir, - enabledForPath: tempWorkspaceDir, - }), - ).toBe(false); - }); - - it('should handle disabling the same extension twice', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-extension', - version: '1.0.0', - }); - - disableExtension('my-extension', SettingScope.User); - disableExtension('my-extension', SettingScope.User); - expect( - isEnabled({ - name: 'my-extension', - configDir: userExtensionsDir, - enabledForPath: tempWorkspaceDir, - }), - ).toBe(false); - }); - - it('should throw an error if you request system scope', () => { - expect(() => - disableExtension('my-extension', SettingScope.System), - ).toThrow('System and SystemDefaults scopes are not supported.'); - }); - - it('should log a disable event', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - disableExtension('ext1', SettingScope.Workspace); - - expect(mockLogExtensionDisable).toHaveBeenCalled(); - expect(ExtensionDisableEvent).toHaveBeenCalledWith( - 'ext1', - SettingScope.Workspace, - ); - }); - }); - - describe('enableExtension', () => { - afterAll(() => { - vi.restoreAllMocks(); - }); - - const getActiveExtensions = (): GeminiCLIExtension[] => { - const manager = new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - ); - const extensions = loadExtensions(manager); - const activeExtensions = annotateActiveExtensions( - extensions, - tempWorkspaceDir, - manager, - ); - return activeExtensions.filter((e) => e.isActive); - }; - - it('should enable an extension at the user scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - disableExtension('ext1', SettingScope.User); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - enableExtension('ext1', SettingScope.User); - activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext1'); - }); - - it('should enable an extension at the workspace scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - disableExtension('ext1', SettingScope.Workspace); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - enableExtension('ext1', SettingScope.Workspace); - activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext1'); - }); - - it('should log an enable event', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - disableExtension('ext1', SettingScope.Workspace); - enableExtension('ext1', SettingScope.Workspace); - - expect(mockLogExtensionEnable).toHaveBeenCalled(); - expect(ExtensionEnableEvent).toHaveBeenCalledWith( - 'ext1', - SettingScope.Workspace, - ); + expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR'); + expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); }); }); - -function isEnabled(options: { - name: string; - configDir: string; - enabledForPath: string; -}) { - const manager = new ExtensionEnablementManager(options.configDir); - return manager.isEnabled(options.name, options.enabledForPath); -} diff --git a/packages/core/src/extension/extensionSettings.test.ts b/packages/core/src/extension/extensionSettings.test.ts index 4d185f896..3705be5bd 100644 --- a/packages/core/src/extension/extensionSettings.test.ts +++ b/packages/core/src/extension/extensionSettings.test.ts @@ -27,14 +27,19 @@ vi.mock('os', async (importOriginal) => { }; }); -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - KeychainTokenStorage: vi.fn(), - }; -}); +vi.mock( + '../mcp/token-storage/keychain-token-storage.js', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../mcp/token-storage/keychain-token-storage.js') + >(); + return { + ...actual, + KeychainTokenStorage: vi.fn(), + }; + }, +); describe('extensionSettings', () => { let tempHomeDir: string; diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 77cfd1927..d135f3e28 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -19,8 +19,11 @@ import * as fsSync from 'node:fs'; import * as path from 'node:path'; import * as tar from 'tar'; import * as archiver from 'archiver'; -import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core'; -import type { ExtensionUpdateState } from './update.js'; +import { + ExtensionUpdateState, + type Extension, + type ExtensionManager, +} from './extensionManager.js'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockArch = vi.hoisted(() => vi.fn()); @@ -123,116 +126,108 @@ describe('git extension helpers', () => { revparse: vi.fn(), }; + const mockExtensionManager = { + loadExtensionConfig: vi.fn(), + } as unknown as ExtensionManager; + beforeEach(() => { vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); }); - it('should return NOT_UPDATABLE for non-git extensions', async () => { - const extension: GeminiCLIExtension = { + function createExtension(overrides: Partial = {}): Extension { + return { + id: 'test-id', name: 'test', path: '/ext', version: '1.0.0', isActive: true, + config: { name: 'test', version: '1.0.0' }, + contextFiles: [], + ...overrides, + }; + } + + it('should return NOT_UPDATABLE for non-git extensions', async () => { + const extension = createExtension({ installMetadata: { type: 'link', source: '', }, - }; - let result: ExtensionUpdateState | undefined = undefined; - await checkForExtensionUpdate( + }); + const result = await checkForExtensionUpdate( extension, - (newState) => (result = newState), + mockExtensionManager, ); expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); }); it('should return ERROR if no remotes found', async () => { - const extension: GeminiCLIExtension = { - name: 'test', - path: '/ext', - version: '1.0.0', - isActive: true, + const extension = createExtension({ installMetadata: { type: 'git', source: '', }, - }; + }); mockGit.getRemotes.mockResolvedValue([]); - let result: ExtensionUpdateState | undefined = undefined; - await checkForExtensionUpdate( + const result = await checkForExtensionUpdate( extension, - (newState) => (result = newState), + mockExtensionManager, ); expect(result).toBe(ExtensionUpdateState.ERROR); }); it('should return UPDATE_AVAILABLE when remote hash is different', async () => { - const extension: GeminiCLIExtension = { - name: 'test', - path: '/ext', - version: '1.0.0', - isActive: true, + const extension = createExtension({ installMetadata: { type: 'git', source: 'my/ext', }, - }; + }); mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, ]); mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); mockGit.revparse.mockResolvedValue('local-hash'); - let result: ExtensionUpdateState | undefined = undefined; - await checkForExtensionUpdate( + const result = await checkForExtensionUpdate( extension, - (newState) => (result = newState), + mockExtensionManager, ); expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); it('should return UP_TO_DATE when remote and local hashes are the same', async () => { - const extension: GeminiCLIExtension = { - name: 'test', - path: '/ext', - version: '1.0.0', - isActive: true, + const extension = createExtension({ installMetadata: { type: 'git', source: 'my/ext', }, - }; + }); mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, ]); mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); mockGit.revparse.mockResolvedValue('same-hash'); - let result: ExtensionUpdateState | undefined = undefined; - await checkForExtensionUpdate( + const result = await checkForExtensionUpdate( extension, - (newState) => (result = newState), + mockExtensionManager, ); expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); }); it('should return ERROR on git error', async () => { - const extension: GeminiCLIExtension = { - name: 'test', - path: '/ext', - version: '1.0.0', - isActive: true, + const extension = createExtension({ installMetadata: { type: 'git', source: 'my/ext', }, - }; + }); mockGit.getRemotes.mockRejectedValue(new Error('git error')); - let result: ExtensionUpdateState | undefined = undefined; - await checkForExtensionUpdate( + const result = await checkForExtensionUpdate( extension, - (newState) => (result = newState), + mockExtensionManager, ); expect(result).toBe(ExtensionUpdateState.ERROR); }); diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index db2f0e80e..22d7d6f61 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -6,7 +6,6 @@ import { simpleGit } from 'simple-git'; import { getErrorMessage } from '../utils/errors.js'; -import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; import * as os from 'node:os'; import * as https from 'node:https'; import * as fs from 'node:fs'; @@ -20,6 +19,7 @@ import { type ExtensionConfig, type ExtensionManager, } from './extensionManager.js'; +import type { ExtensionInstallMetadata } from '../config/config.js'; interface GithubReleaseData { assets: Asset[]; diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 0f90a5e5e..ab3351546 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -22,7 +22,7 @@ import { type ClaudePluginConfig, } from './claude-converter.js'; import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; -import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; +import type { ExtensionInstallMetadata } from '../config/config.js'; export interface MarketplaceInstallOptions { marketplaceUrl: string; diff --git a/packages/core/src/extension/storage.test.ts b/packages/core/src/extension/storage.test.ts index 4f392f94e..7f0b7df0e 100644 --- a/packages/core/src/extension/storage.test.ts +++ b/packages/core/src/extension/storage.test.ts @@ -13,7 +13,7 @@ import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, } from './variables.js'; -import { Storage } from '@qwen-code/qwen-code-core'; +import { Storage } from '../config/storage.js'; vi.mock('node:os'); vi.mock('node:fs', async (importOriginal) => { diff --git a/packages/core/src/extension/storage.ts b/packages/core/src/extension/storage.ts index fec7b38d4..30d95b34e 100644 --- a/packages/core/src/extension/storage.ts +++ b/packages/core/src/extension/storage.ts @@ -1,4 +1,4 @@ -import { Storage } from '@qwen-code/qwen-code-core'; +import { Storage } from '../config/storage.js'; import path from 'node:path'; import * as os from 'node:os'; import { diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts index f905eea60..ccac1c65f 100644 --- a/packages/core/src/extension/variables.ts +++ b/packages/core/src/extension/variables.ts @@ -6,7 +6,7 @@ import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import path from 'node:path'; -import { QWEN_DIR } from '@qwen-code/qwen-code-core'; +import { QWEN_DIR } from '../config/storage.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb14dc610..4f40a8a07 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,8 @@ export * from './utils/subagentGenerator.js'; export * from './utils/projectSummary.js'; export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; +export * from './utils/toml-to-markdown-converter.js'; +export * from './utils/yaml-parser.js'; // Export services export * from './services/fileDiscoveryService.js';