Files
yamadashy--repomix/tests/mcp/tools/attachPackedOutputTool.test.ts
wehub-resource-sync 719032b19f
Update Schema / Update configuration json schema (push) Has been cancelled
Memory Benchmark / Memory Test (Full Analysis) (push) Has been cancelled
CI Quality / Lint GitHub Actions (actionlint) (push) Has been cancelled
CI Quality / Lint GitHub Actions (zizmor) (push) Has been cancelled
CI Quality / Check typos (push) Has been cancelled
CI / Test coverage (push) Has been cancelled
CI / Test (22.x, windows-latest) (push) Has been cancelled
CI / Test (24.x, macos-latest) (push) Has been cancelled
CI / Test (24.x, ubuntu-latest) (push) Has been cancelled
CI / Test (24.x, windows-latest) (push) Has been cancelled
CI / Test (26.x, macos-latest) (push) Has been cancelled
CI / Test (26.x, ubuntu-latest) (push) Has been cancelled
CI / Test (26.x, windows-latest) (push) Has been cancelled
CI / Test with Bun (latest, macos-latest) (push) Has been cancelled
CI / Test with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Test with Bun (latest, windows-latest) (push) Has been cancelled
CI / Build and run (22.x, macos-latest) (push) Has been cancelled
CI / Build and run (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (22.x, windows-latest) (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled
CI Browser Extension / Lint Browser Extension (push) Has been cancelled
CI Browser Extension / Test Browser Extension (push) Has been cancelled
Memory Benchmark / Memory Test (push) Has been cancelled
CI Website / Lint Website Client (push) Has been cancelled
CI Website / Lint Website Server (push) Has been cancelled
CI Website / Test Website Server (push) Has been cancelled
CI Website / Bundle Website Server (push) Has been cancelled
CI / Lint Biome (push) Has been cancelled
CI / Lint oxlint (push) Has been cancelled
CI / Lint TypeScript (push) Has been cancelled
CI / Lint Secretlint (push) Has been cancelled
CI / Test (22.x, macos-latest) (push) Has been cancelled
CI / Test (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, macos-latest) (push) Has been cancelled
CI / Build and run (24.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, windows-latest) (push) Has been cancelled
CI / Build and run (26.x, macos-latest) (push) Has been cancelled
CI / Build and run (26.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (26.x, windows-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, macos-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Docker / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker / build (linux/arm/v7, ubuntu-24.04-arm) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Pack repository with Repomix / pack-repo (push) Has been cancelled
Performance Benchmark History / Benchmark (macos-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (ubuntu-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (windows-latest) (push) Has been cancelled
Performance Benchmark History / Store Results (push) Has been cancelled
Test Repomix Action / Test Node.js 22 (push) Has been cancelled
Test Repomix Action / Test Node.js 24 (push) Has been cancelled
Test Repomix Action / Test Node.js 26 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:37 +08:00

448 lines
15 KiB
TypeScript

import type { Stats } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { registerAttachPackedOutputTool } from '../../../src/mcp/tools/attachPackedOutputTool.js';
import { buildMcpToolErrorResponse, formatPackToolResponse } from '../../../src/mcp/tools/mcpToolRuntime.js';
vi.mock('node:fs/promises');
vi.mock('node:path');
vi.mock('../../../src/mcp/tools/mcpToolRuntime.js', async () => {
const actual = await vi.importActual('../../../src/mcp/tools/mcpToolRuntime.js');
return {
...actual,
formatPackToolResponse: vi.fn(),
buildMcpToolErrorResponse: vi.fn(),
};
});
describe('AttachPackedOutputTool', () => {
const mockServer = {
registerTool: vi.fn().mockReturnThis(),
} as unknown as McpServer;
let toolHandler: (args: { path: string; topFilesLength?: number }) => Promise<CallToolResult>;
const mockXmlContent = `
<repomix>
<file path="src/index.js">console.log('Hello');</file>
<file path="src/utils.js">function helper() {}</file>
<file path="package.json">{"name":"test"}</file>
</repomix>
`;
beforeEach(() => {
vi.resetAllMocks();
registerAttachPackedOutputTool(mockServer);
toolHandler = (mockServer.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][2];
// Mock path functions
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
vi.mocked(path.basename).mockImplementation((p) => p.split('/').pop() || '');
vi.mocked(path.dirname).mockImplementation((p) => p.split('/').slice(0, -1).join('/') || '.');
vi.mocked(path.extname).mockImplementation((p) => {
const parts = p.split('.');
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
});
// Mock fs functions
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => false,
} as unknown as Stats);
vi.mocked(fs.readFile).mockResolvedValue(mockXmlContent);
// Mock mcpToolRuntime functions
vi.mocked(formatPackToolResponse).mockResolvedValue({
content: [{ type: 'text', text: 'Success response' }],
});
vi.mocked(buildMcpToolErrorResponse).mockReturnValue({
isError: true,
content: [{ type: 'text', text: '{"errorMessage":"Error occurred"}' }],
});
});
test('should register tool with correct parameters', () => {
expect(mockServer.registerTool).toHaveBeenCalledWith(
'attach_packed_output',
expect.any(Object), // tool spec
expect.any(Function),
);
});
test('should handle XML file path input', async () => {
const testFilePath = '/test/repomix-output.xml';
const result = await toolHandler({ path: testFilePath });
expect(fs.stat).toHaveBeenCalledWith(testFilePath);
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8');
expect(formatPackToolResponse).toHaveBeenCalled();
const expectedFilePaths = ['src/index.js', 'src/utils.js', 'package.json'];
const expectedCharCounts = {
'src/index.js': "console.log('Hello');".length,
'src/utils.js': 'function helper() {}'.length,
'package.json': '{"name":"test"}'.length,
};
const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0);
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 3,
totalCharacters: totalCharacters,
totalTokens: Math.floor(totalCharacters / 4),
safeFilePaths: expectedFilePaths,
fileCharCounts: expectedCharCounts,
}),
testFilePath,
undefined,
// Attached files are marked for serve-time secret scanning.
true,
);
expect(result).toEqual({
content: [{ type: 'text', text: 'Success response' }],
});
});
test('should handle directory path input', async () => {
const testDirPath = '/test/project';
const expectedXmlPath = '/test/project/repomix-output.xml';
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as unknown as Stats);
vi.mocked(fs.access).mockResolvedValue(undefined);
const result = await toolHandler({ path: testDirPath });
expect(fs.stat).toHaveBeenCalledWith(testDirPath);
expect(fs.access).toHaveBeenCalledWith(expectedXmlPath);
expect(fs.readFile).toHaveBeenCalledWith(expectedXmlPath, 'utf8');
expect(formatPackToolResponse).toHaveBeenCalled();
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'project' },
expect.anything(),
expectedXmlPath,
undefined,
true,
);
expect(result).toEqual({
content: [{ type: 'text', text: 'Success response' }],
});
});
test('should handle custom topFilesLength option', async () => {
const testFilePath = '/test/repomix-output.xml';
const topFilesLength = 20;
await toolHandler({ path: testFilePath, topFilesLength });
expect(formatPackToolResponse).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
testFilePath,
topFilesLength,
true,
);
});
test('should handle non-supported file format error', async () => {
const testFilePath = '/test/not-supported-file.xyz';
const result = await toolHandler({ path: testFilePath });
expect(result.isError).toBe(true);
expect(buildMcpToolErrorResponse).toHaveBeenCalled();
});
test('should handle file not found error', async () => {
const testFilePath = '/test/non-existent.xml';
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT: no such file or directory'));
const result = await toolHandler({ path: testFilePath });
expect(result.isError).toBe(true);
expect(buildMcpToolErrorResponse).toHaveBeenCalled();
});
test('should handle read file error', async () => {
const testFilePath = '/test/repomix-output.xml';
vi.mocked(fs.readFile).mockRejectedValue(new Error('Failed to read file'));
const result = await toolHandler({ path: testFilePath });
expect(result.isError).toBe(true);
expect(buildMcpToolErrorResponse).toHaveBeenCalled();
});
test('should extract file paths correctly from XML content', async () => {
const testFilePath = '/test/repomix-output.xml';
await toolHandler({ path: testFilePath });
const formatPackToolResponseCalls = vi.mocked(formatPackToolResponse).mock.calls;
expect(formatPackToolResponseCalls.length).toBeGreaterThan(0);
// Check that the second argument (packResult) contains the expected file paths
const packResult = formatPackToolResponseCalls[formatPackToolResponseCalls.length - 1][1];
expect(packResult).toHaveProperty('safeFilePaths');
expect(packResult.safeFilePaths).toEqual(['src/index.js', 'src/utils.js', 'package.json']);
});
test('should mark attached outputs for serve-time secret scanning', async () => {
// The attach path does not scan or return content itself; instead it flags the
// registered output so read_repomix_output / grep_repomix_output secret-scan it
// before serving, matching the file_system_read_file boundary.
const testFilePath = '/test/repomix-output.xml';
await toolHandler({ path: testFilePath });
const calls = vi.mocked(formatPackToolResponse).mock.calls;
expect(calls.length).toBeGreaterThan(0);
// The requiresSecretScan flag (5th argument) must be true.
expect(calls[calls.length - 1][4]).toBe(true);
});
test('should handle directory without repomix-output.xml', async () => {
const testDirPath = '/test/empty-project';
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as unknown as Stats);
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT: no such file'));
const result = await toolHandler({ path: testDirPath });
expect(result.isError).toBe(true);
expect(buildMcpToolErrorResponse).toHaveBeenCalled();
});
test('should handle malformed XML by returning zero metrics', async () => {
const testFilePath = '/test/repomix-output.xml';
const malformedXml = '<repomix><file path="test.js">unclosed tag</repomix>';
vi.mocked(fs.readFile).mockResolvedValue(malformedXml);
await toolHandler({ path: testFilePath });
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 0,
totalCharacters: 0,
totalTokens: 0,
safeFilePaths: [],
fileCharCounts: {},
}),
testFilePath,
undefined,
true,
);
});
test('should handle Markdown file path input', async () => {
const testFilePath = '/test/repomix-output.md';
const markdownContent = `
# Files
## File: src/index.js
\`\`\`javascript
console.log('Hello');
\`\`\`
## File: src/utils.js
\`\`\`javascript
function helper() {}
\`\`\`
`;
vi.mocked(fs.readFile).mockResolvedValue(markdownContent);
await toolHandler({ path: testFilePath });
expect(fs.stat).toHaveBeenCalledWith(testFilePath);
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8');
expect(formatPackToolResponse).toHaveBeenCalled();
const expectedFilePaths = ['src/index.js', 'src/utils.js'];
const expectedCharCounts = {
'src/index.js': "console.log('Hello');\n".length,
'src/utils.js': 'function helper() {}\n'.length,
};
const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0);
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 2,
totalCharacters: totalCharacters,
totalTokens: Math.floor(totalCharacters / 4),
safeFilePaths: expectedFilePaths,
fileCharCounts: expectedCharCounts,
}),
testFilePath,
undefined,
true,
);
});
test('should handle Plain text file path input', async () => {
const testFilePath = '/test/repomix-output.txt';
const plainContent = `
================
File: src/index.js
================
console.log('Hello');
================
File: src/utils.js
================
function helper() {}
`;
vi.mocked(fs.readFile).mockResolvedValue(plainContent);
await toolHandler({ path: testFilePath });
expect(fs.stat).toHaveBeenCalledWith(testFilePath);
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8');
expect(formatPackToolResponse).toHaveBeenCalled();
const expectedFilePaths = ['src/index.js', 'src/utils.js'];
const expectedCharCounts = {
'src/index.js': "console.log('Hello');".length,
'src/utils.js': 'function helper() {}'.length,
};
const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0);
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 2,
totalCharacters: totalCharacters,
totalTokens: Math.floor(totalCharacters / 4),
safeFilePaths: expectedFilePaths,
fileCharCounts: expectedCharCounts,
}),
testFilePath,
undefined,
true,
);
});
test('should handle JSON file path input', async () => {
const testFilePath = '/test/repomix-output.json';
const jsonContent = JSON.stringify({
files: {
'src/index.js': "console.log('Hello');",
'src/utils.js': 'function helper() {}',
'package.json': '{"name":"test"}',
},
});
vi.mocked(fs.readFile).mockResolvedValue(jsonContent);
await toolHandler({ path: testFilePath });
expect(fs.stat).toHaveBeenCalledWith(testFilePath);
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'utf8');
expect(formatPackToolResponse).toHaveBeenCalled();
const expectedFilePaths = ['src/index.js', 'src/utils.js', 'package.json'];
const expectedCharCounts = {
'src/index.js': "console.log('Hello');".length,
'src/utils.js': 'function helper() {}'.length,
'package.json': '{"name":"test"}'.length,
};
const totalCharacters = Object.values(expectedCharCounts).reduce((a, b) => a + b, 0);
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 3,
totalCharacters: totalCharacters,
totalTokens: Math.floor(totalCharacters / 4),
safeFilePaths: expectedFilePaths,
fileCharCounts: expectedCharCounts,
}),
testFilePath,
undefined,
true,
);
});
test('should handle malformed JSON by returning zero metrics', async () => {
const testFilePath = '/test/repomix-output.json';
const malformedJson = '{"files": {"test.js": "content"'; // missing closing braces
vi.mocked(fs.readFile).mockResolvedValue(malformedJson);
await toolHandler({ path: testFilePath });
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 0,
totalCharacters: 0,
totalTokens: 0,
safeFilePaths: [],
fileCharCounts: {},
}),
testFilePath,
undefined,
true,
);
});
test('should handle CRLF line endings in Markdown format', async () => {
const testFilePath = '/test/repomix-output.md';
const markdownContentWithCRLF = `# Files\r\n\r\n## File: src/index.js\r\n\`\`\`javascript\r\nconsole.log('Hello');\r\n\`\`\`\r\n\r\n## File: src/utils.js\r\n\`\`\`javascript\r\nfunction helper() {}\r\n\`\`\``;
vi.mocked(fs.readFile).mockResolvedValue(markdownContentWithCRLF);
await toolHandler({ path: testFilePath });
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 2,
safeFilePaths: ['src/index.js', 'src/utils.js'],
fileCharCounts: {
'src/index.js': "console.log('Hello');\r\n".length,
'src/utils.js': 'function helper() {}\r\n'.length,
},
}),
testFilePath,
undefined,
true,
);
});
test('should handle CRLF line endings in Plain text format', async () => {
const testFilePath = '/test/repomix-output.txt';
const plainContentWithCRLF = `================\r\nFile: src/index.js\r\n================\r\nconsole.log('Hello');\r\n\r\n================\r\nFile: src/utils.js\r\n================\r\nfunction helper() {}`;
vi.mocked(fs.readFile).mockResolvedValue(plainContentWithCRLF);
await toolHandler({ path: testFilePath });
expect(formatPackToolResponse).toHaveBeenCalledWith(
{ directory: 'test' },
expect.objectContaining({
totalFiles: 2,
safeFilePaths: ['src/index.js', 'src/utils.js'],
fileCharCounts: {
'src/index.js': "console.log('Hello');".length,
'src/utils.js': 'function helper() {}'.length,
},
}),
testFilePath,
undefined,
true,
);
});
});