From a72947b43de1737ae7e0dbc2ab9450bc04ade458 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:59:02 -0800 Subject: [PATCH 1/3] fix Python project management handling for delete/move --- .../testing-workflow.instructions.md | 163 +++---- docs/managing-python-projects.md | 387 ++++++++++++++++ src/common/workspace.apis.ts | 9 + src/features/projectManager.ts | 59 +++ src/features/settings/settingHelpers.ts | 42 +- .../projectManager.fileEvents.unit.test.ts | 418 ++++++++++++++++++ 6 files changed, 995 insertions(+), 83 deletions(-) create mode 100644 docs/managing-python-projects.md create mode 100644 src/test/features/projectManager.fileEvents.unit.test.ts diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index be56e4a8..f5d97b88 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -20,18 +20,18 @@ This guide covers the full testing lifecycle: **User Requests Testing:** -- "Write tests for this function" -- "Run the tests" -- "Fix the failing tests" -- "Test this code" -- "Add test coverage" +- "Write tests for this function" +- "Run the tests" +- "Fix the failing tests" +- "Test this code" +- "Add test coverage" **File Context Triggers:** -- Working in `**/test/**` directories -- Files ending in `.test.ts` or `.unit.test.ts` -- Test failures or compilation errors -- Coverage reports or test output analysis +- Working in `**/test/**` directories +- Files ending in `.test.ts` or `.unit.test.ts` +- Test failures or compilation errors +- Coverage reports or test output analysis ## Test Types @@ -39,17 +39,17 @@ When implementing tests as an AI agent, choose between two main types: ### Unit Tests (`*.unit.test.ts`) -- **Fast isolated testing** - Mock all external dependencies -- **Use for**: Pure functions, business logic, data transformations -- **Execute with**: `runTests` tool with specific file patterns -- **Mock everything** - VS Code APIs automatically mocked via `/src/test/unittests.ts` +- **Fast isolated testing** - Mock all external dependencies +- **Use for**: Pure functions, business logic, data transformations +- **Execute with**: `runTests` tool with specific file patterns +- **Mock everything** - VS Code APIs automatically mocked via `/src/test/unittests.ts` ### Extension Tests (`*.test.ts`) -- **Full VS Code integration** - Real environment with actual APIs -- **Use for**: Command registration, UI interactions, extension lifecycle -- **Execute with**: VS Code launch configurations or `runTests` tool -- **Slower but comprehensive** - Tests complete user workflows +- **Full VS Code integration** - Real environment with actual APIs +- **Use for**: Command registration, UI interactions, extension lifecycle +- **Execute with**: VS Code launch configurations or `runTests` tool +- **Slower but comprehensive** - Tests complete user workflows ## 🤖 Agent Tool Usage for Test Execution @@ -172,17 +172,17 @@ function analyzeFailure(failure: TestFailure): TestFailureAnalysis { **Choose Unit Tests (`*.unit.test.ts`) when analyzing:** -- Functions with clear inputs/outputs and no VS Code API dependencies -- Data transformation, parsing, or utility functions -- Business logic that can be isolated with mocks -- Error handling scenarios with predictable inputs +- Functions with clear inputs/outputs and no VS Code API dependencies +- Data transformation, parsing, or utility functions +- Business logic that can be isolated with mocks +- Error handling scenarios with predictable inputs **Choose Extension Tests (`*.test.ts`) when analyzing:** -- Functions that register VS Code commands or use `vscode.*` APIs -- UI components, tree views, or command palette interactions -- File system operations requiring workspace context -- Extension lifecycle events (activation, deactivation) +- Functions that register VS Code commands or use `vscode.*` APIs +- UI components, tree views, or command palette interactions +- File system operations requiring workspace context +- Extension lifecycle events (activation, deactivation) **Agent Implementation Pattern:** @@ -300,22 +300,22 @@ function generateTestScenarios(analysis: FunctionAnalysis): TestScenario[] { #### Main Flows -- ✅ **Happy path scenarios** - normal expected usage -- ✅ **Alternative paths** - different configuration combinations -- ✅ **Integration scenarios** - multiple features working together +- ✅ **Happy path scenarios** - normal expected usage +- ✅ **Alternative paths** - different configuration combinations +- ✅ **Integration scenarios** - multiple features working together #### Edge Cases -- 🔸 **Boundary conditions** - empty inputs, missing data -- 🔸 **Error scenarios** - network failures, permission errors -- 🔸 **Data validation** - invalid inputs, type mismatches +- 🔸 **Boundary conditions** - empty inputs, missing data +- 🔸 **Error scenarios** - network failures, permission errors +- 🔸 **Data validation** - invalid inputs, type mismatches #### Real-World Scenarios -- ✅ **Fresh install** - clean slate -- ✅ **Existing user** - migration scenarios -- ✅ **Power user** - complex configurations -- 🔸 **Error recovery** - graceful degradation +- ✅ **Fresh install** - clean slate +- ✅ **Existing user** - migration scenarios +- ✅ **Power user** - complex configurations +- 🔸 **Error recovery** - graceful degradation ### Example Test Plan Structure @@ -324,30 +324,30 @@ function generateTestScenarios(analysis: FunctionAnalysis): TestScenario[] { ### 1. Configuration Migration Tests -- No legacy settings exist -- Legacy settings already migrated -- Fresh migration needed -- Partial migration required -- Migration failures +- No legacy settings exist +- Legacy settings already migrated +- Fresh migration needed +- Partial migration required +- Migration failures ### 2. Configuration Source Tests -- Global search paths -- Workspace search paths -- Settings precedence -- Configuration errors +- Global search paths +- Workspace search paths +- Settings precedence +- Configuration errors ### 3. Path Resolution Tests -- Absolute vs relative paths -- Workspace folder resolution -- Path validation and filtering +- Absolute vs relative paths +- Workspace folder resolution +- Path validation and filtering ### 4. Integration Scenarios -- Combined configurations -- Deduplication logic -- Error handling flows +- Combined configurations +- Deduplication logic +- Error handling flows ``` ## 🔧 Step 4: Set Up Your Test Infrastructure @@ -514,47 +514,47 @@ envConfig.inspect ### Configuration Tests -- Test different setting combinations -- Test setting precedence (workspace > user > default) -- Test configuration errors and recovery -- Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility +- Test different setting combinations +- Test setting precedence (workspace > user > default) +- Test configuration errors and recovery +- Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility ### Data Flow Tests -- Test how data moves through the system -- Test transformations (path resolution, filtering) -- Test state changes (migrations, updates) +- Test how data moves through the system +- Test transformations (path resolution, filtering) +- Test state changes (migrations, updates) ### Error Handling Tests -- Test graceful degradation -- Test error logging -- Test fallback behaviors +- Test graceful degradation +- Test error logging +- Test fallback behaviors ### Integration Tests -- Test multiple features together -- Test real-world scenarios -- Test edge case combinations +- Test multiple features together +- Test real-world scenarios +- Test edge case combinations ## 📊 Step 8: Review and Refine ### Test Quality Checklist -- [ ] **Clear naming** - test names describe the scenario and expected outcome -- [ ] **Good coverage** - main flows, edge cases, error scenarios -- [ ] **Resilient assertions** - won't break due to minor changes -- [ ] **Readable structure** - follows Mock → Run → Assert pattern -- [ ] **Isolated tests** - each test is independent -- [ ] **Fast execution** - tests run quickly with proper mocking +- [ ] **Clear naming** - test names describe the scenario and expected outcome +- [ ] **Good coverage** - main flows, edge cases, error scenarios +- [ ] **Resilient assertions** - won't break due to minor changes +- [ ] **Readable structure** - follows Mock → Run → Assert pattern +- [ ] **Isolated tests** - each test is independent +- [ ] **Fast execution** - tests run quickly with proper mocking ### Common Anti-Patterns to Avoid -- ❌ Testing implementation details instead of behavior -- ❌ Brittle assertions that break on cosmetic changes -- ❌ Order-dependent tests that fail due to processing changes -- ❌ Tests that don't clean up mocks properly -- ❌ Overly complex test setup that's hard to understand +- ❌ Testing implementation details instead of behavior +- ❌ Brittle assertions that break on cosmetic changes +- ❌ Order-dependent tests that fail due to processing changes +- ❌ Tests that don't clean up mocks properly +- ❌ Overly complex test setup that's hard to understand ## 🔄 Reviewing and Improving Existing Tests @@ -567,13 +567,14 @@ envConfig.inspect ### Common Fixes -- Over-complex mocks → Minimal mocks with only needed methods -- Brittle assertions → Behavior-focused with error messages -- Vague test names → Clear scenario descriptions (transform "should return X when Y" into "should [expected behavior] when [scenario context]") -- Missing structure → Mock → Run → Assert pattern -- Untestable Node.js APIs → Create proxy abstraction functions (use function overloads to preserve intelligent typing while making functions mockable) +- Over-complex mocks → Minimal mocks with only needed methods +- Brittle assertions → Behavior-focused with error messages +- Vague test names → Clear scenario descriptions (transform "should return X when Y" into "should [expected behavior] when [scenario context]") +- Missing structure → Mock → Run → Assert pattern +- Untestable Node.js APIs → Create proxy abstraction functions (use function overloads to preserve intelligent typing while making functions mockable) ## 🧠 Agent Learnings -- 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) +- 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) diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md new file mode 100644 index 00000000..9b4380a1 --- /dev/null +++ b/docs/managing-python-projects.md @@ -0,0 +1,387 @@ +# Making and Managing Python Projects + +This guide explains how to set up and manage Python projects in VS Code using the Python Environments extension. By the end, you'll understand what a "project" is, how to create and configure them, and how to assign the right environments and package managers to each one. + +## What is a Python Project? + +A **Python Project** is any file or folder that contains runnable Python code and needs its own environment. Think of it as a way to tell VS Code: "This folder (or file) is a distinct Python codebase that should use a specific Python interpreter and package manager." + +By default, every workspace folder you open in VS Code is automatically treated as a project. However, you can also: + +- Add subfolders as separate projects (useful for mono-repos) +- Add individual Python files as projects (great for standalone scripts) +- Create brand new projects from templates + +### Why use projects? + +Projects solve a common challenge: **different parts of your workspace need different Python environments**. + +| Scenario | Without Projects | With Projects | +|----------|-----------------|---------------| +| Mono-repo with multiple services | All services share one environment | Each service gets its own environment | +| Testing different Python versions | Manual interpreter switching | Assign Python 3.10 to one folder, 3.12 to another | +| Shared workspace with scripts and packages | Confusing environment management | Clear separation of concerns | + +## The Python Environments Panel + +The Python Environments extension adds a dedicated view to VS Code's Activity Bar. This panel has two main sections: + +1. **Python Projects**: Shows all projects in your workspace and their selected environments +2. **Environment Managers**: Shows available environment managers (venv, conda, etc.) with their environments + + + +## Adding Projects to Your Workspace + +There are several ways to add Python projects: + +### Method 1: Add existing files or folders + +Use this when you have existing Python code that should be treated as a separate project. + +1. Open the Python Environments panel in the Activity Bar. +2. In the **Python Projects** section, click the **+** button. +3. Select **Add Existing**. +4. Browse to and select the folder(s) or file(s) you want to add. +5. Select **Open** to add them as projects. + +Alternatively, right-click any folder or Python file in the Explorer and select **Add as Python Project**. + + + +### Method 2: Auto-find projects + +Use this to quickly discover all Python projects in your workspace based on common project markers. + +1. Open the Python Environments panel. +2. Click the **+** button in the Python Projects section. +3. Select **Auto Find**. +4. The extension searches for folders containing `pyproject.toml` or `setup.py` files. +5. Select which discovered projects to add from the list. + +> **Tip**: Auto-find is especially useful when you clone a mono-repo and want to quickly identify all its Python projects. + +### Method 3: Create a new project from a template + +Use this to scaffold a brand new Python project with the correct structure and files. + +1. Open the Command Palette (`Cmd+Shift+P` on macOS, `Ctrl+Shift+P` on Windows/Linux). +2. Run **Python Envs: Create New Project from Template**. +3. Choose a template type: + - **Package**: A structured Python package with `pyproject.toml`, tests folder, and package directory + - **Script**: A simple standalone Python file using PEP 723 inline metadata +4. Enter a name for your project. +5. Choose whether to create a virtual environment. + +The extension creates the project structure, adds it to your workspace, and optionally creates a virtual environment. + +#### Package template structure + +When you create a package named `my_package`, the extension generates: + +``` +my_package_project/ +├── pyproject.toml # Project metadata and dependencies +├── dev-requirements.txt # Development dependencies +├── my_package/ # Your package source code +│ └── __init__.py +└── tests/ # Test directory + └── __init__.py +``` + +#### Script template + +When you create a script, the extension generates a single `.py` file with PEP 723 inline script metadata, which allows you to specify dependencies directly in the file. + +## Assigning Environments to Projects + +Each project can have its own Python environment. This is the core benefit of project management. + +### Set an environment for a project + +1. In the **Python Projects** section, find your project. +2. Click on the environment path shown beneath the project name (or "No environment" if none is set). +3. Select an environment from the list of available environments. + +You can also: + +- Click the environment icon next to a project +- Right-click the project and select **Set Project Environment** + + + +### Use an environment from the Environment Managers section + +1. In the **Environment Managers** section, expand a manager (e.g., venv, conda). +2. Find the environment you want to use. +3. Right-click it and select **Set As Project Environment**. +4. Choose which project should use this environment. + +## Setting Environment and Package Managers + +Beyond selecting which Python interpreter a project uses, you can also specify which **environment manager** and **package manager** the project should use. This affects how environments are created and how packages are installed. + +### Environment managers + +Environment managers control how Python environments are created and discovered: + +| Manager | Description | +|---------|-------------| +| `venv` | Built-in Python virtual environments (default) | +| `conda` | Conda environments from Anaconda or Miniconda | +| `pyenv` | Multiple Python versions via pyenv | +| `poetry` | Poetry-managed environments | +| `pipenv` | Pipenv-managed environments | +| `system` | System-installed Python interpreters | + +### Package managers + +Package managers control how packages are installed in environments: + +| Manager | Description | +|---------|-------------| +| `pip` | Standard Python package installer (default) | +| `conda` | Conda package manager for conda environments | + +### Set managers for a project + +1. Right-click a project in the Python Projects section. +2. Select **Set Environment Manager** to change how environments are created. +3. Select **Set Package Manager** to change how packages are installed. + +> **Note**: The default package manager is typically determined by the environment manager. For example, venv environments use pip by default, while conda environments use the conda package manager. + +## Where Settings Are Stored + +The extension stores project configurations in your VS Code settings. Understanding this helps you manage settings across different scopes. + +### Settings location + +Project settings are stored in the `python-envs.pythonProjects` setting. Depending on your workspace setup: + +| Workspace Type | Settings Location | +|----------------|-------------------| +| Single folder | `.vscode/settings.json` in your workspace | +| Multi-root workspace | `.code-workspace` file | + +### Settings structure + +The `pythonProjects` setting is an array of project configurations: + +```json +{ + "python-envs.pythonProjects": [ + { + "path": "backend", + "envManager": "ms-python.python:venv", + "packageManager": "ms-python.python:pip" + }, + { + "path": "ml-service", + "envManager": "ms-python.python:conda", + "packageManager": "ms-python.python:conda" + } + ] +} +``` + +Each project entry contains: + +| Property | Description | +|----------|-------------| +| `path` | Relative path from workspace root to the project | +| `envManager` | ID of the environment manager (e.g., `ms-python.python:venv`) | +| `packageManager` | ID of the package manager (e.g., `ms-python.python:pip`) | +| `workspace` | (Multi-root only) Name of the workspace folder containing the project | + +### Default managers + +You can set default managers that apply to all projects without explicit overrides: + +```json +{ + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.defaultPackageManager": "ms-python.python:pip" +} +``` + +## Working with Multi-Root Workspaces + +Multi-root workspaces contain multiple top-level folders. The extension handles these seamlessly: + +1. Each workspace folder is automatically treated as a project. +2. You can add sub-projects within any workspace folder. +3. Project settings include a `workspace` property to identify which folder they belong to. +4. When creating a new project from a template, you're prompted to select which workspace folder to use. + +### Example: Multi-root mono-repo + +``` +my-workspace.code-workspace +├── frontend/ → Project with Python 3.12 +│ └── scripts/ → Sub-project with same environment +├── backend/ → Project with Python 3.10, venv +│ ├── api/ → Sub-project with its own venv +│ └── workers/ → Sub-project with its own venv +└── ml-pipeline/ → Project with conda environment +``` + +## Removing Projects + +To remove a project (this does not delete any files): + +1. Right-click the project in the Python Projects section. +2. Select **Remove Python Project**. + +The project is removed from the extension's tracking. Its files remain untouched, and you can always add it back later. + +## Quick Reference: Commands + +Access these via the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`): + +| Command | Description | +|---------|-------------| +| **Python Envs: Create New Project from Template** | Create a new package or script from a template | +| **Python Envs: Add Python Project** | Add existing files/folders as projects | +| **Python Envs: Set Project Environment** | Change the Python interpreter for a project | +| **Python Envs: Set Environment Manager** | Change how environments are created | +| **Python Envs: Set Package Manager** | Change how packages are installed | +| **Python Envs: Create Environment** | Create a new environment for a project | +| **Python Envs: Manage Packages** | Install or uninstall packages | + +## Quick Reference: Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `python-envs.defaultEnvManager` | `"ms-python.python:venv"` | Default environment manager for new projects | +| `python-envs.defaultPackageManager` | `"ms-python.python:pip"` | Default package manager for new projects | +| `python-envs.pythonProjects` | `[]` | List of project configurations | + +## Common Scenarios + +### Scenario 1: Mono-repo with microservices + +You have a repository with multiple Python services, each needing isolated dependencies. + +1. Open the root folder in VS Code. +2. Click **+** in Python Projects and select **Auto Find** to discover all services with `pyproject.toml`. +3. Select the services you want to track. +4. For each service, create a virtual environment by right-clicking and selecting **Create Environment**. +5. Each service now runs with its own isolated environment. + +### Scenario 2: Switching a project from venv to conda + +Your data science project needs packages that install more reliably with conda. + +1. Right-click the project in Python Projects. +2. Select **Set Environment Manager** → **conda**. +3. Select **Set Package Manager** → **conda**. +4. Right-click the project again and select **Create Environment** to create a new conda environment. + +### Scenario 3: Adding a standalone script + +You have a utility script that needs specific packages without affecting your main project. + +1. Right-click the `.py` file in Explorer. +2. Select **Add as Python Project**. +3. The script is now a separate project that can have its own environment. + +## Troubleshooting + +### Project not appearing in the panel + +- Verify the file or folder is inside your workspace +- Check if it was already added (duplicates are prevented) +- Try using **Add Existing** and manually selecting it + +### Environment changes not taking effect + +- Restart any open terminals to use the new environment +- Check that the environment actually exists and is valid +- Verify the `pythonProjects` setting in `.vscode/settings.json` + +### Settings not persisting + +- Ensure you have write access to the workspace folder +- Check if settings are being overridden at a higher scope (User vs. Workspace) +- For multi-root workspaces, verify the `.code-workspace` file is being saved + +## Related Resources + +- [Environment Management](../README.md#environment-management): Learn about creating and managing Python environments +- [Package Management](../README.md#package-management): Learn how to install and manage packages +- [Projects API Reference](projects-api-reference.md): Technical reference for extension authors + + + + +RawEventsVSCodeExt +| where EventName == "ms-python.vscode-python-envs/project_structure" +| extend ProjectCount = toint(Properties["totalprojectcount"]) +| extend Bucket = case( + ProjectCount == 0, "0", + ProjectCount == 1, "1", + ProjectCount == 2, "2", + ProjectCount == 3, "3", + ProjectCount >= 4, "4+", + "unknown") +| summarize Count = count() by Bucket +| order by Bucket asc + +0 4,231 +1 38,743 93.6% +2 1,163 2.8% +3 429 1% +4+ 1,051 2.5% + +1-4 == 41,386 + +RawEventsVSCodeExt +| where EventName == "ms-python.vscode-python-envs/project_structure" +| extend ProjectCount = toint(Properties["uniqueinterpretercount"]) +| extend Bucket = case( + ProjectCount == 0, "0", + ProjectCount == 1, "1", + ProjectCount == 2, "2", + ProjectCount == 3, "3", + ProjectCount >= 4, "4+", + "unknown") +| summarize Count = count() by Bucket +| order by Bucket asc + +0 4,934 10.9% +1 39,054 86.6% +2 820 1.8% +3 138 0.3% +4+ 160 0.35 + +0-4+ = 45,106 + + +RawEventsVSCodeExt +| where EventName == "ms-python.vscode-python-envs/project_structure" +| extend ProjectCount = toint(Properties["projectunderroot"]) +| extend Bucket = case( + ProjectCount == 0, "0", + ProjectCount == 1, "1", + ProjectCount == 2, "2", + ProjectCount == 3, "3", + ProjectCount >= 4, "4+", + "unknown") +| summarize Count = count() by Bucket +| order by Bucket asc + + +0 45,293 98.2% +1 445 1% +2 119 0.25% +3 29 +4+ 194 0.4% + +0-4+: 46,080 \ No newline at end of file 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 4f571c42..c873b73a 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -438,8 +438,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, '/'), @@ -490,6 +491,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 = 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 = 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 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..d6ad436f --- /dev/null +++ b/src/test/features/projectManager.fileEvents.unit.test.ts @@ -0,0 +1,418 @@ +/* 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'; + +/** + * 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('/workspace'); + 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('/workspace/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('/workspace/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('/workspace/old-name'); + const newUri = Uri.file('/workspace/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('/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('/workspace/untracked'); + const newUri = Uri.file('/workspace/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 workspaceUri = Uri.file('/workspace'); + 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('/workspace/old-project'); + const newUri = Uri.file('/workspace/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 workspaceUri = Uri.file('/workspace'); + 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('/workspace/non-existent'); + const newUri = Uri.file('/workspace/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 workspaceUri = Uri.file('/workspace'); + 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('/workspace/pyenv-project'); + const newUri = Uri.file('/workspace/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)', + ); + }); +}); From fb4fa596168e9d02372ea381ba05e9cfe05e899d Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:07:43 -0800 Subject: [PATCH 2/3] help for test runs --- src/features/settings/settingHelpers.ts | 38 ++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index c873b73a..d7775736 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 = @@ -192,7 +192,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) => { @@ -253,7 +253,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; @@ -285,7 +285,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) => { @@ -337,7 +337,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; @@ -374,7 +374,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) => { @@ -403,7 +403,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); @@ -420,11 +420,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) { @@ -473,7 +473,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); @@ -497,7 +497,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): * @param newUri The new URI of the project folder after rename/move */ export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): Promise { - const workspaceFolders = getWorkspaceFolders() ?? []; + const workspaceFolders = workspaceApis.getWorkspaceFolders() ?? []; // Find the workspace folder that contains the old path let targetWorkspace: WorkspaceFolder | undefined; @@ -514,7 +514,7 @@ export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): return; } - const config = getConfiguration('python-envs', targetWorkspace.uri); + const config = workspaceApis.getConfiguration('python-envs', targetWorkspace.uri); const overrides = config.get('pythonProjects', []); const oldNormalizedPath = path.normalize(oldUri.fsPath); @@ -536,7 +536,7 @@ export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): * @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; @@ -564,7 +564,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; @@ -590,7 +590,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; From f2b9ac561a08d1a3b1e7a05dd832badab493998c Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:35:16 -0800 Subject: [PATCH 3/3] make tests realistic for paths --- .../projectManager.fileEvents.unit.test.ts | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/test/features/projectManager.fileEvents.unit.test.ts b/src/test/features/projectManager.fileEvents.unit.test.ts index d6ad436f..84b1d2ba 100644 --- a/src/test/features/projectManager.fileEvents.unit.test.ts +++ b/src/test/features/projectManager.fileEvents.unit.test.ts @@ -8,6 +8,14 @@ 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). * @@ -28,7 +36,7 @@ suite('Project Manager File Event Handling', () => { let updatePythonProjectSettingPathStub: sinon.SinonStub; let clock: sinon.SinonFakeTimers; - const workspaceUri = Uri.file('/workspace'); + const workspaceUri = Uri.file(getTestWorkspacePath()); const workspaceFolder: WorkspaceFolder = { uri: workspaceUri, name: 'workspace', @@ -105,7 +113,7 @@ suite('Project Manager File Event Handling', () => { suite('handleDeletedFiles', () => { test('should remove project and update settings when project folder is deleted', async () => { - const projectUri = Uri.file('/workspace/my-project'); + const projectUri = Uri.file(`${getTestWorkspacePath()}/my-project`); const pm = new PythonProjectManagerImpl(); pm.initialize(); @@ -176,7 +184,7 @@ suite('Project Manager File Event Handling', () => { }); test('should not affect untracked folders', async () => { - const untrackedUri = Uri.file('/workspace/not-a-project'); + const untrackedUri = Uri.file(`${getTestWorkspacePath()}/not-a-project`); const pm = new PythonProjectManagerImpl(); pm.initialize(); @@ -198,8 +206,8 @@ suite('Project Manager File Event Handling', () => { suite('handleRenamedFiles', () => { test('should update project path in settings when project folder is renamed', async () => { - const oldUri = Uri.file('/workspace/old-name'); - const newUri = Uri.file('/workspace/new-name'); + const oldUri = Uri.file(`${getTestWorkspacePath()}/old-name`); + const newUri = Uri.file(`${getTestWorkspacePath()}/new-name`); const pm = new PythonProjectManagerImpl(); pm.initialize(); @@ -236,7 +244,7 @@ suite('Project Manager File Event Handling', () => { const pm = new PythonProjectManagerImpl(); pm.initialize(); - const newUri = Uri.file('/new-workspace'); + const newUri = Uri.file(process.platform === 'win32' ? 'C:\\new-workspace' : '/new-workspace'); // Fire rename event for workspace root renameFilesEmitter.fire({ files: [{ oldUri: workspaceUri, newUri }] }); @@ -254,8 +262,8 @@ suite('Project Manager File Event Handling', () => { }); test('should not affect untracked folder renames', async () => { - const oldUri = Uri.file('/workspace/untracked'); - const newUri = Uri.file('/workspace/untracked-renamed'); + const oldUri = Uri.file(`${getTestWorkspacePath()}/untracked`); + const newUri = Uri.file(`${getTestWorkspacePath()}/untracked-renamed`); const pm = new PythonProjectManagerImpl(); pm.initialize(); @@ -287,7 +295,8 @@ suite('updatePythonProjectSettingPath', () => { }); test('should update project path in pythonProjects setting', async () => { - const workspaceUri = Uri.file('/workspace'); + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); const workspaceFolder: WorkspaceFolder = { uri: workspaceUri, name: 'workspace', @@ -315,8 +324,8 @@ suite('updatePythonProjectSettingPath', () => { }; sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - const oldUri = Uri.file('/workspace/old-project'); - const newUri = Uri.file('/workspace/new-project'); + const oldUri = Uri.file(`${workspacePath}/old-project`); + const newUri = Uri.file(`${workspacePath}/new-project`); await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri); @@ -327,7 +336,8 @@ suite('updatePythonProjectSettingPath', () => { }); test('should not update if project not found in settings', async () => { - const workspaceUri = Uri.file('/workspace'); + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); const workspaceFolder: WorkspaceFolder = { uri: workspaceUri, name: 'workspace', @@ -355,8 +365,8 @@ suite('updatePythonProjectSettingPath', () => { }; sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - const oldUri = Uri.file('/workspace/non-existent'); - const newUri = Uri.file('/workspace/renamed'); + const oldUri = Uri.file(`${workspacePath}/non-existent`); + const newUri = Uri.file(`${workspacePath}/renamed`); await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri); @@ -364,7 +374,8 @@ suite('updatePythonProjectSettingPath', () => { }); test('should preserve envManager and packageManager when updating path', async () => { - const workspaceUri = Uri.file('/workspace'); + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); const workspaceFolder: WorkspaceFolder = { uri: workspaceUri, name: 'workspace', @@ -392,8 +403,8 @@ suite('updatePythonProjectSettingPath', () => { }; sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - const oldUri = Uri.file('/workspace/pyenv-project'); - const newUri = Uri.file('/workspace/pyenv-project-renamed'); + const oldUri = Uri.file(`${workspacePath}/pyenv-project`); + const newUri = Uri.file(`${workspacePath}/pyenv-project-renamed`); await settingHelpers.updatePythonProjectSettingPath(oldUri, newUri);