kimi-code/packages/kaos/test/shell-path-bridge.test.ts
wszqkzqk 30e7f62d2c
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
fix(kaos): resolve Git Bash POSIX paths for file tools on Windows (#2200)
Add a shell path bridge that translates between win32 paths and the
MSYS2/Git Bash path dialect. File tools resolve model-supplied paths
through it before canonicalization and workspace checks: drive-letter
forms translate lexically, root-relative paths resolve via cygpath -w
with per-segment caching, and every failure mode falls back to the
previous behavior.

Fixes #2199
2026-08-20 22:42:16 +08:00

267 lines
9.2 KiB
TypeScript

/**
* Shell path bridge — drives `createShellPathBridge` with injected
* `execFileSync` / `isFile` fakes (no real processes): lexical drive forms,
* pass-through tiers, cygpath resolution and caching, `toShellPath`.
*/
import { describe, expect, it, vi } from 'vitest';
import {
createShellPathBridge,
type ShellPathBridgeDeps,
type ShellPathBridgeEnv,
} from '#/shell-path-bridge';
const WINDOWS_ENV: ShellPathBridgeEnv = {
osKind: 'Windows',
shellName: 'bash',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
};
const POSIX_ENV: ShellPathBridgeEnv = {
osKind: 'Linux',
shellName: 'bash',
shellPath: '/bin/bash',
};
// cygpath.exe candidates probed for `C:\Program Files\Git\bin\bash.exe`.
const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe';
const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe';
interface StubOpts {
readonly existingPaths?: readonly string[];
readonly execFileResults?: Readonly<Record<string, string>>;
readonly execFileSync?: ShellPathBridgeDeps['execFileSync'];
}
function stubDeps(opts: StubOpts = {}) {
const existing = new Set(opts.existingPaths ?? []);
const execFileSync = vi.fn(
opts.execFileSync ??
((file: string, args: readonly string[]): string => {
const result = opts.execFileResults?.[[file, ...args].join(' ')];
if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`);
return result;
}),
);
const deps: ShellPathBridgeDeps = {
execFileSync,
isFile: (path: string) => existing.has(path),
};
return { deps, execFileSync };
}
function cygpathKey(firstSegment: string): string {
return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`;
}
describe('fromShellPath lexical drive forms', () => {
const cases: ReadonlyArray<readonly [string, string]> = [
['/c:/Users/foo', 'C:/Users/foo'],
['/c:', 'C:/'],
['/cygdrive/c/Users/foo', 'C:/Users/foo'],
['/cygdrive/d', 'D:/'],
['/c/Users/foo', 'C:/Users/foo'],
['/C/Users/foo', 'C:/Users/foo'],
['/c/', 'C:/'],
['/c', 'C:/'],
];
for (const [input, expected] of cases) {
it(`rewrites "${input}"`, () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(expected);
expect(execFileSync).not.toHaveBeenCalled();
});
}
});
describe('fromShellPath pass-through', () => {
it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])(
'leaves virtual-fs path %s unchanged',
(input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(input);
expect(execFileSync).not.toHaveBeenCalled();
},
);
it.each([
'/',
'//server/share',
'//server/share/file.txt',
'relative/path',
'relative\\path',
'file.txt',
'C:\\Users\\foo',
'C:/Users/foo',
'~/Documents',
])('leaves %s unchanged without consulting cygpath', (input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(input);
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('fromShellPath cygpath resolution', () => {
it('resolves a root-relative path through cygpath and caches per first segment', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: {
[cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n',
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/scratch/a.txt',
);
expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other');
expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp');
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [
'-w',
'-C',
'UTF8',
'--',
'/tmp',
]);
});
it('folds dot segments before resolving the mount segment', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: {
[cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n',
[cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n',
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/./tmp/note.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/note.txt',
);
expect(bridge.fromShellPath('/../tmp/note.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/note.txt',
);
expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('folds dot segments before lexical drive translation', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects');
expect(execFileSync).not.toHaveBeenCalled();
});
it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe('/');
expect(execFileSync).not.toHaveBeenCalled();
});
it('resolves a drive-root mount and keeps it absolute', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: { [cygpathKey('work')]: 'D:\\\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/work/app')).toBe('D:/app');
expect(bridge.fromShellPath('/work')).toBe('D:/');
expect(execFileSync).toHaveBeenCalledTimes(1);
});
it('prefers cygpath.exe next to bash.exe when present', () => {
const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`;
const { deps, execFileSync } = stubDeps({
existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH],
execFileResults: { [key]: 'C:\\Users\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt');
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']);
});
it('passes through and retries on the next access when cygpath fails', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileSync: () => {
throw new Error('cygpath exited 1');
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('passes through and retries when cygpath output is not an absolute win32 path', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('passes through without spawning when cygpath.exe is missing', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/home/u')).toBe('/home/u');
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('identity outside win32 bash', () => {
it('is identity on posix', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(POSIX_ENV, deps);
expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo');
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo');
expect(execFileSync).not.toHaveBeenCalled();
});
it('is identity on Windows without bash', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(
{ osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' },
deps,
);
expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo');
expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo');
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('toShellPath', () => {
it.each([
['C:\\Users\\foo', '/c/Users/foo'],
['C:/Users/foo', '/c/Users/foo'],
['C:\\', '/c/'],
['D:\\Projects', '/d/Projects'],
['\\\\server\\share\\dir', '//server/share/dir'],
['relative\\path', 'relative/path'],
['already/posix', 'already/posix'],
])('maps %s → %s', (input, expected) => {
const { deps } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.toShellPath(input)).toBe(expected);
});
});