mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
Make a stateful extensions list component, with update statuses (#8301)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -12,10 +12,10 @@ import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
checkForExtensionUpdates,
|
||||
checkForAllExtensionUpdates,
|
||||
checkForExtensionUpdate,
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
ExtensionUpdateStatus,
|
||||
installExtension,
|
||||
loadExtension,
|
||||
loadExtensions,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
uninstallExtension,
|
||||
updateExtension,
|
||||
type Extension,
|
||||
type ExtensionInstallMetadata,
|
||||
} from './extension.js';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope, loadSettings } from './settings.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../ui/state/extensions.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
@@ -829,6 +831,7 @@ function createExtension({
|
||||
addContextFile = false,
|
||||
contextFileName = undefined as string | undefined,
|
||||
mcpServers = {} as Record<string, MCPServerConfig>,
|
||||
installMetadata = undefined as ExtensionInstallMetadata | undefined,
|
||||
} = {}): string {
|
||||
const extDir = path.join(extensionsDir, name);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
@@ -842,9 +845,14 @@ function createExtension({
|
||||
}
|
||||
|
||||
if (contextFileName) {
|
||||
const contextPath = path.join(extDir, contextFileName);
|
||||
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
|
||||
fs.writeFileSync(contextPath, 'context');
|
||||
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
|
||||
}
|
||||
|
||||
if (installMetadata) {
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, INSTALL_METADATA_FILENAME),
|
||||
JSON.stringify(installMetadata),
|
||||
);
|
||||
}
|
||||
return extDir;
|
||||
}
|
||||
@@ -897,8 +905,12 @@ describe('updateExtension', () => {
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const updateInfo = await updateExtension(loadExtension(targetExtDir)!);
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(targetExtDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(extension, tempHomeDir, () => {});
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'gemini-extensions',
|
||||
@@ -914,9 +926,80 @@ describe('updateExtension', () => {
|
||||
);
|
||||
expect(updatedConfig.version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
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.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await updateExtension(extension, tempHomeDir, setExtensionUpdateState);
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(extension, tempHomeDir, setExtensionUpdateState),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdates', () => {
|
||||
describe('checkForAllExtensionUpdates', () => {
|
||||
let tempHomeDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
@@ -939,12 +1022,16 @@ describe('checkForExtensionUpdates', () => {
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
@@ -952,9 +1039,9 @@ describe('checkForExtensionUpdates', () => {
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('test-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.UpdateAvailable);
|
||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
@@ -962,12 +1049,16 @@ describe('checkForExtensionUpdates', () => {
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
@@ -975,9 +1066,121 @@ describe('checkForExtensionUpdates', () => {
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('test-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.UpToDate);
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
|
||||
it('should return NotUpdatable for a non-git extension', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: { source: '/local/path', type: 'local' },
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
const results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('local-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'error-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const results = await checkForAllExtensionUpdates([extension], () => {});
|
||||
const result = results.get('error-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
let tempHomeDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return UpdateAvailable for a git extension with updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
const result = await checkForExtensionUpdate(extension);
|
||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
const result = await checkForExtensionUpdate(extension);
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
|
||||
it('should return NotUpdatable for a non-git extension', async () => {
|
||||
@@ -986,12 +1189,14 @@ describe('checkForExtensionUpdates', () => {
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = { source: '/local/path', type: 'local' };
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('local-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.NotUpdatable);
|
||||
const result = await checkForExtensionUpdate(extension);
|
||||
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
@@ -999,19 +1204,21 @@ describe('checkForExtensionUpdates', () => {
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'error-extension',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
},
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
const extension = annotateActiveExtensions(
|
||||
[loadExtension(extensionDir)!],
|
||||
[],
|
||||
process.cwd(),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('error-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.Error);
|
||||
expect(result?.error).toContain('Failed to check for updates');
|
||||
const result = await checkForExtensionUpdate(extension);
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user