mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 05:35:34 +00:00
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
import type { Client, SFTPWrapper, Stats as SFTPStats } from 'ssh2';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { KaosFileExistsError } from '#/errors';
|
|
import { KaosFileNotFoundError, SSHKaos } from '#/ssh';
|
|
|
|
// ── SSH path resolution: mock SFTP harness ────────────────────────────
|
|
//
|
|
// This test file validates that SSHKaos file/dir operations correctly
|
|
// resolve *relative* paths against the instance's current working
|
|
// directory (`_cwd`) before handing the path off to SFTP.
|
|
//
|
|
// We mock the underlying `SFTPWrapper` so no network traffic is needed.
|
|
// Every SFTP method (readFile, writeFile, stat, lstat, mkdir, appendFile,
|
|
// exists, readdir, realpath) records the absolute path it receives, and
|
|
// returns a deterministic response. After each SSHKaos operation we
|
|
// inspect the recorded path to ensure the resolution worked.
|
|
//
|
|
// The private SSHKaos constructor is bypassed via `Reflect.construct` —
|
|
// TypeScript `private` is compile-time only, and we need a test-only
|
|
// instance that is not backed by a real SSH connection.
|
|
|
|
// ── Mock helpers ──────────────────────────────────────────────────────
|
|
|
|
interface MockSFTP {
|
|
calls: { method: string; path: string; data?: string | Buffer }[];
|
|
files: Map<string, Buffer>;
|
|
dirs: Set<string>;
|
|
mkdirFailures: Map<string, { error: Error; materializeAs: 'dir' | 'file' }>;
|
|
}
|
|
|
|
function makeMockStats(isDir: boolean, size: number = 0): SFTPStats {
|
|
return {
|
|
mode: isDir ? 0o040755 : 0o100644,
|
|
uid: 1000,
|
|
gid: 1000,
|
|
size,
|
|
atime: 0,
|
|
mtime: 0,
|
|
isDirectory: () => isDir,
|
|
isFile: () => !isDir,
|
|
isSymbolicLink: () => false,
|
|
isSocket: () => false,
|
|
isCharacterDevice: () => false,
|
|
isBlockDevice: () => false,
|
|
isFIFO: () => false,
|
|
} as unknown as SFTPStats;
|
|
}
|
|
|
|
function createMockSftp(state: MockSFTP): SFTPWrapper {
|
|
// Only stub the SFTP methods used by the tests below.
|
|
const sftp = {
|
|
realpath(path: string, cb: (err: unknown, absPath: string) => void): void {
|
|
state.calls.push({ method: 'realpath', path });
|
|
// Mock realpath: if path is already absolute, echo it.
|
|
// Otherwise it would be relative — but since SSHKaos always
|
|
// resolves first, we should never see a relative input here.
|
|
cb(null, path);
|
|
},
|
|
stat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void {
|
|
state.calls.push({ method: 'stat', path });
|
|
if (state.dirs.has(path)) {
|
|
cb(null, makeMockStats(true));
|
|
return;
|
|
}
|
|
const buf = state.files.get(path);
|
|
if (buf !== undefined) {
|
|
cb(null, makeMockStats(false, buf.length));
|
|
return;
|
|
}
|
|
// Missing — surface an ENOENT-shaped error.
|
|
const err = Object.assign(new Error(`ENOENT: ${path}`), { code: 2 });
|
|
cb(err, undefined as unknown as SFTPStats);
|
|
},
|
|
lstat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void {
|
|
state.calls.push({ method: 'lstat', path });
|
|
sftp.stat(path, cb);
|
|
},
|
|
readFile(path: string, cb: (err: unknown, data: Buffer) => void): void {
|
|
state.calls.push({ method: 'readFile', path });
|
|
const buf = state.files.get(path);
|
|
if (buf === undefined) {
|
|
cb(new Error(`ENOENT: ${path}`), Buffer.alloc(0));
|
|
return;
|
|
}
|
|
cb(null, buf);
|
|
},
|
|
writeFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void {
|
|
state.calls.push({ method: 'writeFile', path, data });
|
|
const buf = typeof data === 'string' ? Buffer.from(data) : data;
|
|
state.files.set(path, buf);
|
|
cb(null);
|
|
},
|
|
appendFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void {
|
|
state.calls.push({ method: 'appendFile', path, data });
|
|
const buf = typeof data === 'string' ? Buffer.from(data) : data;
|
|
const existing = state.files.get(path) ?? Buffer.alloc(0);
|
|
state.files.set(path, Buffer.concat([existing, buf]));
|
|
cb(null);
|
|
},
|
|
exists(path: string, cb: (exists: boolean) => void): void {
|
|
state.calls.push({ method: 'exists', path });
|
|
cb(state.files.has(path) || state.dirs.has(path));
|
|
},
|
|
mkdir(path: string, cb: (err: unknown) => void): void {
|
|
state.calls.push({ method: 'mkdir', path });
|
|
const failure = state.mkdirFailures.get(path);
|
|
if (failure) {
|
|
if (failure.materializeAs === 'dir') {
|
|
state.dirs.add(path);
|
|
} else {
|
|
state.files.set(path, Buffer.from('collision'));
|
|
}
|
|
cb(failure.error);
|
|
return;
|
|
}
|
|
state.dirs.add(path);
|
|
cb(null);
|
|
},
|
|
readdir(
|
|
path: string,
|
|
cb: (err: unknown, list: { filename: string; attrs: SFTPStats }[]) => void,
|
|
): void {
|
|
state.calls.push({ method: 'readdir', path });
|
|
// Return empty listing for simplicity.
|
|
cb(null, []);
|
|
},
|
|
end(): void {
|
|
// no-op
|
|
},
|
|
};
|
|
return sftp as unknown as SFTPWrapper;
|
|
}
|
|
|
|
function createMockClient(): Client {
|
|
return {
|
|
end(): void {
|
|
// no-op
|
|
},
|
|
} as unknown as Client;
|
|
}
|
|
|
|
/**
|
|
* Construct an SSHKaos with the private constructor bypassed. TS
|
|
* `private` is compile-time only — runtime reflection works. We use
|
|
* `Reflect.construct` to hand-build an instance seeded with the mock
|
|
* SFTP wrapper and a chosen home/cwd.
|
|
*/
|
|
function createMockedKaos(sftp: SFTPWrapper, home: string, cwd: string): SSHKaos {
|
|
const client = createMockClient();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const CtorAny = SSHKaos as any;
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
return new CtorAny(client, sftp, home, cwd) as SSHKaos;
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
describe('e2e: SSHKaos relative path resolution after chdir (mocked SFTP)', () => {
|
|
let state: MockSFTP;
|
|
let sftp: SFTPWrapper;
|
|
let kaos: SSHKaos;
|
|
|
|
beforeEach(() => {
|
|
state = {
|
|
calls: [],
|
|
files: new Map<string, Buffer>(),
|
|
dirs: new Set<string>(),
|
|
mkdirFailures: new Map<string, { error: Error; materializeAs: 'dir' | 'file' }>(),
|
|
};
|
|
sftp = createMockSftp(state);
|
|
kaos = createMockedKaos(sftp, '/home/user', '/home/user');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('readText / readBytes / readLines', () => {
|
|
it('readText resolves relative path against current cwd', async () => {
|
|
// chdir to /remote/tmp; relative "file.txt" must resolve to /remote/tmp/file.txt.
|
|
state.dirs.add('/remote/tmp');
|
|
await kaos.chdir('/remote/tmp');
|
|
|
|
state.files.set('/remote/tmp/file.txt', Buffer.from('hello'));
|
|
|
|
const text = await kaos.readText('file.txt');
|
|
expect(text).toBe('hello');
|
|
|
|
// Verify the SFTP readFile call received the absolute resolved path.
|
|
const readCalls = state.calls.filter((c) => c.method === 'readFile');
|
|
expect(readCalls).toHaveLength(1);
|
|
expect(readCalls[0]!.path).toBe('/remote/tmp/file.txt');
|
|
});
|
|
|
|
it('readText accepts absolute path unchanged', async () => {
|
|
state.dirs.add('/remote/tmp');
|
|
await kaos.chdir('/remote/tmp');
|
|
state.files.set('/etc/hosts', Buffer.from('127.0.0.1 localhost'));
|
|
|
|
const text = await kaos.readText('/etc/hosts');
|
|
expect(text).toBe('127.0.0.1 localhost');
|
|
|
|
const readCalls = state.calls.filter((c) => c.method === 'readFile');
|
|
expect(readCalls.some((c) => c.path === '/etc/hosts')).toBe(true);
|
|
// And NOT /remote/tmp/etc/hosts
|
|
expect(readCalls.some((c) => c.path === '/remote/tmp/etc/hosts')).toBe(false);
|
|
});
|
|
|
|
it('readBytes resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/opt/data');
|
|
await kaos.chdir('/opt/data');
|
|
state.files.set('/opt/data/blob.bin', Buffer.from([1, 2, 3, 4]));
|
|
|
|
const bytes = await kaos.readBytes('blob.bin');
|
|
expect(Array.from(bytes)).toEqual([1, 2, 3, 4]);
|
|
|
|
const readCalls = state.calls.filter((c) => c.method === 'readFile');
|
|
expect(readCalls.some((c) => c.path === '/opt/data/blob.bin')).toBe(true);
|
|
});
|
|
|
|
it('readLines resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/var/log');
|
|
await kaos.chdir('/var/log');
|
|
state.files.set('/var/log/app.log', Buffer.from('line1\nline2\nline3'));
|
|
|
|
const lines: string[] = [];
|
|
for await (const line of kaos.readLines('app.log')) {
|
|
lines.push(line);
|
|
}
|
|
expect(lines).toEqual(['line1', 'line2', 'line3']);
|
|
|
|
const readCalls = state.calls.filter((c) => c.method === 'readFile');
|
|
expect(readCalls.some((c) => c.path === '/var/log/app.log')).toBe(true);
|
|
});
|
|
|
|
it('readLines strips CRLF terminators', async () => {
|
|
state.dirs.add('/var/log');
|
|
await kaos.chdir('/var/log');
|
|
state.files.set('/var/log/app-crlf.log', Buffer.from('line1\r\nline2\r\n'));
|
|
|
|
const lines: string[] = [];
|
|
for await (const line of kaos.readLines('app-crlf.log')) {
|
|
lines.push(line);
|
|
}
|
|
|
|
expect(lines).toEqual(['line1', 'line2']);
|
|
});
|
|
});
|
|
|
|
describe('writeText / writeBytes / appendFile', () => {
|
|
it('writeText resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
const n = await kaos.writeText('out.txt', 'hello world');
|
|
expect(n).toBe('hello world'.length);
|
|
|
|
const writeCalls = state.calls.filter((c) => c.method === 'writeFile');
|
|
expect(writeCalls).toHaveLength(1);
|
|
expect(writeCalls[0]!.path).toBe('/workspace/out.txt');
|
|
|
|
// Mock file store now contains the file at the resolved path.
|
|
expect(state.files.get('/workspace/out.txt')?.toString('utf-8')).toBe('hello world');
|
|
});
|
|
|
|
it('writeBytes resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
const data = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
|
const n = await kaos.writeBytes('blob.bin', data);
|
|
expect(n).toBe(4);
|
|
|
|
const writeCalls = state.calls.filter((c) => c.method === 'writeFile');
|
|
expect(writeCalls).toHaveLength(1);
|
|
expect(writeCalls[0]!.path).toBe('/workspace/blob.bin');
|
|
});
|
|
|
|
it('writeText with mode=a uses appendFile and resolves path', async () => {
|
|
state.dirs.add('/logs');
|
|
await kaos.chdir('/logs');
|
|
|
|
await kaos.writeText('a.log', 'line1\n', { mode: 'a' });
|
|
await kaos.writeText('a.log', 'line2\n', { mode: 'a' });
|
|
|
|
const appendCalls = state.calls.filter((c) => c.method === 'appendFile');
|
|
expect(appendCalls).toHaveLength(2);
|
|
expect(appendCalls[0]!.path).toBe('/logs/a.log');
|
|
expect(appendCalls[1]!.path).toBe('/logs/a.log');
|
|
|
|
expect(state.files.get('/logs/a.log')?.toString('utf-8')).toBe('line1\nline2\n');
|
|
});
|
|
});
|
|
|
|
describe('stat / mkdir', () => {
|
|
it('stat resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/srv');
|
|
await kaos.chdir('/srv');
|
|
state.files.set('/srv/config.json', Buffer.from('{}'));
|
|
|
|
const result = await kaos.stat('config.json');
|
|
expect(result.stSize).toBe(2);
|
|
|
|
const statCalls = state.calls.filter((c) => c.method === 'stat');
|
|
expect(statCalls.some((c) => c.path === '/srv/config.json')).toBe(true);
|
|
});
|
|
|
|
it('stat preserves NO_SUCH_FILE as KaosFileNotFoundError', async () => {
|
|
state.dirs.add('/srv');
|
|
await kaos.chdir('/srv');
|
|
|
|
await expect(kaos.stat('missing.json')).rejects.toBeInstanceOf(KaosFileNotFoundError);
|
|
await expect(kaos.stat('missing.json')).rejects.toMatchObject({ code: 2 });
|
|
});
|
|
|
|
it('stat with followSymlinks=false uses lstat and resolves path', async () => {
|
|
state.dirs.add('/srv');
|
|
await kaos.chdir('/srv');
|
|
state.files.set('/srv/link', Buffer.from('data'));
|
|
|
|
await kaos.stat('link', { followSymlinks: false });
|
|
|
|
const lstatCalls = state.calls.filter((c) => c.method === 'lstat');
|
|
expect(lstatCalls).toHaveLength(1);
|
|
expect(lstatCalls[0]!.path).toBe('/srv/link');
|
|
});
|
|
|
|
it('mkdir resolves relative path against current cwd', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
await kaos.mkdir('newdir');
|
|
|
|
const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir');
|
|
expect(mkdirCalls).toHaveLength(1);
|
|
expect(mkdirCalls[0]!.path).toBe('/workspace/newdir');
|
|
});
|
|
|
|
it('mkdir with parents=true resolves and creates intermediate dirs', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
await kaos.mkdir('a/b/c', { parents: true });
|
|
|
|
const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir');
|
|
const mkdirPaths = mkdirCalls.map((c) => c.path);
|
|
expect(mkdirPaths).toContain('/workspace/a');
|
|
expect(mkdirPaths).toContain('/workspace/a/b');
|
|
expect(mkdirPaths).toContain('/workspace/a/b/c');
|
|
expect(mkdirPaths.every((path) => !path.includes('//'))).toBe(true);
|
|
});
|
|
|
|
it('mkdir existOk=true is idempotent against the resolved path', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
// Pre-seed the target directory.
|
|
state.dirs.add('/workspace/existing');
|
|
|
|
await expect(kaos.mkdir('existing', { existOk: true })).resolves.toBeUndefined();
|
|
|
|
// Without existOk, it should throw.
|
|
await expect(kaos.mkdir('existing', { existOk: false })).rejects.toThrow();
|
|
});
|
|
|
|
it('mkdir with parents=true rejects a raced file collision when existOk=true', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
const racePaths = ['/workspace/collision', '//workspace/collision'];
|
|
for (const racePath of racePaths) {
|
|
state.mkdirFailures.set(racePath, {
|
|
error: new Error('EEXIST'),
|
|
materializeAs: 'file',
|
|
});
|
|
}
|
|
|
|
await expect(
|
|
kaos.mkdir('collision', { parents: true, existOk: true }),
|
|
).rejects.toBeInstanceOf(KaosFileExistsError);
|
|
});
|
|
});
|
|
|
|
describe('iterdir', () => {
|
|
it('iterdir with relative "." resolves to cwd', async () => {
|
|
state.dirs.add('/workspace');
|
|
await kaos.chdir('/workspace');
|
|
|
|
const entries: string[] = [];
|
|
for await (const entry of kaos.iterdir('.')) {
|
|
entries.push(entry);
|
|
}
|
|
// (readdir mock returns empty list; we're asserting the path passed to readdir)
|
|
|
|
const readdirCalls = state.calls.filter((c) => c.method === 'readdir');
|
|
expect(readdirCalls).toHaveLength(1);
|
|
// posix.join('/workspace', '.') normalises to '/workspace'
|
|
expect(readdirCalls[0]!.path).toBe('/workspace');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('iterdir with named relative subdir resolves under cwd', async () => {
|
|
state.dirs.add('/workspace');
|
|
state.dirs.add('/workspace/sub');
|
|
await kaos.chdir('/workspace');
|
|
|
|
const entries: string[] = [];
|
|
for await (const entry of kaos.iterdir('sub')) {
|
|
entries.push(entry);
|
|
}
|
|
|
|
const readdirCalls = state.calls.filter((c) => c.method === 'readdir');
|
|
expect(readdirCalls).toHaveLength(1);
|
|
expect(readdirCalls[0]!.path).toBe('/workspace/sub');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('chdir follows realpath and affects subsequent relative ops', () => {
|
|
it('consecutive chdir + readText rebase the working directory each time', async () => {
|
|
state.dirs.add('/a');
|
|
state.dirs.add('/b');
|
|
state.files.set('/a/one.txt', Buffer.from('A'));
|
|
state.files.set('/b/two.txt', Buffer.from('B'));
|
|
|
|
await kaos.chdir('/a');
|
|
expect(await kaos.readText('one.txt')).toBe('A');
|
|
|
|
await kaos.chdir('/b');
|
|
expect(await kaos.readText('two.txt')).toBe('B');
|
|
|
|
// After chdir(/b), a relative path that only exists under /a
|
|
// must NOT resolve — confirming we've truly rebased.
|
|
await expect(kaos.readText('one.txt')).rejects.toThrow();
|
|
});
|
|
|
|
it('chdir with a relative argument resolves against the prior cwd', async () => {
|
|
state.dirs.add('/home/user');
|
|
state.dirs.add('/home/user/project');
|
|
|
|
await kaos.chdir('/home/user');
|
|
await kaos.chdir('project');
|
|
|
|
expect(kaos.getcwd()).toBe('/home/user/project');
|
|
|
|
state.files.set('/home/user/project/README.md', Buffer.from('readme'));
|
|
expect(await kaos.readText('README.md')).toBe('readme');
|
|
});
|
|
});
|
|
});
|