mirror of
https://github.com/yamadashy/repomix.git
synced 2026-03-29 11:58:46 +02:00
With the streaming pipeline, errors propagate as native Error objects rather than RepomixError, so the isExtractionError check was always false. Retrying extraction errors is acceptable since the retry loop is bounded to 3 attempts.
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
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<typeof vi.fn<typeof globalThis.fetch>>;
|
||
let mockPipeline: ReturnType<typeof vi.fn<typeof pipelineType>>;
|
||
let mockTarExtract: ReturnType<typeof vi.fn<typeof tarExtractType>>;
|
||
let mockCreateGunzip: ReturnType<typeof vi.fn<typeof zlib.createGunzip>>;
|
||
let mockDeps: MockDeps;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
|
||
mockFetch = vi.fn<typeof globalThis.fetch>();
|
||
mockPipeline = vi.fn<typeof pipelineType>().mockResolvedValue(undefined);
|
||
mockTarExtract = vi.fn<typeof tarExtractType>().mockReturnValue(
|
||
new Writable({
|
||
write(_chunk, _enc, cb) {
|
||
cb();
|
||
},
|
||
}) as unknown as ReturnType<typeof tarExtractType>,
|
||
);
|
||
mockCreateGunzip = vi.fn<typeof zlib.createGunzip>().mockReturnValue(
|
||
new Transform({
|
||
transform(chunk, _enc, cb) {
|
||
cb(null, chunk);
|
||
},
|
||
}) as unknown as ReturnType<typeof zlib.createGunzip>,
|
||
);
|
||
|
||
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> = {}): 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<Response>),
|
||
)
|
||
.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<Response>));
|
||
|
||
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);
|
||
});
|
||
});
|
||
});
|