Files
openchamber/packages/web/server/lib/opencode/settings-normalization-runtime.test.js
T
9624d4b6f6 fix(ui,server): normalize Windows drive letter casing for consistent path resolution (#2154)
* 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>
2026-07-13 01:43:35 +03:00

154 lines
5.3 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
const createTestRuntime = (overrides = {}) => {
const defaults = {
os: { homedir: () => '/home/testuser' },
path: {
resolve: (...args) => args[args.length - 1],
sep: '/',
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
},
processLike: { platform: 'linux', env: {} },
realpathSync: (p) => p,
tunnelBootstrapTtlDefaultMs: 600000,
tunnelBootstrapTtlMinMs: 60000,
tunnelBootstrapTtlMaxMs: 3600000,
tunnelSessionTtlDefaultMs: 86400000,
tunnelSessionTtlMinMs: 3600000,
tunnelSessionTtlMaxMs: 604800000,
};
return createSettingsNormalizationRuntime({ ...defaults, ...overrides });
};
describe('settings normalization runtime - symlink resolution', () => {
describe('normalizePathForPersistence', () => {
it('resolves symlinks via realpathSync', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace' ? '/workplace/user' : p,
});
const result = runtime.normalizePathForPersistence('/home/user/workplace');
expect(result).toBe('/workplace/user');
});
it('falls back to original path when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => {
throw new Error('ENOENT');
},
});
const result = runtime.normalizePathForPersistence('/nonexistent/path');
expect(result).toBe('/nonexistent/path');
});
it('passes through when realpathSync is not provided', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
const result = runtime.normalizePathForPersistence('/some/path');
expect(result).toBe('/some/path');
});
it('preserves lowercase colon-prefixed paths on non-Windows platforms', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
expect(runtime.normalizePathForPersistence('c:project')).toBe('c:project');
});
it('uppercases Windows drive letter before and after realpath resolution', () => {
const runtime = createTestRuntime({
processLike: { platform: 'win32', env: {} },
realpathSync: (p) => {
// Simulate safeRealpathSync returning a lowercase drive letter
if (p === 'C:\\Users\\me\\project') return 'c:\\real\\project';
return p;
},
});
const result = runtime.normalizePathForPersistence('c:\\Users\\me\\project');
// Drive letter uppercased on input AND after realpath
expect(result).toBe('C:\\real\\project');
});
});
describe('sanitizeProjects', () => {
it('resolves symlinks in project paths', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/MyProject'
? '/workplace/user/MyProject'
: p,
});
const projects = [
{ id: 'proj1', path: '/home/user/workplace/MyProject', label: 'MyProject', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/workplace/user/MyProject');
});
it('falls back to path.resolve when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => { throw new Error('ENOENT'); },
path: { resolve: (p) => '/resolved' + p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/missing/path', label: 'Missing', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/resolved/missing/path');
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/symlink/a', label: 'A', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
{ id: 'proj2', path: '/symlink/b', label: 'B', color: 'keyword', addedAt: 2000, lastOpenedAt: 2000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('proj1');
});
});
describe('normalizeSettingsPaths', () => {
it('resolves symlinks in lastDirectory', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/LyraRefactoring'
? '/workplace/user/LyraRefactoring'
: p,
});
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/home/user/workplace/LyraRefactoring',
});
expect(result.changed).toBe(true);
expect(result.settings.lastDirectory).toBe('/workplace/user/LyraRefactoring');
});
it('does not flag as changed when path is already canonical', () => {
const runtime = createTestRuntime();
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/real/path',
});
expect(result.changed).toBe(false);
});
});
});