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:
Jacob MacDonald
2025-09-12 09:20:04 -07:00
committed by GitHub
parent 8810ef2f40
commit e89012efa8
15 changed files with 714 additions and 250 deletions
+243 -36
View File
@@ -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);
});
});