diff --git a/.gitignore b/.gitignore index 0ed48310..b9494517 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ node_modules *.vsix .nox/ .venv/ -**/__pycache__/ \ No newline at end of file +**/__pycache__/ + +# Folder for storing AI generated artifacts +ai-artifacts/* \ No newline at end of file diff --git a/package.json b/package.json index a5d4e1f8..09ffd11d 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [], + "default": ["./**/.venv"], "scope": "resource", "items": { "type": "string" @@ -213,6 +213,12 @@ "category": "Python", "icon": "$(refresh)" }, + { + "command": "python-envs.searchSettings", + "title": "%python-envs.searchSettings.title%", + "category": "Python", + "icon": "$(gear)" + }, { "command": "python-envs.refreshPackages", "title": "%python-envs.refreshPackages.title%", @@ -574,6 +580,11 @@ "group": "navigation", "when": "view == env-managers" }, + { + "command": "python-envs.searchSettings", + "group": "navigation", + "when": "view == env-managers" + }, { "command": "python-envs.refreshAllManagers", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index 7b5b568c..3a4ddcec 100644 --- a/package.nls.json +++ b/package.nls.json @@ -11,8 +11,8 @@ "python-envs.terminal.autoActivationType.shellStartup": "Activation using [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) or by modifying the terminal shell startup script. Enable `terminal.integrated.shellIntegration.enabled` or we may need to modify your shell startup scripts for the ideal experience.", "python-envs.terminal.autoActivationType.off": "No automatic activation of environments.", "python-envs.terminal.useEnvFile.description": "Controls whether environment variables from .env files and python.envFile setting are injected into terminals.", - "python-envs.globalSearchPaths.description": "Global search paths for Python environments. Absolute directory paths that are searched at the user level.\n\n**Legacy Setting Support:** This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. All paths from these three settings are combined into a single list of search paths. The legacy settings `python.venvPath` and `python.venvFolders` will be deprecated in the future, after which this setting will fully replace them. Please consider migrating your paths to this setting.", - "python-envs.workspaceSearchPaths.description": "Workspace search paths for Python environments. Can be absolute paths or relative directory paths searched within the workspace.", + "python-envs.globalSearchPaths.description": "Absolute paths to search for Python environments across all workspaces. Use for shared environment folders like `~/envs`.", + "python-envs.workspaceSearchPaths.description": "Paths to search for environments in this workspace. By default, searches for a `.venv` folder in the workspace root.", "python-envs.terminal.revertStartupScriptChanges.title": "Revert Shell Startup Script Changes", "python-envs.reportIssue.title": "Report Issue", "python-envs.setEnvManager.title": "Set Environment Manager", @@ -31,6 +31,7 @@ "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", + "python-envs.searchSettings.title": "Configure Search Settings", "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", diff --git a/src/extension.ts b/src/extension.ts index 357d035e..0032f79c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,8 @@ -import { commands, ExtensionContext, LogOutputChannel, Terminal, Uri, window } from 'vscode'; -import { version as extensionVersion } from '../package.json'; +import { commands, ExtensionContext, extensions, LogOutputChannel, Terminal, Uri, window } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api'; +import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; -import { registerLogger, traceError, traceInfo, traceVerbose, traceWarn } from './common/logging'; +import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; import { clearPersistentState, setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; @@ -67,6 +67,7 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; +import { openSearchSettings } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -102,8 +103,10 @@ export async function activate(context: ExtensionContext): Promise m.refresh(undefined))); }); }), + commands.registerCommand('python-envs.searchSettings', async () => { + await openSearchSettings(); + }), commands.registerCommand('python-envs.refreshPackages', async (item) => { await refreshPackagesCommand(item, envManagers); }), diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index ebf207b4..3d2f9d02 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -33,7 +33,9 @@ export class PythonProjectManagerImpl implements PythonProjectManager { private readonly updateDebounce = createSimpleDebounce(100, () => this.updateProjects()); initialize(): void { - this.add(this.getInitialProjects()); + // Load existing projects from settings without writing back to settings. + // This avoids overwriting user-configured project settings with defaults on reload. + this.loadProjects(this.getInitialProjects()); this.disposables.push( this._onDidChangeProjects, new Disposable(() => this._projects.clear()), @@ -175,6 +177,20 @@ export class PythonProjectManagerImpl implements PythonProjectManager { } } + /** + * Loads projects into the internal map without writing to settings. + * Use this for initial loading from existing settings to avoid overwriting + * user-configured project settings with defaults. + */ + private loadProjects(projects: ProjectArray): void { + projects.forEach((project) => { + this._projects.set(project.uri.toString(), project); + }); + if (projects.length > 0) { + this._onDidChangeProjects.fire(Array.from(this._projects.values())); + } + } + create( name: string, uri: Uri, diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts new file mode 100644 index 00000000..3d40bea0 --- /dev/null +++ b/src/features/views/envManagerSearch.ts @@ -0,0 +1,11 @@ +import { commands } from 'vscode'; + +/** + * Opens environment search settings at workspace level. + */ +export async function openSearchSettings(): Promise { + await commands.executeCommand( + 'workbench.action.openWorkspaceSettings', + '@ext:ms-python.vscode-python-envs "search path"', + ); +} diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index 9f99bd13..23efc347 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -8,7 +8,7 @@ import { PythonProjectApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants'; import { getExtension } from '../../common/extension.apis'; -import { traceError, traceLog, traceWarn } from '../../common/logging'; +import { traceError, traceVerbose, traceWarn } from '../../common/logging'; import { untildify, untildifyArray } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool'; @@ -671,10 +671,23 @@ function getPythonSettingAndUntildify(name: string, scope?: Uri): T | undefin return value; } +/** + * Cross-platform check for absolute paths. + * Uses both current platform's check and Windows-specific check to handle + * Windows paths (e.g., C:\path) when running on Unix systems. + */ +function isAbsolutePath(inputPath: string): boolean { + return path.isAbsolute(inputPath) || path.win32.isAbsolute(inputPath); +} + /** * Gets all extra environment search paths from various configuration sources. * Combines legacy python settings (with migration), globalSearchPaths, and workspaceSearchPaths. - * @returns Array of search directory paths + * + * Paths can include glob patterns which are expanded by the native + * Python Environment Tool (PET) during environment discovery. + * + * @returns Array of search paths (may include glob patterns) */ export async function getAllExtraSearchPaths(): Promise { const searchDirectories: string[] = []; @@ -698,7 +711,7 @@ export async function getAllExtraSearchPaths(): Promise { const trimmedPath = searchPath.trim(); - if (path.isAbsolute(trimmedPath)) { + if (isAbsolutePath(trimmedPath)) { // Absolute path - use as is searchDirectories.push(trimmedPath); } else { @@ -710,20 +723,16 @@ export async function getAllExtraSearchPaths(): Promise { searchDirectories.push(resolvedPath); } } else { - traceWarn('Warning: No workspace folders found for relative path:', trimmedPath); + traceWarn('No workspace folders found for relative search path:', trimmedPath); } } } - // Remove duplicates and return + // Remove duplicates and normalize to forward slashes for cross-platform glob compatibility const uniquePaths = Array.from(new Set(searchDirectories)); - traceLog( - 'getAllExtraSearchPaths completed. Total unique search directories:', - uniquePaths.length, - 'Paths:', - uniquePaths, - ); - return uniquePaths; + const normalizedPaths = uniquePaths.map((p) => p.replace(/\\/g, '/')); + traceVerbose('Environment search directories:', normalizedPaths.length, 'paths'); + return normalizedPaths; } /** @@ -745,6 +754,7 @@ function getGlobalSearchPaths(): string[] { /** * Gets the most specific workspace-level setting available for workspaceSearchPaths. + * Supports glob patterns which are expanded by PET. */ function getWorkspaceSearchPaths(): string[] { try { @@ -753,11 +763,11 @@ function getWorkspaceSearchPaths(): string[] { if (inspection?.globalValue) { traceError( - 'Error: python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', + 'python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', ); } - // For workspace settings, prefer workspaceFolder > workspace + // For workspace settings, prefer workspaceFolder > workspace > default if (inspection?.workspaceFolderValue) { return inspection.workspaceFolderValue; } @@ -766,8 +776,8 @@ function getWorkspaceSearchPaths(): string[] { return inspection.workspaceValue; } - // Default empty array (don't use global value for workspace settings) - return []; + // Use the default value from package.json + return inspection?.defaultValue ?? []; } catch (error) { traceError('Error getting workspaceSearchPaths:', error); return []; diff --git a/src/test/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts new file mode 100644 index 00000000..545e22ea --- /dev/null +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -0,0 +1,675 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { Disposable, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; +import * as workspaceApis from '../../common/workspace.apis'; +import { PythonProjectManagerImpl } from '../../features/projectManager'; +import * as settingHelpers from '../../features/settings/settingHelpers'; +import { PythonProjectSettings } from '../../internal.api'; +import { MockWorkspaceConfiguration } from '../mocks/mockWorkspaceConfig'; + +/** + * Returns a platform-appropriate workspace path for testing. + * On Windows, paths must include a drive letter to work correctly with path.resolve(). + */ +function getTestWorkspacePath(): string { + return process.platform === 'win32' ? 'C:\\workspace' : '/workspace'; +} + +/** + * ============================================================================= + * CRITICAL PRINCIPLE: Settings should ONLY change when user explicitly acts + * ============================================================================= + * + * These tests verify that the extension does NOT write to settings.json unless + * the user explicitly performs an action (like selecting an interpreter via UI). + * + * Scenarios that should NOT write settings: + * - Extension initialization/reload + * - Configuration changes made externally (user edits settings.json directly) + * - Workspace folder changes (user adds/removes folders) + * - Multiple reload cycles + * - Any getter operations (getProjects, get, etc.) + * + * Scenarios that SHOULD write settings: + * - User explicitly adds a project via UI + * - User explicitly selects an interpreter via picker + * - User explicitly changes env/package manager via command + * - Project folder deleted (cleanup orphan settings) + * - Project folder renamed (update path in settings) + */ + +suite('Project Manager Initialization - Settings Preservation', () => { + let disposables: Disposable[]; + let workspaceFoldersChangeEmitter: EventEmitter; + let configChangeEmitter: EventEmitter; + let deleteFilesEmitter: EventEmitter<{ files: readonly Uri[] }>; + let renameFilesEmitter: EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>; + let addPythonProjectSettingStub: sinon.SinonStub; + let setAllManagerSettingsStub: sinon.SinonStub; + let setEnvironmentManagerStub: sinon.SinonStub; + let setPackageManagerStub: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + setup(() => { + disposables = []; + clock = sinon.useFakeTimers(); + + // Create event emitters + workspaceFoldersChangeEmitter = new EventEmitter(); + configChangeEmitter = new EventEmitter(); + deleteFilesEmitter = new EventEmitter<{ files: readonly Uri[] }>(); + renameFilesEmitter = new EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>(); + disposables.push(workspaceFoldersChangeEmitter, configChangeEmitter, deleteFilesEmitter, renameFilesEmitter); + + // Stub workspace events + sinon.stub(workspaceApis, 'onDidChangeWorkspaceFolders').callsFake((listener: any) => { + return workspaceFoldersChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener: any) => { + return configChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: any) => { + return deleteFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidRenameFiles').callsFake((listener: any) => { + return renameFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + + // Stub ALL setting write functions to track any settings writes + addPythonProjectSettingStub = sinon.stub(settingHelpers, 'addPythonProjectSetting').resolves(); + setAllManagerSettingsStub = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves(); + setEnvironmentManagerStub = sinon.stub(settingHelpers, 'setEnvironmentManager').resolves(); + setPackageManagerStub = sinon.stub(settingHelpers, 'setPackageManager').resolves(); + sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + sinon.stub(settingHelpers, 'updatePythonProjectSettingPath').resolves(); + }); + + teardown(() => { + clock.restore(); + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + /** + * Helper to assert NO settings were written by any method + */ + function assertNoSettingsWritten(context: string): void { + assert.ok(!addPythonProjectSettingStub.called, `${context}: addPythonProjectSetting should NOT be called`); + assert.ok(!setAllManagerSettingsStub.called, `${context}: setAllManagerSettings should NOT be called`); + assert.ok(!setEnvironmentManagerStub.called, `${context}: setEnvironmentManager should NOT be called`); + assert.ok(!setPackageManagerStub.called, `${context}: setPackageManager should NOT be called`); + } + + /** + * Creates a mock config where: + * - pythonProjects has explicit venv/pip settings for subprojects + * - defaultEnvManager differs from project settings (conda vs venv) + * This tests that project-specific settings are preserved. + */ + function createMockConfigWithExplicitProjectSettings(): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + // These are existing project settings that should NOT be overwritten + return [ + { path: 'alice', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'alice/bob', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'ada', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + // User changed this to conda + return 'ms-python.python:conda' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:conda' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + return mockConfig; + } + + suite('initialize() - No Settings Writes', () => { + test('initialize() should NOT call add() method', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + + // Spy on the add method - it should NOT be called during initialize() + const addSpy = sinon.spy(pm, 'add'); + + pm.initialize(); + + // Allow any async operations to complete + await clock.tickAsync(150); + + // CRITICAL: initialize() should NOT call add() - it should only load projects into memory + assert.ok( + !addSpy.called, + 'initialize() should NOT call add() - calling add() would write to settings and overwrite user config', + ); + + pm.dispose(); + }); + + test('initialize() should NOT call addPythonProjectSetting', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Allow any async operations to complete + await clock.tickAsync(150); + + // CRITICAL: initialize() should NOT write to settings + assert.ok( + !addPythonProjectSettingStub.called, + 'initialize() should NOT call addPythonProjectSetting - it should only load projects into memory', + ); + + pm.dispose(); + }); + + test('initialize() should load projects from settings without modifying them', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Verify projects are loaded + const projects = pm.getProjects(); + + // Should have workspace root + 3 explicit projects + assert.strictEqual(projects.length, 4, 'Should load workspace root + 3 explicit projects'); + + // Verify the subprojects exist + const aliceProject = projects.find((p) => p.uri.fsPath.endsWith('alice') && !p.uri.fsPath.includes('bob')); + const bobProject = projects.find( + (p) => p.uri.fsPath.includes('alice/bob') || p.uri.fsPath.includes('alice\\bob'), + ); + const adaProject = projects.find((p) => p.uri.fsPath.endsWith('ada')); + + assert.ok(aliceProject, 'alice project should be loaded'); + assert.ok(bobProject, 'alice/bob project should be loaded'); + assert.ok(adaProject, 'ada project should be loaded'); + + pm.dispose(); + }); + + test('project-specific settings should be preserved when defaultEnvManager differs', async () => { + // Scenario: + // 1. User has projects with explicit venv/pip settings + // 2. defaultEnvManager is set to conda + // 3. On reload, the explicit venv/pip settings should remain unchanged + + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + await clock.tickAsync(150); + + // initialize() should load projects without overwriting their explicit settings + assert.ok( + !addPythonProjectSettingStub.called, + 'initialize() should NOT overwrite explicit project settings with defaults', + ); + + pm.dispose(); + }); + }); + + suite('Configuration Changes - No Settings Writes', () => { + test('external settings.json changes should NOT trigger settings writes', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs to track only post-init calls + addPythonProjectSettingStub.resetHistory(); + setAllManagerSettingsStub.resetHistory(); + + // Simulate external configuration change (user edits settings.json) + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => + section === 'python-envs.pythonProjects' || section === 'python-envs.defaultEnvManager', + }); + + // Wait for debounce + await clock.tickAsync(150); + + // Configuration changes should only update in-memory state, NOT write settings + assertNoSettingsWritten('External config change'); + + pm.dispose(); + }); + + test('changing defaultEnvManager externally should NOT rewrite all project settings', async () => { + // Start with venv as default + let currentDefaultEnvManager = 'ms-python.python:venv'; + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [ + { + path: 'project-a', + envManager: 'ms-python.python:poetry', + packageManager: 'ms-python.python:pip', + }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + return currentDefaultEnvManager as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs + addPythonProjectSettingStub.resetHistory(); + + // Simulate user changes defaultEnvManager to conda in settings.json + currentDefaultEnvManager = 'ms-python.python:conda'; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.defaultEnvManager', + }); + + await clock.tickAsync(150); + + // The poetry project setting should NOT be overwritten with conda + assertNoSettingsWritten('Default manager change'); + + pm.dispose(); + }); + }); + + suite('Workspace Folder Changes - No Settings Writes', () => { + test('adding a workspace folder should NOT write project settings', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') return [] as unknown as T; + if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs + addPythonProjectSettingStub.resetHistory(); + + // Simulate adding a new workspace folder + const newFolder: WorkspaceFolder = { + uri: Uri.file(`${workspacePath}/new-folder`), + name: 'new-folder', + index: 1, + }; + (workspaceApis.getWorkspaceFolders as sinon.SinonStub).returns([workspaceFolder, newFolder]); + workspaceFoldersChangeEmitter.fire({ + added: [newFolder], + removed: [], + }); + + await clock.tickAsync(150); + + // Adding workspace folders should NOT automatically create project settings + assertNoSettingsWritten('Workspace folder added'); + + pm.dispose(); + }); + + test('removing a workspace folder should NOT write additional settings', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') return [] as unknown as T; + if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs - we specifically check addPythonProjectSetting and setAllManagerSettings + addPythonProjectSettingStub.resetHistory(); + setAllManagerSettingsStub.resetHistory(); + + // Simulate removing a workspace folder + workspaceFoldersChangeEmitter.fire({ + added: [], + removed: [workspaceFolder], + }); + + await clock.tickAsync(150); + + // Removing workspace folders should NOT write new/additional settings + assert.ok(!addPythonProjectSettingStub.called, 'Should not add settings when folder removed'); + assert.ok(!setAllManagerSettingsStub.called, 'Should not update manager settings when folder removed'); + + pm.dispose(); + }); + }); + + suite('Multiple Reload Cycles - No Settings Accumulation', () => { + test('multiple initializations should NOT accumulate settings writes', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + // Simulate multiple extension reload cycles + for (let i = 0; i < 3; i++) { + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assertNoSettingsWritten(`Reload cycle ${i + 1}`); + + pm.dispose(); + } + }); + + test('reinitializing after dispose should NOT write settings', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm1 = new PythonProjectManagerImpl(); + pm1.initialize(); + await clock.tickAsync(150); + pm1.dispose(); + + // Reset stubs between lifecycle + addPythonProjectSettingStub.resetHistory(); + + const pm2 = new PythonProjectManagerImpl(); + pm2.initialize(); + await clock.tickAsync(150); + + assertNoSettingsWritten('Second initialization'); + + pm2.dispose(); + }); + }); + + suite('Getter Operations - Side-Effect Free', () => { + test('getProjects() should be side-effect free', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // Call getProjects multiple times + for (let i = 0; i < 5; i++) { + pm.getProjects(); + } + + assertNoSettingsWritten('getProjects() calls'); + + pm.dispose(); + }); + + test('get() should be side-effect free', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // Call get() with various URIs + pm.get(Uri.file(`${workspacePath}/alice`)); + pm.get(Uri.file(`${workspacePath}/nonexistent`)); + pm.get(Uri.file(`${workspacePath}/alice/bob/file.py`)); + + assertNoSettingsWritten('get() calls'); + + pm.dispose(); + }); + + test('create() should be side-effect free (does not add to settings)', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // create() just creates the object, doesn't persist it + pm.create('test-project', Uri.file(`${workspacePath}/test`)); + + assertNoSettingsWritten('create() call'); + + pm.dispose(); + }); + }); + + suite('add() - Should Write Settings (for user-initiated additions)', () => { + // Note: Testing add() behavior directly requires more complex mocking because + // add() uses workspace.getConfiguration directly. The key behavioral distinction + // is tested via the file event tests (projectManager.fileEvents.unit.test.ts) + // and the fact that initialize() does NOT call addPythonProjectSetting proves + // the separation of concerns. + + test('add() adds projects to internal map', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + const projectsBefore = pm.getProjects().length; + + // Directly add to internal map to verify the mechanism works + // (Full add() testing requires mocking vscode.workspace which is complex) + const newProjectUri = Uri.file(`${workspacePath}/new-project`); + const newProject = pm.create('new-project', newProjectUri); + (pm as any)._projects.set(newProjectUri.toString(), newProject); + + const projectsAfter = pm.getProjects().length; + assert.strictEqual(projectsAfter, projectsBefore + 1, 'Project should be added to internal map'); + + pm.dispose(); + }); + }); + + suite('Distinction between load and add', () => { + test('initialize() loads existing projects without writing settings', async () => { + const pythonProjects: PythonProjectSettings[] = [ + { + path: 'existing-project', + envManager: 'ms-python.python:poetry', + packageManager: 'ms-python.python:pip', + }, + ]; + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return pythonProjects as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + + // initialize() - should NOT write settings + pm.initialize(); + await clock.tickAsync(150); + + assert.ok(!addPythonProjectSettingStub.called, 'initialize() should not write settings'); + + // Verify existing project is loaded + const projects = pm.getProjects(); + const existingProject = projects.find((p) => p.uri.fsPath.includes('existing-project')); + assert.ok(existingProject, 'Existing project should be loaded from settings'); + + pm.dispose(); + }); + }); +}); + +/** + * Tests that project-specific settings are preserved during reload + * when default manager settings differ from project settings. + */ +suite('Project-Specific Settings Preservation on Reload', () => { + let disposables: Disposable[]; + let clock: sinon.SinonFakeTimers; + let workspaceFoldersChangeEmitter: EventEmitter; + let configChangeEmitter: EventEmitter; + let deleteFilesEmitter: EventEmitter<{ files: readonly Uri[] }>; + let renameFilesEmitter: EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>; + + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'tests-plus-projects', + index: 0, + }; + + setup(() => { + disposables = []; + clock = sinon.useFakeTimers(); + + workspaceFoldersChangeEmitter = new EventEmitter(); + configChangeEmitter = new EventEmitter(); + deleteFilesEmitter = new EventEmitter<{ files: readonly Uri[] }>(); + renameFilesEmitter = new EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>(); + disposables.push(workspaceFoldersChangeEmitter, configChangeEmitter, deleteFilesEmitter, renameFilesEmitter); + + sinon.stub(workspaceApis, 'onDidChangeWorkspaceFolders').callsFake((listener: any) => { + return workspaceFoldersChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener: any) => { + return configChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: any) => { + return deleteFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidRenameFiles').callsFake((listener: any) => { + return renameFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + sinon.stub(settingHelpers, 'updatePythonProjectSettingPath').resolves(); + }); + + teardown(() => { + clock.restore(); + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('venv projects should be preserved when defaultEnvManager is conda', async () => { + // Scenario: Multiple projects have explicit venv/pip settings, + // but defaultEnvManager is set to conda. + // On reload, all project-specific settings must be preserved. + // + // Settings: + // { + // "python-envs.pythonProjects": [ + // { "path": "alice/bob", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" }, + // { "path": "ada", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" }, + // { "path": "alice", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" } + // ], + // "python-envs.defaultEnvManager": "ms-python.python:conda", + // "python-envs.defaultPackageManager": "ms-python.python:conda" + // } + + sinon.stub(settingHelpers, 'addPythonProjectSetting').resolves(); + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [ + { path: 'alice/bob', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'ada', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'alice', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:conda' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:conda' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + // Simulate reload: create new project manager and initialize + const pm = new PythonProjectManagerImpl(); + + // Spy on add() - initialize() should NOT call add() as that would write to settings + const addSpy = sinon.spy(pm, 'add'); + + pm.initialize(); + await clock.tickAsync(150); + + // initialize() should use loadProjects() (read-only), not add() (writes settings) + assert.ok( + !addSpy.called, + `initialize() called add() which would overwrite venv/pip settings with conda defaults. ` + + `add() was called ${addSpy.callCount} time(s).`, + ); + + pm.dispose(); + }); +}); diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 19de0841..43a7fe63 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import path from 'node:path'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import * as logging from '../../../common/logging'; @@ -32,6 +31,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders'); mockUntildify = sinon.stub(pathUtils, 'untildify'); // Also stub the namespace import version that might be used by untildifyArray + // Handle both Unix (~/) and Windows-style paths sinon .stub(pathUtils, 'untildifyArray') .callsFake((paths: string[]) => @@ -104,8 +104,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Legacy and global paths are consolidated', async () => { - // Mock → Legacy paths and globalSearchPaths both exist + test('Legacy and global paths are consolidated (Unix)', async () => { + // Mock → Legacy paths and globalSearchPaths both exist (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -123,8 +123,27 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Legacy paths included alongside new settings', async () => { - // Mock → Legacy paths exist, no globalSearchPaths + test('Legacy and global paths are consolidated (Windows)', async () => { + // Mock → Legacy paths and globalSearchPaths both exist (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\.virtualenvs', 'D:\\shared\\venvs', 'E:\\additional\\path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should consolidate all paths (duplicates removed), normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'D:/shared/venvs', 'E:/additional/path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Legacy paths included alongside new settings (Unix)', async () => { + // Mock → Legacy paths exist, no globalSearchPaths (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs', '/home/user/conda']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); @@ -140,6 +159,23 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Legacy paths included alongside new settings (Windows)', async () => { + // Mock → Legacy paths exist, no globalSearchPaths (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['C:\\Users\\dev\\venvs', 'D:\\conda\\envs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should include all legacy paths, normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'C:/Users/dev/venvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('Legacy and global paths combined with deduplication', async () => { // Mock → Some overlap between legacy and global paths pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); @@ -185,8 +221,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Configuration Source Tests', () => { - test('Global search paths with tilde expansion', async () => { - // Mock → No legacy, global paths with tildes + test('Global search paths with tilde expansion (Unix)', async () => { + // Mock → No legacy, global paths with tildes (Unix ~ expansion) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -207,14 +243,33 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Workspace folder setting preferred over workspace setting', async () => { - // Mock → Workspace settings at different levels + test('Global search paths with absolute paths (Windows)', async () => { + // Mock → No legacy, global paths with Windows absolute paths + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\virtualenvs', 'D:\\conda\\envs'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Paths normalized to forward slashes + const expected = new Set(['C:/Users/dev/virtualenvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Workspace folder setting preferred over workspace setting (Unix)', async () => { + // Mock → Workspace settings at different levels (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceValue: ['workspace-level-path'], - workspaceFolderValue: ['folder-level-path'], + workspaceValue: ['/workspace-level-path'], + workspaceFolderValue: ['/folder-level-path'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -224,11 +279,33 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Use dynamic path construction based on actual workspace URIs - const expected = new Set([ - path.resolve(workspace1.fsPath, 'folder-level-path'), - path.resolve(workspace2.fsPath, 'folder-level-path'), - ]); + // Assert - workspaceFolderValue takes priority, absolute path is kept as-is + const expected = new Set(['/folder-level-path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Workspace folder setting preferred over workspace setting (Windows)', async () => { + // Mock → Workspace settings at different levels (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceValue: ['D:\\workspace-level'], + workspaceFolderValue: ['C:\\folder-level\\path'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/projects/project1'); + const workspace2 = Uri.file('/projects/project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - workspaceFolderValue takes priority, normalized to forward slashes + const expected = new Set(['C:/folder-level/path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -280,8 +357,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Path Resolution Tests', () => { - test('Absolute paths used as-is', async () => { - // Mock → Mix of absolute paths + test('Absolute paths used as-is (Unix)', async () => { + // Mock → Mix of absolute paths (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -297,20 +374,45 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - For absolute paths, they should remain unchanged regardless of platform + // Assert - For absolute paths, they should remain unchanged const expected = new Set(['/absolute/path1', '/absolute/path2', '/absolute/workspace/path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Relative paths resolved against workspace folders', async () => { + test('Absolute paths used as-is (Windows)', async () => { + // Mock → Mix of absolute paths (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\absolute\\path1', 'D:\\absolute\\path2'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['E:\\workspace\\envs'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Windows paths normalized to forward slashes + const expected = new Set(['C:/absolute/path1', 'D:/absolute/path2', 'E:/workspace/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Relative paths are resolved against workspace folders', async () => { // Mock → Relative workspace paths with multiple workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['venvs', '../shared-envs'], + workspaceFolderValue: ['venvs', '.venv'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -320,16 +422,13 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - path.resolve() correctly resolves relative paths (order doesn't matter) - const expected = new Set([ - path.resolve(workspace1.fsPath, 'venvs'), - path.resolve(workspace2.fsPath, 'venvs'), - path.resolve(workspace1.fsPath, '../shared-envs'), // Resolves against workspace1 - path.resolve(workspace2.fsPath, '../shared-envs'), // Resolves against workspace2 - ]); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Relative paths are resolved against each workspace folder + // path.resolve behavior varies by platform, so check the paths contain expected segments + assert.strictEqual(result.length, 4, 'Should have 4 paths (2 relative × 2 workspaces)'); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); }); test('Relative paths without workspace folders logs warning', async () => { @@ -346,13 +445,9 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert + // Assert - Path is not added and warning is logged assert.deepStrictEqual(result, []); - // Check that warning was logged with key terms - don't be brittle about exact wording - assert( - mockTraceWarn.calledWith(sinon.match(/workspace.*folder.*relative.*path/i), 'relative-path'), - 'Should log warning about missing workspace folders', - ); + assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders'); }); test('Empty and whitespace paths are skipped', async () => { @@ -363,7 +458,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { globalValue: ['/valid/path', '', ' ', '/another/valid/path'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['valid-relative', '', ' \t\n ', 'another-valid'], + workspaceFolderValue: ['/workspace/valid', '', ' \t\n ', '/workspace/another'], }); const workspace = Uri.file('/workspace'); @@ -372,13 +467,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Now globalSearchPaths empty strings should be filtered out (order doesn't matter) - const expected = new Set([ - '/valid/path', - '/another/valid/path', - path.resolve(workspace.fsPath, 'valid-relative'), - path.resolve(workspace.fsPath, 'another-valid'), - ]); + // Assert - Empty strings filtered out, valid paths kept + const expected = new Set(['/valid/path', '/another/valid/path', '/workspace/valid', '/workspace/another']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -400,15 +490,15 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Power user - complex mix of all source types', async () => { - // Mock → Complex real-world scenario + test('Power user - complex mix of all source types (Unix)', async () => { + // Mock → Complex real-world scenario (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/legacy/venv/path'); pythonConfig.get.withArgs('venvFolders').returns(['/legacy/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/legacy/venv/path', '/legacy/venvs', '/global/conda', '~/personal/envs'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['.venv', 'project-envs', '/shared/team/envs'], + workspaceFolderValue: ['.venv', '/shared/team/envs'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -420,48 +510,94 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should deduplicate and combine all sources (order doesn't matter) - const expected = new Set([ - '/legacy/venv/path', - '/legacy/venvs', - '/global/conda', - '/home/user/personal/envs', - path.resolve(workspace1.fsPath, '.venv'), - path.resolve(workspace2.fsPath, '.venv'), - path.resolve(workspace1.fsPath, 'project-envs'), - path.resolve(workspace2.fsPath, 'project-envs'), - '/shared/team/envs', - ]); - const actual = new Set(result); + // Assert - Relative paths are resolved against workspace folders, absolutes kept as-is + assert.ok(result.includes('/legacy/venv/path')); + assert.ok(result.includes('/legacy/venvs')); + assert.ok(result.includes('/global/conda')); + assert.ok(result.includes('/home/user/personal/envs')); + assert.ok(result.includes('/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); + }); + + test('Power user - complex mix of all source types (Windows)', async () => { + // Mock → Complex real-world scenario (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns('C:\\legacy\\venv\\path'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\legacy\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\legacy\\venv\\path', 'D:\\legacy\\venvs', 'E:\\global\\conda'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['.venv', 'F:\\shared\\team\\envs'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/workspace/project1'); + const workspace2 = Uri.file('/workspace/project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All paths normalized to forward slashes + assert.ok(result.includes('C:/legacy/venv/path')); + assert.ok(result.includes('D:/legacy/venvs')); + assert.ok(result.includes('E:/global/conda')); + assert.ok(result.includes('F:/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); + // Verify no backslashes remain + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Overlapping paths are deduplicated (Unix)', async () => { + // Mock → Duplicate paths from different sources (Unix-style) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['/shared/path', '/global/unique'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['/shared/path', '/workspace/unique'], + }); + + const workspace = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); - // Check that we have exactly the expected paths (no more, no less) + // Assert - Duplicates should be removed + const expected = new Set(['/shared/path', '/global/unique', '/workspace/unique']); + const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Overlapping paths are deduplicated', async () => { - // Mock → Duplicate paths from different sources + test('Overlapping paths are deduplicated (Windows)', async () => { + // Mock → Duplicate paths from different sources (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ - globalValue: ['/shared/path', '/global/unique'], + globalValue: ['C:\\shared\\path', 'D:\\global\\unique'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['/shared/path', 'workspace-unique'], + workspaceFolderValue: ['C:\\shared\\path', 'E:\\workspace\\unique'], }); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) const workspace = Uri.file('/workspace'); mockGetWorkspaceFolders.returns([{ uri: workspace }]); // Run const result = await getAllExtraSearchPaths(); - // Assert - Duplicates should be removed (order doesn't matter) - const expected = new Set([ - '/shared/path', - '/global/unique', - path.resolve(workspace.fsPath, 'workspace-unique'), - ]); + // Assert - Duplicates should be removed, normalized to forward slashes + const expected = new Set(['C:/shared/path', 'D:/global/unique', 'E:/workspace/unique']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -473,7 +609,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { pythonConfig.get.withArgs('venvFolders').returns(['/legacy/folder']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/global/path'] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['workspace-relative'], + workspaceFolderValue: ['.venv'], }); const workspace = Uri.file('/workspace'); @@ -482,16 +618,92 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should consolidate all path types - const expected = new Set([ - '/legacy/path', - '/legacy/folder', - '/global/path', - path.resolve(workspace.fsPath, 'workspace-relative'), - ]); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Should consolidate all path types, relative resolved against workspace + assert.ok(result.includes('/legacy/path')); + assert.ok(result.includes('/legacy/folder')); + assert.ok(result.includes('/global/path')); + assert.ok(result.some((p) => p.includes('workspace') && p.endsWith('.venv'))); + }); + }); + + suite('Cross-Platform Path Normalization', () => { + test('Backslashes are converted to forward slashes for glob compatibility', async () => { + // Mock → Windows-style paths with backslashes + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\test\\envs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Python\\environments', 'E:\\projects\\**\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes should be converted to forward slashes + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + assert.ok(result.includes('C:/Users/test/envs')); + assert.ok(result.includes('D:/shared/venvs')); + assert.ok(result.includes('C:/Python/environments')); + assert.ok(result.includes('E:/projects/**/.venv')); + }); + + test('Glob patterns with backslashes are normalized', async () => { + // Mock → Glob pattern with Windows backslashes + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\workspace\\**\\venv', 'D:\\projects\\*\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Glob patterns should use forward slashes + assert.ok(result.includes('C:/workspace/**/venv')); + assert.ok(result.includes('D:/projects/*/.venv')); + }); + + test('Linux/macOS paths with forward slashes are preserved', async () => { + // Mock → Unix-style paths (already using forward slashes) + pythonConfig.get.withArgs('venvPath').returns('/home/user/envs'); + pythonConfig.get.withArgs('venvFolders').returns(['/opt/shared/venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['/usr/local/python/environments', '/home/user/projects/**/.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Forward slashes should be preserved as-is + assert.ok(result.includes('/home/user/envs')); + assert.ok(result.includes('/opt/shared/venvs')); + assert.ok(result.includes('/usr/local/python/environments')); + assert.ok(result.includes('/home/user/projects/**/.venv')); + // Verify no backslashes were introduced + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Mixed path separators are normalized to forward slashes', async () => { + // Mock → Paths with mixed separators (edge case) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:/Users\\test/projects\\.venv', '/home/user\\mixed/path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes normalized to forward slashes + assert.ok(result.includes('C:/Users/test/projects/.venv')); + assert.ok(result.includes('/home/user/mixed/path')); }); }); });