import { Transform, Writable } from 'node:stream'; import type { pipeline as pipelineType } from 'node:stream/promises'; import type * as zlib from 'node:zlib'; import type { extract as tarExtractType } from 'tar'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type ArchiveDownloadOptions, downloadGitHubArchive, isArchiveDownloadSupported, type ProgressCallback, } from '../../../src/core/git/gitHubArchive.js'; import type { GitHubRepoInfo } from '../../../src/core/git/gitRemoteParse.js'; import { RepomixError } from '../../../src/shared/errorHandle.js'; // Mock modules vi.mock('../../../src/shared/logger'); // Type for the deps parameter of downloadGitHubArchive interface MockDeps { fetch: typeof globalThis.fetch; pipeline: typeof pipelineType; Transform: typeof Transform; tarExtract: typeof tarExtractType; createGunzip: typeof zlib.createGunzip; } // Simple test data const mockStreamData = new Uint8Array([0x1f, 0x8b, 0x08, 0x00]); // gzip magic bytes describe('gitHubArchive', () => { let mockFetch: ReturnType>; let mockPipeline: ReturnType>; let mockTarExtract: ReturnType>; let mockCreateGunzip: ReturnType>; let mockDeps: MockDeps; beforeEach(() => { vi.clearAllMocks(); mockFetch = vi.fn(); mockPipeline = vi.fn().mockResolvedValue(undefined); mockTarExtract = vi.fn().mockReturnValue( new Writable({ write(_chunk, _enc, cb) { cb(); }, }) as unknown as ReturnType, ); mockCreateGunzip = vi.fn().mockReturnValue( new Transform({ transform(chunk, _enc, cb) { cb(null, chunk); }, }) as unknown as ReturnType, ); mockDeps = { fetch: mockFetch, pipeline: mockPipeline as unknown as typeof pipelineType, Transform, tarExtract: mockTarExtract as unknown as typeof tarExtractType, createGunzip: mockCreateGunzip as unknown as typeof zlib.createGunzip, }; }); describe('downloadGitHubArchive', () => { const mockRepoInfo: GitHubRepoInfo = { owner: 'yamadashy', repo: 'repomix', ref: 'main', }; const mockTargetDirectory = '/test/target'; const mockOptions: ArchiveDownloadOptions = { timeout: 30000, retries: 3, }; const createMockResponse = (overrides: Partial = {}): Response => { return { ok: true, status: 200, headers: new Map([['content-length', mockStreamData.length.toString()]]), body: new ReadableStream({ start(controller) { controller.enqueue(mockStreamData); controller.close(); }, }), ...overrides, } as unknown as Response; }; test('should successfully download and extract archive', async () => { mockFetch.mockResolvedValue(createMockResponse()); await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, mockDeps); // Verify fetch was called with tar.gz URL expect(mockFetch).toHaveBeenCalledWith( 'https://github.com/yamadashy/repomix/archive/refs/heads/main.tar.gz', expect.objectContaining({ signal: expect.any(AbortSignal), }), ); // Verify tar extract was called with correct options expect(mockTarExtract).toHaveBeenCalledWith({ cwd: mockTargetDirectory, strip: 1, }); // Verify streaming pipeline was used expect(mockPipeline).toHaveBeenCalledTimes(1); }); test('should handle progress callback', async () => { const mockProgressCallback: ProgressCallback = vi.fn(); mockFetch.mockResolvedValue(createMockResponse()); await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, mockProgressCallback, mockDeps); expect(mockFetch).toHaveBeenCalled(); expect(mockPipeline).toHaveBeenCalled(); }); test('should retry on network failure', async () => { mockFetch .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce(createMockResponse()); await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, mockDeps); expect(mockFetch).toHaveBeenCalledTimes(3); }); test('should try fallback URLs on 404', async () => { mockFetch .mockResolvedValueOnce( createMockResponse({ ok: false, status: 404, headers: new Map(), body: null, } as unknown as Partial), ) .mockResolvedValueOnce(createMockResponse()); const repoInfoNoRef = { owner: 'yamadashy', repo: 'repomix' }; await downloadGitHubArchive(repoInfoNoRef, mockTargetDirectory, mockOptions, undefined, mockDeps); // Should try HEAD first, then master branch expect(mockFetch).toHaveBeenCalledWith( 'https://github.com/yamadashy/repomix/archive/HEAD.tar.gz', expect.any(Object), ); expect(mockFetch).toHaveBeenCalledWith( 'https://github.com/yamadashy/repomix/archive/refs/heads/master.tar.gz', expect.any(Object), ); }); test('should throw error after all retries fail', async () => { mockFetch.mockRejectedValue(new Error('Network error')); await expect( downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, mockDeps), ).rejects.toThrow(RepomixError); // 2 retries × 2 URLs (main + tag for "main" ref) = 4 total attempts expect(mockFetch).toHaveBeenCalledTimes(4); }); test('should handle extraction error', async () => { mockFetch.mockResolvedValue(createMockResponse()); mockPipeline.mockRejectedValue(new Error('tar extraction failed')); await expect( downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, mockDeps), ).rejects.toThrow(RepomixError); }); test('should handle missing response body', async () => { mockFetch.mockResolvedValue(createMockResponse({ body: null } as unknown as Partial)); await expect( downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, mockDeps), ).rejects.toThrow(RepomixError); }); test('should handle timeout', async () => { mockFetch.mockImplementation( (_url: string | URL | Request, init?: RequestInit) => new Promise((resolve, reject) => { const timer = setTimeout(() => { resolve(createMockResponse()); }, 100); // Respect AbortSignal so timeout actually cancels the fetch init?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('The operation was aborted', 'AbortError')); }); }), ); const shortTimeout = 50; await expect( downloadGitHubArchive( mockRepoInfo, mockTargetDirectory, { timeout: shortTimeout, retries: 1 }, undefined, mockDeps, ), ).rejects.toThrow(); }); }); describe('isArchiveDownloadSupported', () => { test('should return true for any GitHub repository', () => { const repoInfo: GitHubRepoInfo = { owner: 'yamadashy', repo: 'repomix', }; const result = isArchiveDownloadSupported(repoInfo); expect(result).toBe(true); }); test('should return true for repository with ref', () => { const repoInfo: GitHubRepoInfo = { owner: 'yamadashy', repo: 'repomix', ref: 'develop', }; const result = isArchiveDownloadSupported(repoInfo); expect(result).toBe(true); }); }); });