Skip to content

Commit 5724888

Browse files
shtse8claude
andcommitted
test(coverage): cover stat-items and read-content fs-error branches
Mock node:fs and resolvePath so the filesystem-error mapping branches become deterministically reachable: - stat-items: ENOENT -> 'Path not found', EACCES/EPERM -> permission denied, uncoded Error and non-Error rejections -> generic 'Failed to get stats', and an McpError passed straight through. - read-content: EISDIR/EACCES/EPERM specific messages plus the basic 'Filesystem error' fallbacks (unknown code, non-object rejection), the ENOENT resolved-path message, and the whole-file (no-range) read path. These are the real error-mapping branches the integration suites can't hit with a live filesystem. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6547804 commit 5724888

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { vi, describe, it, expect, beforeEach } from 'vitest';
2+
3+
// Mock fs.stat/readFile and resolvePath so the specific filesystem-error
4+
// messages in getSpecificFsErrorMessage (EISDIR / EACCES / EPERM) and the
5+
// basic-message fallbacks are reachable without relying on real OS errors.
6+
vi.mock('node:fs', () => ({
7+
promises: {
8+
stat: vi.fn().mockName('fs.stat'),
9+
readFile: vi.fn().mockName('fs.readFile'),
10+
},
11+
}));
12+
13+
vi.mock('../../src/utils/path-utils.js', () => ({
14+
resolvePath: vi.fn().mockName('pathUtils.resolvePath'),
15+
PROJECT_ROOT: '/project-root',
16+
}));
17+
18+
const isFileStat = (isFile: boolean) => ({ isFile: () => isFile });
19+
20+
describe('read-content filesystem-error branches', () => {
21+
let handler: (args: unknown) => Promise<{ content: { text: string }[] }>;
22+
let fsMock: { stat: ReturnType<typeof vi.fn>; readFile: ReturnType<typeof vi.fn> };
23+
let resolvePath: ReturnType<typeof vi.fn>;
24+
25+
beforeEach(async () => {
26+
fsMock = (await import('node:fs')).promises as never;
27+
resolvePath = (await import('../../src/utils/path-utils.js')).resolvePath as never;
28+
vi.resetAllMocks();
29+
resolvePath.mockImplementation((p: string) => `/project-root/${p}`);
30+
fsMock.stat.mockResolvedValue(isFileStat(true));
31+
const { readContentToolDefinition } = await import('../../src/handlers/read-content.js');
32+
handler = readContentToolDefinition.handler;
33+
});
34+
35+
const firstError = async (args: unknown) => {
36+
const res = await handler(args);
37+
return JSON.parse(res.content[0].text)[0].error as string;
38+
};
39+
40+
it('maps EISDIR to a "Path is a directory" message with the relative path', async () => {
41+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'EISDIR' }));
42+
expect(await firstError({ paths: ['somedir'] })).toBe(
43+
'Path is a directory, not a file: somedir',
44+
);
45+
});
46+
47+
it('maps EACCES to a permission-denied read message', async () => {
48+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'EACCES' }));
49+
expect(await firstError({ paths: ['locked.txt'] })).toBe(
50+
'Permission denied reading file: locked.txt',
51+
);
52+
});
53+
54+
it('maps EPERM to a permission-denied read message', async () => {
55+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'EPERM' }));
56+
expect(await firstError({ paths: ['locked.txt'] })).toMatch(/Permission denied reading file/);
57+
});
58+
59+
it('falls back to a basic filesystem-error message for an unknown code', async () => {
60+
fsMock.stat.mockRejectedValue(Object.assign(new Error('weird'), { code: 'EBUSY' }));
61+
expect(await firstError({ paths: ['busy.txt'] })).toBe('Filesystem error: weird');
62+
});
63+
64+
it('falls back to a basic message for a non-object rejection', async () => {
65+
fsMock.stat.mockRejectedValue('totally not an error object');
66+
expect(await firstError({ paths: ['weird.txt'] })).toBe(
67+
'Filesystem error: totally not an error object',
68+
);
69+
});
70+
71+
it('returns ENOENT "File not found" with the resolved target path', async () => {
72+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'ENOENT' }));
73+
const err = await firstError({ paths: ['gone.txt'] });
74+
expect(err).toMatch(/File not found at resolved path '\/project-root\/gone.txt'/);
75+
expect(err).toContain("from relative path 'gone.txt'");
76+
});
77+
78+
it('reads the whole file when no line range is given', async () => {
79+
fsMock.stat.mockResolvedValue(isFileStat(true));
80+
fsMock.readFile.mockResolvedValue('full body');
81+
const res = await handler({ paths: ['whole.txt'] });
82+
const parsed = JSON.parse(res.content[0].text)[0];
83+
expect(parsed.content).toBe('full body');
84+
expect(parsed.error).toBeUndefined();
85+
});
86+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { vi, describe, it, expect, beforeEach } from 'vitest';
2+
3+
// Mock fs.stat and resolvePath so the error-mapping branches in handleStatError
4+
// (ENOENT / EACCES / EPERM / generic) and the rejected-settled branch are all
5+
// reachable deterministically.
6+
vi.mock('node:fs', () => ({
7+
promises: {
8+
stat: vi.fn().mockName('fs.stat'),
9+
},
10+
}));
11+
12+
vi.mock('../../src/utils/path-utils', () => ({
13+
resolvePath: vi.fn().mockName('pathUtils.resolvePath'),
14+
PROJECT_ROOT: '/project-root',
15+
}));
16+
17+
describe('stat-items error branches', () => {
18+
let handler: (args: unknown) => Promise<{ content: { text: string }[] }>;
19+
let fsMock: { stat: ReturnType<typeof vi.fn> };
20+
let resolvePath: ReturnType<typeof vi.fn>;
21+
22+
beforeEach(async () => {
23+
fsMock = (await import('node:fs')).promises as never;
24+
resolvePath = (await import('../../src/utils/path-utils')).resolvePath as never;
25+
vi.resetAllMocks();
26+
resolvePath.mockImplementation((p: string) => `/project-root/${p}`);
27+
const { statItemsToolDefinition } = await import('../../src/handlers/stat-items');
28+
handler = statItemsToolDefinition.handler;
29+
});
30+
31+
const firstResult = async (args: unknown) => {
32+
const res = await handler(args);
33+
return JSON.parse(res.content[0].text)[0];
34+
};
35+
36+
it('maps ENOENT to a "Path not found" error', async () => {
37+
fsMock.stat.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' }));
38+
const r = await firstResult({ paths: ['missing.txt'] });
39+
expect(r.status).toBe('error');
40+
expect(r.error).toBe('Path not found');
41+
});
42+
43+
it('maps EACCES to a permission-denied error including the path', async () => {
44+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'EACCES' }));
45+
const r = await firstResult({ paths: ['locked.txt'] });
46+
expect(r.error).toBe('Permission denied stating path: locked.txt');
47+
});
48+
49+
it('maps EPERM to a permission-denied error', async () => {
50+
fsMock.stat.mockRejectedValue(Object.assign(new Error('x'), { code: 'EPERM' }));
51+
const r = await firstResult({ paths: ['locked.txt'] });
52+
expect(r.error).toMatch(/Permission denied stating path/);
53+
});
54+
55+
it('falls back to a generic "Failed to get stats" message for an uncoded error', async () => {
56+
fsMock.stat.mockRejectedValue(new Error('disk on fire'));
57+
const r = await firstResult({ paths: ['weird.txt'] });
58+
expect(r.error).toBe('Failed to get stats: disk on fire');
59+
});
60+
61+
it('passes an McpError message straight through (resolvePath rejection)', async () => {
62+
const { McpError, ErrorCode } = await import('@modelcontextprotocol/sdk/types.js');
63+
resolvePath.mockRejectedValueOnce(new McpError(ErrorCode.InvalidParams, 'bad path'));
64+
const r = await firstResult({ paths: ['../escape'] });
65+
expect(r.error).toBe('bad path');
66+
});
67+
68+
it('stringifies a non-Error rejection in the generic branch', async () => {
69+
fsMock.stat.mockRejectedValue('plain string failure');
70+
const r = await firstResult({ paths: ['weird.txt'] });
71+
expect(r.error).toBe('Failed to get stats: plain string failure');
72+
});
73+
74+
it('rejects an empty paths array via the Zod schema', async () => {
75+
const { McpError } = await import('@modelcontextprotocol/sdk/types.js');
76+
await expect(handler({ paths: [] })).rejects.toBeInstanceOf(McpError);
77+
});
78+
});

0 commit comments

Comments
 (0)