959 lines
34 KiB
TypeScript
959 lines
34 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/// <reference types="vitest/globals" />
|
|
|
|
// Mock 'os' first.
|
|
import * as osActual from 'os'; // Import for type info for the mock factory
|
|
vi.mock('os', async (importOriginal) => {
|
|
const actualOs = await importOriginal<typeof osActual>();
|
|
return {
|
|
...actualOs,
|
|
homedir: vi.fn(() => '/mock/home/user'),
|
|
platform: vi.fn(() => 'linux'),
|
|
};
|
|
});
|
|
|
|
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
|
|
vi.mock('./settings.js', async (importActual) => {
|
|
const originalModule = await importActual<typeof import('./settings.js')>();
|
|
return {
|
|
__esModule: true, // Ensure correct module shape
|
|
...originalModule, // Re-export all original members
|
|
// We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir()
|
|
};
|
|
});
|
|
|
|
// NOW import everything else, including the (now effectively re-exported) settings.js
|
|
import * as pathActual from 'path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
|
|
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type Mocked,
|
|
type Mock,
|
|
} from 'vitest';
|
|
import * as fs from 'fs'; // fs will be mocked separately
|
|
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
|
|
|
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
|
|
import {
|
|
loadSettings,
|
|
USER_SETTINGS_PATH, // This IS the mocked path.
|
|
SYSTEM_SETTINGS_PATH,
|
|
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
|
SettingScope,
|
|
} from './settings.js';
|
|
|
|
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
|
// Use the (mocked) SETTINGS_DIRECTORY_NAME for consistency
|
|
const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
|
|
MOCK_WORKSPACE_DIR,
|
|
SETTINGS_DIRECTORY_NAME,
|
|
'settings.json',
|
|
);
|
|
|
|
vi.mock('fs');
|
|
vi.mock('strip-json-comments', () => ({
|
|
default: vi.fn((content) => content),
|
|
}));
|
|
|
|
describe('Settings Loading and Merging', () => {
|
|
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
|
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
|
|
let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
mockFsExistsSync = vi.mocked(fs.existsSync);
|
|
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
|
mockStripJsonComments = vi.mocked(stripJsonComments);
|
|
|
|
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
|
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
|
(jsonString: string) => jsonString,
|
|
);
|
|
(mockFsExistsSync as Mock).mockReturnValue(false);
|
|
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
|
|
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('loadSettings', () => {
|
|
it('should load empty settings if no files exist', () => {
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.system.settings).toEqual({});
|
|
expect(settings.user.settings).toEqual({});
|
|
expect(settings.workspace.settings).toEqual({});
|
|
expect(settings.merged).toEqual({
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
expect(settings.errors.length).toBe(0);
|
|
});
|
|
|
|
it('should load system settings if only system file exists', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
|
|
);
|
|
const systemSettingsContent = {
|
|
theme: 'system-default',
|
|
sandbox: false,
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === SYSTEM_SETTINGS_PATH)
|
|
return JSON.stringify(systemSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
SYSTEM_SETTINGS_PATH,
|
|
'utf-8',
|
|
);
|
|
expect(settings.system.settings).toEqual(systemSettingsContent);
|
|
expect(settings.user.settings).toEqual({});
|
|
expect(settings.workspace.settings).toEqual({});
|
|
expect(settings.merged).toEqual({
|
|
...systemSettingsContent,
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
});
|
|
|
|
it('should load user settings if only user file exists', () => {
|
|
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
|
|
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === expectedUserSettingsPath,
|
|
);
|
|
const userSettingsContent = {
|
|
theme: 'dark',
|
|
contextFileName: 'USER_CONTEXT.md',
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === expectedUserSettingsPath)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
expectedUserSettingsPath,
|
|
'utf-8',
|
|
);
|
|
expect(settings.user.settings).toEqual(userSettingsContent);
|
|
expect(settings.workspace.settings).toEqual({});
|
|
expect(settings.merged).toEqual({
|
|
...userSettingsContent,
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
});
|
|
|
|
it('should load workspace settings if only workspace file exists', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
const workspaceSettingsContent = {
|
|
sandbox: true,
|
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
MOCK_WORKSPACE_SETTINGS_PATH,
|
|
'utf-8',
|
|
);
|
|
expect(settings.user.settings).toEqual({});
|
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
|
expect(settings.merged).toEqual({
|
|
...workspaceSettingsContent,
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
});
|
|
|
|
it('should merge user and workspace settings, with workspace taking precedence', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const userSettingsContent = {
|
|
theme: 'dark',
|
|
sandbox: false,
|
|
contextFileName: 'USER_CONTEXT.md',
|
|
};
|
|
const workspaceSettingsContent = {
|
|
sandbox: true,
|
|
coreTools: ['tool1'],
|
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
|
};
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(settings.user.settings).toEqual(userSettingsContent);
|
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
|
expect(settings.merged).toEqual({
|
|
theme: 'dark',
|
|
sandbox: true,
|
|
coreTools: ['tool1'],
|
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
});
|
|
|
|
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const systemSettingsContent = {
|
|
theme: 'system-theme',
|
|
sandbox: false,
|
|
allowMCPServers: ['server1', 'server2'],
|
|
telemetry: { enabled: false },
|
|
};
|
|
const userSettingsContent = {
|
|
theme: 'dark',
|
|
sandbox: true,
|
|
contextFileName: 'USER_CONTEXT.md',
|
|
};
|
|
const workspaceSettingsContent = {
|
|
sandbox: false,
|
|
coreTools: ['tool1'],
|
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
|
allowMCPServers: ['server1', 'server2', 'server3'],
|
|
};
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === SYSTEM_SETTINGS_PATH)
|
|
return JSON.stringify(systemSettingsContent);
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(settings.system.settings).toEqual(systemSettingsContent);
|
|
expect(settings.user.settings).toEqual(userSettingsContent);
|
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
|
expect(settings.merged).toEqual({
|
|
theme: 'system-theme',
|
|
sandbox: false,
|
|
telemetry: { enabled: false },
|
|
coreTools: ['tool1'],
|
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
|
allowMCPServers: ['server1', 'server2'],
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
});
|
|
|
|
it('should handle contextFileName correctly when only in user settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
const userSettingsContent = { contextFileName: 'CUSTOM.md' };
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.contextFileName).toBe('CUSTOM.md');
|
|
});
|
|
|
|
it('should handle contextFileName correctly when only in workspace settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
const workspaceSettingsContent = {
|
|
contextFileName: 'PROJECT_SPECIFIC.md',
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md');
|
|
});
|
|
|
|
it('should default contextFileName to undefined if not in any settings file', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const userSettingsContent = { theme: 'dark' };
|
|
const workspaceSettingsContent = { sandbox: true };
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.contextFileName).toBeUndefined();
|
|
});
|
|
|
|
it('should load telemetry setting from user settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
const userSettingsContent = { telemetry: true };
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.telemetry).toBe(true);
|
|
});
|
|
|
|
it('should load telemetry setting from workspace settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
const workspaceSettingsContent = { telemetry: false };
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.telemetry).toBe(false);
|
|
});
|
|
|
|
it('should prioritize workspace telemetry setting over user setting', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const userSettingsContent = { telemetry: true };
|
|
const workspaceSettingsContent = { telemetry: false };
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.telemetry).toBe(false);
|
|
});
|
|
|
|
it('should have telemetry as undefined if not in any settings file', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.telemetry).toBeUndefined();
|
|
expect(settings.merged.customThemes).toEqual({});
|
|
expect(settings.merged.mcpServers).toEqual({});
|
|
});
|
|
|
|
it('should merge MCP servers correctly, with workspace taking precedence', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const userSettingsContent = {
|
|
mcpServers: {
|
|
'user-server': {
|
|
command: 'user-command',
|
|
args: ['--user-arg'],
|
|
description: 'User MCP server',
|
|
},
|
|
'shared-server': {
|
|
command: 'user-shared-command',
|
|
description: 'User shared server config',
|
|
},
|
|
},
|
|
};
|
|
const workspaceSettingsContent = {
|
|
mcpServers: {
|
|
'workspace-server': {
|
|
command: 'workspace-command',
|
|
args: ['--workspace-arg'],
|
|
description: 'Workspace MCP server',
|
|
},
|
|
'shared-server': {
|
|
command: 'workspace-shared-command',
|
|
description: 'Workspace shared server config',
|
|
},
|
|
},
|
|
};
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(settings.user.settings).toEqual(userSettingsContent);
|
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
|
expect(settings.merged.mcpServers).toEqual({
|
|
'user-server': {
|
|
command: 'user-command',
|
|
args: ['--user-arg'],
|
|
description: 'User MCP server',
|
|
},
|
|
'workspace-server': {
|
|
command: 'workspace-command',
|
|
args: ['--workspace-arg'],
|
|
description: 'Workspace MCP server',
|
|
},
|
|
'shared-server': {
|
|
command: 'workspace-shared-command',
|
|
description: 'Workspace shared server config',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle MCP servers when only in user settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
const userSettingsContent = {
|
|
mcpServers: {
|
|
'user-only-server': {
|
|
command: 'user-only-command',
|
|
description: 'User only server',
|
|
},
|
|
},
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.mcpServers).toEqual({
|
|
'user-only-server': {
|
|
command: 'user-only-command',
|
|
description: 'User only server',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle MCP servers when only in workspace settings', () => {
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
const workspaceSettingsContent = {
|
|
mcpServers: {
|
|
'workspace-only-server': {
|
|
command: 'workspace-only-command',
|
|
description: 'Workspace only server',
|
|
},
|
|
},
|
|
};
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.mcpServers).toEqual({
|
|
'workspace-only-server': {
|
|
command: 'workspace-only-command',
|
|
description: 'Workspace only server',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should have mcpServers as empty object if not in any settings file', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.merged.mcpServers).toEqual({});
|
|
});
|
|
|
|
it('should handle JSON parsing errors gracefully', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
|
|
const invalidJsonContent = 'invalid json';
|
|
const userReadError = new SyntaxError(
|
|
"Expected ',' or '}' after property value in JSON at position 10",
|
|
);
|
|
const workspaceReadError = new SyntaxError(
|
|
'Unexpected token i in JSON at position 0',
|
|
);
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH) {
|
|
// Simulate JSON.parse throwing for user settings
|
|
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
|
|
throw userReadError;
|
|
});
|
|
return invalidJsonContent; // Content that would cause JSON.parse to throw
|
|
}
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
|
// Simulate JSON.parse throwing for workspace settings
|
|
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
|
|
throw workspaceReadError;
|
|
});
|
|
return invalidJsonContent;
|
|
}
|
|
return '{}'; // Default for other reads
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
// Check that settings are empty due to parsing errors
|
|
expect(settings.user.settings).toEqual({});
|
|
expect(settings.workspace.settings).toEqual({});
|
|
expect(settings.merged).toEqual({
|
|
customThemes: {},
|
|
mcpServers: {},
|
|
});
|
|
|
|
// Check that error objects are populated in settings.errors
|
|
expect(settings.errors).toBeDefined();
|
|
// Assuming both user and workspace files cause errors and are added in order
|
|
expect(settings.errors.length).toEqual(2);
|
|
|
|
const userError = settings.errors.find(
|
|
(e) => e.path === USER_SETTINGS_PATH,
|
|
);
|
|
expect(userError).toBeDefined();
|
|
expect(userError?.message).toBe(userReadError.message);
|
|
|
|
const workspaceError = settings.errors.find(
|
|
(e) => e.path === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
expect(workspaceError).toBeDefined();
|
|
expect(workspaceError?.message).toBe(workspaceReadError.message);
|
|
|
|
// Restore JSON.parse mock if it was spied on specifically for this test
|
|
vi.restoreAllMocks(); // Or more targeted restore if needed
|
|
});
|
|
|
|
it('should resolve environment variables in user settings', () => {
|
|
process.env.TEST_API_KEY = 'user_api_key_from_env';
|
|
const userSettingsContent = {
|
|
apiKey: '$TEST_API_KEY',
|
|
someUrl: 'https://test.com/${TEST_API_KEY}',
|
|
};
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.user.settings.someUrl).toBe(
|
|
'https://test.com/user_api_key_from_env',
|
|
);
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
|
delete process.env.TEST_API_KEY;
|
|
});
|
|
|
|
it('should resolve environment variables in workspace settings', () => {
|
|
process.env.WORKSPACE_ENDPOINT = 'workspace_endpoint_from_env';
|
|
const workspaceSettingsContent = {
|
|
endpoint: '${WORKSPACE_ENDPOINT}/api',
|
|
nested: { value: '$WORKSPACE_ENDPOINT' },
|
|
};
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.workspace.settings.endpoint).toBe(
|
|
'workspace_endpoint_from_env/api',
|
|
);
|
|
expect(settings.workspace.settings.nested.value).toBe(
|
|
'workspace_endpoint_from_env',
|
|
);
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
|
delete process.env.WORKSPACE_ENDPOINT;
|
|
});
|
|
|
|
it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => {
|
|
const userSettingsContent = { configValue: '$SHARED_VAR' };
|
|
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
|
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const originalSharedVar = process.env.SHARED_VAR;
|
|
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
|
delete process.env.SHARED_VAR;
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
|
|
return JSON.stringify(userSettingsContent);
|
|
}
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
}
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.user.settings.configValue).toBe(
|
|
'user_value_for_user_read',
|
|
);
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.workspace.settings.configValue).toBe(
|
|
'workspace_value_for_workspace_read',
|
|
);
|
|
// Merged should take workspace's resolved value
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.merged.configValue).toBe(
|
|
'workspace_value_for_workspace_read',
|
|
);
|
|
|
|
// Restore original environment variable state
|
|
if (originalSharedVar !== undefined) {
|
|
process.env.SHARED_VAR = originalSharedVar;
|
|
} else {
|
|
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
|
}
|
|
});
|
|
|
|
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
|
|
const userSettingsContent = { configValue: '$SHARED_VAR' };
|
|
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
|
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const originalSharedVar = process.env.SHARED_VAR;
|
|
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
|
delete process.env.SHARED_VAR;
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
|
|
return JSON.stringify(userSettingsContent);
|
|
}
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
}
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(settings.user.settings.configValue).toBe(
|
|
'user_value_for_user_read',
|
|
);
|
|
expect(settings.workspace.settings.configValue).toBe(
|
|
'workspace_value_for_workspace_read',
|
|
);
|
|
// Merged should take workspace's resolved value
|
|
expect(settings.merged.configValue).toBe(
|
|
'workspace_value_for_workspace_read',
|
|
);
|
|
|
|
// Restore original environment variable state
|
|
if (originalSharedVar !== undefined) {
|
|
process.env.SHARED_VAR = originalSharedVar;
|
|
} else {
|
|
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
|
}
|
|
});
|
|
|
|
it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => {
|
|
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
|
const systemSettingsContent = { configValue: '$SHARED_VAR' };
|
|
|
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
|
const originalSharedVar = process.env.SHARED_VAR;
|
|
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
|
delete process.env.SHARED_VAR;
|
|
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === SYSTEM_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
|
|
return JSON.stringify(systemSettingsContent);
|
|
}
|
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
|
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
|
return JSON.stringify(workspaceSettingsContent);
|
|
}
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.system.settings.configValue).toBe(
|
|
'system_value_for_system_read',
|
|
);
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.workspace.settings.configValue).toBe(
|
|
'workspace_value_for_workspace_read',
|
|
);
|
|
// Merged should take system's resolved value
|
|
// @ts-expect-error: dynamic property for test
|
|
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
|
|
|
// Restore original environment variable state
|
|
if (originalSharedVar !== undefined) {
|
|
process.env.SHARED_VAR = originalSharedVar;
|
|
} else {
|
|
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
|
}
|
|
});
|
|
|
|
it('should leave unresolved environment variables as is', () => {
|
|
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.user.settings.apiKey).toBe('$UNDEFINED_VAR');
|
|
expect(settings.merged.apiKey).toBe('$UNDEFINED_VAR');
|
|
});
|
|
|
|
it('should resolve multiple environment variables in a single string', () => {
|
|
process.env.VAR_A = 'valueA';
|
|
process.env.VAR_B = 'valueB';
|
|
const userSettingsContent = { path: '/path/$VAR_A/${VAR_B}/end' };
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.user.settings.path).toBe('/path/valueA/valueB/end');
|
|
delete process.env.VAR_A;
|
|
delete process.env.VAR_B;
|
|
});
|
|
|
|
it('should resolve environment variables in arrays', () => {
|
|
process.env.ITEM_1 = 'item1_env';
|
|
process.env.ITEM_2 = 'item2_env';
|
|
const userSettingsContent = { list: ['$ITEM_1', '${ITEM_2}', 'literal'] };
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.user.settings.list).toEqual([
|
|
'item1_env',
|
|
'item2_env',
|
|
'literal',
|
|
]);
|
|
delete process.env.ITEM_1;
|
|
delete process.env.ITEM_2;
|
|
});
|
|
|
|
it('should correctly pass through null, boolean, and number types, and handle undefined properties', () => {
|
|
process.env.MY_ENV_STRING = 'env_string_value';
|
|
process.env.MY_ENV_STRING_NESTED = 'env_string_nested_value';
|
|
|
|
const userSettingsContent = {
|
|
nullVal: null,
|
|
trueVal: true,
|
|
falseVal: false,
|
|
numberVal: 123.45,
|
|
stringVal: '$MY_ENV_STRING',
|
|
nestedObj: {
|
|
nestedNull: null,
|
|
nestedBool: true,
|
|
nestedNum: 0,
|
|
nestedString: 'literal',
|
|
anotherEnv: '${MY_ENV_STRING_NESTED}',
|
|
},
|
|
};
|
|
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
expect(settings.user.settings.nullVal).toBeNull();
|
|
expect(settings.user.settings.trueVal).toBe(true);
|
|
expect(settings.user.settings.falseVal).toBe(false);
|
|
expect(settings.user.settings.numberVal).toBe(123.45);
|
|
expect(settings.user.settings.stringVal).toBe('env_string_value');
|
|
expect(settings.user.settings.undefinedVal).toBeUndefined();
|
|
|
|
expect(settings.user.settings.nestedObj.nestedNull).toBeNull();
|
|
expect(settings.user.settings.nestedObj.nestedBool).toBe(true);
|
|
expect(settings.user.settings.nestedObj.nestedNum).toBe(0);
|
|
expect(settings.user.settings.nestedObj.nestedString).toBe('literal');
|
|
expect(settings.user.settings.nestedObj.anotherEnv).toBe(
|
|
'env_string_nested_value',
|
|
);
|
|
|
|
delete process.env.MY_ENV_STRING;
|
|
delete process.env.MY_ENV_STRING_NESTED;
|
|
});
|
|
|
|
it('should resolve multiple concatenated environment variables in a single string value', () => {
|
|
process.env.TEST_HOST = 'myhost';
|
|
process.env.TEST_PORT = '9090';
|
|
const userSettingsContent = {
|
|
serverAddress: '${TEST_HOST}:${TEST_PORT}/api',
|
|
};
|
|
(mockFsExistsSync as Mock).mockImplementation(
|
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
|
);
|
|
(fs.readFileSync as Mock).mockImplementation(
|
|
(p: fs.PathOrFileDescriptor) => {
|
|
if (p === USER_SETTINGS_PATH)
|
|
return JSON.stringify(userSettingsContent);
|
|
return '{}';
|
|
},
|
|
);
|
|
|
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
expect(settings.user.settings.serverAddress).toBe('myhost:9090/api');
|
|
|
|
delete process.env.TEST_HOST;
|
|
delete process.env.TEST_PORT;
|
|
});
|
|
});
|
|
|
|
describe('LoadedSettings class', () => {
|
|
it('setValue should update the correct scope and recompute merged settings', () => {
|
|
(mockFsExistsSync as Mock).mockReturnValue(false);
|
|
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
|
|
|
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
|
// mkdirSync is mocked in beforeEach to return undefined, which is fine for void usage
|
|
|
|
loadedSettings.setValue(SettingScope.User, 'theme', 'matrix');
|
|
expect(loadedSettings.user.settings.theme).toBe('matrix');
|
|
expect(loadedSettings.merged.theme).toBe('matrix');
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
USER_SETTINGS_PATH,
|
|
JSON.stringify({ theme: 'matrix' }, null, 2),
|
|
'utf-8',
|
|
);
|
|
|
|
loadedSettings.setValue(
|
|
SettingScope.Workspace,
|
|
'contextFileName',
|
|
'MY_AGENTS.md',
|
|
);
|
|
expect(loadedSettings.workspace.settings.contextFileName).toBe(
|
|
'MY_AGENTS.md',
|
|
);
|
|
expect(loadedSettings.merged.contextFileName).toBe('MY_AGENTS.md');
|
|
expect(loadedSettings.merged.theme).toBe('matrix'); // User setting should still be there
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
MOCK_WORKSPACE_SETTINGS_PATH,
|
|
JSON.stringify({ contextFileName: 'MY_AGENTS.md' }, null, 2),
|
|
'utf-8',
|
|
);
|
|
|
|
// System theme overrides user and workspace themes
|
|
loadedSettings.setValue(SettingScope.System, 'theme', 'ocean');
|
|
|
|
expect(loadedSettings.system.settings.theme).toBe('ocean');
|
|
expect(loadedSettings.merged.theme).toBe('ocean');
|
|
});
|
|
});
|
|
});
|