diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index d1d63a19..60a41505 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -594,7 +594,7 @@ envConfig.inspect - Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1) - Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1) +- Use `sinon.useFakeTimers()` with `clock.tickAsync()` instead of `await new Promise(resolve => setTimeout(resolve, ms))` for debounce/timeout handling - eliminates flakiness and speeds up tests significantly (1) - Always compile tests (`npm run compile-tests`) before running them after adding new test cases - test counts will be wrong if running against stale compiled output (1) - Never create "documentation tests" that just `assert.ok(true)` — if mocking limitations prevent testing, either test a different layer that IS mockable, or skip the test entirely with a clear explanation (1) -- **REPEATED**: When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3) -- **REPEATED**: Use OS-agnostic path handling in tests: use `'.'` for relative paths in configs (NOT `/test/workspace`), compare `fsPath` to `Uri.file(expected).fsPath` (NOT raw strings). This breaks on Windows every time! (5) +- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3) diff --git a/src/common/workspace.apis.ts b/src/common/workspace.apis.ts index 213c0b8a..c009cd6d 100644 --- a/src/common/workspace.apis.ts +++ b/src/common/workspace.apis.ts @@ -5,6 +5,7 @@ import { ConfigurationScope, Disposable, FileDeleteEvent, + FileRenameEvent, FileSystemWatcher, GlobPattern, Uri, @@ -63,3 +64,11 @@ export function onDidDeleteFiles( ): Disposable { return workspace.onDidDeleteFiles(listener, thisArgs, disposables); } + +export function onDidRenameFiles( + listener: (e: FileRenameEvent) => any, + thisArgs?: any, + disposables?: Disposable[], +): Disposable { + return workspace.onDidRenameFiles(listener, thisArgs, disposables); +} diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index 733278a9..ebf207b4 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -8,6 +8,8 @@ import { getWorkspaceFolders, onDidChangeConfiguration, onDidChangeWorkspaceFolders, + onDidDeleteFiles, + onDidRenameFiles, } from '../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings, PythonProjectsImpl } from '../internal.api'; import { @@ -15,6 +17,8 @@ import { EditProjectSettings, getDefaultEnvManagerSetting, getDefaultPkgManagerSetting, + removePythonProjectSetting, + updatePythonProjectSettingPath, } from './settings/settingHelpers'; type ProjectArray = PythonProject[]; @@ -45,9 +49,64 @@ export class PythonProjectManagerImpl implements PythonProjectManager { this.updateDebounce.trigger(); } }), + onDidDeleteFiles((e) => { + this.handleDeletedFiles(e.files); + }), + onDidRenameFiles((e) => { + this.handleRenamedFiles(e.files); + }), ); } + /** + * Handles file deletion events. When a project folder is deleted, + * removes the project from the internal map and cleans up settings. + */ + private async handleDeletedFiles(deletedUris: readonly Uri[]): Promise { + const projectsToRemove: PythonProject[] = []; + const workspaces = getWorkspaceFolders() ?? []; + + for (const uri of deletedUris) { + const project = this._projects.get(uri.toString()); + if (project) { + // Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders + const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString()); + if (!isWorkspaceRoot) { + projectsToRemove.push(project); + } + } + } + + if (projectsToRemove.length > 0) { + // Remove from internal map and fire change event + this.remove(projectsToRemove); + // Clean up settings + await removePythonProjectSetting(projectsToRemove.map((p) => ({ project: p }))); + } + } + + /** + * Handles file rename events. When a project folder is renamed/moved, + * updates the project path in settings. + */ + private async handleRenamedFiles(renamedFiles: readonly { oldUri: Uri; newUri: Uri }[]): Promise { + const workspaces = getWorkspaceFolders() ?? []; + + for (const { oldUri, newUri } of renamedFiles) { + const project = this._projects.get(oldUri.toString()); + if (project) { + // Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders + const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString()); + if (!isWorkspaceRoot) { + // Update settings with new path + await updatePythonProjectSettingPath(oldUri, newUri); + // Trigger update to refresh the in-memory projects + this.updateDebounce.trigger(); + } + } + } + } + /** * * Gathers the projects which are configured in settings and all workspace roots. diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 10d484a1..5acef3d4 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -10,7 +10,7 @@ import { import { PythonProject } from '../../api'; import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants'; import { traceError, traceInfo, traceWarn } from '../../common/logging'; -import { getConfiguration, getWorkspaceFile, getWorkspaceFolders } from '../../common/workspace.apis'; +import * as workspaceApis from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings } from '../../internal.api'; function getSettings( @@ -42,7 +42,7 @@ export function isDefaultEnvManagerBroken(): boolean { } export function getDefaultEnvManagerSetting(wm: PythonProjectManager, scope?: Uri): string { - const config = getConfiguration('python-envs', scope); + const config = workspaceApis.getConfiguration('python-envs', scope); const settings = getSettings(wm, config, scope); if (settings && settings.envManager.length > 0) { return settings.envManager; @@ -69,7 +69,7 @@ export function getDefaultPkgManagerSetting( scope?: ConfigurationScope | null, defaultId?: string, ): string { - const config = getConfiguration('python-envs', scope); + const config = workspaceApis.getConfiguration('python-envs', scope); const settings = getSettings(wm, config, scope); if (settings && settings.packageManager.length > 0) { @@ -123,11 +123,11 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr } }); - const workspaceFile = getWorkspaceFile(); + const workspaceFile = workspaceApis.getWorkspaceFile(); const promises: Thenable[] = []; workspaces.forEach((es, w) => { - const config = getConfiguration('python-envs', w); + const config = workspaceApis.getConfiguration('python-envs', w); const overrides = config.get('pythonProjects', []); const projectsInspect = config.inspect('pythonProjects'); const existingProjectsSetting = @@ -173,7 +173,7 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr } }); - const config = getConfiguration('python-envs', undefined); + const config = workspaceApis.getConfiguration('python-envs', undefined); edits .filter((e) => !e.project) .forEach((e) => { @@ -221,7 +221,7 @@ export async function setEnvironmentManager(edits: EditEnvManagerSettings[]): Pr const promises: Thenable[] = []; workspaces.forEach((es, w) => { - const config = getConfiguration('python-envs', w.uri); + const config = workspaceApis.getConfiguration('python-envs', w.uri); const overrides = config.get('pythonProjects', []); const projectsInspect = config.inspect('pythonProjects'); const existingProjectsSetting = projectsInspect?.workspaceValue ?? undefined; @@ -247,7 +247,7 @@ export async function setEnvironmentManager(edits: EditEnvManagerSettings[]): Pr } }); - const config = getConfiguration('python-envs', undefined); + const config = workspaceApis.getConfiguration('python-envs', undefined); edits .filter((e) => !e.project) .forEach((e) => { @@ -295,7 +295,7 @@ export async function setPackageManager(edits: EditPackageManagerSettings[]): Pr const promises: Thenable[] = []; workspaces.forEach((es, w) => { - const config = getConfiguration('python-envs', w.uri); + const config = workspaceApis.getConfiguration('python-envs', w.uri); const overrides = config.get('pythonProjects', []); const projectsInspect = config.inspect('pythonProjects'); const existingProjectsSetting = projectsInspect?.workspaceValue ?? undefined; @@ -321,7 +321,7 @@ export async function setPackageManager(edits: EditPackageManagerSettings[]): Pr } }); - const config = getConfiguration('python-envs', undefined); + const config = workspaceApis.getConfiguration('python-envs', undefined); edits .filter((e) => !e.project) .forEach((e) => { @@ -343,7 +343,7 @@ export interface EditProjectSettings { export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); - const globalConfig = getConfiguration('python-envs', undefined); + const globalConfig = workspaceApis.getConfiguration('python-envs', undefined); const envManager = globalConfig.get('defaultEnvManager', DEFAULT_ENV_MANAGER_ID); const pkgManager = globalConfig.get('defaultPackageManager', DEFAULT_PACKAGE_MANAGER_ID); @@ -360,11 +360,11 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro traceError(`Unable to find workspace for ${e.project.uri.fsPath}`); }); - const isMultiroot = (getWorkspaceFolders() ?? []).length > 1; + const isMultiroot = (workspaceApis.getWorkspaceFolders() ?? []).length > 1; const promises: Thenable[] = []; workspaces.forEach((es, w) => { - const config = getConfiguration('python-envs', w.uri); + const config = workspaceApis.getConfiguration('python-envs', w.uri); const overrides = config.get('pythonProjects', []); es.forEach((e) => { if (isMultiroot) { @@ -378,8 +378,9 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro return path.resolve(w.uri.fsPath, s.path) === pwPath; }); if (index >= 0) { - overrides[index].envManager = e.envManager ?? envManager; - overrides[index].packageManager = e.packageManager ?? pkgManager; + // Preserve existing manager settings if not explicitly provided + overrides[index].envManager = e.envManager ?? overrides[index].envManager; + overrides[index].packageManager = e.packageManager ?? overrides[index].packageManager; } else { overrides.push({ path: path.relative(w.uri.fsPath, pwPath).replace(/\\/g, '/'), @@ -412,7 +413,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): const promises: Thenable[] = []; workspaces.forEach((es, w) => { - const config = getConfiguration('python-envs', w.uri); + const config = workspaceApis.getConfiguration('python-envs', w.uri); const overrides = config.get('pythonProjects', []); es.forEach((e) => { const pwPath = path.normalize(e.project.uri.fsPath); @@ -430,6 +431,43 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): await Promise.all(promises); } +/** + * Updates the path of a project in pythonProjects settings when a folder is renamed/moved. + * @param oldUri The original URI of the project folder + * @param newUri The new URI of the project folder after rename/move + */ +export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): Promise { + const workspaceFolders = workspaceApis.getWorkspaceFolders() ?? []; + + // Find the workspace folder that contains the old path + let targetWorkspace: WorkspaceFolder | undefined; + for (const w of workspaceFolders) { + const oldPath = path.normalize(oldUri.fsPath); + if (oldPath.startsWith(path.normalize(w.uri.fsPath))) { + targetWorkspace = w; + break; + } + } + + if (!targetWorkspace) { + traceError(`Unable to find workspace for ${oldUri.fsPath}`); + return; + } + + const config = workspaceApis.getConfiguration('python-envs', targetWorkspace.uri); + const overrides = config.get('pythonProjects', []); + const oldNormalizedPath = path.normalize(oldUri.fsPath); + + const index = overrides.findIndex((s) => path.resolve(targetWorkspace!.uri.fsPath, s.path) === oldNormalizedPath); + if (index >= 0) { + // Update the path to the new location + const newRelativePath = path.relative(targetWorkspace.uri.fsPath, newUri.fsPath).replace(/\\/g, '/'); + overrides[index].path = newRelativePath; + await config.update('pythonProjects', overrides, ConfigurationTarget.Workspace); + traceInfo(`Updated project path from ${oldUri.fsPath} to ${newUri.fsPath}`); + } +} + /** * Gets user-configured setting for window-scoped settings. * Priority order: globalRemoteValue > globalLocalValue > globalValue @@ -438,7 +476,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): * @returns The user-configured value or undefined if not set by user */ export function getSettingWindowScope(section: string, key: string): T | undefined { - const config = getConfiguration(section); + const config = workspaceApis.getConfiguration(section); const inspect = config.inspect(key); if (!inspect) { return undefined; @@ -466,7 +504,7 @@ export function getSettingWindowScope(section: string, key: string): T | unde * @returns The user-configured value or undefined if not set by user */ export function getSettingWorkspaceScope(section: string, key: string, scope?: Uri): T | undefined { - const config = getConfiguration(section, scope); + const config = workspaceApis.getConfiguration(section, scope); const inspect = config.inspect(key); if (!inspect) { return undefined; @@ -492,7 +530,7 @@ export function getSettingWorkspaceScope(section: string, key: string, scope? * @returns The user-configured value or undefined if not set by user */ export function getSettingUserScope(section: string, key: string): T | undefined { - const config = getConfiguration(section); + const config = workspaceApis.getConfiguration(section); const inspect = config.inspect(key); if (!inspect) { return undefined; diff --git a/src/test/features/projectManager.fileEvents.unit.test.ts b/src/test/features/projectManager.fileEvents.unit.test.ts new file mode 100644 index 00000000..84b1d2ba --- /dev/null +++ b/src/test/features/projectManager.fileEvents.unit.test.ts @@ -0,0 +1,429 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { ConfigurationTarget, 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 { PythonProjectsImpl } 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'; +} + +/** + * Tests for project manager file event handling (delete/rename). + * + * Testing strategy: + * - These tests verify the INTEGRATION between file events and the project manager + * - We mock settingHelpers to verify the correct helper is called with correct args + * - The actual settingHelpers implementation is tested separately in the + * 'updatePythonProjectSettingPath' suite below + * - We use fake timers to avoid flaky setTimeout-based waits for debounce + */ +suite('Project Manager File Event Handling', () => { + let disposables: Disposable[] = []; + let deleteFilesEmitter: EventEmitter<{ files: readonly Uri[] }>; + let renameFilesEmitter: EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>; + let workspaceFoldersChangeEmitter: EventEmitter; + let configChangeEmitter: EventEmitter; + let removePythonProjectSettingStub: sinon.SinonStub; + let updatePythonProjectSettingPathStub: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + + const workspaceUri = Uri.file(getTestWorkspacePath()); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + setup(() => { + // Use fake timers to avoid flaky setTimeout-based waits + clock = sinon.useFakeTimers(); + + // Create event emitters for file system events + deleteFilesEmitter = new EventEmitter<{ files: readonly Uri[] }>(); + renameFilesEmitter = new EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>(); + workspaceFoldersChangeEmitter = new EventEmitter(); + configChangeEmitter = new EventEmitter(); + disposables.push(deleteFilesEmitter, renameFilesEmitter, workspaceFoldersChangeEmitter, configChangeEmitter); + + // Stub workspace APIs + 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, 'onDidChangeWorkspaceFolders').callsFake((listener: any) => { + return workspaceFoldersChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener: any) => { + return configChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').callsFake(() => [workspaceFolder]); + + // Mock configuration + 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); + + // Stub setting helpers + removePythonProjectSettingStub = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + updatePythonProjectSettingPathStub = sinon.stub(settingHelpers, 'updatePythonProjectSettingPath').resolves(); + sinon.stub(settingHelpers, 'addPythonProjectSetting').resolves(); + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + disposables = []; + }); + + /** + * Helper to directly add a project to the manager's internal map for testing. + * This bypasses the `add()` method which has side effects (writes to settings). + * + * Trade-off: We test internal state rather than public API, but this keeps tests + * focused on the file event handling behavior without needing to mock the full + * settings write path that `add()` triggers. + */ + function addProjectDirectly(pm: PythonProjectManagerImpl, name: string, uri: Uri): void { + const project = new PythonProjectsImpl(name, uri); + (pm as any)._projects.set(uri.toString(), project); + } + + suite('handleDeletedFiles', () => { + test('should remove project and update settings when project folder is deleted', async () => { + const projectUri = Uri.file(`${getTestWorkspacePath()}/my-project`); + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Directly add a project to the internal map + addProjectDirectly(pm, 'my-project', projectUri); + + // Verify project exists + assert.ok(pm.get(projectUri), 'Project should exist before deletion'); + + // Track onDidChangeProjects events for UI refresh verification + let changeEventFired = false; + let projectsAfterEvent: readonly any[] = []; + const changeListener = pm.onDidChangeProjects((projects) => { + changeEventFired = true; + projectsAfterEvent = projects ?? []; + }); + + // Fire delete event + deleteFilesEmitter.fire({ files: [projectUri] }); + + // Allow async operations to complete + await clock.tickAsync(150); + + // Verify onDidChangeProjects was fired (triggers UI refresh) + assert.ok(changeEventFired, 'onDidChangeProjects should be fired to trigger UI refresh'); + + // Verify the deleted project is not in the event payload + const deletedInEvent = projectsAfterEvent.find((p) => p.uri.toString() === projectUri.toString()); + assert.strictEqual(deletedInEvent, undefined, 'Deleted project should not be in change event'); + + // Verify project is removed from getProjects() + const projectsAfter = pm.getProjects(); + const deletedProject = projectsAfter.find((p) => p.uri.toString() === projectUri.toString()); + assert.strictEqual(deletedProject, undefined, 'Project should be removed after folder deletion'); + + // Verify settings were updated + assert.ok(removePythonProjectSettingStub.called, 'removePythonProjectSetting should be called'); + + changeListener.dispose(); + pm.dispose(); + }); + + test('should not remove workspace root folder on delete (handled by onDidChangeWorkspaceFolders)', async () => { + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Manually add the workspace root to simulate initialization + // (Full initialization requires more complex mocking of vscode.workspace) + addProjectDirectly(pm, 'workspace', workspaceUri); + + // Verify workspace root exists + const workspaceRootProject = pm.get(workspaceUri); + assert.ok(workspaceRootProject, 'Workspace root should be a project'); + + // Fire delete event for workspace root + deleteFilesEmitter.fire({ files: [workspaceUri] }); + + // Allow async operations to complete + await clock.tickAsync(150); + + // Settings should NOT be updated for workspace root (it's handled by onDidChangeWorkspaceFolders) + assert.ok( + !removePythonProjectSettingStub.called, + 'removePythonProjectSetting should not be called for workspace root', + ); + + pm.dispose(); + }); + + test('should not affect untracked folders', async () => { + const untrackedUri = Uri.file(`${getTestWorkspacePath()}/not-a-project`); + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + const projectsBefore = pm.getProjects(); + + // Fire delete event for untracked folder + deleteFilesEmitter.fire({ files: [untrackedUri] }); + + // Allow async operations to complete + await clock.tickAsync(150); + + const projectsAfter = pm.getProjects(); + assert.strictEqual(projectsAfter.length, projectsBefore.length, 'Projects should remain unchanged'); + assert.ok(!removePythonProjectSettingStub.called, 'removePythonProjectSetting should not be called'); + + pm.dispose(); + }); + }); + + suite('handleRenamedFiles', () => { + test('should update project path in settings when project folder is renamed', async () => { + const oldUri = Uri.file(`${getTestWorkspacePath()}/old-name`); + const newUri = Uri.file(`${getTestWorkspacePath()}/new-name`); + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Directly add a project to the internal map + addProjectDirectly(pm, 'old-name', oldUri); + + // Track onDidChangeProjects events for UI refresh verification + let changeEventFired = false; + const changeListener = pm.onDidChangeProjects(() => { + changeEventFired = true; + }); + + // Fire rename event + renameFilesEmitter.fire({ files: [{ oldUri, newUri }] }); + + // Allow async operations to complete (debounce is 100ms) + await clock.tickAsync(150); + + // Verify settings path update was called + assert.ok(updatePythonProjectSettingPathStub.called, 'updatePythonProjectSettingPath should be called'); + assert.ok( + updatePythonProjectSettingPathStub.calledWith(oldUri, newUri), + 'updatePythonProjectSettingPath should be called with correct URIs', + ); + + // Verify onDidChangeProjects was fired (triggers UI refresh via updateDebounce) + assert.ok(changeEventFired, 'onDidChangeProjects should be fired to trigger UI refresh'); + + changeListener.dispose(); + pm.dispose(); + }); + + test('should not update settings for workspace root folder rename', async () => { + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + const newUri = Uri.file(process.platform === 'win32' ? 'C:\\new-workspace' : '/new-workspace'); + + // Fire rename event for workspace root + renameFilesEmitter.fire({ files: [{ oldUri: workspaceUri, newUri }] }); + + // Allow async operations to complete + await clock.tickAsync(150); + + // Settings should NOT be updated for workspace root + assert.ok( + !updatePythonProjectSettingPathStub.called, + 'updatePythonProjectSettingPath should not be called for workspace root', + ); + + pm.dispose(); + }); + + test('should not affect untracked folder renames', async () => { + const oldUri = Uri.file(`${getTestWorkspacePath()}/untracked`); + const newUri = Uri.file(`${getTestWorkspacePath()}/untracked-renamed`); + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Fire rename event for untracked folder + renameFilesEmitter.fire({ files: [{ oldUri, newUri }] }); + + // Allow async operations to complete + await clock.tickAsync(150); + + assert.ok( + !updatePythonProjectSettingPathStub.called, + 'updatePythonProjectSettingPath should not be called', + ); + + pm.dispose(); + }); + }); +}); + +suite('updatePythonProjectSettingPath', () => { + let updateCalls: Array<{ key: string; value: unknown; target: ConfigurationTarget }>; + + setup(() => { + updateCalls = []; + }); + + teardown(() => { + sinon.restore(); + }); + + test('should update project path in pythonProjects setting', async () => { + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string): T | undefined => { + if (key === 'pythonProjects') { + return [ + { + path: 'old-project', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ] as unknown as T; + } + return undefined; + }; + mockConfig.update = (section: string, value: unknown, target?: boolean | ConfigurationTarget) => { + updateCalls.push({ key: section, value, target: target as ConfigurationTarget }); + return Promise.resolve(); + }; + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const oldUri = Uri.file(`${workspacePath}/old-project`); + const newUri = Uri.file(`${workspacePath}/new-project`); + + await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri); + + assert.strictEqual(updateCalls.length, 1, 'Should have one update call'); + assert.strictEqual(updateCalls[0].key, 'pythonProjects'); + const updatedProjects = updateCalls[0].value as Array<{ path: string }>; + assert.strictEqual(updatedProjects[0].path, 'new-project', 'Path should be updated to new-project'); + }); + + test('should not update if project not found in settings', async () => { + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string): T | undefined => { + if (key === 'pythonProjects') { + return [ + { + path: 'other-project', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ] as unknown as T; + } + return undefined; + }; + mockConfig.update = (section: string, value: unknown, target?: boolean | ConfigurationTarget) => { + updateCalls.push({ key: section, value, target: target as ConfigurationTarget }); + return Promise.resolve(); + }; + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const oldUri = Uri.file(`${workspacePath}/non-existent`); + const newUri = Uri.file(`${workspacePath}/renamed`); + + await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri); + + assert.strictEqual(updateCalls.length, 0, 'Should not update settings when project not found'); + }); + + test('should preserve envManager and packageManager when updating path', async () => { + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string): T | undefined => { + if (key === 'pythonProjects') { + return [ + { + path: 'pyenv-project', + envManager: 'ms-python.python:pyenv', + packageManager: 'ms-python.python:conda', + }, + ] as unknown as T; + } + return undefined; + }; + mockConfig.update = (section: string, value: unknown, target?: boolean | ConfigurationTarget) => { + updateCalls.push({ key: section, value, target: target as ConfigurationTarget }); + return Promise.resolve(); + }; + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const oldUri = Uri.file(`${workspacePath}/pyenv-project`); + const newUri = Uri.file(`${workspacePath}/pyenv-project-renamed`); + + await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri); + + assert.strictEqual(updateCalls.length, 1, 'Should have one update call'); + const updatedProjects = updateCalls[0].value as Array<{ + path: string; + envManager: string; + packageManager: string; + }>; + assert.strictEqual(updatedProjects[0].path, 'pyenv-project-renamed', 'Path should be updated'); + assert.strictEqual( + updatedProjects[0].envManager, + 'ms-python.python:pyenv', + 'envManager should be preserved (not reset to default)', + ); + assert.strictEqual( + updatedProjects[0].packageManager, + 'ms-python.python:conda', + 'packageManager should be preserved (not reset to default)', + ); + }); +});