kimi-code/packages/kaos/test/ssh.test.ts
2026-06-11 17:21:32 +08:00

1472 lines
53 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { KaosFileExistsError, KaosValueError } from '#/errors';
import {
KaosConnectionError,
KaosFileNotFoundError,
KaosPermissionError,
KaosSSHError,
SSHKaos,
} from '#/ssh';
import type { StatResult } from '#/types';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from 'vitest';
// Environment variable configuration for SSH connection
const SSH_SMOKE = process.env['KAOS_SSH_SMOKE'] === '1';
const SSH_HOST = process.env['KAOS_SSH_HOST'] ?? '127.0.0.1';
const SSH_PORT = Number(process.env['KAOS_SSH_PORT'] ?? '22');
const SSH_USERNAME = process.env['KAOS_SSH_USERNAME'];
const SSH_PASSWORD = process.env['KAOS_SSH_PASSWORD'];
const SSH_KEY_PATHS = process.env['KAOS_SSH_KEY_PATHS']?.split(',').filter(Boolean);
const SSH_KEY_CONTENTS = process.env['KAOS_SSH_KEY_CONTENTS']?.split('|||').filter(Boolean);
// S_IFMT mask and file type constants
const S_IFMT = 0o170000;
const S_IFDIR = 0o040000;
const S_IFREG = 0o100000;
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Buffer));
}
return Buffer.concat(chunks);
}
// Explicit opt-in smoke: set KAOS_SSH_SMOKE=1 plus SSH credentials.
describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => {
let sshKaos: SSHKaos;
let remoteBase = '';
beforeAll(async () => {
if (SSH_USERNAME === undefined) {
throw new Error('KAOS_SSH_SMOKE=1 requires KAOS_SSH_USERNAME');
}
// Dynamic import to avoid compilation errors when ssh2 is not available
const { SSHKaos: SSHKaosClass } = await import('#/ssh');
sshKaos = await SSHKaosClass.create({
host: SSH_HOST,
port: SSH_PORT,
username: SSH_USERNAME,
password: SSH_PASSWORD,
keyPaths: SSH_KEY_PATHS,
keyContents: SSH_KEY_CONTENTS,
});
});
beforeEach(async () => {
// Create an isolated remote directory for each test
const uuid = Math.random().toString(36).slice(2);
remoteBase = `${sshKaos.gethome()}/.kaos_test_${process.pid}_${uuid}`;
await sshKaos.mkdir(remoteBase, { parents: true, existOk: true });
await sshKaos.chdir(remoteBase);
});
afterEach(async () => {
// Cleanup the remote directory best-effort, but always restore cwd.
if (remoteBase.length > 0) {
try {
const proc = await sshKaos.exec('rm', '-rf', remoteBase);
await proc.wait();
} finally {
remoteBase = '';
await sshKaos.chdir(sshKaos.gethome());
}
}
});
afterAll(async () => {
if (sshKaos) await sshKaos.close();
});
test('pathClass, home, and cwd', () => {
const home = sshKaos.gethome();
const cwd = sshKaos.getcwd();
expect(sshKaos.pathClass()).toBe('posix');
expect(home.length).toBeGreaterThan(0);
expect(cwd.length).toBeGreaterThan(0);
// Home should be absolute
expect(home.startsWith('/')).toBe(true);
// cwd should be absolute
expect(cwd.startsWith('/')).toBe(true);
});
test('cwd defaults to home immediately after connect', () => {
// A freshly connected session sets cwd to the remote home directory,
// so the two must be string-equal before any chdir.
expect(sshKaos.getcwd()).toBe(sshKaos.gethome());
});
test('chdir updates real path', async () => {
await sshKaos.chdir(remoteBase);
expect(sshKaos.getcwd()).toBe(remoteBase);
await sshKaos.mkdir(remoteBase + '/child', { existOk: true });
await sshKaos.chdir('child');
expect(sshKaos.getcwd()).toBe(remoteBase + '/child');
await sshKaos.chdir('..');
expect(sshKaos.getcwd()).toBe(remoteBase);
});
test('exec respects cwd', async () => {
await sshKaos.chdir(remoteBase);
const proc = await sshKaos.exec('pwd');
const out = (await streamToBuffer(proc.stdout)).toString().trim();
const code = await proc.wait();
expect(code).toBe(0);
expect(out).toBe(remoteBase);
});
test('exec wait before read', async () => {
const proc = await sshKaos.exec('echo', 'output');
const exitCode = await proc.wait();
const output = (await streamToBuffer(proc.stdout)).toString().trim();
expect(exitCode).toBe(0);
expect(output).toBe('output');
});
test('mkdir respects existOk', async () => {
const nestedDir = remoteBase + '/deep/level';
await sshKaos.mkdir(nestedDir, { parents: true, existOk: false });
// Python test_mkdir_respects_exist_ok pins `pytest.raises(FileExistsError)`
// — match that strength by asserting the specific KaosFileExistsError class
// rather than any throwable.
await expect(sshKaos.mkdir(nestedDir, { existOk: false })).rejects.toBeInstanceOf(
KaosFileExistsError,
);
await sshKaos.mkdir(nestedDir, { parents: true, existOk: true });
});
test('stat reports directory and file metadata', async () => {
const dirStat = await sshKaos.stat(remoteBase, { followSymlinks: false });
expect((dirStat.stMode & S_IFMT) === S_IFDIR).toBe(true);
const filePath = remoteBase + '/payload.txt';
const payload = 'metadata';
await sshKaos.writeText(filePath, payload);
const fileStat = await sshKaos.stat(filePath);
expect((fileStat.stMode & S_IFMT) === S_IFREG).toBe(true);
expect(fileStat.stSize).toBe(payload.length);
expect(fileStat.stNlink).toBeGreaterThanOrEqual(0);
});
test('file roundtrip via SSH', async () => {
await sshKaos.chdir(remoteBase);
const textPath = remoteBase + '/text.txt';
const bytesPath = remoteBase + '/blob.bin';
const textPayload = 'Hello SSH\n';
const appended = 'More data\n';
const written = await sshKaos.writeText(textPath, textPayload);
expect(written).toBe(textPayload.length);
const appendedLen = await sshKaos.writeText(textPath, appended, { mode: 'a' });
expect(appendedLen).toBe(appended.length);
const fullText = await sshKaos.readText(textPath);
expect(fullText).toBe(textPayload + appended);
const lines: string[] = [];
for await (const line of sshKaos.readLines(textPath)) {
lines.push(line);
}
expect(lines).toEqual(['Hello SSH', 'More data']);
const bytesPayload = Buffer.from(Array.from({ length: 32 }, (_, i) => i));
const bytesWritten = await sshKaos.writeBytes(bytesPath, bytesPayload);
expect(bytesWritten).toBe(bytesPayload.length);
const roundtrip = await sshKaos.readBytes(bytesPath);
expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0);
expect(sshKaos.getcwd()).toBe(remoteBase);
});
test('iterdir lists child entries', async () => {
await sshKaos.writeText(remoteBase + '/file1.txt', '1');
await sshKaos.writeText(remoteBase + '/file2.log', '2');
await sshKaos.mkdir(remoteBase + '/subdir', { existOk: true });
const entries: string[] = [];
for await (const entry of sshKaos.iterdir(remoteBase)) {
entries.push(entry);
}
const names = new Set(entries.map((e) => e.split('/').pop()!));
expect(names).toEqual(new Set(['file1.txt', 'file2.log', 'subdir']));
});
test('glob is case sensitive', async () => {
await sshKaos.writeText(remoteBase + '/file.log', 'lowercase');
await sshKaos.writeText(remoteBase + '/FILE.LOG', 'uppercase');
const matches = new Set<string>();
for await (const path of sshKaos.glob(remoteBase, '*.log')) {
matches.add(path);
}
expect(matches.has(remoteBase + '/file.log')).toBe(true);
expect(matches.has(remoteBase + '/FILE.LOG')).toBe(false);
await expect(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of sshKaos.glob(remoteBase, '*.log', { caseSensitive: false })) {
// should throw before yielding
}
}).rejects.toThrow('Case insensitive glob is not supported');
});
test('exec streams stdout and stderr', async () => {
const proc = await sshKaos.exec('sh', '-c', "printf 'out\\n' && printf 'err\\n' 1>&2");
const [stdoutData, stderrData] = await Promise.all([
streamToBuffer(proc.stdout),
streamToBuffer(proc.stderr),
]);
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
expect(proc.exitCode).toBe(0);
expect(stdoutData.toString().trim()).toBe('out');
expect(stderrData.toString().trim()).toBe('err');
});
// NOTE: these execWithEnv tests require the remote sshd to accept the
// injected variable names via its `AcceptEnv` directive (or an equivalent
// mechanism). Stock OpenSSH only whitelists LANG/LC_*; if the test server
// is not configured to accept KAOS_TEST_*, these tests will fail — which
// is exactly the signal we want (it reveals the silent env-drop bug that
// the Python version has).
test('execWithEnv delivers a single env var to the remote process', async () => {
const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_MARKER}"'], {
KAOS_TEST_MARKER: 'beacon42',
});
const out = (await streamToBuffer(proc.stdout)).toString();
const code = await proc.wait();
expect(code).toBe(0);
expect(out).toBe('beacon42');
});
test('execWithEnv delivers multiple env vars', async () => {
const proc = await sshKaos.execWithEnv(
['sh', '-c', 'printf "%s|%s" "${KAOS_TEST_A}" "${KAOS_TEST_B}"'],
{ KAOS_TEST_A: 'hello', KAOS_TEST_B: 'world' },
);
const out = (await streamToBuffer(proc.stdout)).toString();
const code = await proc.wait();
expect(code).toBe(0);
expect(out).toBe('hello|world');
});
test('execWithEnv preserves values with shell metacharacters', async () => {
// Single quotes, dollar signs, backticks, pipes, ampersands, redirects,
// double quotes, and a backslash — anything an unsafe impl might mangle.
const value = `it's $HOME \`id\`; | & < > " \\`;
const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_VALUE}"'], {
KAOS_TEST_VALUE: value,
});
const out = (await streamToBuffer(proc.stdout)).toString();
const code = await proc.wait();
expect(code).toBe(0);
expect(out).toBe(value);
});
test('exec rejects empty command', async () => {
await expect((sshKaos.exec as (...args: string[]) => Promise<unknown>)()).rejects.toThrow();
});
test('process kill updates returncode', async () => {
const proc = await sshKaos.exec('sh', '-c', 'echo ready; sleep 30');
// Read the first line to know the process has started
const firstChunk = await new Promise<Buffer>((resolve) => {
proc.stdout.once('data', (chunk: Buffer) => {
resolve(chunk);
});
});
expect(firstChunk.toString().trim()).toBe('ready');
expect(proc.exitCode).toBeNull();
await proc.kill();
const exitCode = await proc.wait();
expect(exitCode).not.toBe(0);
expect(proc.exitCode).toBe(exitCode);
expect(proc.pid).toBe(-1);
});
});
// These tests don't need a live SSH connection — they exercise the
// argument-validation guards that run before any network I/O. We invoke the
// methods through the prototype so no real instance is constructed.
describe('SSHKaos argument validation', () => {
it('exec() throws with the correct class name when args is empty', () => {
const fakeThis = {} as SSHKaos;
expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(KaosValueError);
expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(/SSHKaos\.exec\(\)/);
});
it('execWithEnv() throws with the correct class name when args is empty', () => {
const fakeThis = {} as SSHKaos;
expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow(KaosValueError);
expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow(
/SSHKaos\.execWithEnv\(\)/,
);
});
// glob() is an async generator, so the caseSensitive=false guard fires on
// the first pull rather than at call-time. We verify both the error class
// (KaosValueError) and the fact that it rejects before touching SFTP.
it('glob(caseSensitive: false) rejects with KaosValueError', async () => {
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _cwd: string; _sftp: unknown };
internal._cwd = '/tmp';
internal._sftp = {};
const gen = instance.glob('/some/path', '*', { caseSensitive: false });
await expect(gen.next()).rejects.toBeInstanceOf(KaosValueError);
});
});
// chdir should refuse to treat a regular file (or anything that isn't a
// directory) as the new working directory. Without this guard, `sftp.realpath`
// happily returns file paths and later relative reads/writes/execs would
// resolve against a file — silently wrong. We exercise this by constructing
// a fake SFTP that returns a file stat so the test needs no live SSH.
describe('SSHKaos.chdir directory validation', () => {
// Minimal SFTPWrapper stub with only the methods chdir needs.
function makeFakeSftp(target: string, isDir: boolean): unknown {
return {
realpath(_path: string, cb: (err: Error | null | undefined, absPath: string) => void): void {
cb(null, target);
},
stat(
_path: string,
cb: (err: Error | null | undefined, stats: Record<string, unknown>) => void,
): void {
cb(null, {
mode: isDir ? 0o040755 : 0o100644,
size: 0,
uid: 0,
gid: 0,
atime: 0,
mtime: 0,
isDirectory: () => isDir,
isFile: () => !isDir,
isSymbolicLink: () => false,
isSocket: () => false,
isCharacterDevice: () => false,
isBlockDevice: () => false,
isFIFO: () => false,
});
},
};
}
function makeFakeInstance(sftp: unknown, cwd: string): SSHKaos {
// Bypass the real constructor (which requires a live ssh2 client) and
// populate just the private fields that chdir touches.
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _sftp: unknown; _cwd: string };
internal._sftp = sftp;
internal._cwd = cwd;
return instance;
}
it('rejects a target that resolves to a regular file', async () => {
const target = '/tmp/not-a-dir.txt';
const sftp = makeFakeSftp(target, /*isDir=*/ false);
const kaos = makeFakeInstance(sftp, '/tmp');
await expect(kaos.chdir(target)).rejects.toThrow(KaosValueError);
await expect(kaos.chdir(target)).rejects.toThrow(/not a directory/);
// cwd must remain unchanged on failure.
expect(kaos.getcwd()).toBe('/tmp');
});
it('accepts a target that resolves to a directory', async () => {
const target = '/tmp/real-dir';
const sftp = makeFakeSftp(target, /*isDir=*/ true);
const kaos = makeFakeInstance(sftp, '/tmp');
await kaos.chdir(target);
expect(kaos.getcwd()).toBe(target);
});
});
// These tests pin the SFTPError → KaosError mapping contract. They use a
// fake SFTPWrapper that invokes callbacks with errors carrying the standard
// SFTP status codes (NO_SUCH_FILE=2, PERMISSION_DENIED=3), so they run in
// CI without needing a live SSH connection.
//
// The mapping lives in the promisified SFTP helpers in ssh.ts, so every
// SSHKaos method that touches SFTP automatically throws a KaosSSHError
// subclass (KaosFileNotFoundError / KaosPermissionError / …) instead of
// the raw ssh2 error.
describe('SSHKaos SFTP error mapping', () => {
const NO_SUCH_FILE = 2;
const PERMISSION_DENIED = 3;
interface FailingMethods {
stat?: boolean;
lstat?: boolean;
readFile?: boolean;
writeFile?: boolean;
appendFile?: boolean;
mkdir?: boolean;
readdir?: boolean;
}
function makeSftpError(errorCode: number): Error {
const err = new Error('simulated SFTP error');
(err as unknown as { code: number }).code = errorCode;
return err;
}
// Minimal SFTPWrapper stub. For each I/O method, when `failing[method]` is
// true the callback is invoked with an error carrying `code`; otherwise
// a harmless default is returned. Only the methods that SSHKaos actually
// calls need to be stubbed.
function makeFakeSftp(errorCode: number, failing: FailingMethods): unknown {
const dirStats = {
mode: 0o040755,
size: 0,
uid: 0,
gid: 0,
atime: 0,
mtime: 0,
isDirectory: (): boolean => true,
isFile: (): boolean => false,
isSymbolicLink: (): boolean => false,
isSocket: (): boolean => false,
isCharacterDevice: (): boolean => false,
isBlockDevice: (): boolean => false,
isFIFO: (): boolean => false,
};
return {
realpath(path: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, path);
},
stat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void {
if (failing.stat === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null, dirStats);
},
lstat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void {
if (failing.lstat === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null, dirStats);
},
readFile(_path: string, cb: (err: Error | null, data?: Buffer) => void): void {
if (failing.readFile === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null, Buffer.alloc(0));
},
writeFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void {
if (failing.writeFile === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null);
},
appendFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void {
if (failing.appendFile === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null);
},
mkdir(_path: string, cb: (err: Error | null) => void): void {
if (failing.mkdir === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null);
},
readdir(_path: string, cb: (err: Error | null, list?: unknown[]) => void): void {
if (failing.readdir === true) {
cb(makeSftpError(errorCode));
return;
}
cb(null, []);
},
// exists() always reports the file is absent so plain mkdir takes the
// create path (where `failing.mkdir` decides the outcome).
exists(_path: string, cb: (exists: boolean) => void): void {
cb(false);
},
};
}
function makeFakeKaos(sftp: unknown): SSHKaos {
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string };
internal._sftp = sftp;
internal._cwd = '/';
internal._home = '/';
return instance;
}
// ── stat(): the one method that already wraps errors. ────────────────
it('stat() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { stat: true }));
await expect(kaos.stat('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError);
});
it('stat() maps PERMISSION_DENIED → KaosPermissionError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { stat: true }));
await expect(kaos.stat('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError);
});
it('stat({ followSymlinks: false }) wraps lstat errors the same way', async () => {
const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { lstat: true }));
await expect(kaos.stat('/missing', { followSymlinks: false })).rejects.toBeInstanceOf(
KaosFileNotFoundError,
);
});
it('stat() wraps unmapped failures as the base KaosSSHError', async () => {
// FAILURE=4 is not specifically mapped → generic KaosSSHError.
const kaos = makeFakeKaos(makeFakeSftp(4, { stat: true }));
await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosSSHError);
});
it('stat() maps NO_CONNECTION → KaosConnectionError', async () => {
// SFTP STATUS_CODE.NO_CONNECTION = 6
const kaos = makeFakeKaos(makeFakeSftp(6, { stat: true }));
await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError);
});
it('stat() maps CONNECTION_LOST → KaosConnectionError', async () => {
// SFTP STATUS_CODE.CONNECTION_LOST = 7
const kaos = makeFakeKaos(makeFakeSftp(7, { stat: true }));
await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError);
});
it('stat() wraps errors without a numeric code as generic KaosSSHError', async () => {
// An error object whose `.code` is not a number (or absent entirely)
// must still be wrapped — the mapSftpError fallback should kick in.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
stat(_p: string, cb: (err: Error | null) => void): void {
// Error with no .code field
cb(new Error('no code here'));
},
};
const kaos = makeFakeKaos(sftp);
const err = await kaos.stat('/x').catch((error: unknown) => error);
expect(err).toBeInstanceOf(KaosSSHError);
expect((err as KaosSSHError).message).toContain('no code here');
});
it('stat() wraps non-Error rejections by stringifying them', async () => {
// The helper's getErrorMessage fallback handles non-Error values by
// calling String(error). Reject with a plain string and verify the
// wrap still produces a KaosSSHError with the string in the message.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
stat(_p: string, cb: (err: unknown) => void): void {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
cb('raw-string-rejection');
},
};
const kaos = makeFakeKaos(sftp);
const err = await kaos.stat('/x').catch((error: unknown) => error);
expect(err).toBeInstanceOf(KaosSSHError);
expect((err as KaosSSHError).message).toContain('raw-string-rejection');
});
it('chdir() propagates a realpath failure as a KaosSSHError', async () => {
// sftpRealpath now runs through mapSftpError too — pin that contract.
const sftp = {
realpath(_p: string, cb: (err: Error) => void): void {
const err = new Error('realpath broke');
(err as unknown as { code: number }).code = 2;
cb(err);
},
stat(_p: string, _cb: unknown): void {
throw new Error('should not be called');
},
};
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _sftp: unknown; _cwd: string };
internal._sftp = sftp;
internal._cwd = '/';
await expect(instance.chdir('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError);
});
// ── Other I/O methods: mapping is pushed into the promisified helpers
// in ssh.ts so every method gets the same wrapping for free. ─────────
it('readText() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true }));
await expect(kaos.readText('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError);
});
it('readBytes() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true }));
await expect(kaos.readBytes('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError);
});
it('writeText() maps PERMISSION_DENIED → KaosPermissionError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true }));
await expect(kaos.writeText('/forbidden', 'data')).rejects.toBeInstanceOf(KaosPermissionError);
});
it('writeText(append) maps PERMISSION_DENIED → KaosPermissionError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { appendFile: true }));
await expect(kaos.writeText('/forbidden', 'data', { mode: 'a' })).rejects.toBeInstanceOf(
KaosPermissionError,
);
});
it('writeBytes() maps PERMISSION_DENIED → KaosPermissionError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true }));
await expect(kaos.writeBytes('/forbidden', Buffer.from('x'))).rejects.toBeInstanceOf(
KaosPermissionError,
);
});
it('mkdir() maps PERMISSION_DENIED → KaosPermissionError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { mkdir: true }));
await expect(kaos.mkdir('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError);
});
it('iterdir() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => {
const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readdir: true }));
const gen = kaos.iterdir('/missing');
await expect(gen.next()).rejects.toBeInstanceOf(KaosFileNotFoundError);
});
});
// These tests exercise the pure command-building logic behind execWithEnv
// without needing a live SSH connection. The actual end-to-end delivery of
// env vars is validated by the smoke tests above when KAOS_SSH_SMOKE=1.
describe('SSHKaos._buildExecCommand', () => {
// Bracket access so we can reach the private static helper from tests
// without changing its visibility in the public API.
const build = (
SSHKaos as unknown as {
_buildExecCommand: (args: string[], cwd: string, env?: Record<string, string>) => string;
}
)._buildExecCommand;
it('cd prefix + bare command when no env is supplied', () => {
expect(build(['ls', '-la'], '/home/user')).toBe('cd /home/user && ls -la');
});
it('injects inline assignments before the command', () => {
expect(build(['echo', 'x'], '/home/user', { FOO: 'bar' })).toBe(
'cd /home/user && FOO=bar echo x',
);
});
it('injects multiple env vars in declaration order', () => {
const out = build(['sh', '-c', 'echo $A $B'], '/home/user', { A: '1', B: '2' });
expect(out).toBe("cd /home/user && A=1 B=2 sh -c 'echo $A $B'");
});
it('quotes values containing shell metacharacters', () => {
// Single quote in value → shellQuote escapes via the '"'"' trick.
expect(build(['cmd'], '/home/user', { V: "it's" })).toBe(`cd /home/user && V='it'"'"'s' cmd`);
// Dollar sign, backticks, pipe, ampersand → single-quoted wholesale.
expect(build(['cmd'], '/home/user', { V: '$HOME `id` | &' })).toBe(
`cd /home/user && V='$HOME \`id\` | &' cmd`,
);
});
it('quotes an empty value as empty single quotes', () => {
expect(build(['cmd'], '/home/user', { V: '' })).toBe("cd /home/user && V='' cmd");
});
it('rejects env var names that are not valid POSIX identifiers', () => {
expect(() => build(['cmd'], '/home/user', { '1BAD': 'x' })).toThrow(KaosValueError);
expect(() => build(['cmd'], '/home/user', { 'WITH SPACE': 'x' })).toThrow(KaosValueError);
expect(() => build(['cmd'], '/home/user', { 'WITH=EQUALS': 'x' })).toThrow(KaosValueError);
expect(() => build(['cmd'], '/home/user', { '': 'x' })).toThrow(KaosValueError);
});
it('accepts underscored and mixed-case identifiers', () => {
expect(build(['cmd'], '/home/user', { _UNDER: '1', camelCase: '2' })).toBe(
'cd /home/user && _UNDER=1 camelCase=2 cmd',
);
});
it('skips the cd prefix when cwd is the empty string', () => {
expect(build(['cmd', 'arg'], '', { FOO: 'bar' })).toBe('FOO=bar cmd arg');
});
it('quotes cwd paths with spaces and special characters', () => {
expect(build(['cmd'], "/home/u ser's dir")).toBe(`cd '/home/u ser'"'"'s dir' && cmd`);
});
it('omits the assignment section entirely when env is an empty object', () => {
// Matches the behavior of plain exec() — no leading space, no KEY=...
expect(build(['cmd', 'arg'], '/home/user', {})).toBe('cd /home/user && cmd arg');
});
});
// These tests drive the SSHKaos read/stat/glob/iterdir happy paths via
// fake SFTP wrappers, so they run in CI without a live SSH server. Without
// them the smoke block is the only route to those code paths, which means
// CI only sees the error branches.
describe('SSHKaos mock success paths', () => {
interface TreeNode {
type: 'dir' | 'file';
children?: Record<string, TreeNode>;
content?: Buffer;
// Permission bits only — forces buildStMode to derive the type bits
// from the is* helpers (the "mode without type bits" branch) when true.
stripTypeBits?: boolean;
}
function makeStats(node: TreeNode): unknown {
const isDir = node.type === 'dir';
// Either include type bits (0o040000/0o100000) or strip them to force
// the buildStMode helper to derive them via isDirectory()/isFile().
const baseMode = isDir ? 0o040755 : 0o100644;
const mode = node.stripTypeBits === true ? baseMode & 0o7777 : baseMode;
return {
mode,
size: node.content ? node.content.length : 0,
uid: 1000,
gid: 1000,
atime: 100,
mtime: 200,
isDirectory: () => isDir,
isFile: () => !isDir,
isSymbolicLink: () => false,
isSocket: () => false,
isCharacterDevice: () => false,
isBlockDevice: () => false,
isFIFO: () => false,
};
}
function lookup(root: TreeNode, path: string): TreeNode | undefined {
if (path === '/') return root;
const parts = path.split('/').filter(Boolean);
let current: TreeNode | undefined = root;
for (const part of parts) {
if (!current?.children?.[part]) return undefined;
current = current.children[part];
}
return current;
}
// Fake SFTP that exposes a tree and implements the handful of callbacks
// SSHKaos actually calls. Anything not needed is left unimplemented.
function makeTreeSftp(root: TreeNode): unknown {
return {
realpath(path: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, path);
},
stat(path: string, cb: (err: Error | null, stats?: unknown) => void): void {
const node = lookup(root, path);
if (!node) {
const err = new Error(`no such file: ${path}`);
(err as unknown as { code: number }).code = 2;
cb(err);
return;
}
cb(null, makeStats(node));
},
lstat(path: string, cb: (err: Error | null, stats?: unknown) => void): void {
const node = lookup(root, path);
if (!node) {
const err = new Error(`no such file: ${path}`);
(err as unknown as { code: number }).code = 2;
cb(err);
return;
}
cb(null, makeStats(node));
},
readdir(path: string, cb: (err: Error | null, list?: unknown[]) => void): void {
const node = lookup(root, path);
if (!node || node.type !== 'dir' || !node.children) {
const err = new Error(`not a directory: ${path}`);
(err as unknown as { code: number }).code = 2;
cb(err);
return;
}
cb(
null,
Object.entries(node.children).map(([filename, child]) => ({
filename,
attrs: makeStats(child),
})),
);
},
readFile(path: string, cb: (err: Error | null, data?: Buffer) => void): void {
const node = lookup(root, path);
if (!node || node.type !== 'file') {
const err = new Error(`no such file: ${path}`);
(err as unknown as { code: number }).code = 2;
cb(err);
return;
}
cb(null, node.content ?? Buffer.alloc(0));
},
};
}
function makeFakeKaos(sftp: unknown, cwd = '/'): SSHKaos {
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string };
internal._sftp = sftp;
internal._cwd = cwd;
internal._home = '/home/tester';
return instance;
}
// ── path helpers ────────────────────────────────────────────────────
it('normpath delegates to posix.normalize', () => {
// No I/O — just the pure path function. Pins that normpath collapses
// `..` segments.
const kaos = makeFakeKaos(makeTreeSftp({ type: 'dir', children: {} }));
expect(kaos.normpath('/a/b/../c')).toBe('/a/c');
});
// ── stat + buildStMode variants ─────────────────────────────────────
it('stat() returns a StatResult with file-type bits preserved', async () => {
const root: TreeNode = {
type: 'dir',
children: {
'file.txt': { type: 'file', content: Buffer.from('hi') },
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const fileStat: StatResult = await kaos.stat('/file.txt');
expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true);
expect(fileStat.stSize).toBe(2);
expect(fileStat.stUid).toBe(1000);
});
it('stat() derives type bits from is* helpers when mode lacks them', async () => {
// mode = 0o755 has zero in the S_IFMT bits, so buildStMode must fall
// back to isDirectory()/isFile() to fill them in.
const root: TreeNode = {
type: 'dir',
stripTypeBits: true,
children: {
'bare.txt': { type: 'file', stripTypeBits: true, content: Buffer.from('x') },
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const dirStat = await kaos.stat('/');
expect((dirStat.stMode & 0o170000) === 0o040000).toBe(true);
const fileStat = await kaos.stat('/bare.txt');
expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true);
});
it('stat({ followSymlinks: false }) uses the lstat branch', async () => {
// No actual symlinks — just verify the alternate code path is taken
// (lstat callback is wired up, it returns stats without throwing).
const root: TreeNode = {
type: 'dir',
children: { 'a.txt': { type: 'file', content: Buffer.from('hi') } },
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const result = await kaos.stat('/a.txt', { followSymlinks: false });
expect(result.stSize).toBe(2);
});
// ── iterdir ────────────────────────────────────────────────────────
it('iterdir yields directory entries', async () => {
const root: TreeNode = {
type: 'dir',
children: {
tree: {
type: 'dir',
children: {
'a.txt': { type: 'file', content: Buffer.from('a') },
'b.txt': { type: 'file', content: Buffer.from('b') },
sub: { type: 'dir', children: {} },
},
},
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const entries: string[] = [];
for await (const entry of kaos.iterdir('/tree')) {
entries.push(entry);
}
expect(new Set(entries)).toEqual(new Set(['/tree/a.txt', '/tree/b.txt', '/tree/sub']));
});
// ── glob ────────────────────────────────────────────────────────────
it('glob matches flat patterns against the directory', async () => {
const root: TreeNode = {
type: 'dir',
children: {
tree: {
type: 'dir',
children: {
'root.txt': { type: 'file', content: Buffer.from('r') },
'root.log': { type: 'file', content: Buffer.from('l') },
},
},
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const matches: string[] = [];
for await (const m of kaos.glob('/tree', '*.txt')) {
matches.push(m);
}
expect(matches).toEqual(['/tree/root.txt']);
});
it('glob recurses with **/pattern to match nested files', async () => {
const root: TreeNode = {
type: 'dir',
children: {
tree: {
type: 'dir',
children: {
'root.txt': { type: 'file', content: Buffer.from('r') },
sub: {
type: 'dir',
children: {
'nested.txt': { type: 'file', content: Buffer.from('n') },
deep: {
type: 'dir',
children: {
'deeper.txt': { type: 'file', content: Buffer.from('d') },
},
},
},
},
},
},
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const matches: string[] = [];
for await (const m of kaos.glob('/tree', '**/*.txt')) {
matches.push(m);
}
const names = new Set(matches);
expect(names).toEqual(
new Set(['/tree/root.txt', '/tree/sub/nested.txt', '/tree/sub/deep/deeper.txt']),
);
// Pin the de-dup invariant — no single file should appear twice.
expect(matches.length).toBe(new Set(matches).size);
});
it('glob with bare ** yields basePath and every nested entry', async () => {
const root: TreeNode = {
type: 'dir',
children: {
tree: {
type: 'dir',
children: {
'root.txt': { type: 'file', content: Buffer.from('r') },
sub: {
type: 'dir',
children: {
'nested.txt': { type: 'file', content: Buffer.from('n') },
},
},
},
},
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const matches: string[] = [];
for await (const m of kaos.glob('/tree', '**')) {
matches.push(m);
}
const set = new Set(matches);
expect(set.has('/tree')).toBe(true);
expect(set.has('/tree/root.txt')).toBe(true);
expect(set.has('/tree/sub')).toBe(true);
expect(set.has('/tree/sub/nested.txt')).toBe(true);
});
it('glob with a nested literal path recurses only into the named dir', async () => {
// Pattern `sub/*.txt` → literal `sub` segment, then `*.txt` inside it.
// Exercises the non-`**` recursive branch.
const root: TreeNode = {
type: 'dir',
children: {
tree: {
type: 'dir',
children: {
'root.txt': { type: 'file', content: Buffer.from('r') },
sub: {
type: 'dir',
children: {
'a.txt': { type: 'file', content: Buffer.from('a') },
'b.log': { type: 'file', content: Buffer.from('b') },
},
},
},
},
},
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const matches: string[] = [];
for await (const m of kaos.glob('/tree', 'sub/*.txt')) {
matches.push(m);
}
expect(matches).toEqual(['/tree/sub/a.txt']);
});
it('glob silently skips unreadable directories', async () => {
// If readdir() fails the generator should return without throwing —
// makes glob tolerant of permission-limited subtrees.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void {
const err = new Error('permission denied');
(err as unknown as { code: number }).code = 3;
cb(err);
},
};
const kaos = makeFakeKaos(sftp);
const matches: string[] = [];
for await (const m of kaos.glob('/locked', '*.txt')) {
matches.push(m);
}
expect(matches).toEqual([]);
});
// ── readLines empty-file early return ─────────────────────────────
it('readLines yields nothing for an empty file', async () => {
// `readText` returns an empty string, so the generator must take the
// early-return branch instead of walking `splitlines()`.
const root: TreeNode = {
type: 'dir',
children: { 'empty.txt': { type: 'file', content: Buffer.alloc(0) } },
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const lines: string[] = [];
for await (const line of kaos.readLines('/empty.txt')) {
lines.push(line);
}
expect(lines).toEqual([]);
});
it('readText preserves valid U+FFFD while ignoring invalid utf-8 bytes', async () => {
const data = Buffer.concat([
Buffer.from('A\uFFFDB', 'utf-8'),
Buffer.from([0xff]),
Buffer.from('C', 'utf-8'),
]);
const root: TreeNode = {
type: 'dir',
children: { 'mixed.txt': { type: 'file', content: data } },
};
const kaos = makeFakeKaos(makeTreeSftp(root));
await expect(kaos.readText('/mixed.txt', { errors: 'ignore' })).resolves.toBe('A\uFFFDBC');
});
// ── . / .. filter coverage ────────────────────────────────────────
it('iterdir at root "/" does not produce double-slash paths', async () => {
// Regression: `basePath + '/' + entry.filename` produced `//foo` when
// basePath was the filesystem root. Now uses `posix.join` to collapse.
const root: TreeNode = {
type: 'dir',
children: {
'a.txt': { type: 'file', content: Buffer.from('a') },
sub: { type: 'dir', children: {} },
},
};
const kaos = makeFakeKaos(makeTreeSftp(root), '/');
const entries: string[] = [];
for await (const entry of kaos.iterdir('/')) {
entries.push(entry);
}
expect(new Set(entries)).toEqual(new Set(['/a.txt', '/sub']));
expect(entries.every((e) => !e.includes('//'))).toBe(true);
});
it('glob at root "/" does not produce double-slash paths', async () => {
const root: TreeNode = {
type: 'dir',
children: {
'file.txt': { type: 'file', content: Buffer.from('x') },
},
};
const kaos = makeFakeKaos(makeTreeSftp(root), '/');
const matches: string[] = [];
for await (const m of kaos.glob('/', '*.txt')) {
matches.push(m);
}
expect(matches).toEqual(['/file.txt']);
expect(matches.every((p) => !p.includes('//'))).toBe(true);
});
it('iterdir filters out "." and ".." entries from readdir output', async () => {
// Some SFTP servers include `.` / `..` in readdir results, so the
// filter in SSHKaos.iterdir must skip them unconditionally. Inject
// those entries into the fake to exercise the filter branch.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void {
cb(null, [
{ filename: '.', attrs: { isDirectory: (): boolean => true } },
{ filename: '..', attrs: { isDirectory: (): boolean => true } },
{ filename: 'real.txt', attrs: { isDirectory: (): boolean => false } },
]);
},
};
const kaos = makeFakeKaos(sftp, '/tree');
const entries: string[] = [];
for await (const e of kaos.iterdir('/tree')) {
entries.push(e);
}
// Only the real entry survives — the `.` / `..` are silently dropped.
expect(entries).toEqual(['/tree/real.txt']);
});
it('glob filters "." and ".." entries in both the ** and literal branches', async () => {
// Same fake readdir in two walks: one with `**` (covers the `**`
// branch filter) and one with a literal pattern (covers the non-`**`
// branch filter).
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void {
cb(null, [
{
filename: '.',
attrs: {
isDirectory: (): boolean => true,
isFile: (): boolean => false,
},
},
{
filename: '..',
attrs: {
isDirectory: (): boolean => true,
isFile: (): boolean => false,
},
},
{
filename: 'keeper.txt',
attrs: {
isDirectory: (): boolean => false,
isFile: (): boolean => true,
},
},
]);
},
};
const kaos = makeFakeKaos(sftp, '/tree');
const viaStar: string[] = [];
for await (const m of kaos.glob('/tree', '*.txt')) viaStar.push(m);
expect(viaStar).toEqual(['/tree/keeper.txt']);
const viaStarStar: string[] = [];
for await (const m of kaos.glob('/tree', '**')) viaStarStar.push(m);
// The recursion into `.` / `..` must be skipped — we should see only
// the base dir and the keeper file, never an infinite loop.
expect(new Set(viaStarStar)).toEqual(new Set(['/tree', '/tree/keeper.txt']));
});
// ── glob error handling in the ** branch ──────────────────────────
it('glob ** silently aborts when readdir fails', async () => {
// Similar to the non-`**` readdir-error test, but forcing the code
// through the `**` case's own try/catch. Passing a bare `**` pattern
// takes the `currentPattern === '**'` branch, and the failing
// readdir exercises the swallow-and-return path inside it.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void {
const err = new Error('denied');
(err as unknown as { code: number }).code = 3;
cb(err);
},
};
const kaos = makeFakeKaos(sftp, '/tree');
const matches: string[] = [];
for await (const m of kaos.glob('/tree', '**')) {
matches.push(m);
}
// Pattern `**` with zero-directory match yields basePath itself
// BEFORE the readdir even runs, so we still see `/tree`. The
// important bit is that no exception propagates out.
expect(matches).toEqual(['/tree']);
});
// ── readBytes ──────────────────────────────────────────────────────
it('readBytes(n) returns only the first n bytes of the file', async () => {
const content = Buffer.from('0123456789');
const root: TreeNode = {
type: 'dir',
children: { 'data.bin': { type: 'file', content } },
};
const kaos = makeFakeKaos(makeTreeSftp(root));
const full = await kaos.readBytes('/data.bin');
expect(Buffer.compare(full, content)).toBe(0);
const first4 = await kaos.readBytes('/data.bin', 4);
expect(first4.toString()).toBe('0123');
});
// ── execWithEnv body ───────────────────────────────────────────────
it('execWithEnv calls clientExec with the env-prefixed command', async () => {
// We only exercise the _execInternal path via execWithEnv here — the
// command-building detail is already covered by the _buildExecCommand
// describe block. This test just pins that execWithEnv() with a valid
// non-empty args array routes through the internal helper.
const commands: string[] = [];
const fakeClient = {
exec(cmd: string, _a: unknown, _b?: unknown): void {
commands.push(cmd);
// Mimic a channel that never emits — we just want to observe the
// command string, not wait for real I/O.
throw new Error('stop');
},
};
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as {
_client: unknown;
_cwd: string;
_home: string;
_envLayers: readonly Record<string, string>[];
};
internal._client = fakeClient;
internal._cwd = '/home/tester';
internal._home = '/home/tester';
internal._envLayers = [];
await expect(instance.execWithEnv(['echo', 'hi'], { FOO: 'bar' })).rejects.toThrow('stop');
expect(commands).toHaveLength(1);
expect(commands[0]).toBe('cd /home/tester && FOO=bar echo hi');
});
});
// These tests pin the non-race mkdir error branches in SSHKaos — the
// "path already exists but is a file" and "parents=true with a final
// directory already present under existOk=false" cases. All driven via
// fake SFTP wrappers so no live server is required.
describe('SSHKaos mkdir existOk edge cases', () => {
function makeFakeKaos(sftp: unknown): SSHKaos {
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internal = instance as unknown as { _sftp: unknown; _cwd: string };
internal._sftp = sftp;
internal._cwd = '/';
return instance;
}
function makeFileStats(): unknown {
return {
mode: 0o100644,
size: 0,
uid: 0,
gid: 0,
atime: 0,
mtime: 0,
isDirectory: (): boolean => false,
isFile: (): boolean => true,
isSymbolicLink: (): boolean => false,
isSocket: (): boolean => false,
isCharacterDevice: (): boolean => false,
isBlockDevice: (): boolean => false,
isFIFO: (): boolean => false,
};
}
function makeDirStats(): unknown {
return {
mode: 0o040755,
size: 0,
uid: 0,
gid: 0,
atime: 0,
mtime: 0,
isDirectory: (): boolean => true,
isFile: (): boolean => false,
isSymbolicLink: (): boolean => false,
isSocket: (): boolean => false,
isCharacterDevice: (): boolean => false,
isBlockDevice: (): boolean => false,
isFIFO: (): boolean => false,
};
}
it('mkdir (non-parents) rejects when existOk=true but path is a file', async () => {
// simple mkdir branch: exists=true + existOk=true + not a directory
// must throw instead of silently accepting the file collision.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
exists(_p: string, cb: (exists: boolean) => void): void {
cb(true);
},
stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void {
cb(null, makeFileStats());
},
};
const kaos = makeFakeKaos(sftp);
await expect(kaos.mkdir('/existing-file', { existOk: true })).rejects.toBeInstanceOf(
KaosFileExistsError,
);
});
it('mkdir(parents=true) rejects when the final path exists and existOk=false', async () => {
// Recursive branch: walks to the final component, finds it already
// exists (as a directory, even), and since existOk=false the call
// must surface a KaosFileExistsError instead of silently succeeding.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
exists(_p: string, cb: (exists: boolean) => void): void {
cb(true);
},
stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void {
cb(null, makeDirStats());
},
};
const kaos = makeFakeKaos(sftp);
await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: false })).rejects.toBeInstanceOf(
KaosFileExistsError,
);
});
it('mkdir(parents=true) rejects when an intermediate path is a regular file', async () => {
// Recursive branch: during the walk, an intermediate component
// already exists but is a regular file (not a directory). That must
// be a hard failure regardless of existOk, because the next sftpMkdir
// would otherwise fail with a confusing ENOTDIR.
const sftp = {
realpath(p: string, cb: (err: Error | null, abs: string) => void): void {
cb(null, p);
},
exists(_p: string, cb: (exists: boolean) => void): void {
cb(true);
},
stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void {
cb(null, makeFileStats());
},
};
const kaos = makeFakeKaos(sftp);
await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: true })).rejects.toBeInstanceOf(
KaosFileExistsError,
);
});
});
describe('SSHKaos.close lifecycle', () => {
class FakeClient extends EventEmitter {
closed = false;
end(): void {
queueMicrotask(() => {
this.closed = true;
this.emit('close');
});
}
exec(
_command: string,
optionsOrCallback:
| ((err: Error | undefined, channel: never) => void)
| Record<string, unknown>,
maybeCallback?: (err: Error | undefined, channel: never) => void,
): void {
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback;
if (callback === undefined) {
return;
}
if (this.closed) {
callback(new Error('channel closed'), undefined as never);
return;
}
callback(undefined, undefined as never);
}
}
function createCloseableKaos(): SSHKaos {
const instance = Object.create(SSHKaos.prototype) as SSHKaos;
const internals = instance as unknown as {
_client: FakeClient;
_cwd: string;
_home: string;
_sftp: { end(): void };
_envLayers: readonly Record<string, string>[];
};
internals._client = new FakeClient();
internals._cwd = '/tmp';
internals._home = '/tmp';
internals._envLayers = [];
internals._sftp = {
end(): void {
// no-op
},
};
return instance;
}
it('awaits the close event before allowing follow-up execs to observe the closed state', async () => {
const kaos = createCloseableKaos();
await kaos.close();
await expect(kaos.exec('pwd')).rejects.toThrow(/channel closed/);
});
});