mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
7ff8c8b155
- outputGenerate: tests titled "throws RepomixError…" / "wraps … in
RepomixError" now assert the rejection is an instance of RepomixError
in addition to the message regex, matching the test names.
- LanguageParser: collapse the duplicate getParserForLang('javascript')
rejection assertions into a single .catch capture that checks both
type and message.
- calculateMetrics: vi.mocked(initTaskRunner).mockReset() before
mockReturnValueOnce so a future test that omits taskRunner can't
silently consume the override.
- packager: pre-attach a no-op .catch on the rejected warmupPromise so
vitest's unhandled-rejection detection doesn't fire before pack
awaits it. Production code mirrors this pattern in packager.ts:262.
527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import process from 'node:process';
|
|
import { DOMParser } from '@xmldom/xmldom';
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import type { ProcessedFile } from '../../../src/core/file/fileTypes.js';
|
|
import { buildOutputGeneratorContext, generateOutput } from '../../../src/core/output/outputGenerate.js';
|
|
import { RepomixError } from '../../../src/shared/errorHandle.js';
|
|
import { createMockConfig } from '../../testing/testUtils.js';
|
|
|
|
const createStrictXmlParser = () => {
|
|
return new DOMParser({
|
|
onError: (_level, msg) => {
|
|
throw new Error(msg);
|
|
},
|
|
});
|
|
};
|
|
|
|
describe('outputGenerate', () => {
|
|
const mockDeps = {
|
|
buildOutputGeneratorContext: vi.fn(),
|
|
generateHandlebarOutput: vi.fn(),
|
|
generateParsableXmlOutput: vi.fn(),
|
|
generateParsableJsonOutput: vi.fn(),
|
|
sortOutputFiles: vi.fn(),
|
|
};
|
|
test('generateOutput should use sortOutputFiles before generating content', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
git: { sortByChanges: true },
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [
|
|
{ path: 'file1.txt', content: 'content1' },
|
|
{ path: 'file2.txt', content: 'content2' },
|
|
];
|
|
const sortedFiles = [
|
|
{ path: 'file2.txt', content: 'content2' },
|
|
{ path: 'file1.txt', content: 'content1' },
|
|
];
|
|
|
|
mockDeps.sortOutputFiles.mockResolvedValue(sortedFiles);
|
|
mockDeps.buildOutputGeneratorContext.mockResolvedValue({
|
|
processedFiles: sortedFiles,
|
|
config: mockConfig,
|
|
treeString: '',
|
|
generationDate: new Date().toISOString(),
|
|
instruction: '',
|
|
filesEnabled: true,
|
|
});
|
|
mockDeps.generateHandlebarOutput.mockResolvedValue('mock output');
|
|
|
|
const output = await generateOutput(
|
|
[process.cwd()],
|
|
mockConfig,
|
|
mockProcessedFiles,
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
mockDeps,
|
|
);
|
|
|
|
expect(mockDeps.sortOutputFiles).toHaveBeenCalledWith(mockProcessedFiles, mockConfig);
|
|
expect(mockDeps.buildOutputGeneratorContext).toHaveBeenCalledWith(
|
|
[process.cwd()],
|
|
mockConfig,
|
|
[],
|
|
sortedFiles,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(output).toBe('mock output');
|
|
});
|
|
|
|
test('generateOutput should write correct content to file (plain style)', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
topFilesLength: 2,
|
|
showLineNumbers: false,
|
|
removeComments: false,
|
|
removeEmptyLines: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [
|
|
{ path: 'file1.txt', content: 'content1' },
|
|
{ path: 'dir/file2.txt', content: 'content2' },
|
|
];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
|
|
expect(output).toContain('File Summary');
|
|
expect(output).toContain('File: file1.txt');
|
|
expect(output).toContain('content1');
|
|
expect(output).toContain('File: dir/file2.txt');
|
|
expect(output).toContain('content2');
|
|
});
|
|
|
|
test('generateOutput should write correct content to file (parsable xml style)', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'xml',
|
|
parsableStyle: true,
|
|
topFilesLength: 2,
|
|
showLineNumbers: false,
|
|
removeComments: false,
|
|
removeEmptyLines: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [
|
|
{ path: 'file1.txt', content: '<div>foo</div>' },
|
|
{ path: 'dir/file2.txt', content: 'if (a && b)' },
|
|
];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
|
|
const doc = createStrictXmlParser().parseFromString(output, 'text/xml');
|
|
const repomix = doc.documentElement;
|
|
if (!repomix) throw new Error('documentElement is null');
|
|
|
|
expect(repomix.getElementsByTagName('file_summary').length).toBe(1);
|
|
|
|
const fileElements = repomix.getElementsByTagName('file');
|
|
expect(fileElements.length).toBe(2);
|
|
expect(fileElements[0].getAttribute('path')).toBe(mockProcessedFiles[0].path);
|
|
expect(fileElements[0].textContent).toBe(mockProcessedFiles[0].content);
|
|
expect(fileElements[1].getAttribute('path')).toBe(mockProcessedFiles[1].path);
|
|
expect(fileElements[1].textContent).toBe(mockProcessedFiles[1].content);
|
|
});
|
|
|
|
test('generateOutput should write correct content to file (parsable markdown style)', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'markdown',
|
|
parsableStyle: true,
|
|
topFilesLength: 2,
|
|
showLineNumbers: false,
|
|
removeComments: false,
|
|
removeEmptyLines: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [
|
|
{ path: 'file1.txt', content: 'content1' },
|
|
{ path: 'dir/file2.txt', content: '```\ncontent2\n```' },
|
|
];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
|
|
expect(output).toContain('# File Summary');
|
|
expect(output).toContain('## File: file1.txt');
|
|
expect(output).toContain('````\ncontent1\n````');
|
|
expect(output).toContain('## File: dir/file2.txt');
|
|
expect(output).toContain('````\n```\ncontent2\n```\n````');
|
|
});
|
|
|
|
test('generateOutput (txt) should omit generationHeader when fileSummaryEnabled is false, but always include headerText if provided', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
fileSummary: false,
|
|
headerText: 'ALWAYS SHOW THIS HEADER',
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
expect(output).not.toContain('This file is a merged representation'); // generationHeader
|
|
expect(output).toContain('ALWAYS SHOW THIS HEADER');
|
|
});
|
|
|
|
test('generateOutput (xml) omits generationHeader when fileSummaryEnabled is false, but always includes headerText', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.xml',
|
|
style: 'xml',
|
|
fileSummary: false,
|
|
headerText: 'XML HEADER',
|
|
parsableStyle: true,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: '<div>foo</div>' }];
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
const doc = createStrictXmlParser().parseFromString(output, 'text/xml');
|
|
const repomix = doc.documentElement;
|
|
if (!repomix) throw new Error('documentElement is null');
|
|
expect(repomix.getElementsByTagName('file_summary').length).toBe(0);
|
|
const header = repomix.getElementsByTagName('user_provided_header')[0];
|
|
expect(header.textContent).toBe('XML HEADER');
|
|
});
|
|
|
|
test('generateOutput (markdown) omits generationHeader when fileSummaryEnabled is false, but always includes headerText', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.md',
|
|
style: 'markdown',
|
|
fileSummary: false,
|
|
headerText: 'MARKDOWN HEADER',
|
|
parsableStyle: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
expect(output).not.toContain('This file is a merged representation');
|
|
expect(output).toContain('MARKDOWN HEADER');
|
|
});
|
|
|
|
test('generateOutput should include git diffs when enabled', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
git: { includeDiffs: true },
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
const mockGitDiffResult = {
|
|
workTreeDiffContent: 'diff --git a/file.txt',
|
|
stagedDiffContent: 'diff --git b/staged.txt',
|
|
};
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, [], mockGitDiffResult);
|
|
|
|
expect(output).toContain('Git Diffs');
|
|
expect(output).toContain('diff --git a/file.txt');
|
|
expect(output).toContain('diff --git b/staged.txt');
|
|
});
|
|
|
|
test('generateOutput should include git logs when enabled', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
git: { includeLogs: true },
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
const mockGitLogResult = {
|
|
logContent: 'commit abc123\nAuthor: test',
|
|
commits: [
|
|
{
|
|
date: '2024-01-01',
|
|
message: 'Initial commit',
|
|
files: ['file1.txt'],
|
|
},
|
|
],
|
|
};
|
|
|
|
const output = await generateOutput(
|
|
[process.cwd()],
|
|
mockConfig,
|
|
mockProcessedFiles,
|
|
[],
|
|
undefined,
|
|
mockGitLogResult,
|
|
);
|
|
|
|
expect(output).toContain('Git Logs');
|
|
expect(output).toContain('Initial commit');
|
|
expect(output).toContain('2024-01-01');
|
|
});
|
|
|
|
test('generateOutput should write correct content to file (json style)', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.json',
|
|
style: 'json',
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [
|
|
{ path: 'file1.txt', content: 'content1' },
|
|
{ path: 'file2.txt', content: 'content2' },
|
|
];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
|
|
const parsed = JSON.parse(output);
|
|
expect(parsed).toHaveProperty('files');
|
|
expect(parsed.files).toHaveProperty('file1.txt');
|
|
expect(parsed.files).toHaveProperty('file2.txt');
|
|
expect(parsed.files['file1.txt']).toBe('content1');
|
|
expect(parsed.files['file2.txt']).toBe('content2');
|
|
});
|
|
|
|
test('generateOutput should exclude files section when files output is disabled', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
files: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []);
|
|
|
|
expect(output).not.toContain('File: file1.txt');
|
|
expect(output).not.toContain('content1');
|
|
});
|
|
|
|
test('generateOutput should exclude directory structure when disabled', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: false,
|
|
},
|
|
});
|
|
const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }];
|
|
|
|
const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, ['file1.txt']);
|
|
|
|
expect(output).not.toContain('Directory Structure');
|
|
});
|
|
|
|
test('generateOutput throws RepomixError for unsupported style', async () => {
|
|
const mockConfig = createMockConfig({
|
|
output: { filePath: 'output.txt', style: 'unsupported' as 'plain' },
|
|
});
|
|
|
|
await expect(generateOutput([process.cwd()], mockConfig, [], [])).rejects.toBeInstanceOf(RepomixError);
|
|
});
|
|
});
|
|
|
|
describe('buildOutputGeneratorContext', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const baseConfig = (overrides = {}) =>
|
|
createMockConfig({
|
|
cwd: '/repo',
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: false,
|
|
},
|
|
...overrides,
|
|
});
|
|
|
|
test('reads the instruction file when configured', async () => {
|
|
vi.spyOn(fs, 'readFile').mockResolvedValue('be helpful');
|
|
const config = baseConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: false,
|
|
instructionFilePath: 'INSTRUCTIONS.md',
|
|
},
|
|
});
|
|
|
|
const ctx = await buildOutputGeneratorContext(['/repo'], config, [], []);
|
|
|
|
expect(ctx.instruction).toBe('be helpful');
|
|
});
|
|
|
|
test('throws RepomixError when instruction file is missing', async () => {
|
|
vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('ENOENT'));
|
|
const config = baseConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: false,
|
|
instructionFilePath: 'missing.md',
|
|
},
|
|
});
|
|
|
|
const promise = buildOutputGeneratorContext(['/repo'], config, [], []);
|
|
await expect(promise).rejects.toBeInstanceOf(RepomixError);
|
|
await expect(promise).rejects.toThrow(/Instruction file not found/);
|
|
});
|
|
|
|
test('uses pre-computed emptyDirPaths when includeEmptyDirectories is on', async () => {
|
|
const config = baseConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: true,
|
|
includeEmptyDirectories: true,
|
|
},
|
|
});
|
|
const searchFiles = vi.fn();
|
|
|
|
const ctx = await buildOutputGeneratorContext(
|
|
['/repo'],
|
|
config,
|
|
[],
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
['empty-dir'],
|
|
{
|
|
listDirectories: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
searchFiles,
|
|
},
|
|
);
|
|
|
|
// Pre-computed paths win — searchFiles should not be called.
|
|
expect(searchFiles).not.toHaveBeenCalled();
|
|
expect(ctx.treeString).toContain('empty-dir');
|
|
});
|
|
|
|
test('falls back to searchFiles when emptyDirPaths is not provided', async () => {
|
|
const config = baseConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: true,
|
|
includeEmptyDirectories: true,
|
|
},
|
|
});
|
|
const searchFiles = vi.fn().mockResolvedValue({ filePaths: [], emptyDirPaths: ['scanned-dir'] });
|
|
|
|
const ctx = await buildOutputGeneratorContext(
|
|
['/repo'],
|
|
config,
|
|
[],
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
listDirectories: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
searchFiles,
|
|
},
|
|
);
|
|
|
|
expect(searchFiles).toHaveBeenCalledWith('/repo', config);
|
|
expect(ctx.treeString).toContain('scanned-dir');
|
|
});
|
|
|
|
test('wraps searchFiles failure in RepomixError', async () => {
|
|
const config = baseConfig({
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: true,
|
|
includeEmptyDirectories: true,
|
|
},
|
|
});
|
|
|
|
const promise = buildOutputGeneratorContext(['/repo'], config, [], [], undefined, undefined, undefined, undefined, {
|
|
listDirectories: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
searchFiles: vi.fn().mockRejectedValue(new Error('boom')),
|
|
});
|
|
await expect(promise).rejects.toBeInstanceOf(RepomixError);
|
|
await expect(promise).rejects.toThrow(/Failed to search for empty directories.*boom/);
|
|
});
|
|
|
|
test('includes the full directory tree when includeFullDirectoryStructure is on', async () => {
|
|
const config = baseConfig({
|
|
include: ['src/**/*.ts'],
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: true,
|
|
includeFullDirectoryStructure: true,
|
|
},
|
|
});
|
|
const listDirectories = vi.fn().mockResolvedValue(['src', 'src/feature']);
|
|
const listFiles = vi.fn().mockResolvedValue(['src/index.ts', 'src/feature/extra.ts']);
|
|
|
|
const ctx = await buildOutputGeneratorContext(
|
|
['/repo'],
|
|
config,
|
|
['src/index.ts'],
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
listDirectories,
|
|
listFiles,
|
|
searchFiles: vi.fn(),
|
|
},
|
|
);
|
|
|
|
expect(listDirectories).toHaveBeenCalledWith('/repo', config);
|
|
expect(listFiles).toHaveBeenCalledWith('/repo', config);
|
|
// The extra file from listFiles (not in allFilePaths) should be merged into the tree.
|
|
expect(ctx.treeString).toContain('extra.ts');
|
|
});
|
|
|
|
test('wraps full-tree listing failure in RepomixError', async () => {
|
|
const config = baseConfig({
|
|
include: ['src/**/*.ts'],
|
|
output: {
|
|
filePath: 'output.txt',
|
|
style: 'plain',
|
|
directoryStructure: true,
|
|
includeFullDirectoryStructure: true,
|
|
},
|
|
});
|
|
|
|
const promise = buildOutputGeneratorContext(
|
|
['/repo'],
|
|
config,
|
|
['src/index.ts'],
|
|
[],
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
listDirectories: vi.fn().mockRejectedValue(new Error('list failed')),
|
|
listFiles: vi.fn().mockResolvedValue([]),
|
|
searchFiles: vi.fn(),
|
|
},
|
|
);
|
|
await expect(promise).rejects.toBeInstanceOf(RepomixError);
|
|
await expect(promise).rejects.toThrow(/Failed to build full directory structure.*list failed/);
|
|
});
|
|
});
|