mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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 {
|
|
useExtensionUpdates,
|
|
useSettingInputRequests,
|
|
useConfirmUpdateRequests,
|
|
usePluginChoiceRequests,
|
|
} from './useExtensionUpdates.js';
|
|
import {
|
|
QWEN_DIR,
|
|
type ExtensionManager,
|
|
type Extension,
|
|
type ExtensionUpdateInfo,
|
|
ExtensionUpdateState,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { MessageType } from '../types.js';
|
|
|
|
vi.mock('os', async (importOriginal) => {
|
|
const mockedOs = await importOriginal<typeof os>();
|
|
return {
|
|
...mockedOs,
|
|
homedir: vi.fn(),
|
|
};
|
|
});
|
|
|
|
function createMockExtension(overrides: Partial<Extension> = {}): 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<void>,
|
|
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('useConfirmUpdateRequests', () => {
|
|
it('should add a confirmation request', () => {
|
|
const { result } = renderHook(() => useConfirmUpdateRequests());
|
|
|
|
const onConfirm = vi.fn();
|
|
act(() => {
|
|
result.current.addConfirmUpdateExtensionRequest({
|
|
prompt: 'Test prompt',
|
|
onConfirm,
|
|
});
|
|
});
|
|
|
|
expect(result.current.confirmUpdateExtensionRequests).toHaveLength(1);
|
|
expect(result.current.confirmUpdateExtensionRequests[0].prompt).toBe(
|
|
'Test prompt',
|
|
);
|
|
});
|
|
|
|
it('should remove a confirmation request when confirmed', () => {
|
|
const { result } = renderHook(() => useConfirmUpdateRequests());
|
|
|
|
const onConfirm = vi.fn();
|
|
act(() => {
|
|
result.current.addConfirmUpdateExtensionRequest({
|
|
prompt: 'Test prompt',
|
|
onConfirm,
|
|
});
|
|
});
|
|
|
|
expect(result.current.confirmUpdateExtensionRequests).toHaveLength(1);
|
|
|
|
// Confirm the request
|
|
act(() => {
|
|
result.current.confirmUpdateExtensionRequests[0].onConfirm(true);
|
|
});
|
|
|
|
expect(result.current.confirmUpdateExtensionRequests).toHaveLength(0);
|
|
expect(onConfirm).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('should handle multiple confirmation requests', () => {
|
|
const { result } = renderHook(() => useConfirmUpdateRequests());
|
|
|
|
const onConfirm1 = vi.fn();
|
|
const onConfirm2 = vi.fn();
|
|
|
|
act(() => {
|
|
result.current.addConfirmUpdateExtensionRequest({
|
|
prompt: 'Prompt 1',
|
|
onConfirm: onConfirm1,
|
|
});
|
|
result.current.addConfirmUpdateExtensionRequest({
|
|
prompt: 'Prompt 2',
|
|
onConfirm: onConfirm2,
|
|
});
|
|
});
|
|
|
|
expect(result.current.confirmUpdateExtensionRequests).toHaveLength(2);
|
|
|
|
// Confirm first request
|
|
act(() => {
|
|
result.current.confirmUpdateExtensionRequests[0].onConfirm(false);
|
|
});
|
|
|
|
expect(result.current.confirmUpdateExtensionRequests).toHaveLength(1);
|
|
expect(result.current.confirmUpdateExtensionRequests[0].prompt).toBe(
|
|
'Prompt 2',
|
|
);
|
|
expect(onConfirm1).toHaveBeenCalledWith(false);
|
|
});
|
|
});
|
|
|
|
describe('useSettingInputRequests', () => {
|
|
it('should add a setting input request', () => {
|
|
const { result } = renderHook(() => useSettingInputRequests());
|
|
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addSettingInputRequest({
|
|
settingName: 'API_KEY',
|
|
settingDescription: 'Enter your API key',
|
|
sensitive: true,
|
|
onSubmit,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(1);
|
|
expect(result.current.settingInputRequests[0].settingName).toBe('API_KEY');
|
|
expect(result.current.settingInputRequests[0].settingDescription).toBe(
|
|
'Enter your API key',
|
|
);
|
|
expect(result.current.settingInputRequests[0].sensitive).toBe(true);
|
|
});
|
|
|
|
it('should remove a setting input request when submitted', () => {
|
|
const { result } = renderHook(() => useSettingInputRequests());
|
|
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addSettingInputRequest({
|
|
settingName: 'API_KEY',
|
|
settingDescription: 'Enter your API key',
|
|
sensitive: true,
|
|
onSubmit,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(1);
|
|
|
|
// Submit the value
|
|
act(() => {
|
|
result.current.settingInputRequests[0].onSubmit('my-secret-key');
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(0);
|
|
expect(onSubmit).toHaveBeenCalledWith('my-secret-key');
|
|
expect(onCancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should remove a setting input request when cancelled', () => {
|
|
const { result } = renderHook(() => useSettingInputRequests());
|
|
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addSettingInputRequest({
|
|
settingName: 'API_KEY',
|
|
settingDescription: 'Enter your API key',
|
|
sensitive: true,
|
|
onSubmit,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(1);
|
|
|
|
// Cancel the request
|
|
act(() => {
|
|
result.current.settingInputRequests[0].onCancel();
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(0);
|
|
expect(onCancel).toHaveBeenCalled();
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle multiple setting input requests in sequence', () => {
|
|
const { result } = renderHook(() => useSettingInputRequests());
|
|
|
|
const onSubmit1 = vi.fn();
|
|
const onCancel1 = vi.fn();
|
|
const onSubmit2 = vi.fn();
|
|
const onCancel2 = vi.fn();
|
|
|
|
act(() => {
|
|
result.current.addSettingInputRequest({
|
|
settingName: 'USERNAME',
|
|
settingDescription: 'Enter username',
|
|
sensitive: false,
|
|
onSubmit: onSubmit1,
|
|
onCancel: onCancel1,
|
|
});
|
|
result.current.addSettingInputRequest({
|
|
settingName: 'PASSWORD',
|
|
settingDescription: 'Enter password',
|
|
sensitive: true,
|
|
onSubmit: onSubmit2,
|
|
onCancel: onCancel2,
|
|
});
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(2);
|
|
|
|
// Submit first request
|
|
act(() => {
|
|
result.current.settingInputRequests[0].onSubmit('john_doe');
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(1);
|
|
expect(result.current.settingInputRequests[0].settingName).toBe('PASSWORD');
|
|
expect(onSubmit1).toHaveBeenCalledWith('john_doe');
|
|
|
|
// Submit second request
|
|
act(() => {
|
|
result.current.settingInputRequests[0].onSubmit('secret123');
|
|
});
|
|
|
|
expect(result.current.settingInputRequests).toHaveLength(0);
|
|
expect(onSubmit2).toHaveBeenCalledWith('secret123');
|
|
});
|
|
});
|
|
|
|
describe('useExtensionUpdates', () => {
|
|
let tempHomeDir: string;
|
|
let userExtensionsDir: string;
|
|
|
|
beforeEach(() => {
|
|
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 });
|
|
});
|
|
|
|
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 extension = createMockExtension({
|
|
name: 'test-extension',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some/repo',
|
|
autoUpdate: false,
|
|
},
|
|
});
|
|
const addItem = vi.fn();
|
|
const cwd = '/test/cwd';
|
|
|
|
const extensionManager = createMockExtensionManager(
|
|
[extension],
|
|
async (callback) => {
|
|
callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE);
|
|
},
|
|
);
|
|
|
|
renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd));
|
|
|
|
await waitFor(() => {
|
|
expect(addItem).toHaveBeenCalledWith(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'You have 1 extension with an update available, run "/extensions list" for more information.',
|
|
},
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should check for updates and automatically update if autoUpdate is true', async () => {
|
|
const extension = createMockExtension({
|
|
name: 'test-extension',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some.git/repo',
|
|
autoUpdate: true,
|
|
},
|
|
});
|
|
|
|
const addItem = vi.fn();
|
|
|
|
const extensionManager = createMockExtensionManager(
|
|
[extension],
|
|
async (callback) => {
|
|
callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE);
|
|
},
|
|
{
|
|
originalVersion: '1.0.0',
|
|
updatedVersion: '1.1.0',
|
|
name: 'test-extension',
|
|
},
|
|
);
|
|
|
|
renderHook(() =>
|
|
useExtensionUpdates(extensionManager, addItem, tempHomeDir),
|
|
);
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(addItem).toHaveBeenCalledWith(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Extension "test-extension" successfully updated: 1.0.0 → 1.1.0.',
|
|
},
|
|
expect.any(Number),
|
|
);
|
|
},
|
|
{ timeout: 4000 },
|
|
);
|
|
});
|
|
|
|
it('should batch update notifications for multiple extensions', async () => {
|
|
const extension1 = createMockExtension({
|
|
id: 'test-extension-1-id',
|
|
name: 'test-extension-1',
|
|
version: '1.0.0',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some.git/repo1',
|
|
autoUpdate: true,
|
|
},
|
|
});
|
|
const extension2 = createMockExtension({
|
|
id: 'test-extension-2-id',
|
|
name: 'test-extension-2',
|
|
version: '2.0.0',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some.git/repo2',
|
|
autoUpdate: true,
|
|
},
|
|
});
|
|
|
|
const addItem = vi.fn();
|
|
let updateCallCount = 0;
|
|
|
|
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',
|
|
};
|
|
}
|
|
return {
|
|
originalVersion: '2.0.0',
|
|
updatedVersion: '2.1.0',
|
|
name: 'test-extension-2',
|
|
};
|
|
}),
|
|
} as unknown as ExtensionManager;
|
|
|
|
renderHook(() =>
|
|
useExtensionUpdates(extensionManager, addItem, tempHomeDir),
|
|
);
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(addItem).toHaveBeenCalledTimes(2);
|
|
expect(addItem).toHaveBeenCalledWith(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Extension "test-extension-1" successfully updated: 1.0.0 → 1.1.0.',
|
|
},
|
|
expect.any(Number),
|
|
);
|
|
expect(addItem).toHaveBeenCalledWith(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Extension "test-extension-2" successfully updated: 2.0.0 → 2.1.0.',
|
|
},
|
|
expect.any(Number),
|
|
);
|
|
},
|
|
{ timeout: 4000 },
|
|
);
|
|
});
|
|
|
|
it('should batch update notifications for multiple extensions with autoUpdate: false', async () => {
|
|
const extension1 = createMockExtension({
|
|
id: 'test-extension-1-id',
|
|
name: 'test-extension-1',
|
|
version: '1.0.0',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some/repo1',
|
|
autoUpdate: false,
|
|
},
|
|
});
|
|
const extension2 = createMockExtension({
|
|
id: 'test-extension-2-id',
|
|
name: 'test-extension-2',
|
|
version: '2.0.0',
|
|
installMetadata: {
|
|
type: 'git',
|
|
source: 'https://some/repo2',
|
|
autoUpdate: false,
|
|
},
|
|
});
|
|
|
|
const addItem = vi.fn();
|
|
const cwd = '/test/cwd';
|
|
|
|
const extensionManager = createMockExtensionManager(
|
|
[extension1, extension2],
|
|
async (callback) => {
|
|
callback('test-extension-1', ExtensionUpdateState.UPDATE_AVAILABLE);
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
callback('test-extension-2', ExtensionUpdateState.UPDATE_AVAILABLE);
|
|
},
|
|
);
|
|
|
|
renderHook(() => useExtensionUpdates(extensionManager, addItem, cwd));
|
|
|
|
await waitFor(() => {
|
|
expect(addItem).toHaveBeenCalledTimes(1);
|
|
expect(addItem).toHaveBeenCalledWith(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'You have 2 extensions with an update available, run "/extensions list" for more information.',
|
|
},
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('usePluginChoiceRequests', () => {
|
|
it('should add a plugin choice request', () => {
|
|
const { result } = renderHook(() => usePluginChoiceRequests());
|
|
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addPluginChoiceRequest({
|
|
marketplaceName: 'test-marketplace',
|
|
plugins: [
|
|
{ name: 'plugin1', description: 'First plugin' },
|
|
{ name: 'plugin2', description: 'Second plugin' },
|
|
],
|
|
onSelect,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
|
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
|
|
'test-marketplace',
|
|
);
|
|
expect(result.current.pluginChoiceRequests[0].plugins).toHaveLength(2);
|
|
});
|
|
|
|
it('should remove a plugin choice request when a plugin is selected', () => {
|
|
const { result } = renderHook(() => usePluginChoiceRequests());
|
|
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addPluginChoiceRequest({
|
|
marketplaceName: 'test-marketplace',
|
|
plugins: [{ name: 'plugin1' }],
|
|
onSelect,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
|
|
|
// Select a plugin
|
|
act(() => {
|
|
result.current.pluginChoiceRequests[0].onSelect('plugin1');
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(0);
|
|
expect(onSelect).toHaveBeenCalledWith('plugin1');
|
|
expect(onCancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should remove a plugin choice request when cancelled', () => {
|
|
const { result } = renderHook(() => usePluginChoiceRequests());
|
|
|
|
const onSelect = vi.fn();
|
|
const onCancel = vi.fn();
|
|
act(() => {
|
|
result.current.addPluginChoiceRequest({
|
|
marketplaceName: 'test-marketplace',
|
|
plugins: [{ name: 'plugin1' }],
|
|
onSelect,
|
|
onCancel,
|
|
});
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
|
|
|
// Cancel the request
|
|
act(() => {
|
|
result.current.pluginChoiceRequests[0].onCancel();
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(0);
|
|
expect(onCancel).toHaveBeenCalled();
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle multiple plugin choice requests', () => {
|
|
const { result } = renderHook(() => usePluginChoiceRequests());
|
|
|
|
const onSelect1 = vi.fn();
|
|
const onCancel1 = vi.fn();
|
|
const onSelect2 = vi.fn();
|
|
const onCancel2 = vi.fn();
|
|
|
|
act(() => {
|
|
result.current.addPluginChoiceRequest({
|
|
marketplaceName: 'marketplace-1',
|
|
plugins: [{ name: 'plugin1' }],
|
|
onSelect: onSelect1,
|
|
onCancel: onCancel1,
|
|
});
|
|
result.current.addPluginChoiceRequest({
|
|
marketplaceName: 'marketplace-2',
|
|
plugins: [{ name: 'plugin2' }],
|
|
onSelect: onSelect2,
|
|
onCancel: onCancel2,
|
|
});
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(2);
|
|
|
|
// Select from first request
|
|
act(() => {
|
|
result.current.pluginChoiceRequests[0].onSelect('plugin1');
|
|
});
|
|
|
|
expect(result.current.pluginChoiceRequests).toHaveLength(1);
|
|
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
|
|
'marketplace-2',
|
|
);
|
|
expect(onSelect1).toHaveBeenCalledWith('plugin1');
|
|
});
|
|
});
|