* fix(ui,server): normalize Windows drive letter casing for consistent path resolution Provider settings were failing to persist for specific projects on Windows because path normalization was inconsistent across the codebase. Some normalizePath functions uppercased the Windows drive letter (c:\ -> C:\) and others did not, causing: - directoryScoped cache misses (different keys for the same directory) - broken model selection in the affected project - lost conversation history (sessions could not match their project) - false cache hits in resolveConfigDirectory on undefined inputs This change extracts a single shared normalizePath utility and uses it from the 5 client sites that were missing the drive letter normalization. The server-side normalizePathForPersistence is updated to uppercase the drive letter both before and after safeRealpathSync, so the persisted path is consistent even when realpath returns a symlink/junction with a lowercase drive letter on some Windows environments. Fixes #2109 * test(ui,server): add coverage for Windows path normalization Address review feedback on #2154: - Add Windows-platform test for normalizePathForPersistence covering drive letter uppercase on input and after realpath resolution - Add dedicated test suite for the shared normalizePath utility - Defensive fix: normalizePath now returns null for paths that consist only of slashes (\\, ///), matching the documented contract Refs #2154 * fix(server): scope drive casing normalization to Windows --------- Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
|
|
import { normalizePath } from './pathNormalization';
|
|
|
|
describe('normalizePath', () => {
|
|
describe('non-string inputs', () => {
|
|
test('returns null for null', () => {
|
|
expect(normalizePath(null)).toBeNull();
|
|
});
|
|
|
|
test('returns null for undefined', () => {
|
|
expect(normalizePath(undefined)).toBeNull();
|
|
});
|
|
|
|
test('returns null for empty string', () => {
|
|
expect(normalizePath('')).toBeNull();
|
|
});
|
|
|
|
test('returns null for whitespace-only', () => {
|
|
expect(normalizePath(' ')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('backslashes', () => {
|
|
test('converts backslashes to forward slashes', () => {
|
|
expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project');
|
|
});
|
|
});
|
|
|
|
describe('drive letter casing', () => {
|
|
test('uppercases lowercase Windows drive letter', () => {
|
|
expect(normalizePath('c:\\Users\\me\\project')).toBe('C:/Users/me/project');
|
|
});
|
|
|
|
test('preserves already-uppercase drive letter', () => {
|
|
expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project');
|
|
});
|
|
|
|
test('does not match multi-character tokens before colon', () => {
|
|
expect(normalizePath('abc:def')).toBe('abc:def');
|
|
});
|
|
|
|
test('does not touch drive letter in middle of path', () => {
|
|
// Only the leading drive letter is touched; a "c:" later in the
|
|
// path is left alone (no upper-casing, no backslash conversion of
|
|
// the surrounding characters beyond the backslash-to-slash step).
|
|
expect(normalizePath('/foo/c:\\bar')).toBe('/foo/c:/bar');
|
|
});
|
|
});
|
|
|
|
describe('trailing slashes', () => {
|
|
test('strips a single trailing slash', () => {
|
|
expect(normalizePath('C:/Users/me/')).toBe('C:/Users/me');
|
|
});
|
|
|
|
test('strips multiple trailing slashes', () => {
|
|
expect(normalizePath('C:/Users/me///')).toBe('C:/Users/me');
|
|
});
|
|
|
|
test('preserves root /', () => {
|
|
expect(normalizePath('/')).toBe('/');
|
|
});
|
|
|
|
test('preserves single-char after slash strip', () => {
|
|
expect(normalizePath('C:/')).toBe('C:');
|
|
});
|
|
});
|
|
|
|
describe('degenerate slash-only inputs', () => {
|
|
// '///' → stays '///' after backslash replace → trailing-slash strip
|
|
// yields '' → null. This is the new defensive behavior.
|
|
test('returns null for multiple forward slashes', () => {
|
|
expect(normalizePath('///')).toBeNull();
|
|
});
|
|
|
|
// '\\\\' in source = 2 backslash chars → replace to '//' → strip → '' → null.
|
|
test('returns null for multiple backslashes', () => {
|
|
expect(normalizePath('\\\\')).toBeNull();
|
|
});
|
|
|
|
// A single backslash '\\' is normalized to a single forward slash
|
|
// and treated as the filesystem root, returned as '/'. (This is the
|
|
// pre-existing behavior; the defensive fix only adds null for
|
|
// slash-only inputs that strip down to ''.)
|
|
test('normalizes a single backslash to the root "/"', () => {
|
|
expect(normalizePath('\\')).toBe('/');
|
|
});
|
|
});
|
|
|
|
describe('Unix paths', () => {
|
|
test('passes through a Unix path unchanged', () => {
|
|
expect(normalizePath('/home/user/project')).toBe('/home/user/project');
|
|
});
|
|
|
|
test('strips trailing slashes from Unix paths', () => {
|
|
expect(normalizePath('/home/user/project/')).toBe('/home/user/project');
|
|
});
|
|
});
|
|
});
|