mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-21 03:21:11 -07:00
Optimize and improve test coverage for cli/src/config (#13485)
This commit is contained in:
@@ -4,484 +4,365 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import {
|
||||
checkForExtensionUpdate,
|
||||
cloneFromGit,
|
||||
extractFile,
|
||||
findReleaseAsset,
|
||||
fetchReleaseFromGithub,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
checkForExtensionUpdate,
|
||||
downloadFromGitHubRelease,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
} 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 fs from 'node:fs';
|
||||
import * as https from 'node:https';
|
||||
import * as tar from 'tar';
|
||||
import * as archiver from 'archiver';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
import { loadSettings } from '../settings.js';
|
||||
import type { ExtensionSetting } from './extensionSettings.js';
|
||||
import * as extract from 'extract-zip';
|
||||
import type { ExtensionManager } from '../extension-manager.js';
|
||||
import { fetchJson } from './github_fetch.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type {
|
||||
GeminiCLIExtension,
|
||||
ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../extension.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>();
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
platform: mockPlatform,
|
||||
arch: mockArch,
|
||||
Storage: {
|
||||
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'),
|
||||
getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'),
|
||||
},
|
||||
debugLogger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('simple-git');
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:https');
|
||||
vi.mock('tar');
|
||||
vi.mock('extract-zip');
|
||||
vi.mock('./github_fetch.js');
|
||||
vi.mock('../extension-manager.js');
|
||||
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
|
||||
vi.mock('../settings.js', () => ({
|
||||
loadSettings: vi.fn(),
|
||||
USER_SETTINGS_PATH: '/mock/settings.json',
|
||||
}));
|
||||
|
||||
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('github.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneFromGit', () => {
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
let mockGit: {
|
||||
clone: ReturnType<typeof vi.fn>;
|
||||
getRemotes: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
checkout: ReturnType<typeof vi.fn>;
|
||||
listRemote: ReturnType<typeof vi.fn>;
|
||||
revparse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
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' } },
|
||||
]);
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
await cloneFromGit(
|
||||
{
|
||||
type: 'git',
|
||||
source: 'https://github.com/owner/repo.git',
|
||||
ref: 'v1.0.0',
|
||||
},
|
||||
'/dest',
|
||||
);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
|
||||
'--depth',
|
||||
'1',
|
||||
]);
|
||||
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/owner/repo.git',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
|
||||
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';
|
||||
it('should throw if no remotes found', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
|
||||
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
|
||||
).rejects.toThrow('Unable to find any remotes');
|
||||
});
|
||||
|
||||
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'));
|
||||
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
|
||||
|
||||
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||
'Failed to clone Git repository from http://my-repo.com',
|
||||
);
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
|
||||
).rejects.toThrow('Failed to clone Git repository');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
|
||||
let extensionManager: ExtensionManager;
|
||||
let mockRequestConsent: MockedFunction<
|
||||
(consent: string) => Promise<boolean>
|
||||
>;
|
||||
let mockPromptForSettings: MockedFunction<
|
||||
(setting: ExtensionSetting) => Promise<string>
|
||||
>;
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fsSync.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fsSync.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
mockRequestConsent = vi.fn();
|
||||
mockRequestConsent.mockResolvedValue(true);
|
||||
mockPromptForSettings = vi.fn();
|
||||
mockPromptForSettings.mockResolvedValue('');
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: loadSettings(tempWorkspaceDir).merged,
|
||||
});
|
||||
describe('tryParseGithubUrl', () => {
|
||||
it.each([
|
||||
['https://github.com/owner/repo', 'owner', 'repo'],
|
||||
['https://github.com/owner/repo.git', 'owner', 'repo'],
|
||||
['git@github.com:owner/repo.git', 'owner', 'repo'],
|
||||
['owner/repo', 'owner', 'repo'],
|
||||
])('should parse %s to %s/%s', (url, owner, repo) => {
|
||||
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
testName: 'should return NOT_UPDATABLE for non-git extensions',
|
||||
extension: {
|
||||
installMetadata: { type: 'link', source: '' },
|
||||
},
|
||||
mockSetup: () => {},
|
||||
expected: ExtensionUpdateState.NOT_UPDATABLE,
|
||||
},
|
||||
{
|
||||
testName: 'should return ERROR if no remotes found',
|
||||
extension: {
|
||||
installMetadata: { type: 'git', source: '' },
|
||||
},
|
||||
mockSetup: () => {
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
},
|
||||
expected: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
{
|
||||
testName:
|
||||
'should return UPDATE_AVAILABLE when remote hash is different',
|
||||
extension: {
|
||||
installMetadata: { type: 'git', source: 'my/ext' },
|
||||
},
|
||||
mockSetup: () => {
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('local-hash');
|
||||
},
|
||||
expected: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
{
|
||||
testName:
|
||||
'should return UP_TO_DATE when remote and local hashes are the same',
|
||||
extension: {
|
||||
installMetadata: { type: 'git', source: 'my/ext' },
|
||||
},
|
||||
mockSetup: () => {
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('same-hash');
|
||||
},
|
||||
expected: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
{
|
||||
testName: 'should return ERROR on git error',
|
||||
extension: {
|
||||
installMetadata: { type: 'git', source: 'my/ext' },
|
||||
},
|
||||
mockSetup: () => {
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
|
||||
},
|
||||
expected: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
])('$testName', async ({ extension, mockSetup, expected }) => {
|
||||
const fullExtension: GeminiCLIExtension = {
|
||||
name: 'test',
|
||||
id: 'test-id',
|
||||
path: '/ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
contextFiles: [],
|
||||
...extension,
|
||||
} as unknown as GeminiCLIExtension;
|
||||
mockSetup();
|
||||
const result = await checkForExtensionUpdate(
|
||||
fullExtension,
|
||||
extensionManager,
|
||||
it('should return null for non-GitHub URLs', () => {
|
||||
expect(tryParseGithubUrl('https://gitlab.com/owner/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for invalid formats', () => {
|
||||
expect(() => tryParseGithubUrl('invalid')).toThrow(
|
||||
'Invalid GitHub repository source',
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchReleaseFromGithub', () => {
|
||||
it.each([
|
||||
{
|
||||
ref: undefined,
|
||||
allowPreRelease: true,
|
||||
mockedResponse: [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }],
|
||||
expectedUrl:
|
||||
'https://api.github.com/repos/owner/repo/releases?per_page=1',
|
||||
expectedResult: { tag_name: 'v1.0.0-alpha' },
|
||||
},
|
||||
{
|
||||
ref: undefined,
|
||||
allowPreRelease: false,
|
||||
mockedResponse: { tag_name: 'v0.9.0' },
|
||||
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
expectedResult: { tag_name: 'v0.9.0' },
|
||||
},
|
||||
{
|
||||
ref: 'v0.9.0',
|
||||
allowPreRelease: undefined,
|
||||
mockedResponse: { tag_name: 'v0.9.0' },
|
||||
expectedUrl:
|
||||
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
|
||||
expectedResult: { tag_name: 'v0.9.0' },
|
||||
},
|
||||
{
|
||||
ref: undefined,
|
||||
allowPreRelease: undefined,
|
||||
mockedResponse: { tag_name: 'v0.9.0' },
|
||||
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
expectedResult: { tag_name: 'v0.9.0' },
|
||||
},
|
||||
])(
|
||||
'should fetch release with ref=$ref and allowPreRelease=$allowPreRelease',
|
||||
async ({
|
||||
ref,
|
||||
allowPreRelease,
|
||||
mockedResponse,
|
||||
expectedUrl,
|
||||
expectedResult,
|
||||
}) => {
|
||||
fetchJsonMock.mockResolvedValueOnce(mockedResponse);
|
||||
it('should fetch latest release if no ref provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
ref,
|
||||
allowPreRelease,
|
||||
);
|
||||
await fetchReleaseFromGithub('owner', 'repo');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(expectedUrl);
|
||||
expect(result).toEqual(expectedResult);
|
||||
},
|
||||
);
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch specific ref if provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pre-releases if allowed', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
|
||||
});
|
||||
|
||||
it('should return null if no releases found', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
let mockExtensionManager: ExtensionManager;
|
||||
let mockGit: {
|
||||
getRemotes: ReturnType<typeof vi.fn>;
|
||||
listRemote: ReturnType<typeof vi.fn>;
|
||||
revparse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
} as unknown as ExtensionManager;
|
||||
mockGit = {
|
||||
getRemotes: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
});
|
||||
|
||||
it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
version: '1.0.0',
|
||||
} as unknown as ExtensionConfig);
|
||||
|
||||
const linkExt = {
|
||||
installMetadata: { type: 'link' },
|
||||
} as unknown as GeminiCLIExtension;
|
||||
expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.NOT_UPDATABLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return UPDATE_AVAILABLE if git remote hash differs', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'url' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('local-hash');
|
||||
|
||||
const ext = {
|
||||
path: '/path',
|
||||
installMetadata: { type: 'git', source: 'url' },
|
||||
} as unknown as GeminiCLIExtension;
|
||||
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return UP_TO_DATE if git remote hash matches', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'url' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('hash');
|
||||
|
||||
const ext = {
|
||||
path: '/path',
|
||||
installMetadata: { type: 'git', source: 'url' },
|
||||
} as unknown as GeminiCLIExtension;
|
||||
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.UP_TO_DATE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFromGitHubRelease', () => {
|
||||
it('should fail if no release data found', async () => {
|
||||
// Mock fetchJson to throw for latest release check
|
||||
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.failureReason).toBe('failed to fetch release data');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
|
||||
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
|
||||
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
|
||||
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
|
||||
{ name: 'extension-generic.tar.gz', url: 'url5' },
|
||||
];
|
||||
|
||||
it.each([
|
||||
{ platform: 'darwin', arch: 'arm64', expected: assets[0] },
|
||||
{ platform: 'linux', arch: 'arm64', expected: assets[2] },
|
||||
|
||||
{ platform: 'sunos', arch: 'x64', expected: undefined },
|
||||
])(
|
||||
'should find asset matching platform and architecture',
|
||||
|
||||
({ platform, arch, expected }) => {
|
||||
mockPlatform.mockReturnValue(platform);
|
||||
mockArch.mockReturnValue(arch);
|
||||
const result = findReleaseAsset(assets);
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should find generic asset if it is the only one', () => {
|
||||
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
|
||||
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(singleAsset);
|
||||
expect(result).toEqual(singleAsset[0]);
|
||||
it('should find platform/arch specific asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(os.arch).mockReturnValue('arm64');
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
|
||||
{ name: 'linux.x64.tar.gz', url: 'url2' },
|
||||
];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
|
||||
it('should return undefined if multiple generic assets exist', () => {
|
||||
const multipleGenericAssets = [
|
||||
{ name: 'extension-1.tar.gz', url: 'aurl1' },
|
||||
{ name: 'extension-2.tar.gz', url: 'aurl2' },
|
||||
];
|
||||
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
const result = findReleaseAsset(multipleGenericAssets);
|
||||
expect(result).toBeUndefined();
|
||||
it('should find generic asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitHubRepoForReleases', () => {
|
||||
it.each([
|
||||
{
|
||||
source: 'https://github.com/owner/repo.git',
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
},
|
||||
{
|
||||
source: 'https://github.com/owner/repo',
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
},
|
||||
{
|
||||
source: 'https://github.com/owner/repo/',
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
},
|
||||
{
|
||||
source: 'git@github.com:owner/repo.git',
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
},
|
||||
{ source: 'owner/repo', owner: 'owner', repo: 'repo' },
|
||||
{ source: 'owner/repo.git', owner: 'owner', repo: 'repo' },
|
||||
])(
|
||||
'should parse owner and repo from $source',
|
||||
({ source, owner, repo }) => {
|
||||
const result = tryParseGithubUrl(source)!;
|
||||
expect(result.owner).toBe(owner);
|
||||
expect(result.repo).toBe(repo);
|
||||
},
|
||||
);
|
||||
describe('downloadFile', () => {
|
||||
it('should download file successfully', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
it('should return null on a non-GitHub URL', () => {
|
||||
const source = 'https://example.com/owner/repo.git';
|
||||
expect(tryParseGithubUrl(source)).toBe(null);
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
const promise = downloadFile('url', '/dest');
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: 'invalid-format' },
|
||||
{ source: 'https://github.com/owner/repo/extra' },
|
||||
])(
|
||||
'should throw error for invalid source format: $source',
|
||||
({ source }) => {
|
||||
expect(() => tryParseGithubUrl(source)).toThrow(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
it('should fail on non-200 status', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 404 });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
it('should extract tar.gz using tar', async () => {
|
||||
await extractFile('file.tar.gz', '/dest');
|
||||
expect(tar.x).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
it('should extract zip using extract-zip', async () => {
|
||||
vi.mocked(extract.default || extract).mockResolvedValue(undefined);
|
||||
await extractFile('file.zip', '/dest');
|
||||
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
|
||||
});
|
||||
|
||||
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'],
|
||||
it('should throw for unsupported extensions', async () => {
|
||||
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
|
||||
'Unsupported file extension',
|
||||
);
|
||||
|
||||
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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user