2025-08-25 17:02:10 +00:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
|
2025-09-16 14:23:49 -04:00
|
|
|
import { handleInstall, installCommand } from './install.js';
|
2025-08-25 17:02:10 +00:00
|
|
|
import yargs from 'yargs';
|
2025-10-30 08:32:33 -07:00
|
|
|
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
|
|
|
|
import type { ExtensionManager } from '../../config/extension-manager.js';
|
|
|
|
|
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
|
|
|
|
import type * as fs from 'node:fs/promises';
|
|
|
|
|
import type { Stats } from 'node:fs';
|
|
|
|
|
|
|
|
|
|
const mockInstallOrUpdateExtension: Mock<
|
|
|
|
|
typeof ExtensionManager.prototype.installOrUpdateExtension
|
|
|
|
|
> = vi.hoisted(() => vi.fn());
|
|
|
|
|
const mockRequestConsentNonInteractive: Mock<
|
|
|
|
|
typeof requestConsentNonInteractive
|
|
|
|
|
> = vi.hoisted(() => vi.fn());
|
|
|
|
|
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
2025-09-16 14:23:49 -04:00
|
|
|
|
2025-10-23 11:39:36 -07:00
|
|
|
vi.mock('../../config/extensions/consent.js', () => ({
|
2025-09-25 10:57:59 -07:00
|
|
|
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
2025-09-16 14:23:49 -04:00
|
|
|
}));
|
|
|
|
|
|
2025-10-23 11:39:36 -07:00
|
|
|
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
|
|
|
|
|
const actual =
|
|
|
|
|
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
ExtensionManager: vi.fn().mockImplementation(() => ({
|
|
|
|
|
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
2025-10-28 09:04:30 -07:00
|
|
|
loadExtensions: vi.fn(),
|
2025-10-23 11:39:36 -07:00
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-16 14:23:49 -04:00
|
|
|
vi.mock('../../utils/errors.js', () => ({
|
|
|
|
|
getErrorMessage: vi.fn((error: Error) => error.message),
|
2025-09-02 08:15:47 -07:00
|
|
|
}));
|
2025-08-25 17:02:10 +00:00
|
|
|
|
2025-10-07 12:01:45 -04:00
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
|
|
|
stat: mockStat,
|
|
|
|
|
default: {
|
|
|
|
|
stat: mockStat,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2025-11-21 21:08:06 -05:00
|
|
|
vi.mock('../utils.js', () => ({
|
|
|
|
|
exitCli: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-08-25 17:02:10 +00:00
|
|
|
describe('extensions install command', () => {
|
|
|
|
|
it('should fail if no source is provided', () => {
|
|
|
|
|
const validationParser = yargs([]).command(installCommand).fail(false);
|
|
|
|
|
expect(() => validationParser.parse('install')).toThrow(
|
2025-10-07 12:01:45 -04:00
|
|
|
'Not enough non-option arguments: got 0, need at least 1',
|
2025-08-25 17:02:10 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-09-16 14:23:49 -04:00
|
|
|
|
|
|
|
|
describe('handleInstall', () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
let debugLogSpy: MockInstance;
|
|
|
|
|
let debugErrorSpy: MockInstance;
|
2025-09-16 14:23:49 -04:00
|
|
|
let processSpy: MockInstance;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2025-10-30 08:32:33 -07:00
|
|
|
debugLogSpy = vi.spyOn(debugLogger, 'log');
|
|
|
|
|
debugErrorSpy = vi.spyOn(debugLogger, 'error');
|
2025-09-16 14:23:49 -04:00
|
|
|
processSpy = vi
|
|
|
|
|
.spyOn(process, 'exit')
|
|
|
|
|
.mockImplementation(() => undefined as never);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
2025-10-10 14:28:13 -07:00
|
|
|
mockInstallOrUpdateExtension.mockClear();
|
2025-09-25 10:57:59 -07:00
|
|
|
mockRequestConsentNonInteractive.mockClear();
|
2025-10-07 12:01:45 -04:00
|
|
|
mockStat.mockClear();
|
2025-10-23 11:39:36 -07:00
|
|
|
vi.clearAllMocks();
|
2025-09-16 14:23:49 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should install an extension from a http source', async () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
mockInstallOrUpdateExtension.mockResolvedValue({
|
|
|
|
|
name: 'http-extension',
|
|
|
|
|
} as unknown as GeminiCLIExtension);
|
2025-09-16 14:23:49 -04:00
|
|
|
|
|
|
|
|
await handleInstall({
|
|
|
|
|
source: 'http://google.com',
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
2025-09-16 14:23:49 -04:00
|
|
|
'Extension "http-extension" installed successfully and enabled.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should install an extension from a https source', async () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
mockInstallOrUpdateExtension.mockResolvedValue({
|
|
|
|
|
name: 'https-extension',
|
|
|
|
|
} as unknown as GeminiCLIExtension);
|
2025-09-16 14:23:49 -04:00
|
|
|
|
|
|
|
|
await handleInstall({
|
|
|
|
|
source: 'https://google.com',
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
2025-09-16 14:23:49 -04:00
|
|
|
'Extension "https-extension" installed successfully and enabled.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should install an extension from a git source', async () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
mockInstallOrUpdateExtension.mockResolvedValue({
|
|
|
|
|
name: 'git-extension',
|
|
|
|
|
} as unknown as GeminiCLIExtension);
|
2025-09-16 14:23:49 -04:00
|
|
|
|
|
|
|
|
await handleInstall({
|
|
|
|
|
source: 'git@some-url',
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
2025-09-16 14:23:49 -04:00
|
|
|
'Extension "git-extension" installed successfully and enabled.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws an error from an unknown source', async () => {
|
2025-10-07 12:01:45 -04:00
|
|
|
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
2025-09-16 14:23:49 -04:00
|
|
|
await handleInstall({
|
|
|
|
|
source: 'test://google.com',
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.');
|
2025-09-16 14:23:49 -04:00
|
|
|
expect(processSpy).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-17 10:36:23 -07:00
|
|
|
it('should install an extension from a sso source', async () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
mockInstallOrUpdateExtension.mockResolvedValue({
|
|
|
|
|
name: 'sso-extension',
|
|
|
|
|
} as unknown as GeminiCLIExtension);
|
2025-09-17 10:36:23 -07:00
|
|
|
|
|
|
|
|
await handleInstall({
|
|
|
|
|
source: 'sso://google.com',
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
2025-09-17 10:36:23 -07:00
|
|
|
'Extension "sso-extension" installed successfully and enabled.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-16 14:23:49 -04:00
|
|
|
it('should install an extension from a local path', async () => {
|
2025-10-30 08:32:33 -07:00
|
|
|
mockInstallOrUpdateExtension.mockResolvedValue({
|
|
|
|
|
name: 'local-extension',
|
|
|
|
|
} as unknown as GeminiCLIExtension);
|
|
|
|
|
mockStat.mockResolvedValue({} as Stats);
|
2025-09-16 14:23:49 -04:00
|
|
|
await handleInstall({
|
2025-10-07 12:01:45 -04:00
|
|
|
source: '/some/path',
|
2025-09-16 14:23:49 -04:00
|
|
|
});
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
2025-09-16 14:23:49 -04:00
|
|
|
'Extension "local-extension" installed successfully and enabled.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should throw an error if install extension fails', async () => {
|
2025-10-10 14:28:13 -07:00
|
|
|
mockInstallOrUpdateExtension.mockRejectedValue(
|
2025-09-16 14:23:49 -04:00
|
|
|
new Error('Install extension failed'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await handleInstall({ source: 'git@some-url' });
|
|
|
|
|
|
2025-10-30 08:32:33 -07:00
|
|
|
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
2025-09-16 14:23:49 -04:00
|
|
|
expect(processSpy).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
});
|