Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
import { EventEmitter } from 'events';
|
|
import path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { registerFsRoutes } from './routes.js';
|
|
|
|
const createRouteRegistry = () => {
|
|
const routes = new Map();
|
|
return {
|
|
app: {
|
|
get(routePath, handler) {
|
|
routes.set(`GET ${routePath}`, handler);
|
|
},
|
|
post(routePath, handler) {
|
|
routes.set(`POST ${routePath}`, handler);
|
|
},
|
|
},
|
|
getRoute(method, routePath) {
|
|
return routes.get(`${method} ${routePath}`);
|
|
},
|
|
};
|
|
};
|
|
|
|
const createMockResponse = () => {
|
|
let statusCode = 200;
|
|
let body = null;
|
|
return {
|
|
status(code) {
|
|
statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload) {
|
|
body = payload;
|
|
return this;
|
|
},
|
|
type() {
|
|
return this;
|
|
},
|
|
send(payload) {
|
|
body = payload;
|
|
return this;
|
|
},
|
|
get statusCode() {
|
|
return statusCode;
|
|
},
|
|
get body() {
|
|
return body;
|
|
},
|
|
};
|
|
};
|
|
|
|
// Fake child process: emits the configured stdout then closes with the given code.
|
|
const createSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
|
|
const calls = [];
|
|
const spawn = vi.fn((_shell, args) => {
|
|
const command = args[args.length - 1];
|
|
calls.push(command);
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => {};
|
|
queueMicrotask(() => {
|
|
const out = stdoutByCommand[command];
|
|
if (out) child.stdout.emit('data', Buffer.from(out));
|
|
child.emit('close', exitCode, null);
|
|
});
|
|
return child;
|
|
});
|
|
return { spawn, calls };
|
|
};
|
|
|
|
const createDeferredSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => {
|
|
const calls = [];
|
|
const pending = [];
|
|
const spawn = vi.fn((_shell, args) => {
|
|
const command = args[args.length - 1];
|
|
calls.push(command);
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => {};
|
|
pending.push({ child, command });
|
|
return child;
|
|
});
|
|
const closeNext = () => {
|
|
const entry = pending.shift();
|
|
if (!entry) return;
|
|
const out = stdoutByCommand[entry.command];
|
|
if (out) entry.child.stdout.emit('data', Buffer.from(out));
|
|
entry.child.emit('close', exitCode, null);
|
|
};
|
|
return { spawn, calls, closeNext };
|
|
};
|
|
|
|
const registerExec = ({ spawn }) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
stat: async () => ({ isDirectory: () => true }),
|
|
},
|
|
spawn,
|
|
crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/exec');
|
|
};
|
|
|
|
const registerWrite = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('POST', '/api/fs/write');
|
|
};
|
|
|
|
const registerRead = (fsPromises) => {
|
|
const { app, getRoute } = createRouteRegistry();
|
|
registerFsRoutes(app, {
|
|
os: { homedir: () => '/home/user' },
|
|
path: path.posix,
|
|
fsPromises: {
|
|
realpath: async (targetPath) => targetPath,
|
|
...fsPromises,
|
|
},
|
|
spawn: vi.fn(),
|
|
crypto: { randomUUID: () => 'job-0' },
|
|
normalizeDirectoryPath: (p) => p,
|
|
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
|
buildAugmentedPath: () => '/usr/bin',
|
|
resolveGitBinaryForSpawn: () => 'git',
|
|
openchamberUserConfigRoot: '/home/user/.config',
|
|
});
|
|
return getRoute('GET', '/api/fs/read');
|
|
};
|
|
|
|
const callExec = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
const callWrite = async (handler, body) => {
|
|
const res = createMockResponse();
|
|
await handler({ body }, res);
|
|
return res;
|
|
};
|
|
|
|
const callRead = async (handler, query) => {
|
|
const res = createMockResponse();
|
|
await handler({ query }, res);
|
|
return res;
|
|
};
|
|
|
|
describe('fs write', () => {
|
|
it('does not rewrite a file when content is unchanged', async () => {
|
|
const fsPromises = {
|
|
readFile: vi.fn(async () => 'same'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'same' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
|
|
expect(fsPromises.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes a file when content changed', async () => {
|
|
const fsPromises = {
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'new' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
|
|
expect(fsPromises.mkdir).toHaveBeenCalledWith('/repo', { recursive: true });
|
|
const tmp = fsPromises.writeFile.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/file\.txt\.tmp-/);
|
|
expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, 'new', 'utf8');
|
|
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.txt');
|
|
expect(fsPromises.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes through existing symlinks without replacing the link', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link.txt') return '/repo/target.txt';
|
|
return targetPath;
|
|
}),
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/link.txt', content: 'new' });
|
|
|
|
expect(res.body).toEqual({ success: true, path: '/repo/link.txt' });
|
|
expect(fsPromises.readFile).toHaveBeenCalledWith('/repo/target.txt', 'utf8');
|
|
const tmp = fsPromises.writeFile.mock.calls[0][0];
|
|
expect(tmp).toMatch(/^\/repo\/target\.txt\.tmp-/);
|
|
expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/target.txt');
|
|
expect(fsPromises.rename).not.toHaveBeenCalledWith(expect.any(String), '/repo/link.txt');
|
|
});
|
|
|
|
it('rejects existing symlinks that resolve outside the workspace', async () => {
|
|
const fsPromises = {
|
|
realpath: vi.fn(async (targetPath) => {
|
|
if (targetPath === '/repo/link.txt') return '/outside/target.txt';
|
|
return targetPath;
|
|
}),
|
|
readFile: vi.fn(async () => 'old'),
|
|
mkdir: vi.fn(async () => undefined),
|
|
writeFile: vi.fn(async () => undefined),
|
|
rename: vi.fn(async () => undefined),
|
|
unlink: vi.fn(async () => undefined),
|
|
};
|
|
const handler = registerWrite(fsPromises);
|
|
|
|
const res = await callWrite(handler, { path: '/repo/link.txt', content: 'new' });
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body).toEqual({ error: 'Access denied' });
|
|
expect(fsPromises.writeFile).not.toHaveBeenCalled();
|
|
expect(fsPromises.rename).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('fs read', () => {
|
|
it('logs when empty-read retries are exhausted after non-empty stat', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const fsPromises = {
|
|
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
|
readFile: vi.fn(async () => ''),
|
|
};
|
|
const handler = registerRead(fsPromises);
|
|
|
|
const res = await callRead(handler, { path: '/repo/file.txt' });
|
|
|
|
expect(res.body).toBe('');
|
|
expect(fsPromises.readFile).toHaveBeenCalledTimes(4);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Read retry exhausted for /repo/file.txt'));
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('fs exec git-read cache', () => {
|
|
beforeEach(() => {
|
|
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
|
});
|
|
|
|
it('caches an allowlisted git rev-parse across identical requests', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
const second = await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(first.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(second.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(second.body.success).toBe(true);
|
|
// Spawned once; the second request is served from cache.
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('dedupes concurrent identical git-read requests while the first is in flight', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
|
const { spawn, calls, closeNext } = createDeferredSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = callExec(handler, { commands: [command], cwd: '/repo' });
|
|
const second = callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(calls.length).toBe(1);
|
|
|
|
closeNext();
|
|
const [firstRes, secondRes] = await Promise.all([first, second]);
|
|
|
|
expect(firstRes.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(secondRes.body.results[0].stdout).toBe('/repo/.git\n.git');
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('returns the current request command for normalized cache hits', async () => {
|
|
const firstCommand = 'git rev-parse --absolute-git-dir';
|
|
const secondCommand = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [firstCommand]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
const first = await callExec(handler, { commands: [firstCommand], cwd: '/repo' });
|
|
const second = await callExec(handler, { commands: [secondCommand], cwd: '/repo' });
|
|
|
|
expect(first.body.results[0].command).toBe(firstCommand);
|
|
expect(second.body.results[0].command).toBe(secondCommand);
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('keys the cache by working directory', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo-a' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo-b' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('never caches non-allowlisted commands', async () => {
|
|
const command = 'git status';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: 'clean\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('does not cache failed git-read results', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('disables caching when TTL is 0', async () => {
|
|
process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS = '0';
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn });
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
expect(calls.length).toBe(2);
|
|
});
|
|
|
|
it('re-runs once a cached entry ages past the TTL', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } });
|
|
const handler = registerExec({ spawn }); // default 30s TTL
|
|
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
vi.advanceTimersByTime(31_000);
|
|
await callExec(handler, { commands: [command], cwd: '/repo' });
|
|
|
|
// Stale entry is not served; a fresh subprocess fires.
|
|
expect(calls.length).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('bounds the cache by evicting the least-recently-used entry past the count cap', async () => {
|
|
const command = 'git rev-parse --absolute-git-dir';
|
|
const { spawn, calls } = createSpawn(); // exit 0, empty stdout — still cacheable
|
|
const handler = registerExec({ spawn });
|
|
|
|
// Fill to the 500-entry ceiling with distinct working directories.
|
|
for (let i = 0; i < 500; i += 1) {
|
|
await callExec(handler, { commands: [command], cwd: `/repo-${i}` });
|
|
}
|
|
const afterFill = calls.length;
|
|
expect(afterFill).toBe(500);
|
|
|
|
// One more distinct dir evicts the oldest entry (/repo-0).
|
|
await callExec(handler, { commands: [command], cwd: '/repo-overflow' });
|
|
// Evicted entry must re-run; a surviving entry must still be served.
|
|
await callExec(handler, { commands: [command], cwd: '/repo-0' }); // evicted -> spawns
|
|
await callExec(handler, { commands: [command], cwd: '/repo-499' }); // cached -> no spawn
|
|
|
|
expect(calls.length).toBe(afterFill + 2);
|
|
});
|
|
});
|