/** * @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, parseGitHubRepoForReleases, } 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 '@qwen-code/qwen-code-core'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockArch = vi.hoisted(() => vi.fn()); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, platform: mockPlatform, arch: mockArch, }; }); vi.mock('simple-git'); 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: '', }, }; let result: ExtensionUpdateState | undefined = undefined; await checkForExtensionUpdate( extension, (newState) => (result = newState), ); 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: '', }, }; mockGit.getRemotes.mockResolvedValue([]); let result: ExtensionUpdateState | undefined = undefined; await checkForExtensionUpdate( extension, (newState) => (result = newState), ); 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', }, }; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, ]); mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); mockGit.revparse.mockResolvedValue('local-hash'); let result: ExtensionUpdateState | undefined = undefined; await checkForExtensionUpdate( extension, (newState) => (result = newState), ); 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', }, }; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, ]); mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); mockGit.revparse.mockResolvedValue('same-hash'); let result: ExtensionUpdateState | undefined = undefined; await checkForExtensionUpdate( extension, (newState) => (result = newState), ); 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', }, }; mockGit.getRemotes.mockRejectedValue(new Error('git error')); let result: ExtensionUpdateState | undefined = undefined; await checkForExtensionUpdate( extension, (newState) => (result = newState), ); expect(result).toBe(ExtensionUpdateState.ERROR); }); }); 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 } = parseGitHubRepoForReleases(source); expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should parse owner and repo from a full GitHub UR without .git', () => { const source = 'https://github.com/owner/repo'; const { owner, repo } = parseGitHubRepoForReleases(source); expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should fail on a GitHub SSH URL', () => { const source = 'git@github.com:owner/repo.git'; expect(() => parseGitHubRepoForReleases(source)).toThrow( 'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.', ); }); it('should fail on a non-GitHub URL', () => { const source = 'https://example.com/owner/repo.git'; expect(() => parseGitHubRepoForReleases(source)).toThrow( 'Invalid GitHub repository source: https://example.com/owner/repo.git. Expected "owner/repo" or a github repo uri.', ); }); it('should parse owner and repo from a shorthand string', () => { const source = 'owner/repo'; const { owner, repo } = parseGitHubRepoForReleases(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 } = parseGitHubRepoForReleases(source); expect(owner).toBe('owner'); expect(repo).toBe('repo'); }); it('should throw error for invalid source format', () => { const source = 'invalid-format'; expect(() => parseGitHubRepoForReleases(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(() => parseGitHubRepoForReleases(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:'); }); }); });