A project could pin the model new chats start on, but not the level to run it at: the default cascade dropped any variant as soon as a project model won, and only ever considered the global one — which belongs to the global model. Projects now carry `defaultVariant` alongside `defaultModel`, stored and sanitized only next to that model, and the cascade passes it through. Both controls sit in one "Defaults for new chats" group laid out like the Sessions defaults, and the level appears only for models that offer them.
170 lines
6.0 KiB
JavaScript
170 lines
6.0 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('keeps a default thinking level next to its model and drops it alone', () => {
|
|
const runtime = createTestRuntime({
|
|
realpathSync: (p) => p,
|
|
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
|
|
});
|
|
|
|
const projects = [
|
|
{ id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' },
|
|
{ id: 'proj2', path: '/b', defaultVariant: 'high' },
|
|
];
|
|
|
|
const result = runtime.sanitizeProjects(projects);
|
|
expect(result[0].defaultVariant).toBe('high');
|
|
expect(result[1].defaultVariant).toBe(undefined);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|