Files
gemini-cli/packages/cli/src/config/extensions/github.test.ts
T
2025-10-20 23:15:23 +00:00

507 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
extractFile,
findReleaseAsset,
fetchReleaseFromGithub,
tryParseGithubUrl,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensionEnablement.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
platform: mockPlatform,
arch: mockArch,
};
});
vi.mock('simple-git');
const fetchJsonMock = vi.hoisted(() => vi.fn());
vi.mock('./github_fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./github_fetch.js')>();
return {
...actual,
fetchJson: fetchJsonMock,
};
});
describe('git extension helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('cloneFromGit', () => {
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
};
beforeEach(() => {
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should clone, fetch and checkout a repo', async () => {
const installMetadata = {
source: 'http://my-repo.com',
ref: 'my-ref',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
'--depth',
'1',
]);
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
});
it('should use HEAD if ref is not provided', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
});
it('should throw if no remotes are found', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([]);
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
});
it('should throw on clone error', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.clone.mockRejectedValue(new Error('clone failed'));
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
});
});
describe('checkForExtensionUpdate', () => {
const mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
beforeEach(() => {
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should return NOT_UPDATABLE for non-git extensions', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'link',
source: '',
},
contextFiles: [],
};
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
it('should return ERROR if no remotes found', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: '',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
it('should return ERROR on git error', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});
describe('fetchReleaseFromGithub', () => {
it('should fetch the latest release if allowPreRelease is true', async () => {
const releases = [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }];
fetchJsonMock.mockResolvedValueOnce(releases);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases?per_page=1',
);
expect(result).toEqual(releases[0]);
});
it('should fetch the latest release if allowPreRelease is false', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
false,
);
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
expect(result).toEqual(release);
});
it('should fetch a release by tag if ref is provided', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub('owner', 'repo', 'v0.9.0');
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
);
expect(result).toEqual(release);
});
it('should fetch latest stable release if allowPreRelease is undefined', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
expect(result).toEqual(release);
});
});
describe('findReleaseAsset', () => {
const assets = [
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' },
{ name: 'extension-generic.tar.gz', browser_download_url: 'url5' },
];
it('should find asset matching platform and architecture', () => {
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(assets);
expect(result).toEqual(assets[0]);
});
it('should find asset matching platform if arch does not match', () => {
mockPlatform.mockReturnValue('linux');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(assets);
expect(result).toEqual(assets[2]);
});
it('should return undefined if no matching asset is found', () => {
mockPlatform.mockReturnValue('sunos');
mockArch.mockReturnValue('x64');
const result = findReleaseAsset(assets);
expect(result).toBeUndefined();
});
it('should find generic asset if it is the only one', () => {
const singleAsset = [
{ name: 'extension.tar.gz', browser_download_url: 'url' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(singleAsset);
expect(result).toEqual(singleAsset[0]);
});
it('should return undefined if multiple generic assets exist', () => {
const multipleGenericAssets = [
{ name: 'extension-1.tar.gz', browser_download_url: 'url1' },
{ name: 'extension-2.tar.gz', browser_download_url: 'url2' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(multipleGenericAssets);
expect(result).toBeUndefined();
});
});
describe('parseGitHubRepoForReleases', () => {
it('should parse owner and repo from a full GitHub URL', () => {
const source = 'https://github.com/owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL without .git', () => {
const source = 'https://github.com/owner/repo';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL with a trailing slash', () => {
const source = 'https://github.com/owner/repo/';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a GitHub SSH URL', () => {
const source = 'git@github.com:owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(tryParseGithubUrl(source)).toBe(null);
});
it('should parse owner and repo from a shorthand string', () => {
const source = 'owner/repo';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should handle .git suffix in repo name', () => {
const source = 'owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should throw error for invalid source format', () => {
const source = 'invalid-format';
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.',
);
});
it('should throw error for source with too many parts', () => {
const source = 'https://github.com/owner/repo/extra';
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.',
);
});
});
describe('extractFile', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should extract a .tar.gz file', async () => {
const archivePath = path.join(tempDir, 'test.tar.gz');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello tar');
// Create the tar.gz file
await tar.c(
{
gzip: true,
file: archivePath,
cwd: tempDir,
},
['test.txt'],
);
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello tar');
});
it('should extract a .zip file', async () => {
const archivePath = path.join(tempDir, 'test.zip');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello zip');
// Create the zip file
const output = fsSync.createWriteStream(archivePath);
const archive = archiver.create('zip');
const streamFinished = new Promise((resolve, reject) => {
output.on('close', () => resolve(null));
archive.on('error', reject);
});
archive.pipe(output);
archive.file(dummyFilePath, { name: 'test.txt' });
await archive.finalize();
await streamFinished;
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello zip');
});
it('should throw an error for unsupported file types', async () => {
const unsupportedFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(unsupportedFilePath, 'some content');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
await expect(
extractFile(unsupportedFilePath, extractionDest),
).rejects.toThrow('Unsupported file extension for extraction:');
});
});
});