Files
Codeman/test/routes/file-walk-bounds.test.ts
T
Codeman maintainer 37dfb73b1e fix: flag a Files panel folder that did not answer the probe (release review)
A subfolder whose bounded probe answered `unknown` came back as `children: []`
with no `incomplete` and `truncated` false, so a dead mount, or a healthy one
slower than the 1.5 s probe on first touch, read as an empty folder and a
search missed it in silence. `unknown` is never `absent` (#516).

Such a folder now goes on the walk's `unread` list, which flags the node and
sets `truncated` in both walks. A folder the filesystem says is gone drops out
quietly as before. The row said "file limit reached", which would be false
here, so it now reads "Not fully scanned" for either reason.

Reproduced on a real disk with a parent at mode 0444, where the child's stat
answers EACCES: the API, the search and the panel all report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:41:15 +01:00

179 lines
7.2 KiB
TypeScript

/**
* GET /api/sessions/:id/files: what one listing costs the server.
*
* test/routes/file-search-mode.test.ts pins what the walk returns, on real folders. This file pins
* what it reads, which is where a real tree cannot go: 100 folders of 50,000 files is minutes of
* setup, and a dead network mount cannot be made in a test. So `fs.readdir` and `fs.stat` are
* stubbed here, on the one object the route and the bounded probe both call through.
*
* Port: none (app.inject).
*/
import { Dirent, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const UV_DIRENT_FILE = 1;
const UV_DIRENT_DIR = 2;
const dirent = (name: string, type: number, parent: string): Dirent =>
new (Dirent as unknown as new (name: string, type: number, path: string) => Dirent)(name, type, parent);
describe('files endpoint read bounds', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
});
afterEach(() => {
vi.restoreAllMocks();
});
const get = async (query: string) => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/files${query}`,
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data;
};
describe('over a workspace far bigger than the budget', () => {
const FOLDERS = 100;
const FILES_EACH = 50_000;
const WORKSPACE = '/virtual/ws';
let entriesServed = 0;
beforeEach(() => {
entriesServed = 0;
const folders = Array.from({ length: FOLDERS }, (_, index) =>
dirent(`class-${String(index).padStart(5, '0')}`, UV_DIRENT_DIR, WORKSPACE)
);
const files = Array.from({ length: FILES_EACH }, (_, index) =>
dirent(`image-${String(index).padStart(8, '0')}.jpg`, UV_DIRENT_FILE, 'x')
);
vi.spyOn(fs, 'readdir').mockImplementation((async (path: string) => {
const entries = path === WORKSPACE ? folders : files;
entriesServed += entries.length;
return [...entries];
}) as never);
vi.spyOn(fs, 'stat').mockImplementation((async (path: string) => ({
size: 1,
isDirectory: () => !String(path).endsWith('.jpg'),
})) as never);
harness.ctx._session.workingDir = WORKSPACE;
});
it('stops reading once it has read 250,000 entries, not at the end of the workspace', async () => {
const data = await get('');
// Past the ceiling by at most the folder that crossed it.
expect(entriesServed).toBeLessThanOrEqual(250_000 + FILES_EACH);
// What was read is still used, up to the budget, and nothing unread passes as empty.
expect(data.totalFiles + data.totalDirectories).toBe(5000);
expect(data.truncated).toBe(true);
expect(data.tree).toHaveLength(FOLDERS);
for (const folder of data.tree) expect(folder.incomplete).toBe(true);
expect(data.tree.filter((folder: { children: unknown[] }) => folder.children.length === 0)).toHaveLength(
FOLDERS - 5
);
});
it('stops a search the same way', async () => {
const data = await get('?q=image-00000000');
expect(entriesServed).toBeLessThanOrEqual(250_000 + FILES_EACH);
expect(data.matches.length).toBeGreaterThan(0);
expect(data.truncated).toBe(true);
});
});
describe('with a dead mount below the workspace', () => {
let workspace: string;
let deadFolder: string;
let deadReads: number;
let releaseStat: (() => void) | undefined;
beforeEach(() => {
workspace = mkdtempSync(join(tmpdir(), 'codeman-file-dead-'));
deadFolder = join(workspace, 'z-nas');
mkdirSync(deadFolder);
mkdirSync(join(workspace, 'a-fine'));
writeFileSync(join(workspace, 'a-fine', 'needle.ts'), '');
harness.ctx._session.workingDir = workspace;
deadReads = 0;
const never = new Promise<never>(() => {});
const realReaddir = fs.readdir.bind(fs);
const realStat = fs.stat.bind(fs);
vi.spyOn(fs, 'readdir').mockImplementation(((path: string, options: never) => {
if (path !== deadFolder) return realReaddir(path, options);
deadReads++;
return never;
}) as never);
vi.spyOn(fs, 'stat').mockImplementation(((path: string) => {
if (path !== deadFolder) return realStat(path);
// Settled in afterEach: the probe calls a path stalled for as long as its stat is pending.
return new Promise((_resolve, reject) => {
releaseStat = () => reject(Object.assign(new Error('gone'), { code: 'EIO' }));
});
}) as never);
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
releaseStat?.();
releaseStat = undefined;
await new Promise((settled) => setTimeout(settled, 0));
rmSync(workspace, { recursive: true, force: true });
});
const deadline = <T>(work: Promise<T>): Promise<T> =>
Promise.race([
work,
new Promise<T>((_resolve, reject) => {
setTimeout(() => reject(new Error('the listing never answered')), 5000).unref();
}),
]);
it('answers without it, and costs it one stuck stat however often the panel asks', async () => {
const first = await deadline(get(''));
// Nothing was read from it, so `children: []` alone would pass it off as an empty folder (#516:
// unknown is never absent).
const dead = first.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(dead).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [], incomplete: true });
expect(first.truncated).toBe(true);
expect(first.tree[0].children.map((node: { name: string }) => node.name)).toEqual(['needle.ts']);
expect(deadReads).toBe(0);
const statCalls = () => vi.mocked(fs.stat).mock.calls.filter(([path]) => path === deadFolder).length;
const stats = statCalls();
const search = await deadline(get('?q=needle'));
expect(search.matches.map((match: { path: string }) => match.path)).toEqual([join('a-fine', 'needle.ts')]);
// A hit could be behind it, which is what the search's own `truncated` is for.
expect(search.truncated).toBe(true);
expect(statCalls()).toBe(stats);
expect(deadReads).toBe(0);
});
it('leaves a folder alone once the filesystem says it is gone: that is not an unfinished scan', async () => {
vi.mocked(fs.stat).mockImplementation((async (path: string) => {
if (path === deadFolder) throw Object.assign(new Error('gone'), { code: 'ENOENT' });
return { size: 1, isDirectory: () => true };
}) as never);
const data = await deadline(get(''));
const gone = data.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(gone).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [] });
expect(data.truncated).toBe(false);
expect(deadReads).toBe(0);
});
});
});