diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 17a41d8d9..bb18392bc 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -4,30 +4,51 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, type MockInstance } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; const mockInstallExtension = vi.hoisted(() => vi.fn()); +const mockRefreshCache = vi.hoisted(() => vi.fn()); +const mockParseInstallSource = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); -const mockStat = vi.hoisted(() => vi.fn()); +const mockRequestConsentOrFail = vi.hoisted(() => vi.fn()); +const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); +const mockLoadSettings = vi.hoisted(() => vi.fn()); -vi.mock('../../config/extension.js', () => ({ - installExtension: mockInstallExtension, +vi.mock('@qwen-code/qwen-code-core', () => ({ + ExtensionManager: vi.fn().mockImplementation(() => ({ + installExtension: mockInstallExtension, + refreshCache: mockRefreshCache, + })), + parseInstallSource: mockParseInstallSource, +})); + +vi.mock('./consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, + requestConsentOrFail: mockRequestConsentOrFail, +})); + +vi.mock('../../config/trustedFolders.js', () => ({ + isWorkspaceTrusted: mockIsWorkspaceTrusted, +})); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, })); vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); -vi.mock('node:fs/promises', () => ({ - stat: mockStat, - default: { - stat: mockStat, - }, -})); - describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]) @@ -51,17 +72,21 @@ describe('handleInstall', () => { processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); + mockRefreshCache.mockResolvedValue(undefined); + mockLoadSettings.mockReturnValue({ merged: {} }); + mockIsWorkspaceTrusted.mockReturnValue(true); }); afterEach(() => { - mockInstallExtension.mockClear(); - mockRequestConsentNonInteractive.mockClear(); - mockStat.mockClear(); - vi.resetAllMocks(); + vi.clearAllMocks(); }); it('should install an extension from a http source', async () => { - mockInstallExtension.mockResolvedValue('http-extension'); + mockParseInstallSource.mockResolvedValue({ + type: 'http', + url: 'http://google.com', + }); + mockInstallExtension.mockResolvedValue({ name: 'http-extension' }); await handleInstall({ source: 'http://google.com', @@ -73,7 +98,11 @@ describe('handleInstall', () => { }); it('should install an extension from a https source', async () => { - mockInstallExtension.mockResolvedValue('https-extension'); + mockParseInstallSource.mockResolvedValue({ + type: 'https', + url: 'https://google.com', + }); + mockInstallExtension.mockResolvedValue({ name: 'https-extension' }); await handleInstall({ source: 'https://google.com', @@ -85,7 +114,11 @@ describe('handleInstall', () => { }); it('should install an extension from a git source', async () => { - mockInstallExtension.mockResolvedValue('git-extension'); + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'git-extension' }); await handleInstall({ source: 'git@some-url', @@ -97,7 +130,9 @@ describe('handleInstall', () => { }); it('throws an error from an unknown source', async () => { - mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory')); + mockParseInstallSource.mockRejectedValue( + new Error('Install source not found.'), + ); await handleInstall({ source: 'test://google.com', }); @@ -107,7 +142,11 @@ describe('handleInstall', () => { }); it('should install an extension from a sso source', async () => { - mockInstallExtension.mockResolvedValue('sso-extension'); + mockParseInstallSource.mockResolvedValue({ + type: 'sso', + url: 'sso://google.com', + }); + mockInstallExtension.mockResolvedValue({ name: 'sso-extension' }); await handleInstall({ source: 'sso://google.com', @@ -119,8 +158,12 @@ describe('handleInstall', () => { }); it('should install an extension from a local path', async () => { - mockInstallExtension.mockResolvedValue('local-extension'); - mockStat.mockResolvedValue({}); + mockParseInstallSource.mockResolvedValue({ + type: 'local', + path: '/some/path', + }); + mockInstallExtension.mockResolvedValue({ name: 'local-extension' }); + await handleInstall({ source: '/some/path', }); @@ -131,6 +174,10 @@ describe('handleInstall', () => { }); it('should throw an error if install extension fails', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); mockInstallExtension.mockRejectedValue( new Error('Install extension failed'), ); diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index 357235b5f..94c85f656 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -7,11 +7,16 @@ import yargs from 'yargs'; import { addCommand } from './add.js'; import { loadSettings, SettingScope } from '../../config/settings.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), -})); +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(), + writeFile: vi.fn(), + }; +}); vi.mock('os', () => { const homedir = vi.fn(() => '/home/user'); diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index bf186c729..438dcad59 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -7,19 +7,15 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings } from '../../config/settings.js'; -import { loadExtensions } from '../../config/extension.js'; -import { ExtensionStorage } from '../../config/extensions/storage.js'; -import { createTransport } from '@qwen-code/qwen-code-core'; +import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; +import { createTransport, ExtensionManager } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; vi.mock('../../config/settings.js', () => ({ loadSettings: vi.fn(), })); -vi.mock('../../config/extension.js', () => ({ - loadExtensions: vi.fn(), - ExtensionStorage: { - getUserExtensionsDir: vi.fn(), - }, +vi.mock('../../config/trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), })); vi.mock('@qwen-code/qwen-code-core', () => ({ createTransport: vi.fn(), @@ -28,20 +24,15 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', }, - Storage: vi.fn().mockImplementation((_cwd: string) => ({ - getGlobalSettingsPath: () => '/tmp/qwen/settings.json', - getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json', - getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash', - })), - QWEN_CONFIG_DIR: '.qwen', + ExtensionManager: vi.fn(), getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), })); vi.mock('@modelcontextprotocol/sdk/client/index.js'); -const mockedExtensionStorage = ExtensionStorage as vi.Mock; const mockedLoadSettings = loadSettings as vi.Mock; -const mockedLoadExtensions = loadExtensions as vi.Mock; +const mockedIsWorkspaceTrusted = isWorkspaceTrusted as vi.Mock; const mockedCreateTransport = createTransport as vi.Mock; +const MockedExtensionManager = ExtensionManager as vi.Mock; const MockedClient = Client as vi.Mock; interface MockClient { @@ -58,6 +49,10 @@ describe('mcp list command', () => { let consoleSpy: vi.SpyInstance; let mockClient: MockClient; let mockTransport: MockTransport; + let mockExtensionManager: { + refreshCache: vi.Mock; + getLoadedExtensions: vi.Mock; + }; beforeEach(() => { vi.resetAllMocks(); @@ -71,12 +66,15 @@ describe('mcp list command', () => { close: vi.fn(), }; + mockExtensionManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + getLoadedExtensions: vi.fn().mockReturnValue([]), + }; + MockedClient.mockImplementation(() => mockClient); mockedCreateTransport.mockResolvedValue(mockTransport); - mockedLoadExtensions.mockReturnValue([]); - mockedExtensionStorage.getUserExtensionsDir.mockReturnValue( - '/mocked/extensions/dir', - ); + MockedExtensionManager.mockImplementation(() => mockExtensionManager); + mockedIsWorkspaceTrusted.mockReturnValue(true); }); afterEach(() => { @@ -152,8 +150,9 @@ describe('mcp list command', () => { }, }); - mockedLoadExtensions.mockReturnValue([ + mockExtensionManager.getLoadedExtensions.mockReturnValue([ { + isActive: true, config: { name: 'test-extension', mcpServers: { 'extension-server': { command: '/ext/server' } }, diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts index eb7dedce5..0f3847e40 100644 --- a/packages/cli/src/commands/mcp/remove.test.ts +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -9,10 +9,14 @@ import yargs from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { removeCommand } from './remove.js'; -vi.mock('fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), -})); +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(), + writeFile: vi.fn(), + }; +}); vi.mock('../../config/settings.js', async () => { const actual = await vi.importActual('../../config/settings.js'); diff --git a/packages/cli/src/services/FileCommandLoader-extension.test.ts b/packages/cli/src/services/FileCommandLoader-extension.test.ts index 38af0865d..7f5790921 100644 --- a/packages/cli/src/services/FileCommandLoader-extension.test.ts +++ b/packages/cli/src/services/FileCommandLoader-extension.test.ts @@ -4,324 +4,340 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import * as path from 'node:path'; -import * as os from 'node:os'; +import mock from 'mock-fs'; import { FileCommandLoader } from './FileCommandLoader.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { Storage } from '@qwen-code/qwen-code-core'; describe('FileCommandLoader - Extension Commands Support', () => { - let tempDir: string; - let mockConfig: Partial; + const projectRoot = '/test/project'; + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = path.join(projectRoot, '.qwen', 'commands'); - beforeEach(async () => { - tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'file-command-loader-ext-test-'), - ); - - mockConfig = { - getFolderTrustFeature: () => false, - getFolderTrust: () => true, - getProjectRoot: () => tempDir, - storage: new Storage(tempDir), - getExtensions: () => [], - }; - }); - - afterEach(async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + afterEach(() => { + mock.restore(); }); it('should load commands from extension with config.commands path', async () => { - // Setup extension structure - const extensionDir = path.join(tempDir, '.qwen', 'extensions', 'test-ext'); - const customCommandsDir = path.join(extensionDir, 'custom-cmds'); - await fs.promises.mkdir(customCommandsDir, { recursive: true }); + const extensionDir = path.join( + projectRoot, + '.qwen', + 'extensions', + 'test-ext', + ); - // Create extension config with custom commands path const extensionConfig = { name: 'test-ext', version: '1.0.0', commands: 'custom-cmds', }; - await fs.promises.writeFile( - path.join(extensionDir, 'qwen-extension.json'), - JSON.stringify(extensionConfig), - ); - // Create a test command in custom directory - const commandContent = - '---\ndescription: Test command from extension\n---\nDo something'; - await fs.promises.writeFile( - path.join(customCommandsDir, 'test.md'), - commandContent, - ); - - // 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: [], + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [extensionDir]: { + 'qwen-extension.json': JSON.stringify(extensionConfig), + 'custom-cmds': { + 'test.md': + '---\ndescription: Test command from extension\n---\nDo something', + }, }, - ]; + }); - const loader = new FileCommandLoader(mockConfig as Config); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + id: 'test-ext', + config: extensionConfig, + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + contextFiles: [], + }, + ]), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('test-ext:test'); + expect(commands[0].name).toBe('test'); + expect(commands[0].extensionName).toBe('test-ext'); expect(commands[0].description).toBe( '[test-ext] Test command from extension', ); }); it('should load commands from extension with multiple commands paths', async () => { - // Setup extension structure - const extensionDir = path.join(tempDir, '.qwen', 'extensions', 'multi-ext'); - const cmdsDir1 = path.join(extensionDir, 'commands1'); - const cmdsDir2 = path.join(extensionDir, 'commands2'); - await fs.promises.mkdir(cmdsDir1, { recursive: true }); - await fs.promises.mkdir(cmdsDir2, { recursive: true }); + const extensionDir = path.join( + projectRoot, + '.qwen', + 'extensions', + 'multi-ext', + ); - // Create extension config with multiple commands paths const extensionConfig = { name: 'multi-ext', version: '1.0.0', commands: ['commands1', 'commands2'], }; - await fs.promises.writeFile( - path.join(extensionDir, 'qwen-extension.json'), - JSON.stringify(extensionConfig), - ); - // Create test commands in both directories - await fs.promises.writeFile( - path.join(cmdsDir1, 'cmd1.md'), - '---\n---\nCommand 1', - ); - await fs.promises.writeFile( - path.join(cmdsDir2, 'cmd2.md'), - '---\n---\nCommand 2', - ); - - // Mock config to return the extension - mockConfig.getExtensions = () => [ - { - id: 'multi-ext', - config: extensionConfig, - contextFiles: [], - name: 'multi-ext', - version: '1.0.0', - isActive: true, - path: extensionDir, + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [extensionDir]: { + 'qwen-extension.json': JSON.stringify(extensionConfig), + commands1: { + 'cmd1.md': '---\n---\nCommand 1', + }, + commands2: { + 'cmd2.md': '---\n---\nCommand 2', + }, }, - ]; + }); - const loader = new FileCommandLoader(mockConfig as Config); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + id: 'multi-ext', + config: extensionConfig, + contextFiles: [], + name: 'multi-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(2); const commandNames = commands.map((c) => c.name).sort(); - expect(commandNames).toEqual(['multi-ext:cmd1', 'multi-ext:cmd2']); + expect(commandNames).toEqual(['cmd1', 'cmd2']); + expect(commands.every((c) => c.extensionName === 'multi-ext')).toBe(true); }); it('should fallback to default "commands" directory when config.commands not specified', async () => { - // Setup extension structure with default commands directory const extensionDir = path.join( - tempDir, + projectRoot, '.qwen', 'extensions', 'default-ext', ); - const defaultCommandsDir = path.join(extensionDir, 'commands'); - await fs.promises.mkdir(defaultCommandsDir, { recursive: true }); - // Create extension config without commands field const extensionConfig = { name: 'default-ext', version: '1.0.0', }; - await fs.promises.writeFile( - path.join(extensionDir, 'qwen-extension.json'), - JSON.stringify(extensionConfig), - ); - // Create a test command in default directory - await fs.promises.writeFile( - path.join(defaultCommandsDir, 'default.md'), - '---\n---\nDefault command', - ); - - // Mock config to return the extension - mockConfig.getExtensions = () => [ - { - id: 'default-ext', - config: extensionConfig, - contextFiles: [], - name: 'default-ext', - version: '1.0.0', - isActive: true, - path: extensionDir, + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [extensionDir]: { + 'qwen-extension.json': JSON.stringify(extensionConfig), + commands: { + 'default.md': '---\n---\nDefault command', + }, }, - ]; + }); - const loader = new FileCommandLoader(mockConfig as Config); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + id: 'default-ext', + config: extensionConfig, + contextFiles: [], + name: 'default-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('default-ext:default'); + expect(commands[0].name).toBe('default'); + expect(commands[0].extensionName).toBe('default-ext'); }); it('should handle extension without commands directory gracefully', async () => { - // Setup extension structure without commands directory const extensionDir = path.join( - tempDir, + projectRoot, '.qwen', 'extensions', 'no-cmds-ext', ); - await fs.promises.mkdir(extensionDir, { recursive: true }); - // Create extension config const extensionConfig = { name: 'no-cmds-ext', version: '1.0.0', }; - await fs.promises.writeFile( - path.join(extensionDir, 'qwen-extension.json'), - JSON.stringify(extensionConfig), - ); - // 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, - path: extensionDir, + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [extensionDir]: { + 'qwen-extension.json': JSON.stringify(extensionConfig), + // No commands directory }, - ]; + }); - const loader = new FileCommandLoader(mockConfig as Config); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + id: 'no-cmds-ext', + config: extensionConfig, + contextFiles: [], + name: 'no-cmds-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); // Should not throw and return empty array expect(commands).toHaveLength(0); }); - it('should prefix extension commands with extension name', async () => { - // Setup extension + it('should set extensionName property for extension commands', async () => { const extensionDir = path.join( - tempDir, + projectRoot, '.qwen', 'extensions', 'prefix-ext', ); - const commandsDir = path.join(extensionDir, 'commands'); - await fs.promises.mkdir(commandsDir, { recursive: true }); const extensionConfig = { name: 'prefix-ext', version: '1.0.0', }; - await fs.promises.writeFile( - path.join(extensionDir, 'qwen-extension.json'), - JSON.stringify(extensionConfig), - ); - await fs.promises.writeFile( - path.join(commandsDir, 'mycommand.md'), - '---\n---\nMy command', - ); - - mockConfig.getExtensions = () => [ - { - id: 'prefix-ext', - config: extensionConfig, - contextFiles: [], - name: 'prefix-ext', - version: '1.0.0', - isActive: true, - path: extensionDir, + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [extensionDir]: { + 'qwen-extension.json': JSON.stringify(extensionConfig), + commands: { + 'mycommand.md': '---\n---\nMy command', + }, }, - ]; + }); - const loader = new FileCommandLoader(mockConfig as Config); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + id: 'prefix-ext', + config: extensionConfig, + contextFiles: [], + name: 'prefix-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('prefix-ext:mycommand'); + expect(commands[0].name).toBe('mycommand'); + expect(commands[0].extensionName).toBe('prefix-ext'); + expect(commands[0].description).toMatch(/^\[prefix-ext\]/); }); it('should load commands from multiple extensions in alphabetical order', async () => { - // Setup two extensions - const ext1Dir = path.join(tempDir, '.qwen', 'extensions', 'ext-b'); - const ext2Dir = path.join(tempDir, '.qwen', 'extensions', 'ext-a'); + const ext1Dir = path.join(projectRoot, '.qwen', 'extensions', 'ext-b'); + const ext2Dir = path.join(projectRoot, '.qwen', 'extensions', 'ext-a'); - await fs.promises.mkdir(path.join(ext1Dir, 'commands'), { - recursive: true, - }); - await fs.promises.mkdir(path.join(ext2Dir, 'commands'), { - recursive: true, + mock({ + [userCommandsDir]: {}, + [projectCommandsDir]: {}, + [ext1Dir]: { + 'qwen-extension.json': JSON.stringify({ + name: 'ext-b', + version: '1.0.0', + }), + commands: { + 'cmd.md': '---\n---\nCommand B', + }, + }, + [ext2Dir]: { + 'qwen-extension.json': JSON.stringify({ + name: 'ext-a', + version: '1.0.0', + }), + commands: { + 'cmd.md': '---\n---\nCommand A', + }, + }, }); - // Extension B - await fs.promises.writeFile( - path.join(ext1Dir, 'qwen-extension.json'), - JSON.stringify({ name: 'ext-b', version: '1.0.0' }), - ); - await fs.promises.writeFile( - path.join(ext1Dir, 'commands', 'cmd.md'), - '---\n---\nCommand B', - ); + const mockConfig = { + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => true), + getProjectRoot: vi.fn(() => projectRoot), + storage: new Storage(projectRoot), + getExtensions: vi.fn(() => [ + { + 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, + }, + ]), + } as unknown as Config; - // Extension A - await fs.promises.writeFile( - path.join(ext2Dir, 'qwen-extension.json'), - JSON.stringify({ name: 'ext-a', version: '1.0.0' }), - ); - await fs.promises.writeFile( - path.join(ext2Dir, 'commands', 'cmd.md'), - '---\n---\nCommand A', - ); - - mockConfig.getExtensions = () => [ - { - 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); + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(2); // Extensions are sorted alphabetically, so ext-a comes before ext-b - expect(commands[0].name).toBe('ext-a:cmd'); - expect(commands[1].name).toBe('ext-b:cmd'); + expect(commands[0].extensionName).toBe('ext-a'); + expect(commands[1].extensionName).toBe('ext-b'); }); }); diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index e9c491274..a791f4236 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -568,9 +568,9 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(3); const commandNames = commands.map((cmd) => cmd.name); - expect(commandNames).toEqual(['user', 'project', 'test-ext:ext']); + expect(commandNames).toEqual(['user', 'project', 'ext']); - const extCommand = commands.find((cmd) => cmd.name === 'test-ext:ext'); + const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); expect(extCommand?.description).toMatch(/^\[test-ext\]/); }); @@ -656,14 +656,14 @@ describe('FileCommandLoader', () => { expect(result1.content).toEqual([{ text: 'Project deploy command' }]); } - expect(commands[2].name).toBe('test-ext:deploy'); + expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); expect(commands[2].description).toMatch(/^\[test-ext\]/); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { - raw: '/test-ext:deploy', - name: 'test-ext:deploy', + raw: '/test-ext.deploy', + name: 'test-ext.deploy', args: '', }, }), @@ -729,7 +729,7 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('active-ext:active'); + expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); expect(commands[0].description).toMatch(/^\[active-ext\]/); }); @@ -803,17 +803,17 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(3); const commandNames = commands.map((cmd) => cmd.name).sort(); - expect(commandNames).toEqual(['a:b:c', 'a:b:d:e', 'a:simple']); + expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']); - const nestedCmd = commands.find((cmd) => cmd.name === 'a:b:c'); + const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); expect(nestedCmd?.description).toMatch(/^\[a\]/); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ invocation: { - raw: '/a:b:c', - name: 'a:b:c', + raw: '/a.b:c', + name: 'a.b:c', args: '', }, }), diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts index a752d0731..dcc642890 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -35,7 +35,10 @@ vi.mock('node:fs', async () => { vi.mock('node:fs/promises', async () => { const memfs = await vi.importActual('memfs'); - return memfs.fs.promises; + return { + ...memfs.fs.promises, + default: memfs.fs.promises, + }; }); const CWD = '/test/project'; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 449add116..f50a209f6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -75,7 +75,9 @@ vi.mock('../tools/tool-registry', () => { }); vi.mock('../utils/memoryDiscovery.js', () => ({ - loadServerHierarchicalMemory: vi.fn(), + loadServerHierarchicalMemory: vi + .fn() + .mockResolvedValue({ memoryContent: '', fileCount: 0 }), })); // Mock individual tools if their constructors are complex or have side effects @@ -1303,7 +1305,10 @@ describe('BaseLlmClient Lifecycle', () => { const authType = AuthType.USE_GEMINI; const mockContentConfig = { model: 'gemini-flash', apiKey: 'test-key' }; - vi.mocked(createContentGeneratorConfig).mockReturnValue(mockContentConfig); + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: mockContentConfig, + sources: {}, + }); await config.refreshAuth(authType); diff --git a/packages/core/src/extension/extensionSettings.test.ts b/packages/core/src/extension/extensionSettings.test.ts index 3705be5bd..8d29fcdd6 100644 --- a/packages/core/src/extension/extensionSettings.test.ts +++ b/packages/core/src/extension/extensionSettings.test.ts @@ -215,7 +215,7 @@ describe('extensionSettings', () => { SENSITIVE_VAR: 'secret', }; const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345`, + `Qwen Code Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); const envPath = path.join(extensionDir, '.env'); @@ -255,7 +255,7 @@ describe('extensionSettings', () => { }; const previousSettings = { SENSITIVE_VAR: 'secret' }; const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345`, + `Qwen Code Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); @@ -520,7 +520,7 @@ describe('extensionSettings', () => { const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345`, + `Qwen Code Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret'); @@ -543,7 +543,7 @@ describe('extensionSettings', () => { ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); const workspaceKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + `Qwen Code Extensions test-ext 12345 ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); @@ -580,7 +580,7 @@ describe('extensionSettings', () => { 'VAR1=user-value1\nVAR3=user-value3', ); const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext ${extensionId}`, + `Qwen Code Extensions test-ext ${extensionId}`, ); await userKeychain.setSecret('VAR2', 'user-secret2'); @@ -591,7 +591,7 @@ describe('extensionSettings', () => { ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); const workspaceKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, + `Qwen Code Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); @@ -620,7 +620,7 @@ describe('extensionSettings', () => { const userEnvPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345`, + `Qwen Code Extensions test-ext 12345`, ); await userKeychain.setSecret('VAR2', 'value2'); mockRequestSetting.mockClear(); @@ -670,7 +670,7 @@ describe('extensionSettings', () => { ); const userKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345`, + `Qwen Code Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); }); @@ -687,7 +687,7 @@ describe('extensionSettings', () => { ); const workspaceKeychain = new KeychainTokenStorage( - `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + `Qwen Code Extensions test-ext 12345 ${tempWorkspaceDir}`, ); expect(await workspaceKeychain.getSecret('VAR2')).toBe( 'new-workspace-secret', diff --git a/packages/core/src/extension/storage.test.ts b/packages/core/src/extension/storage.test.ts index 7f0b7df0e..fbbeba3b7 100644 --- a/packages/core/src/extension/storage.test.ts +++ b/packages/core/src/extension/storage.test.ts @@ -26,7 +26,7 @@ vi.mock('node:fs', async (importOriginal) => { }, }; }); -vi.mock('@google/gemini-cli-core'); +vi.mock('../config/storage.js'); describe('ExtensionStorage', () => { const mockHomeDir = '/mock/home'; @@ -38,8 +38,7 @@ describe('ExtensionStorage', () => { vi.mocked(Storage).mockImplementation( () => ({ - getExtensionsDir: () => - path.join(mockHomeDir, '.gemini', 'extensions'), + getExtensionsDir: () => path.join(mockHomeDir, '.qwen', 'extensions'), }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any ); storage = new ExtensionStorage(extensionName); @@ -52,7 +51,7 @@ describe('ExtensionStorage', () => { it('should return the correct extension directory', () => { const expectedDir = path.join( mockHomeDir, - '.gemini', + '.qwen', 'extensions', extensionName, ); @@ -62,7 +61,7 @@ describe('ExtensionStorage', () => { it('should return the correct config path', () => { const expectedPath = path.join( mockHomeDir, - '.gemini', + '.qwen', 'extensions', extensionName, EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME @@ -73,7 +72,7 @@ describe('ExtensionStorage', () => { it('should return the correct env file path', () => { const expectedPath = path.join( mockHomeDir, - '.gemini', + '.qwen', 'extensions', extensionName, EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME @@ -82,19 +81,19 @@ describe('ExtensionStorage', () => { }); it('should return the correct user extensions directory', () => { - const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions'); + const expectedDir = path.join(mockHomeDir, '.qwen', 'extensions'); expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir); }); it('should create a temporary directory', async () => { - const mockTmpDir = '/tmp/gemini-extension-123'; + const mockTmpDir = '/tmp/qwen-extension-123'; vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); const result = await ExtensionStorage.createTmpDir(); expect(fs.promises.mkdtemp).toHaveBeenCalledWith( - path.join('/tmp', 'gemini-extension'), + path.join('/tmp', 'qwen-extension'), ); expect(result).toBe(mockTmpDir); }); diff --git a/packages/core/src/extension/storage.ts b/packages/core/src/extension/storage.ts index 30d95b34e..41aa2d120 100644 --- a/packages/core/src/extension/storage.ts +++ b/packages/core/src/extension/storage.ts @@ -30,7 +30,17 @@ export class ExtensionStorage { } static getUserExtensionsDir(): string { - const storage = new Storage(os.homedir()); + const homeDir = os.homedir(); + // Fallback for test environments where os.homedir might be mocked to return undefined + if (!homeDir) { + const tmpDir = os.tmpdir(); + if (!tmpDir) { + // Ultimate fallback when both os.homedir and os.tmpdir are mocked + return '/tmp/.qwen/extensions'; + } + return path.join(tmpDir, '.qwen', 'extensions'); + } + const storage = new Storage(homeDir); return storage.getExtensionsDir(); }