Files
repomix-mirror/tests/core/output/outputGenerate.test.ts
Kazuki Yamada 7ff8c8b155 test: Address PR review feedback (RepomixError instances + mock isolation)
- 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.
2026-04-26 22:29:06 +09:00

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/);
});
});