mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-18 05:34:23 +00:00
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import * as win32Path from 'node:path/win32';
|
|
|
|
import { resetCurrentKaos, setCurrentKaos } from '#/current';
|
|
import type { KaosToken } from '#/current';
|
|
import type { Kaos } from '#/kaos';
|
|
import { LocalKaos } from '#/local';
|
|
import { KaosPath } from '#/path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
function makeMockKaos(pathClass: 'posix' | 'win32', overrides: Partial<Kaos> = {}): Kaos {
|
|
return {
|
|
name: `mock-${pathClass}`,
|
|
pathClass: () => pathClass,
|
|
normpath: (p: string) => (pathClass === 'win32' ? win32Path.normalize(p) : p),
|
|
gethome: () => (pathClass === 'win32' ? 'C:\\Users\\test' : '/home/test'),
|
|
getcwd: () => (pathClass === 'win32' ? 'C:\\work\\project' : '/work/project'),
|
|
chdir: async () => {},
|
|
stat: async () => ({
|
|
stMode: 0,
|
|
stIno: 0,
|
|
stDev: 0,
|
|
stNlink: 0,
|
|
stUid: 0,
|
|
stGid: 0,
|
|
stSize: 0,
|
|
stAtime: 0,
|
|
stMtime: 0,
|
|
stCtime: 0,
|
|
}),
|
|
iterdir: async function* () {},
|
|
glob: async function* () {},
|
|
readBytes: async () => Buffer.alloc(0),
|
|
readText: async () => '',
|
|
readLines: async function* () {},
|
|
writeBytes: async () => 0,
|
|
writeText: async () => 0,
|
|
mkdir: async () => {},
|
|
exec: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
execWithEnv: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('KaosPath', () => {
|
|
let token: KaosToken;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-path-'));
|
|
const kaos = new LocalKaos();
|
|
token = setCurrentKaos(kaos);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
resetCurrentKaos(token);
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('join and parent', () => {
|
|
it('should join paths with div', () => {
|
|
const p = new KaosPath('/usr');
|
|
const child = p.div('local').div('bin');
|
|
expect(child.toString()).toBe('/usr/local/bin');
|
|
});
|
|
|
|
it('should join paths with joinpath', () => {
|
|
const p = new KaosPath('/usr');
|
|
const child = p.joinpath('local', 'bin');
|
|
expect(child.toString()).toBe('/usr/local/bin');
|
|
});
|
|
|
|
it('should treat absolute joinpath segments as new roots', () => {
|
|
const p = new KaosPath('/usr');
|
|
expect(p.joinpath('/etc').toString()).toBe('/etc');
|
|
expect(new KaosPath('/usr', '/etc').toString()).toBe('/etc');
|
|
});
|
|
|
|
it('should accept backslashes as separators on posix', () => {
|
|
const p = new KaosPath('foo\\bar\\baz');
|
|
expect(p.toString()).toBe('foo/bar/baz');
|
|
expect(p.name).toBe('baz');
|
|
expect(p.parent.toString()).toBe('foo/bar');
|
|
});
|
|
|
|
it('should preserve parent references until canonical()', () => {
|
|
const p = new KaosPath('/usr').joinpath('../etc');
|
|
expect(p.toString()).toBe('/usr/../etc');
|
|
expect(p.canonical().toString()).toBe('/etc');
|
|
});
|
|
|
|
it('should accept another KaosPath as div() argument', () => {
|
|
// Exercises the `other instanceof KaosPath` branch in div() — the
|
|
// existing tests only pass raw strings so this covers the other
|
|
// side of the ternary.
|
|
const p = new KaosPath('/usr');
|
|
const sub = new KaosPath('local');
|
|
expect(p.div(sub).toString()).toBe('/usr/local');
|
|
});
|
|
|
|
it('should return the parent path', () => {
|
|
const p = new KaosPath('/usr/local/bin');
|
|
expect(p.parent.toString()).toBe('/usr/local');
|
|
expect(p.parent.parent.toString()).toBe('/usr');
|
|
});
|
|
|
|
it('should return the name', () => {
|
|
const p = new KaosPath('/usr/local/bin');
|
|
expect(p.name).toBe('bin');
|
|
});
|
|
|
|
it('should detect absolute paths', () => {
|
|
expect(new KaosPath('/usr').isAbsolute()).toBe(true);
|
|
expect(new KaosPath('relative/path').isAbsolute()).toBe(false);
|
|
});
|
|
|
|
it('should default to "." when constructed with no arguments', () => {
|
|
// KaosPath() with no args represents the current directory placeholder.
|
|
// Used as a neutral starting point before chaining .div()/.joinpath().
|
|
const p = new KaosPath();
|
|
expect(p.toString()).toBe('.');
|
|
});
|
|
});
|
|
|
|
describe('home and cwd', () => {
|
|
it('should return home directory via KaosPath.home()', () => {
|
|
const home = KaosPath.home();
|
|
expect(home.isAbsolute()).toBe(true);
|
|
expect(home.toString()).toBe(homedir());
|
|
});
|
|
|
|
it('should return cwd via KaosPath.cwd()', () => {
|
|
const cwd = KaosPath.cwd();
|
|
expect(cwd.isAbsolute()).toBe(true);
|
|
expect(cwd.toString()).toBe(process.cwd());
|
|
});
|
|
});
|
|
|
|
describe('expanduser', () => {
|
|
it('should expand ~ to home directory', () => {
|
|
const p = new KaosPath('~/Documents');
|
|
const expanded = p.expanduser();
|
|
expect(expanded.toString()).toBe(join(homedir(), 'Documents'));
|
|
expect(expanded.isAbsolute()).toBe(true);
|
|
});
|
|
|
|
it('should expand bare ~ to home directory', () => {
|
|
const p = new KaosPath('~');
|
|
const expanded = p.expanduser();
|
|
expect(expanded.toString()).toBe(homedir());
|
|
});
|
|
|
|
it('should not expand ~ in the middle of a path', () => {
|
|
const p = new KaosPath('/usr/~test');
|
|
const expanded = p.expanduser();
|
|
expect(expanded.toString()).toBe('/usr/~test');
|
|
});
|
|
});
|
|
|
|
describe('canonical and relativeTo', () => {
|
|
it('should resolve .. in an absolute path', () => {
|
|
const p = new KaosPath('/usr/local/../bin');
|
|
const c = p.canonical();
|
|
expect(c.toString()).toBe('/usr/bin');
|
|
});
|
|
|
|
it('should make relative paths absolute using cwd', () => {
|
|
const p = new KaosPath('foo/bar');
|
|
const c = p.canonical();
|
|
expect(c.isAbsolute()).toBe(true);
|
|
expect(c.toString()).toBe(join(process.cwd(), 'foo/bar'));
|
|
});
|
|
|
|
it('should resolve .. in a relative path against cwd', () => {
|
|
// Pins the Python test_kaos_path.py::test_canonical_and_relative_to case:
|
|
// a relative input that also contains '..' must first rebase to cwd and
|
|
// then collapse the parent reference.
|
|
const p = new KaosPath('nested/../file.txt').canonical();
|
|
expect(p.isAbsolute()).toBe(true);
|
|
expect(p.toString()).toBe(join(process.cwd(), 'file.txt'));
|
|
});
|
|
|
|
it('should compute relativeTo correctly', () => {
|
|
const child = new KaosPath('/usr/local/bin');
|
|
const base = new KaosPath('/usr/local');
|
|
const rel = child.relativeTo(base);
|
|
expect(rel.toString()).toBe('bin');
|
|
});
|
|
|
|
it('should return dot for identical paths', () => {
|
|
const p = new KaosPath('/usr/local');
|
|
expect(p.relativeTo(new KaosPath('/usr/local')).toString()).toBe('.');
|
|
});
|
|
|
|
it('should reject sibling paths for relativeTo', () => {
|
|
const a = new KaosPath('/usr/local/lib');
|
|
const base = new KaosPath('/usr/local/bin');
|
|
expect(() => a.relativeTo(base)).toThrow(/not within/);
|
|
});
|
|
|
|
it('should reject when base is deeper than target', () => {
|
|
// The target is strictly shorter than base — there is no way for it to
|
|
// live "within" a deeper base. Hits the parts-length guard separately
|
|
// from the loop-based prefix-mismatch guard.
|
|
const a = new KaosPath('/usr');
|
|
const base = new KaosPath('/usr/local/bin');
|
|
expect(() => a.relativeTo(base)).toThrow(/not within/);
|
|
});
|
|
|
|
it('should compute relativeTo between two relative paths (empty root)', () => {
|
|
// Both operands have no absolute root, so splitPathLexically takes
|
|
// the `root.length === 0 ? path : path.slice(root.length)` else branch.
|
|
// Pinning this keeps the purely-relative relativeTo path honest.
|
|
const child = new KaosPath('a/b/c');
|
|
const base = new KaosPath('a/b');
|
|
expect(child.relativeTo(base).toString()).toBe('c');
|
|
});
|
|
|
|
it('should use win32 separators when the current kaos reports win32', async () => {
|
|
// Build a minimal Kaos mock that claims win32 path semantics. All
|
|
// paths produced by KaosPath must use forward slashes, even when the
|
|
// underlying path class is win32.
|
|
const winKaos: Kaos = {
|
|
name: 'mock-win32',
|
|
pathClass: () => 'win32',
|
|
normpath: (p: string) => win32Path.normalize(p).replaceAll('\\', '/'),
|
|
gethome: () => 'C:/Users/test',
|
|
getcwd: () => 'C:/work/project',
|
|
chdir: async () => {},
|
|
stat: async () => ({
|
|
stMode: 0,
|
|
stIno: 0,
|
|
stDev: 0,
|
|
stNlink: 0,
|
|
stUid: 0,
|
|
stGid: 0,
|
|
stSize: 0,
|
|
stAtime: 0,
|
|
stMtime: 0,
|
|
stCtime: 0,
|
|
}),
|
|
iterdir: async function* () {},
|
|
glob: async function* () {},
|
|
readBytes: async () => Buffer.alloc(0),
|
|
readText: async () => '',
|
|
readLines: async function* () {},
|
|
writeBytes: async () => 0,
|
|
writeText: async () => 0,
|
|
mkdir: async () => {},
|
|
exec: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
execWithEnv: async () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
};
|
|
|
|
// Nested set so the outer beforeEach token is still restored cleanly
|
|
// by afterEach; we explicitly reset the inner token here.
|
|
const innerToken = setCurrentKaos(winKaos);
|
|
try {
|
|
const rel = new KaosPath('foo\\bar').canonical();
|
|
// Resolved against 'C:/work/project' → 'C:/work/project/foo/bar'.
|
|
expect(rel.toString()).toBe('C:/work/project/foo/bar');
|
|
expect(rel.toString().includes('\\')).toBe(false);
|
|
|
|
const abs = new KaosPath('C:\\foo\\..\\bar').canonical();
|
|
expect(abs.toString()).toBe('C:/bar');
|
|
|
|
expect(() => new KaosPath('D:\\logs').relativeTo(new KaosPath('C:\\work'))).toThrow(
|
|
/not within/,
|
|
);
|
|
expect(
|
|
new KaosPath('C:\\Work\\Project').relativeTo(new KaosPath('c:\\work')).toString(),
|
|
).toBe('Project');
|
|
|
|
expect(new KaosPath('C:\\base').joinpath('D:\\logs').toString()).toBe('D:/logs');
|
|
expect(new KaosPath('C:\\base').joinpath('\\rooted').toString()).toBe('C:/rooted');
|
|
expect(new KaosPath('C:\\base').joinpath('C:relative').toString()).toBe(
|
|
'C:/base/relative',
|
|
);
|
|
expect(new KaosPath('C:\\base').joinpath('D:relative').toString()).toBe('D:relative');
|
|
expect(() => new KaosPath('D:relative').canonical()).toThrow(/drive-relative/);
|
|
await expect(new KaosPath('D:relative').readText()).rejects.toThrow(/drive-relative/);
|
|
} finally {
|
|
resetCurrentKaos(innerToken);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('exists and file operations', () => {
|
|
it('should reject I/O when the current kaos path class differs from the path', async () => {
|
|
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
|
const p = new KaosPath('C:\\work\\project\\file.txt');
|
|
resetCurrentKaos(innerToken);
|
|
|
|
await expect(p.readText()).rejects.toThrow(/Cannot read win32 path/);
|
|
await expect(p.stat()).rejects.toThrow(/Cannot stat win32 path/);
|
|
await expect(p.exists()).rejects.toThrow(/Cannot check win32 path/);
|
|
});
|
|
|
|
it('should detect file existence', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'hello.txt'));
|
|
expect(await p.exists()).toBe(false);
|
|
await p.writeText('hello');
|
|
expect(await p.exists()).toBe(true);
|
|
});
|
|
|
|
it('should write and read text', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'test.txt'));
|
|
await p.writeText('hello world');
|
|
const text = await p.readText();
|
|
expect(text).toBe('hello world');
|
|
});
|
|
|
|
it('should identify files and directories', async () => {
|
|
const f = new KaosPath(join(tmpDir, 'file.txt'));
|
|
await f.writeText('content');
|
|
expect(await f.isFile()).toBe(true);
|
|
expect(await f.isDir()).toBe(false);
|
|
|
|
const d = new KaosPath(tmpDir);
|
|
expect(await d.isDir()).toBe(true);
|
|
expect(await d.isFile()).toBe(false);
|
|
});
|
|
|
|
it('should append text', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'append.txt'));
|
|
await p.writeText('hello');
|
|
await p.appendText(' world');
|
|
const text = await p.readText();
|
|
expect(text).toBe('hello world');
|
|
});
|
|
|
|
it('should create directories', async () => {
|
|
const d = new KaosPath(join(tmpDir, 'a', 'b', 'c'));
|
|
await d.mkdir({ parents: true });
|
|
expect(await d.isDir()).toBe(true);
|
|
});
|
|
|
|
it('should handle existOk for mkdir', async () => {
|
|
const d = new KaosPath(tmpDir);
|
|
await expect(d.mkdir({ existOk: true })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should return false for isFile on a non-existent path', async () => {
|
|
// exists()/isFile()/isDir() all swallow stat errors and return false
|
|
// for missing paths. Pin that behavior so a regression does not
|
|
// silently start throwing on absent files.
|
|
const p = new KaosPath(join(tmpDir, 'does-not-exist.txt'));
|
|
expect(await p.isFile()).toBe(false);
|
|
});
|
|
|
|
it('should return false for isDir on a non-existent path', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'also-missing'));
|
|
expect(await p.isDir()).toBe(false);
|
|
});
|
|
|
|
it('should append text with an explicit encoding option', async () => {
|
|
// appendText accepts an encoding option that threads through to
|
|
// kaos.writeText({ mode: 'a', encoding }). Exercising the explicit
|
|
// encoding branch keeps the option wiring honest.
|
|
const p = new KaosPath(join(tmpDir, 'enc-append.txt'));
|
|
await p.writeText('head-');
|
|
await p.appendText('tail', { encoding: 'utf-8' });
|
|
expect(await p.readText()).toBe('head-tail');
|
|
});
|
|
});
|
|
|
|
describe('iterdir and glob', () => {
|
|
it('should iterate directory entries', async () => {
|
|
await new KaosPath(join(tmpDir, 'a.txt')).writeText('a');
|
|
await new KaosPath(join(tmpDir, 'b.txt')).writeText('b');
|
|
await new KaosPath(join(tmpDir, 'c.md')).writeText('c');
|
|
|
|
const entries: string[] = [];
|
|
const dir = new KaosPath(tmpDir);
|
|
for await (const entry of dir.iterdir()) {
|
|
entries.push(entry.name);
|
|
}
|
|
expect([...entries].toSorted()).toEqual(['a.txt', 'b.txt', 'c.md']);
|
|
});
|
|
|
|
it('should glob for matching files', async () => {
|
|
await new KaosPath(join(tmpDir, 'foo.txt')).writeText('foo');
|
|
await new KaosPath(join(tmpDir, 'bar.txt')).writeText('bar');
|
|
await new KaosPath(join(tmpDir, 'baz.md')).writeText('baz');
|
|
|
|
const matches: string[] = [];
|
|
const dir = new KaosPath(tmpDir);
|
|
for await (const entry of dir.glob('*.txt')) {
|
|
matches.push(entry.name);
|
|
}
|
|
expect([...matches].toSorted()).toEqual(['bar.txt', 'foo.txt']);
|
|
});
|
|
});
|
|
|
|
describe('read and write bytes', () => {
|
|
it('should write and read binary data', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'data.bin'));
|
|
const data = Buffer.from([0x00, 0x01, 0x02, 0xfe, 0xff]);
|
|
const written = await p.writeBytes(data);
|
|
expect(written).toBe(5);
|
|
|
|
const read = await p.readBytes();
|
|
expect(Buffer.compare(read, data)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('readLines', () => {
|
|
it('should yield lines from a file', async () => {
|
|
const p = new KaosPath(join(tmpDir, 'lines.txt'));
|
|
await p.writeText('line1\nline2\nline3');
|
|
|
|
const lines: string[] = [];
|
|
for await (const line of p.readLines()) {
|
|
lines.push(line);
|
|
}
|
|
// readLines yields lines with trailing \n except for the last line
|
|
expect(lines).toEqual(['line1\n', 'line2\n', 'line3']);
|
|
});
|
|
});
|
|
|
|
describe('fromLocalPath and toLocalPath', () => {
|
|
it('should round-trip a path string', () => {
|
|
const original = '/usr/local/bin/node';
|
|
const p = KaosPath.fromLocalPath(original);
|
|
expect(p.toLocalPath()).toBe(original);
|
|
expect(p.toString()).toBe(original);
|
|
});
|
|
|
|
it('should return backslashes for win32 toLocalPath', () => {
|
|
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
|
try {
|
|
const p = new KaosPath('C:/Users/test/file.txt');
|
|
expect(p.toLocalPath()).toBe('C:\\Users\\test\\file.txt');
|
|
expect(p.toString()).toBe('C:/Users/test/file.txt');
|
|
} finally {
|
|
resetCurrentKaos(innerToken);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('equals', () => {
|
|
it('returns true for two KaosPath instances with the same path string', () => {
|
|
const a = new KaosPath('/foo/bar');
|
|
const b = new KaosPath('/foo/bar');
|
|
expect(a.equals(b)).toBe(true);
|
|
});
|
|
|
|
it('returns false for different path strings', () => {
|
|
const a = new KaosPath('/foo/bar');
|
|
const b = new KaosPath('/foo/baz');
|
|
expect(a.equals(b)).toBe(false);
|
|
});
|
|
|
|
it('returns true after joining equivalent segments', () => {
|
|
const a = new KaosPath('/foo').div('bar');
|
|
const b = new KaosPath('/foo/bar');
|
|
expect(a.equals(b)).toBe(true);
|
|
});
|
|
|
|
it('is symmetric', () => {
|
|
const a = new KaosPath('/a/b/c');
|
|
const b = new KaosPath('/a/b/c');
|
|
expect(a.equals(b)).toBe(b.equals(a));
|
|
});
|
|
|
|
it('returns false for the same string with different path classes', () => {
|
|
const posixPath = new KaosPath('C:\\workspace');
|
|
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
|
try {
|
|
const winPath = new KaosPath('C:\\workspace');
|
|
// Both path classes normalise backslashes to forward slashes;
|
|
// they differ only in pathClass, so equals is still false.
|
|
expect(posixPath.toString()).toBe('C:/workspace');
|
|
expect(winPath.toString()).toBe('C:/workspace');
|
|
expect(posixPath.equals(winPath)).toBe(false);
|
|
} finally {
|
|
resetCurrentKaos(innerToken);
|
|
}
|
|
});
|
|
|
|
it('rejects div() with a KaosPath from a different path class', () => {
|
|
const posixPath = new KaosPath('/workspace');
|
|
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
|
try {
|
|
const winPath = new KaosPath('C:\\workspace');
|
|
expect(() => posixPath.div(winPath)).toThrow(/Cannot join win32 path to posix path/);
|
|
} finally {
|
|
resetCurrentKaos(innerToken);
|
|
}
|
|
});
|
|
});
|
|
});
|