From ad3ae471dac01dd0b57ef72c66cc0484ad4055ef Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:13:15 -0800 Subject: [PATCH 01/25] Checkpoint from VS Code for cloud agent session --- docs/overlapping-projects-test-ownership.md | 312 ++++ docs/project-based-testing-design.md | 994 +++++++++++++ env-api.js | 14 + env-api.js.map | 1 + env-api.ts | 1265 +++++++++++++++++ .../testController/workspaceTestAdapter.ts | 2 +- 6 files changed, 2587 insertions(+), 1 deletion(-) create mode 100644 docs/overlapping-projects-test-ownership.md create mode 100644 docs/project-based-testing-design.md create mode 100644 env-api.js create mode 100644 env-api.js.map create mode 100644 env-api.ts diff --git a/docs/overlapping-projects-test-ownership.md b/docs/overlapping-projects-test-ownership.md new file mode 100644 index 000000000000..3a07668da687 --- /dev/null +++ b/docs/overlapping-projects-test-ownership.md @@ -0,0 +1,312 @@ +# Overlapping Projects and Test Ownership Resolution + +## Problem Statement + +When Python projects have nested directory structures, test discovery can result in the same test file being discovered by multiple projects. We need a deterministic way to assign each test to exactly one project. + +## Scenario Example + +### Project Structure + +``` +root/alice/ ← ProjectA root +├── .venv/ ← ProjectA's Python environment +│ └── bin/python +├── alice_test.py +│ ├── test: t1 +│ └── test: t2 +└── bob/ ← ProjectB root (nested) + ├── .venv/ ← ProjectB's Python environment + │ └── bin/python + └── bob_test.py + └── test: t1 +``` + +### Project Definitions + +| Project | URI | Python Executable | +|-----------|-------------------|--------------------------------------| +| ProjectA | `root/alice` | `root/alice/.venv/bin/python` | +| ProjectB | `root/alice/bob` | `root/alice/bob/.venv/bin/python` | + +### Discovery Results + +#### ProjectA Discovery (on `root/alice/`) + +Discovers 3 tests: +1. ✓ `root/alice/alice_test.py::t1` +2. ✓ `root/alice/alice_test.py::t2` +3. ✓ `root/alice/bob/bob_test.py::t1` ← **Found in subdirectory** + +#### ProjectB Discovery (on `root/alice/bob/`) + +Discovers 1 test: +1. ✓ `root/alice/bob/bob_test.py::t1` ← **Same test as ProjectA found!** + +### Conflict + +**Both ProjectA and ProjectB discovered:** `root/alice/bob/bob_test.py::t1` + +Which project should own this test in the Test Explorer? + +## Resolution Strategy + +### Using PythonProject API as Source of Truth + +The `vscode-python-environments` extension provides: +```typescript +interface PythonProject { + readonly name: string; + readonly uri: Uri; +} + +// Query which project owns a specific URI +getPythonProject(uri: Uri): Promise +``` + +### Resolution Process + +For the conflicting test `root/alice/bob/bob_test.py::t1`: + +```typescript +// Query: Which project owns this file? +const project = await getPythonProject(Uri.file("root/alice/bob/bob_test.py")); + +// Result: ProjectB (the most specific/nested project) +// project.uri = "root/alice/bob" +``` + +### Final Test Ownership + +| Test | Discovered By | Owned By | Reason | +|-----------------------------------|-------------------|------------|-------------------------------------------| +| `root/alice/alice_test.py::t1` | ProjectA | ProjectA | Only discovered by ProjectA | +| `root/alice/alice_test.py::t2` | ProjectA | ProjectA | Only discovered by ProjectA | +| `root/alice/bob/bob_test.py::t1` | ProjectA, ProjectB | **ProjectB** | API returns ProjectB for this URI | + +## Implementation Rules + +### 1. Discovery Runs Independently +Each project runs discovery using its own Python executable and configuration, discovering all tests it can find (including subdirectories). + +### 2. Detect Overlaps and Query API Only When Needed +After all projects complete discovery, detect which test files were found by multiple projects: +```typescript +// Build map of test file -> projects that discovered it +const testFileToProjects = new Map>(); +for (const project of allProjects) { + for (const testFile of project.discoveredTestFiles) { + if (!testFileToProjects.has(testFile.path)) { + testFileToProjects.set(testFile.path, new Set()); + } + testFileToProjects.get(testFile.path).add(project.id); + } +} + +// Query API only for overlapping tests or tests within nested projects +for (const [filePath, projectIds] of testFileToProjects) { + if (projectIds.size > 1) { + // Multiple projects found it - use API to resolve + const owner = await getPythonProject(Uri.file(filePath)); + assignToProject(owner.uri, filePath); + } else if (hasNestedProjectForPath(filePath, allProjects)) { + // Only one project found it, but nested project exists - verify with API + const owner = await getPythonProject(Uri.file(filePath)); + assignToProject(owner.uri, filePath); + } else { + // Unambiguous - assign to the only project that found it + assignToProject([...projectIds][0], filePath); + } +} +``` + +This optimization reduces API calls significantly since most projects don't have overlapping discovery. + +### 3. Filter Discovery Results +ProjectA's final tests: +```typescript +const projectATests = discoveredTests.filter(test => + getPythonProject(test.uri) === projectA +); +// Result: Only alice_test.py tests remain +``` + +ProjectB's final tests: +```typescript +const projectBTests = discoveredTests.filter(test => + getPythonProject(test.uri) === projectB +); +// Result: Only bob_test.py tests remain +``` + +### 4. Add to TestController +Each project only adds tests that the API says it owns: +```typescript +// ProjectA adds its filtered tests under ProjectA node +populateTestTree(testController, projectATests, projectANode, projectAResolver); + +// ProjectB adds its filtered tests under ProjectB node +populateTestTree(testController, projectBTests, projectBNode, projectBResolver); +``` + +## Test Explorer UI Result + +``` +📁 Workspace: root + 📦 Project: ProjectA (root/alice) + 📄 alice_test.py + ✓ t1 + ✓ t2 + 📦 Project: ProjectB (root/alice/bob) + 📄 bob_test.py + ✓ t1 +``` + +## Edge Cases + +### Case 1: No Project Found +```typescript +const project = await getPythonProject(testUri); +if (!project) { + // File is not part of any project + // Could belong to workspace-level tests (fallback) +} +``` + +### Case 2: Project Changed After Discovery +If a test file's project assignment changes (e.g., user creates new `pyproject.toml`), the next discovery cycle will re-assign ownership correctly. + +### Case 3: Deeply Nested Projects +``` +root/a/ ← ProjectA + root/a/b/ ← ProjectB + root/a/b/c/ ← ProjectC +``` + +API always returns the **most specific** (deepest) project for a given URI. + +## Algorithm Summary + +```typescript +async function assignTestsToProjects( + allProjects: ProjectAdapter[], + testController: TestController +): Promise { + for (const project of allProjects) { + // 1. Run discovery with project's Python executable + const discoveredTests = await project.discoverTests(); + + // 2. Filter to tests actually owned by this project + const ownedTests = []; + for (const test of discoveredTests) { + const owningProject = await getPythonProject(test.uri); + // 1. Run discovery for all projects + await Promise.all(allProjects.map(p => p.discoverTests())); + + // 2. Build overlap detection map + const testFileToProjects = new Map>(); + for (const project of allProjects) { + for (const testFile of project.discoveredTestFiles) { + if (!testFileToProjects.has(testFile.path)) { + testFileToProjects.set(testFile.path, new Set()); + } + testFileToProjects.get(testFile.path).add(project); + } + } + + // 3. Resolve ownership (query API only when needed) + const testFileToOwner = new Map(); + for (const [filePath, projects] of testFileToProjects) { + if (projects.size === 1) { + // No overlap - assign to only discoverer + const project = [...projects][0]; + // Still check if nested project exists for this path + if (!hasNestedProjectForPath(filePath, allProjects, project)) { + testFileToOwner.set(filePath, project); + continue; + } + } + + // Overlap or nested project exists - use API as source of truth + const owningProject = await getPythonProject(Uri.file(filePath)); + if (owningProject) { + const project = allProjects.find(p => p.projectUri.fsPath === owningProject.uri.fsPath); + if (project) { + testFileToOwner.set(filePath, project); + } + } + } + + // 4. Add tests to their owning project's tree + for (const [filePath, owningProject] of testFileToOwner) { + const tests = owningProject.discoveredTestFiles.get(filePath); + populateProjectTestTree(owningProject, tests); + } +} + +function hasNestedProjectForPath( + testFilePath: string, + allProjects: ProjectAdapter[], + excludeProject?: ProjectAdapter +): boolean { + return allProjects.some(p => + p !== excludeProject && + testFilePath.startsWith(p.projectUri.fsPath) + );project-based ownership, TestItem IDs must include project context: +```typescript +// Instead of: "/root/alice/bob/bob_test.py::t1" +// Use: "projectB::/root/alice/bob/bob_test.py::t1" +testItemId = `${projectId}::${testPath}`; +``` + +### Discovery Filtering in populateTestTree + +The `populateTestTree` function needs to be project-aware: +```typescript +export async function populateTestTree( + testController: TestController, + testTreeData: DiscoveredTestNode, + testRoot: TestItem | undefined, + resultResolver: ITestResultResolver, + projectId: string, + getPythonProject: (uri: Uri) => Promise, + token?: CancellationToken, +): Promise { + // For each discovered test, check ownership + for (const testNode of testTreeData.children) { + const testFileUri = Uri.file(testNode.path); + const owningProject = await getPythonProject(testFileUri); + + // Only add if this project owns the test + if (owningProject?.uri.fsPath === projectId.split('::')[0]) { + // Add test to tree + addTestItemToTree(testController, testNode, testRoot, projectId); + } + } +} +``` + +### ResultResolver Scoping + +Each project's ResultResolver maintains mappings only for tests it owns: +```typescript +class PythonResultResolver { + constructor( + testController: TestController, + testProvider: TestProvider, + workspaceUri: Uri, + projectId: string // Scopes all IDs to this project + ) { + this.projectId = projectId; + } + + // Maps include projectId prefix + runIdToTestItem: Map // "projectA::test.py::t1" -> TestItem + runIdToVSid: Map // "projectA::test.py::t1" -> vsCodeId + vsIdToRunId: Map // vsCodeId -> "projectA::test.py::t1" +} +``` + +--- + +**Key Takeaway**: Discovery finds tests broadly; the PythonProject API decides ownership narrowly. diff --git a/docs/project-based-testing-design.md b/docs/project-based-testing-design.md new file mode 100644 index 000000000000..3130b6a84977 --- /dev/null +++ b/docs/project-based-testing-design.md @@ -0,0 +1,994 @@ +# Project-Based Testing Architecture Design + +## Overview + +This document describes the architecture for supporting multiple Python projects within a single VS Code workspace, where each project has its own Python executable and test configuration. + +**Key Concepts:** +- **Project**: A combination of a Python executable + URI (folder/file) +- **Workspace**: Contains one or more projects +- **Test Ownership**: Determined by PythonProject API, not discovery results +- **ID Scoping**: All test IDs are project-scoped to prevent collisions + +--- + +## Architecture Diagram + +``` +VS Code Workspace + └─ PythonTestController (singleton) + ├─ TestController (VS Code API, shared) + ├─ workspaceProjects: Map> + ├─ vsIdToProject: Map (persistent) + └─ Workspace1 + ├─ ProjectA + │ ├─ pythonExecutable: /workspace1/backend/.venv/bin/python + │ ├─ projectUri: /workspace1/backend + │ ├─ discoveryAdapter + │ ├─ executionAdapter + │ └─ resultResolver + │ ├─ runIdToVSid: Map + │ ├─ vsIdToRunId: Map + │ └─ runIdToTestItem: Map + └─ ProjectB + ├─ pythonExecutable: /workspace1/frontend/.venv/bin/python + └─ ... (same structure) +``` + +--- + +## Core Objects + +### 1. PythonTestController (Extension Singleton) + +```typescript +class PythonTestController { + // VS Code shared test controller + testController: TestController + + // === PERSISTENT STATE === + // Workspace → Projects + workspaceProjects: Map> + + // Fast lookups for execution + vsIdToProject: Map + fileUriToProject: Map + projectToVsIds: Map> + + // === TEMPORARY STATE (DISCOVERY ONLY) === + workspaceDiscoveryState: Map + + // === METHODS === + activate() + refreshTestData(uri) + runTests(request, token) + discoverWorkspaceProjects(workspaceUri) +} +``` + +### 2. ProjectAdapter (Per Project) + +```typescript +interface ProjectAdapter { + // === IDENTITY === + projectId: string // Hash of PythonProject object + projectName: string // Display name + projectUri: Uri // Project root folder/file + workspaceUri: Uri // Parent workspace + + // === API OBJECTS (from vscode-python-environments extension) === + pythonProject: PythonProject // From pythonEnvApi.projects.getProjects() + pythonEnvironment: PythonEnvironment // From pythonEnvApi.resolveEnvironment() + // Note: pythonEnvironment.execInfo contains execution details + // pythonEnvironment.sysPrefix contains sys.prefix for the environment + + // === TEST INFRASTRUCTURE === + testProvider: TestProvider // 'pytest' | 'unittest' + discoveryAdapter: ITestDiscoveryAdapter + executionAdapter: ITestExecutionAdapter + resultResolver: PythonResultResolver + + // === DISCOVERY STATE === + rawDiscoveryData: DiscoveredTestPayload // Before filtering (ALL discovered tests) + ownedTests: DiscoveredTestNode // After filtering (API-confirmed owned tests) + // ownedTests is the filtered tree structure that will be passed to populateTestTree() + // It's the root node containing only this project's tests after overlap resolution + + // === LIFECYCLE === + isDiscovering: boolean + isExecuting: boolean + projectRootTestItem: TestItem +} +``` + +### 3. PythonResultResolver (Per Project) + +```typescript +class PythonResultResolver { + projectId: string + workspaceUri: Uri + testProvider: TestProvider + + // === TEST ID MAPPINGS (per-test entries) === + runIdToTestItem: Map + runIdToVSid: Map + vsIdToRunId: Map + + // === COVERAGE === + detailedCoverageMap: Map + + // === METHODS === + resolveDiscovery(payload, token) + resolveExecution(payload, runInstance) + cleanupStaleReferences() +} +``` + +### 4. WorkspaceDiscoveryState (Temporary) + +```typescript +interface WorkspaceDiscoveryState { + workspaceUri: Uri + + // Overlap detection + fileToProjects: Map> + + // API resolution results (maps to actual PythonProject from API) + fileOwnership: Map + // Value is the ProjectAdapter whose pythonProject.uri matches API response + // e.g., await pythonEnvApi.projects.getPythonProject(filePath) returns PythonProject, + // then we find the ProjectAdapter with matching pythonProject.uri + + // Progress tracking (NEW - not in current multi-workspace design) + projectsCompleted: Set + totalProjects: number + isComplete: boolean + // Advantage: Allows parallel discovery with proper completion tracking + // Current design discovers workspaces sequentially; this enables: + // 1. All projects discover in parallel + // 2. Overlap resolution waits for ALL projects to complete + // 3. Can show progress UI ("Discovering 3/5 projects...") +} +``` + +--- + +## ID System + +### ID Types + +| ID Type | Format | Scope | Purpose | Example | +|---------|--------|-------|---------|---------| +| **workspaceUri** | VS Code Uri | Global | Workspace identification | `Uri("/workspace1")` | +| **projectId** | Hash string | Unique per project | Project identification | `"project-abc123"` | +| **vsId** | `{projectId}::{path}::{testName}` | Global (unique) | VS Code TestItem.id | `"project-abc123::/ws/alice/test_alice.py::test_alice1"` | +| **runId** | Framework-specific | Per-project | Python subprocess | `"test_alice.py::test_alice1"` | + +**Workspace Tracking:** +- `workspaceProjects: Map>` - outer key is workspaceUri +- Each ProjectAdapter stores `workspaceUri` for reverse lookup +- TestItem.uri contains file path, workspace determined via `workspaceService.getWorkspaceFolder(uri)` + +### ID Conversion Flow + +``` +Discovery: runId (from Python) → create vsId → store in maps → create TestItem +Execution: TestItem.id (vsId) → lookup vsId → get runId → pass to Python +``` + +--- + +## State Management + +### Per-Workspace State + +```typescript +// Created during workspace activation +workspaceProjects: { + Uri("/workspace1"): { + "project-abc123": ProjectAdapter {...}, + "project-def456": ProjectAdapter {...} + } +} + +// Created during discovery, cleared after +workspaceDiscoveryState: { + Uri("/workspace1"): { + fileToProjects: Map {...}, + fileOwnership: Map {...} + } +} +``` + +### Per-Project State (Persistent) + +Using example structure: +``` + ← workspace root + ← ProjectA (project-alice) + + + + ← ProjectB (project-bob, nested) + + +``` + +```typescript +// ProjectA (alice) +ProjectAdapter { + projectId: "project-alice", + projectUri: Uri("/workspace/tests-plus-projects/alice"), + pythonEnvironment: { execInfo: { run: { executable: "/alice/.venv/bin/python" }}}, + resultResolver: { + runIdToVSid: { + "test_alice.py::test_alice1": "project-alice::/workspace/alice/test_alice.py::test_alice1", + "test_alice.py::test_alice2": "project-alice::/workspace/alice/test_alice.py::test_alice2" + } + } +} + +// ProjectB (bob) - nested project +ProjectAdapter { + projectId: "project-bob", + projectUri: Uri("/workspace/tests-plus-projects/alice/bob"), + pythonEnvironment: { execInfo: { run: { executable: "/alice/bob/.venv/bin/python" }}}, + resultResolver: { + runIdToVSid: { + "test_bob.py::test_bob1": "project-bob::/workspace/alice/bob/test_bob.py::test_bob1", + "test_bob.py::test_bob2": "project-bob::/workspace/alice/bob/test_bob.py::test_bob2" + } + } +} +``` + +### Per-Test State + +```typescript +// ProjectA's resolver - only alice tests +runIdToTestItem["test_alice.py::test_alice1"] → TestItem +runIdToVSid["test_alice.py::test_alice1"] → "project-alice::/workspace/alice/test_alice.py::test_alice1" +vsIdToRunId["project-alice::/workspace/alice/test_alice.py::test_alice1"] → "test_alice.py::test_alice1" + +// ProjectB's resolver - only bob tests +runIdToTestItem["test_bob.py::test_bob1"] → TestItem +runIdToVSid["test_bob.py::test_bob1"] → "project-bob::/workspace/alice/bob/test_bob.py::test_bob1" +vsIdToRunId["project-bob::/workspace/alice/bob/test_bob.py::test_bob1"] → "test_bob.py::test_bob1" +``` + +--- + +## Discovery Flow + +### Phase 1: Discover Projects + +```typescript +async function activate() { + for workspace in workspaceService.workspaceFolders { + projects = await discoverWorkspaceProjects(workspace.uri) + + for project in projects { + projectAdapter = createProjectAdapter(project) + workspaceProjects[workspace.uri][project.id] = projectAdapter + } + } +} + +async function discoverWorkspaceProjects(workspaceUri) { + // Use PythonEnvironmentApi to get all projects in workspace + pythonProjects = await pythonEnvApi.projects.getProjects(workspaceUri) + + return Promise.all(pythonProjects.map(async (pythonProject) => { + // Resolve full environment details + pythonEnv = await pythonEnvApi.resolveEnvironment(pythonProject.uri) + + return { + projectId: hash(pythonProject), // Hash the entire PythonProject object + projectName: pythonProject.name, + projectUri: pythonProject.uri, + pythonProject: pythonProject, // Store API object + pythonEnvironment: pythonEnv, // Store resolved environment + workspaceUri: workspaceUri + } + })) +} +``` + +### Phase 2: Run Discovery Per Project + +```typescript +async function refreshTestData(uri) { + workspace = getWorkspaceFolder(uri) + projects = workspaceProjects[workspace.uri].values() + + // Initialize discovery state + discoveryState = new WorkspaceDiscoveryState() + workspaceDiscoveryState[workspace.uri] = discoveryState + + // Run discovery for all projects in parallel + await Promise.all( + projects.map(p => discoverProject(p, discoveryState)) + ) + + // Resolve overlaps and assign tests + await resolveOverlapsAndAssignTests(workspace.uri) + + // Clear temporary state + workspaceDiscoveryState.delete(workspace.uri) + // Removes WorkspaceDiscoveryState for this workspace, which includes: + // - fileToProjects map (no longer needed after ownership determined) + // - fileOwnership map (results already used to filter ownedTests) + // - projectsCompleted tracking (discovery finished) + // This reduces memory footprint; persistent mappings (vsIdToProject, etc.) remain +} +``` + +### Phase 3: Detect Overlaps + +```typescript +async function discoverProject(project, discoveryState) { + // Run Python discovery subprocess + rawData = await project.discoveryAdapter.discoverTests( + project.projectUri, + executionFactory, + token, + project.pythonExecutable + ) + + project.rawDiscoveryData = rawData + + // Track which projects discovered which files + for testFile in rawData.testFiles { + if (!discoveryState.fileToProjects.has(testFile.path)) { + discoveryState.fileToProjects[testFile.path] = new Set() + } + discoveryState.fileToProjects[testFile.path].add(project) + } +} +``` + +### Phase 4: Resolve Ownership + +**Time Complexity:** O(F × P) where F = files discovered, P = projects per workspace +**Optimized to:** O(F_overlap × API_cost) where F_overlap = overlapping files only + +```typescript +async function resolveOverlapsAndAssignTests(workspaceUri) { + discoveryState = workspaceDiscoveryState[workspaceUri] + projects = workspaceProjects[workspaceUri].values() + + // Query API only for overlaps or nested projects + for [filePath, projectSet] in discoveryState.fileToProjects { + if (projectSet.size > 1) { + // OVERLAP - query API + apiProject = await pythonEnvApi.projects.getPythonProject(filePath) + discoveryState.fileOwnership[filePath] = findProject(apiProject.uri) + } + else if (hasNestedProjectForPath(filePath, projects)) { + // Nested project exists - verify with API + apiProject = await pythonEnvApi.projects.getPythonProject(filePath) + discoveryState.fileOwnership[filePath] = findProject(apiProject.uri) + } + else { + // No overlap - assign to only discoverer + discoveryState.fileOwnership[filePath] = [...projectSet][0] + } + } + + // Filter each project's raw data to only owned tests + for project in projects { + project.ownedTests = project.rawDiscoveryData.tests.filter(test => + discoveryState.fileOwnership[test.filePath] === project + ) + + // Create TestItems and build mappings + await finalizeProjectDiscovery(project) + } +} +``` +// NOTE: can you add in the time complexity for this larger functions + +### Phase 5: Create TestItems and Mappings + +**Time Complexity:** O(T) where T = tests owned by project + +```typescript +async function finalizeProjectDiscovery(project) { + // Pass filtered data to resolver + project.resultResolver.resolveDiscovery(project.ownedTests, token) + + // Create TestItems in TestController + testItems = await populateTestTree( + testController, + project.ownedTests, + project.projectRootTestItem, + project.resultResolver, + project.projectId + ) + + // Build persistent mappings + for testItem in testItems { + vsId = testItem.id + + // Global mappings for execution + vsIdToProject[vsId] = project + fileUriToProject[testItem.uri.fsPath] = project + + if (!projectToVsIds.has(project.projectId)) { + projectToVsIds[project.projectId] = new Set() + } + projectToVsIds[project.projectId].add(vsId) + } +} +``` + +--- + +## Execution Flow + +### Phase 1: Group Tests by Project + +**Time Complexity:** O(T) where T = tests in run request + +**Note:** Similar to existing `getTestItemsForWorkspace()` in controller.ts but groups by project instead of workspace + +```typescript +async function runTests(request: TestRunRequest, token) { + testItems = request.include || getAllTestItems() + + // Group by project using persistent mapping (similar pattern to getTestItemsForWorkspace) + testsByProject = new Map() + + for testItem in testItems { + vsId = testItem.id + project = vsIdToProject[vsId] // O(1) lookup + + if (!testsByProject.has(project)) { + testsByProject[project] = [] + } + testsByProject[project].push(testItem) + } + + // Execute each project + runInstance = testController.createTestRun(request, ...) + + await Promise.all( + [...testsByProject].map(([project, tests]) => + runTestsForProject(project, tests, runInstance, token) + ) + ) + + runInstance.end() +} +``` +// NOTE: there is already an existing function that does this but instead for workspaces for multiroot ones, see getTestItemsForWorkspace in controller.ts + +### Phase 2: Convert vsId → runId + +**Time Complexity:** O(T_project) where T_project = tests for this specific project + +```typescript +async function runTestsForProject(project, testItems, runInstance, token) { + runIds = [] + + for testItem in testItems { + vsId = testItem.id + + // Use project's resolver to get runId + runId = project.resultResolver.vsIdToRunId[vsId] + if (runId) { + runIds.push(runId) + runInstance.started(testItem) + } + } + + // Execute with project's Python executable + await project.executionAdapter.runTests( + project.projectUri, + runIds, // Pass to Python subprocess + runInstance, + executionFactory, + token, + project.pythonExecutable + ) +} +``` + +### Phase 3: Report Results + +```typescript +// Python subprocess sends results back with runIds +async function handleTestResult(payload, runInstance, project) { + // Resolver converts runId → TestItem + testItem = project.resultResolver.runIdToTestItem[payload.testId] + + if (payload.outcome === "passed") { + runInstance.passed(testItem) + } else if (payload.outcome === "failed") { + runInstance.failed(testItem, message) + } +} +``` + +--- + +## Key Algorithms + +### Overlap Detection + +```typescript +function hasNestedProjectForPath(testFilePath, allProjects, excludeProject) { + return allProjects.some(p => + p !== excludeProject && + testFilePath.startsWith(p.projectUri.fsPath) + ) +} +``` + +### Project Cleanup/Refresh + +```typescript +async function refreshProject(project) { + // 1. Get all vsIds for this project + vsIds = projectToVsIds[project.projectId] || new Set() + + // 2. Remove old mappings + for vsId in vsIds { + vsIdToProject.delete(vsId) + + testItem = project.resultResolver.runIdToTestItem[vsId] + if (testItem) { + fileUriToProject.delete(testItem.uri.fsPath) + } + } + projectToVsIds.delete(project.projectId) + + // 3. Clear project's resolver + project.resultResolver.testItemIndex.clear() + + // 4. Clear TestItems from TestController + if (project.projectRootTestItem) { + childIds = [...project.projectRootTestItem.children].map(c => c.id) + for id in childIds { + project.projectRootTestItem.children.delete(id) + } + } + + // 5. Re-run discovery + await discoverProject(project, ...) + await finalizeProjectDiscovery(project) +} +``` + +### File Change Handling + +```typescript +function onDidSaveTextDocument(doc) { + fileUri = doc.uri.fsPath + + // Find owning project + project = fileUriToProject[fileUri] + + if (project) { + // Refresh only this project + refreshProject(project) + } +} +``` + +--- + +## Critical Design Decisions + +### 1. Project-Scoped vsIds +**Decision**: Include projectId in every vsId +**Rationale**: Prevents collisions, enables fast project lookup, clear ownership + +### 2. One Resolver Per Project +**Decision**: Each project has its own ResultResolver +**Rationale**: Clean isolation, no cross-project contamination, independent lifecycles + +### 3. Overlap Resolution Before Mapping +**Decision**: Filter tests before resolver processes them +**Rationale**: Resolvers only see owned tests, no orphaned mappings, simpler state + +### 4. Persistent Execution Mappings +**Decision**: Maintain vsIdToProject map permanently +**Rationale**: Fast execution grouping, avoid vsId parsing, support file watches + +### 5. Temporary Discovery State +**Decision**: Build fileToProjects during discovery, clear after +**Rationale**: Only needed for overlap detection, reduce memory footprint + +--- + +## Migration from Current Architecture + +### Current (Workspace-Level) +``` +Workspace → WorkspaceTestAdapter → ResultResolver → Tests +``` + +### New (Project-Level) +``` +Workspace → [ProjectAdapter₁, ProjectAdapter₂, ...] → ResultResolver → Tests + ↓ ↓ + pythonExec₁ pythonExec₂ +``` + +### Backward Compatibility +- Workspaces without multiple projects: Single ProjectAdapter created automatically +- Existing tests: Assigned to default project based on workspace interpreter +- Settings: Read per-project from pythonProject.uri + +--- + +## Open Questions / Future Considerations + +1. **Project Discovery**: How often to re-scan for new projects? - don't rescan until discovery is re-triggered. +2. **Project Changes**: Handle pyproject.toml changes triggering project re-initialization - no this will be handled by the api and done later +3. **UI**: Show project name in test tree? Collapsible project nodes? - show project notes +4. **Performance**: Cache API queries for file ownership? - not right now +5. **Multi-root Workspaces**: Each workspace root as separate entity? - yes as you see it right now + +--- + +## Summary + +This architecture enables multiple Python projects per workspace by: +1. Creating a ProjectAdapter for each Python executable + URI combination +2. Running independent test discovery per project +3. Using PythonProject API to resolve overlapping test ownership +4. Maintaining project-scoped ID mappings for clean separation +5. Grouping tests by project during execution +6. Preserving current test adapter patterns at project level + +**Key Principle**: Each project is an isolated testing context with its own Python environment, discovery, execution, and result tracking. + +--- + +## Implementation Details & Decisions + +### 1. TestItem Hierarchy + +Following VS Code TestController API, projects are top-level items: + +```typescript +// TestController.items structure +testController.items = [ + ProjectA_RootItem { + id: "project-alice::/workspace/alice", + label: "alice (Python 3.11)", + children: [test files...] + }, + ProjectB_RootItem { + id: "project-bob::/workspace/alice/bob", + label: "bob (Python 3.9)", + children: [test files...] + } +] +``` + +**Creation timing:** `projectRootTestItem` created during `createProjectAdapter()` in activate phase, before discovery runs. + +--- + +### 2. Error Handling Strategy + +**Principle:** Simple and transparent - show errors to users, iterate based on feedback. + +| Failure Scenario | Behavior | +|------------------|----------| +| API `getPythonProject()` fails/timeout | Assign to discovering project (first in set), log warning | +| Project discovery fails | Call `traceError()` with details, show error node in test tree | +| ALL projects fail | Show error nodes for each, user sees all failures | +| API returns `undefined` | Assign to discovering project, log warning | +| No projects found | Create single default project using workspace interpreter | + +```typescript +try { + apiProject = await pythonEnvApi.projects.getPythonProject(filePath) +} catch (error) { + traceError(`Failed to resolve ownership for ${filePath}: ${error}`) + // Fallback: assign to first discovering project + discoveryState.fileOwnership[filePath] = [...projectSet][0] +} +``` + +--- + +### 3. Settings & Configuration + +**Decision:** Settings are per-workspace, shared by all projects in that workspace. + +```typescript +// All projects in workspace1 use same settings +const settings = this.configSettings.getSettings(workspace.uri) + +projectA.testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest' +projectB.testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest' +``` + +**Limitations:** +- Cannot have pytest project and unittest project in same workspace +- All projects share `pytestArgs`, `cwd`, etc. +- Future: Per-project settings via API + +**pytest.ini discovery:** Each project's Python subprocess discovers its own pytest.ini when running from `project.projectUri` + +--- + +### 4. Backwards Compatibility + +**Decision:** Graceful degradation if python-environments extension not available. + +```typescript +async function discoverWorkspaceProjects(workspaceUri) { + try { + pythonProjects = await pythonEnvApi.projects.getProjects(workspaceUri) + + if (pythonProjects.length === 0) { + // Fallback: create single default project + return [createDefaultProject(workspaceUri)] + } + + return pythonProjects.map(...) + } catch (error) { + traceError('Python environments API not available, using single project mode') + // Fallback: single project with workspace interpreter + return [createDefaultProject(workspaceUri)] + } +} + +function createDefaultProject(workspaceUri) { + const interpreter = await interpreterService.getActiveInterpreter(workspaceUri) + return { + projectId: hash(workspaceUri), + projectUri: workspaceUri, + pythonEnvironment: { execInfo: { run: { executable: interpreter.path }}}, + // ... rest matches current workspace behavior + } +} +``` + +--- + +### 5. Project Discovery Triggers + +**Decision:** Triggered on file save (inefficient but follows current pattern). + +```typescript +// CURRENT BEHAVIOR: Triggers on any test file save +watchForTestContentChangeOnSave() { + onDidSaveTextDocument(async (doc) => { + if (matchesTestPattern(doc.uri)) { + // NOTE: This is inefficient - re-discovers ALL projects in workspace + // even though only one file changed. Future optimization: only refresh + // affected project using fileUriToProject mapping + await refreshTestData(doc.uri) + } + }) +} + +// FUTURE OPTIMIZATION (commented out for now): +// watchForTestContentChangeOnSave() { +// onDidSaveTextDocument(async (doc) => { +// project = fileUriToProject.get(doc.uri.fsPath) +// if (project) { +// await refreshProject(project) // Only refresh one project +// } +// }) +// } +``` + +**Trigger points:** +1. ✅ `activate()` - discovers all projects on startup +2. ✅ File save matching test pattern - full workspace refresh +3. ✅ Settings file change - full workspace refresh +4. ❌ `onDidChangeProjects` event - not implemented yet (future) + +--- + +### 6. Cancellation & Timeouts + +**Decision:** Single cancellation token cancels all project discoveries/executions (kill switch). + +```typescript +// Discovery cancellation +async function refreshTestData(uri) { + // One cancellation token for ALL projects in workspace + const token = this.refreshCancellation.token + + await Promise.all( + projects.map(p => discoverProject(p, discoveryState, token)) + ) + // If token.isCancellationRequested, ALL projects stop +} + +// Execution cancellation +async function runTests(request, token) { + // If token cancelled, ALL project executions stop + await Promise.all( + [...testsByProject].map(([project, tests]) => + runTestsForProject(project, tests, runInstance, token) + ) + ) +} +``` + +**No per-project timeouts** - keep simple, complexity added later if needed. + +--- + +### 7. Path Normalization + +**Decision:** Absolute paths used everywhere, no relative path handling. + +```typescript +// Python subprocess returns absolute paths +rawData = { + tests: [{ + path: "/workspace/alice/test_alice.py", // ← absolute + id: "test_alice.py::test_alice1" + }] +} + +// vsId constructed with absolute path +vsId = `${projectId}::/workspace/alice/test_alice.py::test_alice1` + +// TestItem.uri is absolute +testItem.uri = Uri.file("/workspace/alice/test_alice.py") +``` + +**Path conversion responsibility:** Python adapters (pytest/unittest) ensure paths are absolute before returning to controller. + +--- + +### 8. Resolver Initialization + +**Decision:** Resolver created with ProjectAdapter, empty until discovery populates it. + +```typescript +function createProjectAdapter(pythonProject) { + const resultResolver = new PythonResultResolver( + this.testController, + testProvider, + pythonProject.uri, + projectId // Pass project ID for scoping + ) + + return { + projectId, + resultResolver, // ← Empty maps, will be filled during discovery + // ... + } +} + +// During discovery, resolver is populated +await project.resultResolver.resolveDiscovery(project.ownedTests, token) +``` + +--- + +### 9. Debug Integration + +**Decision:** Debug launcher is project-aware, uses project's Python executable. + +```typescript +async function executeTestsForProvider(project, testItems, ...) { + await project.executionAdapter.runTests( + project.projectUri, + runIds, + runInstance, + this.pythonExecFactory, + token, + request.profile?.kind, + this.debugLauncher, // ← Launcher handles project executable + project.pythonEnvironment // ← Pass project's Python, not workspace + ) +} + +// In executionAdapter +async function runTests(..., debugLauncher, pythonEnvironment) { + if (isDebugging) { + await debugLauncher.launchDebugger({ + testIds: runIds, + interpreter: pythonEnvironment.execInfo.run.executable // ← Project-specific + }) + } +} +``` + +--- + +### 10. State Persistence + +**Decision:** No persistence - everything rebuilds on VS Code reload. + +- ✅ Rebuild `workspaceProjects` map during `activate()` +- ✅ Rebuild `vsIdToProject` map during discovery +- ✅ Rebuild TestItems during discovery +- ✅ Clear `rawDiscoveryData` after filtering (not persisted) + +**Rationale:** Simpler, avoids stale state issues. Performance acceptable for typical workspaces (<100ms per project). + +--- + +### 11. File Watching + +**Decision:** Watchers are per-workspace (shared by all projects). + +```typescript +// Single watcher for workspace, all projects react +watchForSettingsChanges(workspace) { + pattern = new RelativePattern(workspace, "**/{settings.json,pytest.ini,...}") + watcher = this.workspaceService.createFileSystemWatcher(pattern) + + watcher.onDidChange((uri) => { + // NOTE: Inefficient - refreshes ALL projects in workspace + // even if only one project's pytest.ini changed + this.refreshTestData(uri) + }) +} +``` + +**Not per-project** because settings are per-workspace (see #3). + +--- + +### 12. Empty/Loading States + +**Decision:** Match current behavior - blank test explorer, then populate. + +- Before first discovery: Empty test explorer (no items) +- During discovery: No loading indicators (happens fast enough) +- After discovery failure: Error nodes shown in tree + +**No special UI** for loading states in initial implementation. + +--- + +### 13. Coverage Integration + +**Decision:** Push to future implementation - out of scope for initial release. + +Coverage display questions deferred: +- Merging coverage from multiple projects +- Per-project coverage percentages +- Overlapping file coverage + +Current `detailedCoverageMap` remains per-project; UI integration TBD. + +--- + +## Implementation Notes + +### Dynamic Adapter Management + +**Current Issue:** testAdapters are created only during `activate()` and require extension reload to change. + +**Required Changes:** +1. **Add Project Detection Service:** Listen to `pythonEnvApi.projects.onDidChangeProjects` event +2. **Dynamic Creation:** Create ProjectAdapter on-demand when new PythonProject detected +3. **Dynamic Removal:** Clean up ProjectAdapter when PythonProject removed: + ```typescript + async function removeProject(project: ProjectAdapter) { + // 1. Remove from workspaceProjects map + // 2. Clear all vsIdToProject entries + // 3. Remove TestItems from TestController + // 4. Dispose adapters and resolver + } + ``` +4. **Hot Reload:** Trigger discovery for new projects without full extension restart + +### Unittest Support + +**Current Scope:** Focus on pytest-based projects initially. + +**Future Work:** Unittest will use same ProjectAdapter pattern but: +- Different `discoveryAdapter` (UnittestTestDiscoveryAdapter) +- Different `executionAdapter` (UnittestTestExecutionAdapter) +- Same ownership resolution and ID mapping patterns +- Already supported in current architecture via `testProvider` field + +**Not in Scope:** Mixed pytest/unittest within same project (projects are single-framework) diff --git a/env-api.js b/env-api.js new file mode 100644 index 000000000000..1ba5a52dd449 --- /dev/null +++ b/env-api.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PackageChangeKind = exports.EnvironmentChangeKind = void 0; +var EnvironmentChangeKind; +(function (EnvironmentChangeKind) { + EnvironmentChangeKind["add"] = "add"; + EnvironmentChangeKind["remove"] = "remove"; +})(EnvironmentChangeKind || (exports.EnvironmentChangeKind = EnvironmentChangeKind = {})); +var PackageChangeKind; +(function (PackageChangeKind) { + PackageChangeKind["add"] = "add"; + PackageChangeKind["remove"] = "remove"; +})(PackageChangeKind || (exports.PackageChangeKind = PackageChangeKind = {})); +//# sourceMappingURL=env-api.js.map \ No newline at end of file diff --git a/env-api.js.map b/env-api.js.map new file mode 100644 index 000000000000..f67ee2559f8a --- /dev/null +++ b/env-api.js.map @@ -0,0 +1 @@ +{"version":3,"file":"env-api.js","sourceRoot":"","sources":["env-api.ts"],"names":[],"mappings":";;;AA8RA,IAAY,qBAUX;AAVD,WAAY,qBAAqB;IAI7B,oCAAW,CAAA;IAKX,0CAAiB,CAAA;AACrB,CAAC,EAVW,qBAAqB,qCAArB,qBAAqB,QAUhC;AAyOD,IAAY,iBAUX;AAVD,WAAY,iBAAiB;IAIzB,gCAAW,CAAA;IAKX,sCAAiB,CAAA;AACrB,CAAC,EAVW,iBAAiB,iCAAjB,iBAAiB,QAU5B"} \ No newline at end of file diff --git a/env-api.ts b/env-api.ts new file mode 100644 index 000000000000..0b60339b6bd2 --- /dev/null +++ b/env-api.ts @@ -0,0 +1,1265 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + Disposable, + Event, + FileChangeType, + LogOutputChannel, + MarkdownString, + TaskExecution, + Terminal, + TerminalOptions, + ThemeIcon, + Uri, +} from 'vscode'; + +/** + * The path to an icon, or a theme-specific configuration of icons. + */ +export type IconPath = + | Uri + | { + /** + * The icon path for the light theme. + */ + light: Uri; + /** + * The icon path for the dark theme. + */ + dark: Uri; + } + | ThemeIcon; + +/** + * Options for executing a Python executable. + */ +export interface PythonCommandRunConfiguration { + /** + * Path to the binary like `python.exe` or `python3` to execute. This should be an absolute path + * to an executable that can be spawned. + */ + executable: string; + + /** + * Arguments to pass to the python executable. These arguments will be passed on all execute calls. + * This is intended for cases where you might want to do interpreter specific flags. + */ + args?: string[]; +} + +/** + * Contains details on how to use a particular python environment + * + * Running In Terminal: + * 1. If {@link PythonEnvironmentExecutionInfo.activatedRun} is provided, then that will be used. + * 2. If {@link PythonEnvironmentExecutionInfo.activatedRun} is not provided, then: + * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is known, then that will be used. + * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is not known, then: + * - 'unknown' will be used if provided. + * - {@link PythonEnvironmentExecutionInfo.activation} will be used otherwise. + * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is not provided, then {@link PythonEnvironmentExecutionInfo.activation} will be used. + * - If {@link PythonEnvironmentExecutionInfo.activation} is not provided, then {@link PythonEnvironmentExecutionInfo.run} will be used. + * + * Creating a Terminal: + * 1. If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is known, then that will be used. + * 2. If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is not known, then {@link PythonEnvironmentExecutionInfo.activation} will be used. + * 3. If {@link PythonEnvironmentExecutionInfo.shellActivation} is not provided, then: + * - 'unknown' will be used if provided. + * - {@link PythonEnvironmentExecutionInfo.activation} will be used otherwise. + * 4. If {@link PythonEnvironmentExecutionInfo.activation} is not provided, then {@link PythonEnvironmentExecutionInfo.run} will be used. + * + */ +export interface PythonEnvironmentExecutionInfo { + /** + * Details on how to run the python executable. + */ + run: PythonCommandRunConfiguration; + + /** + * Details on how to run the python executable after activating the environment. + * If set this will overrides the {@link PythonEnvironmentExecutionInfo.run} command. + */ + activatedRun?: PythonCommandRunConfiguration; + + /** + * Details on how to activate an environment. + */ + activation?: PythonCommandRunConfiguration[]; + + /** + * Details on how to activate an environment using a shell specific command. + * If set this will override the {@link PythonEnvironmentExecutionInfo.activation}. + * 'unknown' is used if shell type is not known. + * If 'unknown' is not provided and shell type is not known then + * {@link PythonEnvironmentExecutionInfo.activation} if set. + */ + shellActivation?: Map; + + /** + * Details on how to deactivate an environment. + */ + deactivation?: PythonCommandRunConfiguration[]; + + /** + * Details on how to deactivate an environment using a shell specific command. + * If set this will override the {@link PythonEnvironmentExecutionInfo.deactivation} property. + * 'unknown' is used if shell type is not known. + * If 'unknown' is not provided and shell type is not known then + * {@link PythonEnvironmentExecutionInfo.deactivation} if set. + */ + shellDeactivation?: Map; +} + +/** + * Interface representing the ID of a Python environment. + */ +export interface PythonEnvironmentId { + /** + * The unique identifier of the Python environment. + */ + id: string; + + /** + * The ID of the manager responsible for the Python environment. + */ + managerId: string; +} + +/** + * Display information for an environment group. + */ +export interface EnvironmentGroupInfo { + /** + * The name of the environment group. This is used as an identifier for the group. + * + * Note: The first instance of the group with the given name will be used in the UI. + */ + readonly name: string; + + /** + * The description of the environment group. + */ + readonly description?: string; + + /** + * The tooltip for the environment group, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString; + + /** + * The icon path for the environment group, which can be a string, Uri, or an object with light and dark theme paths. + */ + readonly iconPath?: IconPath; +} + +/** + * Interface representing information about a Python environment. + */ +export interface PythonEnvironmentInfo { + /** + * The name of the Python environment. + */ + readonly name: string; + + /** + * The display name of the Python environment. + */ + readonly displayName: string; + + /** + * The short display name of the Python environment. + */ + readonly shortDisplayName?: string; + + /** + * The display path of the Python environment. + */ + readonly displayPath: string; + + /** + * The version of the Python environment. + */ + readonly version: string; + + /** + * Path to the python binary or environment folder. + */ + readonly environmentPath: Uri; + + /** + * The description of the Python environment. + */ + readonly description?: string; + + /** + * The tooltip for the Python environment, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString; + + /** + * The icon path for the Python environment, which can be a string, Uri, or an object with light and dark theme paths. + */ + readonly iconPath?: IconPath; + + /** + * Information on how to execute the Python environment. This is required for executing Python code in the environment. + */ + readonly execInfo: PythonEnvironmentExecutionInfo; + + /** + * `sys.prefix` is the path to the base directory of the Python installation. Typically obtained by executing `sys.prefix` in the Python interpreter. + * This is required by extension like Jupyter, Pylance, and other extensions to provide better experience with python. + */ + readonly sysPrefix: string; + + /** + * Optional `group` for this environment. This is used to group environments in the Environment Manager UI. + */ + readonly group?: string | EnvironmentGroupInfo; +} + +/** + * Interface representing a Python environment. + */ +export interface PythonEnvironment extends PythonEnvironmentInfo { + /** + * The ID of the Python environment. + */ + readonly envId: PythonEnvironmentId; +} + +/** + * Type representing the scope for setting a Python environment. + * Can be undefined or a URI. + */ +export type SetEnvironmentScope = undefined | Uri | Uri[]; + +/** + * Type representing the scope for getting a Python environment. + * Can be undefined or a URI. + */ +export type GetEnvironmentScope = undefined | Uri; + +/** + * Type representing the scope for creating a Python environment. + * Can be a Python project or 'global'. + */ +export type CreateEnvironmentScope = Uri | Uri[] | 'global'; +/** + * The scope for which environments are to be refreshed. + * - `undefined`: Search for environments globally and workspaces. + * - {@link Uri}: Environments in the workspace/folder or associated with the Uri. + */ +export type RefreshEnvironmentsScope = Uri | undefined; + +/** + * The scope for which environments are required. + * - `"all"`: All environments. + * - `"global"`: Python installations that are usually a base for creating virtual environments. + * - {@link Uri}: Environments for the workspace/folder/file pointed to by the Uri. + */ +export type GetEnvironmentsScope = Uri | 'all' | 'global'; + +/** + * Event arguments for when the current Python environment changes. + */ +export type DidChangeEnvironmentEventArgs = { + /** + * The URI of the environment that changed. + */ + readonly uri: Uri | undefined; + + /** + * The old Python environment before the change. + */ + readonly old: PythonEnvironment | undefined; + + /** + * The new Python environment after the change. + */ + readonly new: PythonEnvironment | undefined; +}; + +/** + * Enum representing the kinds of environment changes. + */ +export enum EnvironmentChangeKind { + /** + * Indicates that an environment was added. + */ + add = 'add', + + /** + * Indicates that an environment was removed. + */ + remove = 'remove', +} + +/** + * Event arguments for when the list of Python environments changes. + */ +export type DidChangeEnvironmentsEventArgs = { + /** + * The kind of change that occurred (add or remove). + */ + kind: EnvironmentChangeKind; + + /** + * The Python environment that was added or removed. + */ + environment: PythonEnvironment; +}[]; + +/** + * Type representing the context for resolving a Python environment. + */ +export type ResolveEnvironmentContext = Uri; + +export interface QuickCreateConfig { + /** + * The description of the quick create step. + */ + readonly description: string; + + /** + * The detail of the quick create step. + */ + readonly detail?: string; +} + +/** + * Interface representing an environment manager. + */ +export interface EnvironmentManager { + /** + * The name of the environment manager. Allowed characters (a-z, A-Z, 0-9, -, _). + */ + readonly name: string; + + /** + * The display name of the environment manager. + */ + readonly displayName?: string; + + /** + * The preferred package manager ID for the environment manager. This is a combination + * of publisher id, extension id, and {@link EnvironmentManager.name package manager name}. + * `.:` + * + * @example + * 'ms-python.python:pip' + */ + readonly preferredPackageManagerId: string; + + /** + * The description of the environment manager. + */ + readonly description?: string; + + /** + * The tooltip for the environment manager, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString | undefined; + + /** + * The icon path for the environment manager, which can be a string, Uri, or an object with light and dark theme paths. + */ + readonly iconPath?: IconPath; + + /** + * The log output channel for the environment manager. + */ + readonly log?: LogOutputChannel; + + /** + * The quick create details for the environment manager. Having this method also enables the quick create feature + * for the environment manager. Should Implement {@link EnvironmentManager.create} to support quick create. + */ + quickCreateConfig?(): QuickCreateConfig | undefined; + + /** + * Creates a new Python environment within the specified scope. Create should support adding a .gitignore file if it creates a folder within the workspace. If a manager does not support environment creation, do not implement this method; the UI disables "create" options when `this.manager.create === undefined`. + * @param scope - The scope within which to create the environment. + * @param options - Optional parameters for creating the Python environment. + * @returns A promise that resolves to the created Python environment, or undefined if creation failed. + */ + create?(scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions): Promise; + + /** + * Removes the specified Python environment. + * @param environment - The Python environment to remove. + * @returns A promise that resolves when the environment is removed. + */ + remove?(environment: PythonEnvironment): Promise; + + /** + * Refreshes the list of Python environments within the specified scope. + * @param scope - The scope within which to refresh environments. + * @returns A promise that resolves when the refresh is complete. + */ + refresh(scope: RefreshEnvironmentsScope): Promise; + + /** + * Retrieves a list of Python environments within the specified scope. + * @param scope - The scope within which to retrieve environments. + * @returns A promise that resolves to an array of Python environments. + */ + getEnvironments(scope: GetEnvironmentsScope): Promise; + + /** + * Event that is fired when the list of Python environments changes. + */ + onDidChangeEnvironments?: Event; + + /** + * Sets the current Python environment within the specified scope. + * @param scope - The scope within which to set the environment. + * @param environment - The Python environment to set. If undefined, the environment is unset. + * @returns A promise that resolves when the environment is set. + */ + set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise; + + /** + * Retrieves the current Python environment within the specified scope. + * @param scope - The scope within which to retrieve the environment. + * @returns A promise that resolves to the current Python environment, or undefined if none is set. + */ + get(scope: GetEnvironmentScope): Promise; + + /** + * Event that is fired when the current Python environment changes. + */ + onDidChangeEnvironment?: Event; + + /** + * Resolves the specified Python environment. The environment can be either a {@link PythonEnvironment} or a {@link Uri} context. + * + * This method is used to obtain a fully detailed {@link PythonEnvironment} object. The input can be: + * - A {@link PythonEnvironment} object, which might be missing key details such as {@link PythonEnvironment.execInfo}. + * - A {@link Uri} object, which typically represents either: + * - A folder that contains the Python environment. + * - The path to a Python executable. + * + * @param context - The context for resolving the environment, which can be a {@link PythonEnvironment} or a {@link Uri}. + * @returns A promise that resolves to the fully detailed {@link PythonEnvironment}, or `undefined` if the environment cannot be resolved. + */ + resolve(context: ResolveEnvironmentContext): Promise; + + /** + * Clears the environment manager's cache. + * + * @returns A promise that resolves when the cache is cleared. + */ + clearCache?(): Promise; +} + +/** + * Interface representing a package ID. + */ +export interface PackageId { + /** + * The ID of the package. + */ + id: string; + + /** + * The ID of the package manager. + */ + managerId: string; + + /** + * The ID of the environment in which the package is installed. + */ + environmentId: string; +} + +/** + * Interface representing package information. + */ +export interface PackageInfo { + /** + * The name of the package. + */ + readonly name: string; + + /** + * The display name of the package. + */ + readonly displayName: string; + + /** + * The version of the package. + */ + readonly version?: string; + + /** + * The description of the package. + */ + readonly description?: string; + + /** + * The tooltip for the package, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString | undefined; + + /** + * The icon path for the package, which can be a string, Uri, or an object with light and dark theme paths. + */ + readonly iconPath?: IconPath; + + /** + * The URIs associated with the package. + */ + readonly uris?: readonly Uri[]; +} + +/** + * Interface representing a package. + */ +export interface Package extends PackageInfo { + /** + * The ID of the package. + */ + readonly pkgId: PackageId; +} + +/** + * Enum representing the kinds of package changes. + */ +export enum PackageChangeKind { + /** + * Indicates that a package was added. + */ + add = 'add', + + /** + * Indicates that a package was removed. + */ + remove = 'remove', +} + +/** + * Event arguments for when packages change. + */ +export interface DidChangePackagesEventArgs { + /** + * The Python environment in which the packages changed. + */ + environment: PythonEnvironment; + + /** + * The package manager responsible for the changes. + */ + manager: PackageManager; + + /** + * The list of changes, each containing the kind of change and the package affected. + */ + changes: { kind: PackageChangeKind; pkg: Package }[]; +} + +/** + * Interface representing a package manager. + */ +export interface PackageManager { + /** + * The name of the package manager. Allowed characters (a-z, A-Z, 0-9, -, _). + */ + name: string; + + /** + * The display name of the package manager. + */ + displayName?: string; + + /** + * The description of the package manager. + */ + description?: string; + + /** + * The tooltip for the package manager, which can be a string or a Markdown string. + */ + tooltip?: string | MarkdownString | undefined; + + /** + * The icon path for the package manager, which can be a string, Uri, or an object with light and dark theme paths. + */ + iconPath?: IconPath; + + /** + * The log output channel for the package manager. + */ + log?: LogOutputChannel; + + /** + * Installs/Uninstall packages in the specified Python environment. + * @param environment - The Python environment in which to install packages. + * @param options - Options for managing packages. + * @returns A promise that resolves when the installation is complete. + */ + manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise; + + /** + * Refreshes the package list for the specified Python environment. + * @param environment - The Python environment for which to refresh the package list. + * @returns A promise that resolves when the refresh is complete. + */ + refresh(environment: PythonEnvironment): Promise; + + /** + * Retrieves the list of packages for the specified Python environment. + * @param environment - The Python environment for which to retrieve packages. + * @returns An array of packages, or undefined if the packages could not be retrieved. + */ + getPackages(environment: PythonEnvironment): Promise; + + /** + * Event that is fired when packages change. + */ + onDidChangePackages?: Event; + + /** + * Clears the package manager's cache. + * @returns A promise that resolves when the cache is cleared. + */ + clearCache?(): Promise; +} + +/** + * Interface representing a Python project. + */ +export interface PythonProject { + /** + * The name of the Python project. + */ + readonly name: string; + + /** + * The URI of the Python project. + */ + readonly uri: Uri; + + /** + * The description of the Python project. + */ + readonly description?: string; + + /** + * The tooltip for the Python project, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString; +} + +/** + * Options for creating a Python project. + */ +export interface PythonProjectCreatorOptions { + /** + * The name of the Python project. + */ + name: string; + + /** + * Path provided as the root for the project. + */ + rootUri: Uri; + + /** + * Boolean indicating whether the project should be created without any user input. + */ + quickCreate?: boolean; +} + +/** + * Interface representing a creator for Python projects. + */ +export interface PythonProjectCreator { + /** + * The name of the Python project creator. + */ + readonly name: string; + + /** + * The display name of the Python project creator. + */ + readonly displayName?: string; + + /** + * The description of the Python project creator. + */ + readonly description?: string; + + /** + * The tooltip for the Python project creator, which can be a string or a Markdown string. + */ + readonly tooltip?: string | MarkdownString; + + /** + * Creates a new Python project(s) or, if files are not a project, returns Uri(s) to the created files. + * Anything that needs its own python environment constitutes a project. + * @param options Optional parameters for creating the Python project. + * @returns A promise that resolves to one of the following: + * - PythonProject or PythonProject[]: when a single or multiple projects are created. + * - Uri or Uri[]: when files are created that do not constitute a project. + * - undefined: if project creation fails. + */ + create(options?: PythonProjectCreatorOptions): Promise; + + /** + * A flag indicating whether the project creator supports quick create where no user input is required. + */ + readonly supportsQuickCreate?: boolean; +} + +/** + * Event arguments for when Python projects change. + */ +export interface DidChangePythonProjectsEventArgs { + /** + * The list of Python projects that were added. + */ + added: PythonProject[]; + + /** + * The list of Python projects that were removed. + */ + removed: PythonProject[]; +} + +export type PackageManagementOptions = + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + }; + +/** + * Options for creating a Python environment. + */ +export interface CreateEnvironmentOptions { + /** + * Provides some context about quick create based on user input. + * - if true, the environment should be created without any user input or prompts. + * - if false, the environment creation can show user input or prompts. + * This also means user explicitly skipped the quick create option. + * - if undefined, the environment creation can show user input or prompts. + * You can show quick create option to the user if you support it. + */ + quickCreate?: boolean; + /** + * Packages to install in addition to the automatically picked packages as a part of creating environment. + */ + additionalPackages?: string[]; +} + +/** + * Object representing the process started using run in background API. + */ +export interface PythonProcess { + /** + * The process ID of the Python process. + */ + readonly pid?: number; + + /** + * The standard input of the Python process. + */ + readonly stdin: NodeJS.WritableStream; + + /** + * The standard output of the Python process. + */ + readonly stdout: NodeJS.ReadableStream; + + /** + * The standard error of the Python process. + */ + readonly stderr: NodeJS.ReadableStream; + + /** + * Kills the Python process. + */ + kill(): void; + + /** + * Event that is fired when the Python process exits. + */ + onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; +} + +export interface PythonEnvironmentManagerRegistrationApi { + /** + * Register an environment manager implementation. + * + * @param manager Environment Manager implementation to register. + * @returns A disposable that can be used to unregister the environment manager. + * @see {@link EnvironmentManager} + */ + registerEnvironmentManager(manager: EnvironmentManager): Disposable; +} + +export interface PythonEnvironmentItemApi { + /** + * Create a Python environment item from the provided environment info. This item is used to interact + * with the environment. + * + * @param info Some details about the environment like name, version, etc. needed to interact with the environment. + * @param manager The environment manager to associate with the environment. + * @returns The Python environment. + */ + createPythonEnvironmentItem(info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment; +} + +export interface PythonEnvironmentManagementApi { + /** + * Create a Python environment using environment manager associated with the scope. + * + * @param scope Where the environment is to be created. + * @param options Optional parameters for creating the Python environment. + * @returns The Python environment created. `undefined` if not created. + */ + createEnvironment( + scope: CreateEnvironmentScope, + options?: CreateEnvironmentOptions, + ): Promise; + + /** + * Remove a Python environment. + * + * @param environment The Python environment to remove. + * @returns A promise that resolves when the environment has been removed. + */ + removeEnvironment(environment: PythonEnvironment): Promise; +} + +export interface PythonEnvironmentsApi { + /** + * Initiates a refresh of Python environments within the specified scope. + * @param scope - The scope within which to search for environments. + * @returns A promise that resolves when the search is complete. + */ + refreshEnvironments(scope: RefreshEnvironmentsScope): Promise; + + /** + * Retrieves a list of Python environments within the specified scope. + * @param scope - The scope within which to retrieve environments. + * @returns A promise that resolves to an array of Python environments. + */ + getEnvironments(scope: GetEnvironmentsScope): Promise; + + /** + * Event that is fired when the list of Python environments changes. + * @see {@link DidChangeEnvironmentsEventArgs} + */ + onDidChangeEnvironments: Event; + + /** + * This method is used to get the details missing from a PythonEnvironment. Like + * {@link PythonEnvironment.execInfo} and other details. + * + * @param context : The PythonEnvironment or Uri for which details are required. + */ + resolveEnvironment(context: ResolveEnvironmentContext): Promise; +} + +export interface PythonProjectEnvironmentApi { + /** + * Sets the current Python environment within the specified scope. + * @param scope - The scope within which to set the environment. + * @param environment - The Python environment to set. If undefined, the environment is unset. + */ + setEnvironment(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise; + + /** + * Retrieves the current Python environment within the specified scope. + * @param scope - The scope within which to retrieve the environment. + * @returns A promise that resolves to the current Python environment, or undefined if none is set. + */ + getEnvironment(scope: GetEnvironmentScope): Promise; + + /** + * Event that is fired when the selected Python environment changes for Project, Folder or File. + * @see {@link DidChangeEnvironmentEventArgs} + */ + onDidChangeEnvironment: Event; +} + +export interface PythonEnvironmentManagerApi + extends PythonEnvironmentManagerRegistrationApi, + PythonEnvironmentItemApi, + PythonEnvironmentManagementApi, + PythonEnvironmentsApi, + PythonProjectEnvironmentApi {} + +export interface PythonPackageManagerRegistrationApi { + /** + * Register a package manager implementation. + * + * @param manager Package Manager implementation to register. + * @returns A disposable that can be used to unregister the package manager. + * @see {@link PackageManager} + */ + registerPackageManager(manager: PackageManager): Disposable; +} + +export interface PythonPackageGetterApi { + /** + * Refresh the list of packages in a Python Environment. + * + * @param environment The Python Environment for which the list of packages is to be refreshed. + * @returns A promise that resolves when the list of packages has been refreshed. + */ + refreshPackages(environment: PythonEnvironment): Promise; + + /** + * Get the list of packages in a Python Environment. + * + * @param environment The Python Environment for which the list of packages is required. + * @returns The list of packages in the Python Environment. + */ + getPackages(environment: PythonEnvironment): Promise; + + /** + * Event raised when the list of packages in a Python Environment changes. + * @see {@link DidChangePackagesEventArgs} + */ + onDidChangePackages: Event; +} + +export interface PythonPackageItemApi { + /** + * Create a package item from the provided package info. + * + * @param info The package info. + * @param environment The Python Environment in which the package is installed. + * @param manager The package manager that installed the package. + * @returns The package item. + */ + createPackageItem(info: PackageInfo, environment: PythonEnvironment, manager: PackageManager): Package; +} + +export interface PythonPackageManagementApi { + /** + * Install/Uninstall packages into a Python Environment. + * + * @param environment The Python Environment into which packages are to be installed. + * @param packages The packages to install. + * @param options Options for installing packages. + */ + managePackages(environment: PythonEnvironment, options: PackageManagementOptions): Promise; +} + +export interface PythonPackageManagerApi + extends PythonPackageManagerRegistrationApi, + PythonPackageGetterApi, + PythonPackageManagementApi, + PythonPackageItemApi {} + +export interface PythonProjectCreationApi { + /** + * Register a Python project creator. + * + * @param creator The project creator to register. + * @returns A disposable that can be used to unregister the project creator. + * @see {@link PythonProjectCreator} + */ + registerPythonProjectCreator(creator: PythonProjectCreator): Disposable; +} +export interface PythonProjectGetterApi { + /** + * Get all python projects. + */ + getPythonProjects(): readonly PythonProject[]; + + /** + * Get the python project for a given URI. + * + * @param uri The URI of the project + * @returns The project or `undefined` if not found. + */ + getPythonProject(uri: Uri): PythonProject | undefined; +} + +export interface PythonProjectModifyApi { + /** + * Add a python project or projects to the list of projects. + * + * @param projects The project or projects to add. + */ + addPythonProject(projects: PythonProject | PythonProject[]): void; + + /** + * Remove a python project from the list of projects. + * + * @param project The project to remove. + */ + removePythonProject(project: PythonProject): void; + + /** + * Event raised when python projects are added or removed. + * @see {@link DidChangePythonProjectsEventArgs} + */ + onDidChangePythonProjects: Event; +} + +/** + * The API for interacting with Python projects. A project in python is any folder or file that is a contained + * in some manner. For example, a PEP-723 compliant file can be treated as a project. A folder with a `pyproject.toml`, + * or just python files can be treated as a project. All this allows you to do is set a python environment for that project. + * + * By default all `vscode.workspace.workspaceFolders` are treated as projects. + */ +export interface PythonProjectApi extends PythonProjectCreationApi, PythonProjectGetterApi, PythonProjectModifyApi {} + +export interface PythonTerminalCreateOptions extends TerminalOptions { + /** + * Whether to disable activation on create. + */ + disableActivation?: boolean; +} + +export interface PythonTerminalCreateApi { + /** + * Creates a terminal and activates any (activatable) environment for the terminal. + * + * @param environment The Python environment to activate. + * @param options Options for creating the terminal. + * + * Note: Non-activatable environments have no effect on the terminal. + */ + createTerminal(environment: PythonEnvironment, options: PythonTerminalCreateOptions): Promise; +} + +/** + * Options for running a Python script or module in a terminal. + * + * Example: + * * Running Script: `python myscript.py --arg1` + * ```typescript + * { + * args: ["myscript.py", "--arg1"] + * } + * ``` + * * Running a module: `python -m my_module --arg1` + * ```typescript + * { + * args: ["-m", "my_module", "--arg1"] + * } + * ``` + */ +export interface PythonTerminalExecutionOptions { + /** + * Current working directory for the terminal. This in only used to create the terminal. + */ + cwd: string | Uri; + + /** + * Arguments to pass to the python executable. + */ + args?: string[]; + + /** + * Set `true` to show the terminal. + */ + show?: boolean; +} + +export interface PythonTerminalRunApi { + /** + * Runs a Python script or module in a terminal. This API will create a terminal if one is not available to use. + * If a terminal is available, it will be used to run the script or module. + * + * Note: + * - If you restart VS Code, this will create a new terminal, this is a limitation of VS Code. + * - If you close the terminal, this will create a new terminal. + * - In cases of multi-root/project scenario, it will create a separate terminal for each project. + */ + runInTerminal(environment: PythonEnvironment, options: PythonTerminalExecutionOptions): Promise; + + /** + * Runs a Python script or module in a dedicated terminal. This API will create a terminal if one is not available to use. + * If a terminal is available, it will be used to run the script or module. This terminal will be dedicated to the script, + * and selected based on the `terminalKey`. + * + * @param terminalKey A unique key to identify the terminal. For scripts you can use the Uri of the script file. + */ + runInDedicatedTerminal( + terminalKey: Uri | string, + environment: PythonEnvironment, + options: PythonTerminalExecutionOptions, + ): Promise; +} + +/** + * Options for running a Python task. + * + * Example: + * * Running Script: `python myscript.py --arg1` + * ```typescript + * { + * args: ["myscript.py", "--arg1"] + * } + * ``` + * * Running a module: `python -m my_module --arg1` + * ```typescript + * { + * args: ["-m", "my_module", "--arg1"] + * } + * ``` + */ +export interface PythonTaskExecutionOptions { + /** + * Name of the task to run. + */ + name: string; + + /** + * Arguments to pass to the python executable. + */ + args: string[]; + + /** + * The Python project to use for the task. + */ + project?: PythonProject; + + /** + * Current working directory for the task. Default is the project directory for the script being run. + */ + cwd?: string; + + /** + * Environment variables to set for the task. + */ + env?: { [key: string]: string }; +} + +export interface PythonTaskRunApi { + /** + * Run a Python script or module as a task. + * + */ + runAsTask(environment: PythonEnvironment, options: PythonTaskExecutionOptions): Promise; +} + +/** + * Options for running a Python script or module in the background. + */ +export interface PythonBackgroundRunOptions { + /** + * The Python environment to use for running the script or module. + */ + args: string[]; + + /** + * Current working directory for the script or module. Default is the project directory for the script being run. + */ + cwd?: string; + + /** + * Environment variables to set for the script or module. + */ + env?: { [key: string]: string | undefined }; +} +export interface PythonBackgroundRunApi { + /** + * Run a Python script or module in the background. This API will create a new process to run the script or module. + */ + runInBackground(environment: PythonEnvironment, options: PythonBackgroundRunOptions): Promise; +} + +export interface PythonExecutionApi + extends PythonTerminalCreateApi, + PythonTerminalRunApi, + PythonTaskRunApi, + PythonBackgroundRunApi {} + +/** + * Event arguments for when the monitored `.env` files or any other sources change. + */ +export interface DidChangeEnvironmentVariablesEventArgs { + /** + * The URI of the file that changed. No `Uri` means a non-file source of environment variables changed. + */ + uri?: Uri; + + /** + * The type of change that occurred. + */ + changeType: FileChangeType; +} + +export interface PythonEnvironmentVariablesApi { + /** + * Get environment variables for a workspace. This picks up `.env` file from the root of the + * workspace. + * + * Order of overrides: + * 1. `baseEnvVar` if given or `process.env` + * 2. `.env` file from the "python.envFile" setting in the workspace. + * 3. `.env` file at the root of the python project. + * 4. `overrides` in the order provided. + * + * @param uri The URI of the project, workspace or a file in a for which environment variables are required.If not provided, + * it fetches the environment variables for the global scope. + * @param overrides Additional environment variables to override the defaults. + * @param baseEnvVar The base environment variables that should be used as a starting point. + */ + getEnvironmentVariables( + uri: Uri | undefined, + overrides?: ({ [key: string]: string | undefined } | Uri)[], + baseEnvVar?: { [key: string]: string | undefined }, + ): Promise<{ [key: string]: string | undefined }>; + + /** + * Event raised when `.env` file changes or any other monitored source of env variable changes. + */ + onDidChangeEnvironmentVariables: Event; +} + +/** + * The API for interacting with Python environments, package managers, and projects. + */ +export interface PythonEnvironmentApi + extends PythonEnvironmentManagerApi, + PythonPackageManagerApi, + PythonProjectApi, + PythonExecutionApi, + PythonEnvironmentVariablesApi {} diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index 75b9489f708e..3e0dd98b5a7a 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -62,7 +62,7 @@ export class WorkspaceTestAdapter { // first fetch all the individual test Items that we necessarily want includes.forEach((t) => { const nodes = getTestCaseNodes(t); - testCaseNodes.push(...nodes); + testCaseNodes.push(...nodes); }); // iterate through testItems nodes and fetch their unittest runID to pass in as argument testCaseNodes.forEach((node) => { From 7af8ea3785230901f7a89085409435cc722e8f58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:21:31 +0000 Subject: [PATCH 02/25] Phase 1: Add core infrastructure for project-based testing - Created ProjectAdapter and WorkspaceDiscoveryState interfaces - Added project utility functions (ID generation, scoping, nested project detection) - Updated PythonResultResolver to support optional projectId parameter - Modified populateTestTree to create project-scoped test IDs - Updated TestDiscoveryHandler to handle project-scoped error nodes Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../testController/common/projectAdapter.ts | 135 ++++++++++++++++++ .../testController/common/projectUtils.ts | 106 ++++++++++++++ .../testController/common/resultResolver.ts | 26 +++- .../common/testDiscoveryHandler.ts | 17 ++- .../testing/testController/common/utils.ts | 21 ++- 5 files changed, 293 insertions(+), 12 deletions(-) create mode 100644 src/client/testing/testController/common/projectAdapter.ts create mode 100644 src/client/testing/testController/common/projectUtils.ts diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts new file mode 100644 index 000000000000..6e388acb31a6 --- /dev/null +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestItem, Uri } from 'vscode'; +import { TestProvider } from '../../types'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver, DiscoveredTestPayload, DiscoveredTestNode } from './types'; +import { PythonEnvironment, PythonProject } from '../../../envExt/types'; + +/** + * Represents a single Python project with its own test infrastructure. + * A project is defined as a combination of a Python executable + URI (folder/file). + */ +export interface ProjectAdapter { + // === IDENTITY === + /** + * Unique identifier for this project, generated by hashing the PythonProject object. + */ + projectId: string; + + /** + * Display name for the project (e.g., "alice (Python 3.11)"). + */ + projectName: string; + + /** + * URI of the project root folder or file. + */ + projectUri: Uri; + + /** + * Parent workspace URI containing this project. + */ + workspaceUri: Uri; + + // === API OBJECTS (from vscode-python-environments extension) === + /** + * The PythonProject object from the environment API. + */ + pythonProject: PythonProject; + + /** + * The resolved PythonEnvironment with execution details. + * Contains execInfo.run.executable for running tests. + */ + pythonEnvironment: PythonEnvironment; + + // === TEST INFRASTRUCTURE === + /** + * Test framework provider ('pytest' | 'unittest'). + */ + testProvider: TestProvider; + + /** + * Adapter for test discovery. + */ + discoveryAdapter: ITestDiscoveryAdapter; + + /** + * Adapter for test execution. + */ + executionAdapter: ITestExecutionAdapter; + + /** + * Result resolver for this project (maps test IDs and handles results). + */ + resultResolver: ITestResultResolver; + + // === DISCOVERY STATE === + /** + * Raw discovery data before filtering (all discovered tests). + * Cleared after ownership resolution to save memory. + */ + rawDiscoveryData?: DiscoveredTestPayload; + + /** + * Filtered tests that this project owns (after API verification). + * This is the tree structure passed to populateTestTree(). + */ + ownedTests?: DiscoveredTestNode; + + // === LIFECYCLE === + /** + * Whether discovery is currently running for this project. + */ + isDiscovering: boolean; + + /** + * Whether tests are currently executing for this project. + */ + isExecuting: boolean; + + /** + * Root TestItem for this project in the VS Code test tree. + * All project tests are children of this item. + */ + projectRootTestItem?: TestItem; +} + +/** + * Temporary state used during workspace-wide test discovery. + * Created at the start of discovery and cleared after ownership resolution. + */ +export interface WorkspaceDiscoveryState { + /** + * The workspace being discovered. + */ + workspaceUri: Uri; + + /** + * Maps test file paths to the set of projects that discovered them. + * Used to detect overlapping discovery. + */ + fileToProjects: Map>; + + /** + * Maps test file paths to their owning project (after API resolution). + * Value is the ProjectAdapter whose pythonProject.uri matches API response. + */ + fileOwnership: Map; + + /** + * Progress tracking for parallel discovery. + */ + projectsCompleted: Set; + + /** + * Total number of projects in this workspace. + */ + totalProjects: number; + + /** + * Whether all projects have completed discovery. + */ + isComplete: boolean; +} diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts new file mode 100644 index 000000000000..be74235263b9 --- /dev/null +++ b/src/client/testing/testController/common/projectUtils.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as crypto from 'crypto'; +import { Uri } from 'vscode'; +import { ProjectAdapter } from './projectAdapter'; +import { PythonProject } from '../../../envExt/types'; + +/** + * Generates a unique project ID by hashing the PythonProject object. + * This ensures consistent IDs across extension reloads for the same project. + * + * @param pythonProject The PythonProject object from the environment API + * @returns A unique string identifier for the project + */ +export function generateProjectId(pythonProject: PythonProject): string { + // Create a stable string representation of the project + const projectString = JSON.stringify({ + name: pythonProject.name, + uri: pythonProject.uri.toString(), + }); + + // Generate a hash to create a shorter, unique ID + const hash = crypto.createHash('sha256').update(projectString).digest('hex'); + return `project-${hash.substring(0, 12)}`; +} + +/** + * Creates a project-scoped VS Code test item ID. + * Format: "{projectId}::{testPath}" + * + * @param projectId The unique project identifier + * @param testPath The test path (e.g., "/workspace/test.py::test_func") + * @returns The project-scoped VS Code test ID + */ +export function createProjectScopedVsId(projectId: string, testPath: string): string { + return `${projectId}::${testPath}`; +} + +/** + * Parses a project-scoped VS Code test ID to extract the project ID and test path. + * + * @param vsId The VS Code test item ID + * @returns Object containing projectId and testPath, or null if invalid + */ +export function parseProjectScopedVsId(vsId: string): { projectId: string; testPath: string } | null { + const separatorIndex = vsId.indexOf('::'); + if (separatorIndex === -1) { + return null; + } + + return { + projectId: vsId.substring(0, separatorIndex), + testPath: vsId.substring(separatorIndex + 2), + }; +} + +/** + * Checks if a test file path is within a nested project's directory. + * This is used to determine when to query the API for ownership even if + * only one project discovered the file. + * + * @param testFilePath Absolute path to the test file + * @param allProjects All projects in the workspace + * @param excludeProject Optional project to exclude from the check (typically the discoverer) + * @returns True if the file is within any nested project's directory + */ +export function hasNestedProjectForPath( + testFilePath: string, + allProjects: ProjectAdapter[], + excludeProject?: ProjectAdapter, +): boolean { + return allProjects.some( + (p) => + p !== excludeProject && + testFilePath.startsWith(p.projectUri.fsPath), + ); +} + +/** + * Finds the project that owns a specific test file based on project URI. + * This is typically used after the API returns ownership information. + * + * @param projectUri The URI of the owning project (from API) + * @param allProjects All projects to search + * @returns The ProjectAdapter with matching URI, or undefined if not found + */ +export function findProjectByUri(projectUri: Uri, allProjects: ProjectAdapter[]): ProjectAdapter | undefined { + return allProjects.find((p) => p.projectUri.fsPath === projectUri.fsPath); +} + +/** + * Creates a display name for a project including Python version. + * Format: "{projectName} (Python {version})" + * + * @param projectName The name of the project + * @param pythonVersion The Python version string (e.g., "3.11.2") + * @returns Formatted display name + */ +export function createProjectDisplayName(projectName: string, pythonVersion: string): string { + // Extract major.minor version if full version provided + const versionMatch = pythonVersion.match(/^(\d+\.\d+)/); + const shortVersion = versionMatch ? versionMatch[1] : pythonVersion; + + return `${projectName} (Python ${shortVersion})`; +} diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index 959d08fee1a9..7cd4352c7de4 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -26,10 +26,23 @@ export class PythonResultResolver implements ITestResultResolver { public detailedCoverageMap = new Map(); - constructor(testController: TestController, testProvider: TestProvider, private workspaceUri: Uri) { + /** + * Optional project ID for scoping test IDs. + * When set, all test IDs are prefixed with "{projectId}::" for project-based testing. + * When undefined, uses legacy workspace-level IDs for backward compatibility. + */ + private projectId?: string; + + constructor( + testController: TestController, + testProvider: TestProvider, + private workspaceUri: Uri, + projectId?: string, + ) { this.testController = testController; this.testProvider = testProvider; - // Initialize a new TestItemIndex which will be used to track test items in this workspace + this.projectId = projectId; + // Initialize a new TestItemIndex which will be used to track test items in this workspace/project this.testItemIndex = new TestItemIndex(); } @@ -46,6 +59,14 @@ export class PythonResultResolver implements ITestResultResolver { return this.testItemIndex.vsIdToRunIdMap; } + /** + * Gets the project ID for this resolver (if any). + * Used for project-scoped test ID generation. + */ + public getProjectId(): string | undefined { + return this.projectId; + } + public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { PythonResultResolver.discoveryHandler.processDiscovery( payload, @@ -54,6 +75,7 @@ export class PythonResultResolver implements ITestResultResolver { this.workspaceUri, this.testProvider, token, + this.projectId, ); sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { tool: this.testProvider, diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index 50f4fa71406a..cadbdf1eb1d1 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -27,6 +27,7 @@ export class TestDiscoveryHandler { workspaceUri: Uri, testProvider: TestProvider, token?: CancellationToken, + projectId?: string, ): void { if (!payload) { // No test data is available @@ -38,10 +39,13 @@ export class TestDiscoveryHandler { // Check if there were any errors in the discovery process. if (rawTestData.status === 'error') { - this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider); + this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider, projectId); } else { // remove error node only if no errors exist. - testController.items.delete(`DiscoveryError:${workspacePath}`); + const errorNodeId = projectId + ? `${projectId}::DiscoveryError:${workspacePath}` + : `DiscoveryError:${workspacePath}`; + testController.items.delete(errorNodeId); } if (rawTestData.tests || rawTestData.tests === null) { @@ -64,6 +68,7 @@ export class TestDiscoveryHandler { vsIdToRunId: testItemIndex.vsIdToRunIdMap, } as any, token, + projectId, ); } } @@ -76,6 +81,7 @@ export class TestDiscoveryHandler { workspaceUri: Uri, error: string[] | undefined, testProvider: TestProvider, + projectId?: string, ): void { const workspacePath = workspaceUri.fsPath; const testingErrorConst = @@ -83,7 +89,10 @@ export class TestDiscoveryHandler { traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); - let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`); + const errorNodeId = projectId + ? `${projectId}::DiscoveryError:${workspacePath}` + : `DiscoveryError:${workspacePath}`; + let errorNode = testController.items.get(errorNodeId); const message = util.format( `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, error?.join('\r\n\r\n') ?? '', @@ -91,6 +100,8 @@ export class TestDiscoveryHandler { if (errorNode === undefined) { const options = buildErrorNodeOptions(workspaceUri, message, testProvider); + // Update the error node ID to include project scope if applicable + options.id = errorNodeId; errorNode = createErrorTestItem(testController, options); testController.items.add(errorNode); } diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 606865e5ad7e..86d5cc9063bd 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -211,10 +211,13 @@ export function populateTestTree( testRoot: TestItem | undefined, resultResolver: ITestResultResolver, token?: CancellationToken, + projectId?: string, ): void { // If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller. if (!testRoot) { - testRoot = testController.createTestItem(testTreeData.path, testTreeData.name, Uri.file(testTreeData.path)); + // Create project-scoped ID if projectId is provided + const rootId = projectId ? `${projectId}::${testTreeData.path}` : testTreeData.path; + testRoot = testController.createTestItem(rootId, testTreeData.name, Uri.file(testTreeData.path)); testRoot.canResolveChildren = true; testRoot.tags = [RunTestTag, DebugTestTag]; @@ -226,7 +229,9 @@ export function populateTestTree( testTreeData.children.forEach((child) => { if (!token?.isCancellationRequested) { if (isTestItem(child)) { - const testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); + // Create project-scoped vsId + const vsId = projectId ? `${projectId}::${child.id_}` : child.id_; + const testItem = testController.createTestItem(vsId, child.name, Uri.file(child.path)); testItem.tags = [RunTestTag, DebugTestTag]; let range: Range | undefined; @@ -245,15 +250,17 @@ export function populateTestTree( testItem.tags = [RunTestTag, DebugTestTag]; testRoot!.children.add(testItem); - // add to our map + // add to our map - use runID as key, vsId as value resultResolver.runIdToTestItem.set(child.runID, testItem); - resultResolver.runIdToVSid.set(child.runID, child.id_); - resultResolver.vsIdToRunId.set(child.id_, child.runID); + resultResolver.runIdToVSid.set(child.runID, vsId); + resultResolver.vsIdToRunId.set(vsId, child.runID); } else { let node = testController.items.get(child.path); if (!node) { - node = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); + // Create project-scoped ID for non-test nodes + const nodeId = projectId ? `${projectId}::${child.id_}` : child.id_; + node = testController.createTestItem(nodeId, child.name, Uri.file(child.path)); node.canResolveChildren = true; node.tags = [RunTestTag, DebugTestTag]; @@ -274,7 +281,7 @@ export function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, resultResolver, token); + populateTestTree(testController, child, node, resultResolver, token, projectId); } } }); From 460faa81ea0ff4c181d2a217c98b776c0e169c2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:24:28 +0000 Subject: [PATCH 03/25] Phase 2: Add project discovery integration - Added project-based state maps (workspaceProjects, vsIdToProject, fileUriToProject, projectToVsIds) - Implemented discoverWorkspaceProjects() to query Python Environment API - Created createProjectAdapter() to build ProjectAdapter from PythonProject - Added createDefaultProject() for backward compatibility - Imported necessary types from environment API - Added flag to enable/disable project-based testing Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../testing/testController/controller.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..ac6f47fe9757 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,6 +52,10 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter'; +import { generateProjectId, createProjectDisplayName } from './common/projectUtils'; +import { PythonEnvironmentApi, PythonProject, PythonEnvironment } from '../../envExt/types'; +import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -62,8 +66,24 @@ type TriggerType = EventPropertyType[TriggerKeyType]; export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); + // === NEW: PROJECT-BASED STATE === + // Map of workspace URI -> Map of project ID -> ProjectAdapter + private readonly workspaceProjects: Map> = new Map(); + + // Fast lookup maps for test execution + private readonly vsIdToProject: Map = new Map(); + private readonly fileUriToProject: Map = new Map(); + private readonly projectToVsIds: Map> = new Map(); + + // Temporary discovery state (created during discovery, cleared after) + private readonly workspaceDiscoveryState: Map = new Map(); + + // Flag to enable/disable project-based testing + private useProjectBasedTesting = false; + private readonly triggerTypes: TriggerType[] = []; private readonly testController: TestController; @@ -216,6 +236,231 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }); } + /** + * Discovers Python projects in a workspace using the Python Environment API. + * Falls back to creating a single default project if API is unavailable or returns no projects. + */ + private async discoverWorkspaceProjects(workspaceUri: Uri): Promise { + try { + // Check if we should use the environment extension + if (!useEnvExtension()) { + traceVerbose('Python Environments extension not enabled, using single project mode'); + return [await this.createDefaultProject(workspaceUri)]; + } + + // Get the environment API + const envExtApi = await getEnvExtApi(); + + // Query for all Python projects in this workspace + const pythonProjects = envExtApi.getPythonProjects(); + + // Filter projects to only those in this workspace + const workspaceProjects = pythonProjects.filter( + (project) => project.uri.fsPath.startsWith(workspaceUri.fsPath), + ); + + if (workspaceProjects.length === 0) { + traceVerbose( + `No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`, + ); + return [await this.createDefaultProject(workspaceUri)]; + } + + // Create ProjectAdapter for each Python project + const projectAdapters: ProjectAdapter[] = []; + for (const pythonProject of workspaceProjects) { + try { + const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); + projectAdapters.push(adapter); + } catch (error) { + traceError(`Failed to create project adapter for ${pythonProject.uri.fsPath}:`, error); + // Continue with other projects + } + } + + if (projectAdapters.length === 0) { + traceVerbose('All project adapters failed to create, falling back to default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + return projectAdapters; + } catch (error) { + traceError('Failed to discover workspace projects, falling back to single project mode:', error); + return [await this.createDefaultProject(workspaceUri)]; + } + } + + /** + * Creates a ProjectAdapter from a PythonProject object. + */ + private async createProjectAdapter( + pythonProject: PythonProject, + workspaceUri: Uri, + ): Promise { + // Generate unique project ID + const projectId = generateProjectId(pythonProject); + + // Resolve the Python environment + const envExtApi = await getEnvExtApi(); + const pythonEnvironment = await envExtApi.resolveEnvironment(pythonProject.uri); + + if (!pythonEnvironment) { + throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`); + } + + // Get workspace settings (shared by all projects in workspace) + const settings = this.configSettings.getSettings(workspaceUri); + + // Determine test provider + const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; + + // Create result resolver with project ID + const resultResolver = new PythonResultResolver( + this.testController, + testProvider, + workspaceUri, + projectId, + ); + + // Create discovery and execution adapters + let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; + + if (testProvider === UNITTEST_PROVIDER) { + discoveryAdapter = new UnittestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } else { + discoveryAdapter = new PytestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new PytestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } + + // Create display name with Python version + const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + + // Create project adapter + const projectAdapter: ProjectAdapter = { + projectId, + projectName, + projectUri: pythonProject.uri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + + return projectAdapter; + } + + /** + * Creates a default project adapter using the workspace interpreter. + * Used for backward compatibility when environment API is unavailable. + */ + private async createDefaultProject(workspaceUri: Uri): Promise { + const settings = this.configSettings.getSettings(workspaceUri); + const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; + + // Create result resolver WITHOUT project ID (legacy mode) + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); + + // Create discovery and execution adapters + let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; + + if (testProvider === UNITTEST_PROVIDER) { + discoveryAdapter = new UnittestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } else { + discoveryAdapter = new PytestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new PytestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } + + // Get active interpreter + const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); + + // Create a mock PythonEnvironment from the interpreter + const pythonEnvironment: PythonEnvironment = { + name: 'default', + displayName: interpreter?.displayName || 'Python', + shortDisplayName: interpreter?.displayName || 'Python', + displayPath: interpreter?.path || 'python', + version: interpreter?.version?.raw || '3.x', + environmentPath: Uri.file(interpreter?.path || 'python'), + sysPrefix: interpreter?.sysPrefix || '', + execInfo: { + run: { + executable: interpreter?.path || 'python', + }, + }, + envId: { + id: 'default', + managerId: 'default', + }, + }; + + // Create a mock PythonProject + const pythonProject: PythonProject = { + name: workspaceUri.fsPath.split('/').pop() || 'workspace', + uri: workspaceUri, + }; + + // Use workspace URI as project ID for default project + const projectId = `default-${workspaceUri.fsPath}`; + + const projectAdapter: ProjectAdapter = { + projectId, + projectName: pythonProject.name, + projectUri: workspaceUri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + + return projectAdapter; + } + public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { if (options?.forceRefresh) { if (uri === undefined) { From b670e8eff810e731ad52f13eb8f764508357ce7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:25:44 +0000 Subject: [PATCH 04/25] Update activate() to support project-based testing - Modified activate() to check useProjectBasedTesting flag - Calls discoverWorkspaceProjects() for each workspace when enabled - Populates workspaceProjects map with discovered projects - Created activateLegacyWorkspace() for backward compatibility - Falls back to legacy mode if project discovery fails - Maintains full backward compatibility with flag disabled Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../testing/testController/controller.ts | 140 ++++++++++++------ 1 file changed, 96 insertions(+), 44 deletions(-) diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index ac6f47fe9757..57b4e47c55b7 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -182,58 +182,110 @@ export class PythonTestController implements ITestController, IExtensionSingleAc public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - workspaces.forEach((workspace) => { - const settings = this.configSettings.getSettings(workspace.uri); - - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; - if (settings.testing.unittestEnabled) { - testProvider = UNITTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - testProvider = PYTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new PytestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, + // Try to use project-based testing if enabled + if (this.useProjectBasedTesting) { + try { + await Promise.all( + Array.from(workspaces).map(async (workspace) => { + try { + // Discover projects in this workspace + const projects = await this.discoverWorkspaceProjects(workspace.uri); + + // Create map for this workspace + const projectsMap = new Map(); + projects.forEach((project) => { + projectsMap.set(project.projectId, project); + }); + + this.workspaceProjects.set(workspace.uri, projectsMap); + + traceVerbose( + `Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, + ); + + // Set up file watchers if auto-discovery is enabled + const settings = this.configSettings.getSettings(workspace.uri); + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } + } catch (error) { + traceError(`Failed to activate project-based testing for ${workspace.uri.fsPath}:`, error); + // Fall back to legacy mode for this workspace + await this.activateLegacyWorkspace(workspace); + } + }), ); + return; + } catch (error) { + traceError('Failed to activate project-based testing, falling back to legacy mode:', error); + this.useProjectBasedTesting = false; } + } + + // Legacy activation (backward compatibility) + workspaces.forEach((workspace) => { + this.activateLegacyWorkspace(workspace); + }); + } - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, + /** + * Activates testing for a workspace using the legacy single-adapter approach. + * Used for backward compatibility when project-based testing is disabled or unavailable. + */ + private activateLegacyWorkspace(workspace: WorkspaceFolder): void { + const settings = this.configSettings.getSettings(workspace.uri); + + let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; + let testProvider: TestProvider; + let resultResolver: PythonResultResolver; + + if (settings.testing.unittestEnabled) { + testProvider = UNITTEST_PROVIDER; + resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); + discoveryAdapter = new UnittestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } else { + testProvider = PYTEST_PROVIDER; + resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); + discoveryAdapter = new PytestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new PytestTestExecutionAdapter( + this.configSettings, resultResolver, + this.envVarsService, ); + } + + const workspaceTestAdapter = new WorkspaceTestAdapter( + testProvider, + discoveryAdapter, + executionAdapter, + workspace.uri, + resultResolver, + ); - this.testAdapters.set(workspace.uri, workspaceTestAdapter); + this.testAdapters.set(workspace.uri, workspaceTestAdapter); - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); - } - }); + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } } /** From 8e13f1dbfd99ce8499a68bec897f6ef9115d4db5 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:39:06 -0800 Subject: [PATCH 05/25] updates --- docs/overlapping-projects-test-ownership.md | 312 ---- docs/project-based-testing-design.md | 28 + env-api.js | 14 - env-api.js.map | 1 - env-api.ts | 1265 ----------------- .../testController/common/projectUtils.ts | 38 +- .../testController/common/resultResolver.ts | 2 +- .../common/testDiscoveryHandler.ts | 5 +- .../testing/testController/common/utils.ts | 7 +- .../testing/testController/controller.ts | 251 ++-- 10 files changed, 169 insertions(+), 1754 deletions(-) delete mode 100644 docs/overlapping-projects-test-ownership.md delete mode 100644 env-api.js delete mode 100644 env-api.js.map delete mode 100644 env-api.ts diff --git a/docs/overlapping-projects-test-ownership.md b/docs/overlapping-projects-test-ownership.md deleted file mode 100644 index 3a07668da687..000000000000 --- a/docs/overlapping-projects-test-ownership.md +++ /dev/null @@ -1,312 +0,0 @@ -# Overlapping Projects and Test Ownership Resolution - -## Problem Statement - -When Python projects have nested directory structures, test discovery can result in the same test file being discovered by multiple projects. We need a deterministic way to assign each test to exactly one project. - -## Scenario Example - -### Project Structure - -``` -root/alice/ ← ProjectA root -├── .venv/ ← ProjectA's Python environment -│ └── bin/python -├── alice_test.py -│ ├── test: t1 -│ └── test: t2 -└── bob/ ← ProjectB root (nested) - ├── .venv/ ← ProjectB's Python environment - │ └── bin/python - └── bob_test.py - └── test: t1 -``` - -### Project Definitions - -| Project | URI | Python Executable | -|-----------|-------------------|--------------------------------------| -| ProjectA | `root/alice` | `root/alice/.venv/bin/python` | -| ProjectB | `root/alice/bob` | `root/alice/bob/.venv/bin/python` | - -### Discovery Results - -#### ProjectA Discovery (on `root/alice/`) - -Discovers 3 tests: -1. ✓ `root/alice/alice_test.py::t1` -2. ✓ `root/alice/alice_test.py::t2` -3. ✓ `root/alice/bob/bob_test.py::t1` ← **Found in subdirectory** - -#### ProjectB Discovery (on `root/alice/bob/`) - -Discovers 1 test: -1. ✓ `root/alice/bob/bob_test.py::t1` ← **Same test as ProjectA found!** - -### Conflict - -**Both ProjectA and ProjectB discovered:** `root/alice/bob/bob_test.py::t1` - -Which project should own this test in the Test Explorer? - -## Resolution Strategy - -### Using PythonProject API as Source of Truth - -The `vscode-python-environments` extension provides: -```typescript -interface PythonProject { - readonly name: string; - readonly uri: Uri; -} - -// Query which project owns a specific URI -getPythonProject(uri: Uri): Promise -``` - -### Resolution Process - -For the conflicting test `root/alice/bob/bob_test.py::t1`: - -```typescript -// Query: Which project owns this file? -const project = await getPythonProject(Uri.file("root/alice/bob/bob_test.py")); - -// Result: ProjectB (the most specific/nested project) -// project.uri = "root/alice/bob" -``` - -### Final Test Ownership - -| Test | Discovered By | Owned By | Reason | -|-----------------------------------|-------------------|------------|-------------------------------------------| -| `root/alice/alice_test.py::t1` | ProjectA | ProjectA | Only discovered by ProjectA | -| `root/alice/alice_test.py::t2` | ProjectA | ProjectA | Only discovered by ProjectA | -| `root/alice/bob/bob_test.py::t1` | ProjectA, ProjectB | **ProjectB** | API returns ProjectB for this URI | - -## Implementation Rules - -### 1. Discovery Runs Independently -Each project runs discovery using its own Python executable and configuration, discovering all tests it can find (including subdirectories). - -### 2. Detect Overlaps and Query API Only When Needed -After all projects complete discovery, detect which test files were found by multiple projects: -```typescript -// Build map of test file -> projects that discovered it -const testFileToProjects = new Map>(); -for (const project of allProjects) { - for (const testFile of project.discoveredTestFiles) { - if (!testFileToProjects.has(testFile.path)) { - testFileToProjects.set(testFile.path, new Set()); - } - testFileToProjects.get(testFile.path).add(project.id); - } -} - -// Query API only for overlapping tests or tests within nested projects -for (const [filePath, projectIds] of testFileToProjects) { - if (projectIds.size > 1) { - // Multiple projects found it - use API to resolve - const owner = await getPythonProject(Uri.file(filePath)); - assignToProject(owner.uri, filePath); - } else if (hasNestedProjectForPath(filePath, allProjects)) { - // Only one project found it, but nested project exists - verify with API - const owner = await getPythonProject(Uri.file(filePath)); - assignToProject(owner.uri, filePath); - } else { - // Unambiguous - assign to the only project that found it - assignToProject([...projectIds][0], filePath); - } -} -``` - -This optimization reduces API calls significantly since most projects don't have overlapping discovery. - -### 3. Filter Discovery Results -ProjectA's final tests: -```typescript -const projectATests = discoveredTests.filter(test => - getPythonProject(test.uri) === projectA -); -// Result: Only alice_test.py tests remain -``` - -ProjectB's final tests: -```typescript -const projectBTests = discoveredTests.filter(test => - getPythonProject(test.uri) === projectB -); -// Result: Only bob_test.py tests remain -``` - -### 4. Add to TestController -Each project only adds tests that the API says it owns: -```typescript -// ProjectA adds its filtered tests under ProjectA node -populateTestTree(testController, projectATests, projectANode, projectAResolver); - -// ProjectB adds its filtered tests under ProjectB node -populateTestTree(testController, projectBTests, projectBNode, projectBResolver); -``` - -## Test Explorer UI Result - -``` -📁 Workspace: root - 📦 Project: ProjectA (root/alice) - 📄 alice_test.py - ✓ t1 - ✓ t2 - 📦 Project: ProjectB (root/alice/bob) - 📄 bob_test.py - ✓ t1 -``` - -## Edge Cases - -### Case 1: No Project Found -```typescript -const project = await getPythonProject(testUri); -if (!project) { - // File is not part of any project - // Could belong to workspace-level tests (fallback) -} -``` - -### Case 2: Project Changed After Discovery -If a test file's project assignment changes (e.g., user creates new `pyproject.toml`), the next discovery cycle will re-assign ownership correctly. - -### Case 3: Deeply Nested Projects -``` -root/a/ ← ProjectA - root/a/b/ ← ProjectB - root/a/b/c/ ← ProjectC -``` - -API always returns the **most specific** (deepest) project for a given URI. - -## Algorithm Summary - -```typescript -async function assignTestsToProjects( - allProjects: ProjectAdapter[], - testController: TestController -): Promise { - for (const project of allProjects) { - // 1. Run discovery with project's Python executable - const discoveredTests = await project.discoverTests(); - - // 2. Filter to tests actually owned by this project - const ownedTests = []; - for (const test of discoveredTests) { - const owningProject = await getPythonProject(test.uri); - // 1. Run discovery for all projects - await Promise.all(allProjects.map(p => p.discoverTests())); - - // 2. Build overlap detection map - const testFileToProjects = new Map>(); - for (const project of allProjects) { - for (const testFile of project.discoveredTestFiles) { - if (!testFileToProjects.has(testFile.path)) { - testFileToProjects.set(testFile.path, new Set()); - } - testFileToProjects.get(testFile.path).add(project); - } - } - - // 3. Resolve ownership (query API only when needed) - const testFileToOwner = new Map(); - for (const [filePath, projects] of testFileToProjects) { - if (projects.size === 1) { - // No overlap - assign to only discoverer - const project = [...projects][0]; - // Still check if nested project exists for this path - if (!hasNestedProjectForPath(filePath, allProjects, project)) { - testFileToOwner.set(filePath, project); - continue; - } - } - - // Overlap or nested project exists - use API as source of truth - const owningProject = await getPythonProject(Uri.file(filePath)); - if (owningProject) { - const project = allProjects.find(p => p.projectUri.fsPath === owningProject.uri.fsPath); - if (project) { - testFileToOwner.set(filePath, project); - } - } - } - - // 4. Add tests to their owning project's tree - for (const [filePath, owningProject] of testFileToOwner) { - const tests = owningProject.discoveredTestFiles.get(filePath); - populateProjectTestTree(owningProject, tests); - } -} - -function hasNestedProjectForPath( - testFilePath: string, - allProjects: ProjectAdapter[], - excludeProject?: ProjectAdapter -): boolean { - return allProjects.some(p => - p !== excludeProject && - testFilePath.startsWith(p.projectUri.fsPath) - );project-based ownership, TestItem IDs must include project context: -```typescript -// Instead of: "/root/alice/bob/bob_test.py::t1" -// Use: "projectB::/root/alice/bob/bob_test.py::t1" -testItemId = `${projectId}::${testPath}`; -``` - -### Discovery Filtering in populateTestTree - -The `populateTestTree` function needs to be project-aware: -```typescript -export async function populateTestTree( - testController: TestController, - testTreeData: DiscoveredTestNode, - testRoot: TestItem | undefined, - resultResolver: ITestResultResolver, - projectId: string, - getPythonProject: (uri: Uri) => Promise, - token?: CancellationToken, -): Promise { - // For each discovered test, check ownership - for (const testNode of testTreeData.children) { - const testFileUri = Uri.file(testNode.path); - const owningProject = await getPythonProject(testFileUri); - - // Only add if this project owns the test - if (owningProject?.uri.fsPath === projectId.split('::')[0]) { - // Add test to tree - addTestItemToTree(testController, testNode, testRoot, projectId); - } - } -} -``` - -### ResultResolver Scoping - -Each project's ResultResolver maintains mappings only for tests it owns: -```typescript -class PythonResultResolver { - constructor( - testController: TestController, - testProvider: TestProvider, - workspaceUri: Uri, - projectId: string // Scopes all IDs to this project - ) { - this.projectId = projectId; - } - - // Maps include projectId prefix - runIdToTestItem: Map // "projectA::test.py::t1" -> TestItem - runIdToVSid: Map // "projectA::test.py::t1" -> vsCodeId - vsIdToRunId: Map // vsCodeId -> "projectA::test.py::t1" -} -``` - ---- - -**Key Takeaway**: Discovery finds tests broadly; the PythonProject API decides ownership narrowly. diff --git a/docs/project-based-testing-design.md b/docs/project-based-testing-design.md index 3130b6a84977..e1427266695d 100644 --- a/docs/project-based-testing-design.md +++ b/docs/project-based-testing-design.md @@ -751,6 +751,34 @@ function createDefaultProject(workspaceUri) { } ``` +**Workspaces Without Environment Extension:** + +When the Python Environments extension is not available or returns no projects: + +1. **Detection**: `discoverWorkspaceProjects()` catches API errors or empty results +2. **Fallback Strategy**: Calls `createDefaultProject(workspaceUri)` which: + - Uses the workspace's active interpreter via `interpreterService.getActiveInterpreter()` + - Creates a mock `PythonProject` with workspace URI as project root + - Generates a project ID from the workspace URI: `default-{workspaceUri.fsPath}` + - Mimics the legacy single-workspace behavior, but wrapped in `ProjectAdapter` structure + +3. **Key Characteristics**: + - Single project per workspace (legacy behavior preserved) + - No project scoping in test IDs (projectId is optional in resolver) + - Uses workspace-level Python interpreter settings + - All tests belong to this single "default" project + - Fully compatible with existing test discovery/execution flows + +4. **Graceful Upgrade Path**: + - When user later installs the Python Environments extension, next discovery will: + - Detect actual Python projects in the workspace + - Replace the default project with real project adapters + - Rebuild test tree with proper project scoping + - No data migration needed - discovery rebuilds from scratch + +This design ensures zero functional degradation for users without the new extension, while providing an instant upgrade path when they adopt it. +``` + --- ### 5. Project Discovery Triggers diff --git a/env-api.js b/env-api.js deleted file mode 100644 index 1ba5a52dd449..000000000000 --- a/env-api.js +++ /dev/null @@ -1,14 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.PackageChangeKind = exports.EnvironmentChangeKind = void 0; -var EnvironmentChangeKind; -(function (EnvironmentChangeKind) { - EnvironmentChangeKind["add"] = "add"; - EnvironmentChangeKind["remove"] = "remove"; -})(EnvironmentChangeKind || (exports.EnvironmentChangeKind = EnvironmentChangeKind = {})); -var PackageChangeKind; -(function (PackageChangeKind) { - PackageChangeKind["add"] = "add"; - PackageChangeKind["remove"] = "remove"; -})(PackageChangeKind || (exports.PackageChangeKind = PackageChangeKind = {})); -//# sourceMappingURL=env-api.js.map \ No newline at end of file diff --git a/env-api.js.map b/env-api.js.map deleted file mode 100644 index f67ee2559f8a..000000000000 --- a/env-api.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"env-api.js","sourceRoot":"","sources":["env-api.ts"],"names":[],"mappings":";;;AA8RA,IAAY,qBAUX;AAVD,WAAY,qBAAqB;IAI7B,oCAAW,CAAA;IAKX,0CAAiB,CAAA;AACrB,CAAC,EAVW,qBAAqB,qCAArB,qBAAqB,QAUhC;AAyOD,IAAY,iBAUX;AAVD,WAAY,iBAAiB;IAIzB,gCAAW,CAAA;IAKX,sCAAiB,CAAA;AACrB,CAAC,EAVW,iBAAiB,iCAAjB,iBAAiB,QAU5B"} \ No newline at end of file diff --git a/env-api.ts b/env-api.ts deleted file mode 100644 index 0b60339b6bd2..000000000000 --- a/env-api.ts +++ /dev/null @@ -1,1265 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { - Disposable, - Event, - FileChangeType, - LogOutputChannel, - MarkdownString, - TaskExecution, - Terminal, - TerminalOptions, - ThemeIcon, - Uri, -} from 'vscode'; - -/** - * The path to an icon, or a theme-specific configuration of icons. - */ -export type IconPath = - | Uri - | { - /** - * The icon path for the light theme. - */ - light: Uri; - /** - * The icon path for the dark theme. - */ - dark: Uri; - } - | ThemeIcon; - -/** - * Options for executing a Python executable. - */ -export interface PythonCommandRunConfiguration { - /** - * Path to the binary like `python.exe` or `python3` to execute. This should be an absolute path - * to an executable that can be spawned. - */ - executable: string; - - /** - * Arguments to pass to the python executable. These arguments will be passed on all execute calls. - * This is intended for cases where you might want to do interpreter specific flags. - */ - args?: string[]; -} - -/** - * Contains details on how to use a particular python environment - * - * Running In Terminal: - * 1. If {@link PythonEnvironmentExecutionInfo.activatedRun} is provided, then that will be used. - * 2. If {@link PythonEnvironmentExecutionInfo.activatedRun} is not provided, then: - * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is known, then that will be used. - * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is not known, then: - * - 'unknown' will be used if provided. - * - {@link PythonEnvironmentExecutionInfo.activation} will be used otherwise. - * - If {@link PythonEnvironmentExecutionInfo.shellActivation} is not provided, then {@link PythonEnvironmentExecutionInfo.activation} will be used. - * - If {@link PythonEnvironmentExecutionInfo.activation} is not provided, then {@link PythonEnvironmentExecutionInfo.run} will be used. - * - * Creating a Terminal: - * 1. If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is known, then that will be used. - * 2. If {@link PythonEnvironmentExecutionInfo.shellActivation} is provided and shell type is not known, then {@link PythonEnvironmentExecutionInfo.activation} will be used. - * 3. If {@link PythonEnvironmentExecutionInfo.shellActivation} is not provided, then: - * - 'unknown' will be used if provided. - * - {@link PythonEnvironmentExecutionInfo.activation} will be used otherwise. - * 4. If {@link PythonEnvironmentExecutionInfo.activation} is not provided, then {@link PythonEnvironmentExecutionInfo.run} will be used. - * - */ -export interface PythonEnvironmentExecutionInfo { - /** - * Details on how to run the python executable. - */ - run: PythonCommandRunConfiguration; - - /** - * Details on how to run the python executable after activating the environment. - * If set this will overrides the {@link PythonEnvironmentExecutionInfo.run} command. - */ - activatedRun?: PythonCommandRunConfiguration; - - /** - * Details on how to activate an environment. - */ - activation?: PythonCommandRunConfiguration[]; - - /** - * Details on how to activate an environment using a shell specific command. - * If set this will override the {@link PythonEnvironmentExecutionInfo.activation}. - * 'unknown' is used if shell type is not known. - * If 'unknown' is not provided and shell type is not known then - * {@link PythonEnvironmentExecutionInfo.activation} if set. - */ - shellActivation?: Map; - - /** - * Details on how to deactivate an environment. - */ - deactivation?: PythonCommandRunConfiguration[]; - - /** - * Details on how to deactivate an environment using a shell specific command. - * If set this will override the {@link PythonEnvironmentExecutionInfo.deactivation} property. - * 'unknown' is used if shell type is not known. - * If 'unknown' is not provided and shell type is not known then - * {@link PythonEnvironmentExecutionInfo.deactivation} if set. - */ - shellDeactivation?: Map; -} - -/** - * Interface representing the ID of a Python environment. - */ -export interface PythonEnvironmentId { - /** - * The unique identifier of the Python environment. - */ - id: string; - - /** - * The ID of the manager responsible for the Python environment. - */ - managerId: string; -} - -/** - * Display information for an environment group. - */ -export interface EnvironmentGroupInfo { - /** - * The name of the environment group. This is used as an identifier for the group. - * - * Note: The first instance of the group with the given name will be used in the UI. - */ - readonly name: string; - - /** - * The description of the environment group. - */ - readonly description?: string; - - /** - * The tooltip for the environment group, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString; - - /** - * The icon path for the environment group, which can be a string, Uri, or an object with light and dark theme paths. - */ - readonly iconPath?: IconPath; -} - -/** - * Interface representing information about a Python environment. - */ -export interface PythonEnvironmentInfo { - /** - * The name of the Python environment. - */ - readonly name: string; - - /** - * The display name of the Python environment. - */ - readonly displayName: string; - - /** - * The short display name of the Python environment. - */ - readonly shortDisplayName?: string; - - /** - * The display path of the Python environment. - */ - readonly displayPath: string; - - /** - * The version of the Python environment. - */ - readonly version: string; - - /** - * Path to the python binary or environment folder. - */ - readonly environmentPath: Uri; - - /** - * The description of the Python environment. - */ - readonly description?: string; - - /** - * The tooltip for the Python environment, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString; - - /** - * The icon path for the Python environment, which can be a string, Uri, or an object with light and dark theme paths. - */ - readonly iconPath?: IconPath; - - /** - * Information on how to execute the Python environment. This is required for executing Python code in the environment. - */ - readonly execInfo: PythonEnvironmentExecutionInfo; - - /** - * `sys.prefix` is the path to the base directory of the Python installation. Typically obtained by executing `sys.prefix` in the Python interpreter. - * This is required by extension like Jupyter, Pylance, and other extensions to provide better experience with python. - */ - readonly sysPrefix: string; - - /** - * Optional `group` for this environment. This is used to group environments in the Environment Manager UI. - */ - readonly group?: string | EnvironmentGroupInfo; -} - -/** - * Interface representing a Python environment. - */ -export interface PythonEnvironment extends PythonEnvironmentInfo { - /** - * The ID of the Python environment. - */ - readonly envId: PythonEnvironmentId; -} - -/** - * Type representing the scope for setting a Python environment. - * Can be undefined or a URI. - */ -export type SetEnvironmentScope = undefined | Uri | Uri[]; - -/** - * Type representing the scope for getting a Python environment. - * Can be undefined or a URI. - */ -export type GetEnvironmentScope = undefined | Uri; - -/** - * Type representing the scope for creating a Python environment. - * Can be a Python project or 'global'. - */ -export type CreateEnvironmentScope = Uri | Uri[] | 'global'; -/** - * The scope for which environments are to be refreshed. - * - `undefined`: Search for environments globally and workspaces. - * - {@link Uri}: Environments in the workspace/folder or associated with the Uri. - */ -export type RefreshEnvironmentsScope = Uri | undefined; - -/** - * The scope for which environments are required. - * - `"all"`: All environments. - * - `"global"`: Python installations that are usually a base for creating virtual environments. - * - {@link Uri}: Environments for the workspace/folder/file pointed to by the Uri. - */ -export type GetEnvironmentsScope = Uri | 'all' | 'global'; - -/** - * Event arguments for when the current Python environment changes. - */ -export type DidChangeEnvironmentEventArgs = { - /** - * The URI of the environment that changed. - */ - readonly uri: Uri | undefined; - - /** - * The old Python environment before the change. - */ - readonly old: PythonEnvironment | undefined; - - /** - * The new Python environment after the change. - */ - readonly new: PythonEnvironment | undefined; -}; - -/** - * Enum representing the kinds of environment changes. - */ -export enum EnvironmentChangeKind { - /** - * Indicates that an environment was added. - */ - add = 'add', - - /** - * Indicates that an environment was removed. - */ - remove = 'remove', -} - -/** - * Event arguments for when the list of Python environments changes. - */ -export type DidChangeEnvironmentsEventArgs = { - /** - * The kind of change that occurred (add or remove). - */ - kind: EnvironmentChangeKind; - - /** - * The Python environment that was added or removed. - */ - environment: PythonEnvironment; -}[]; - -/** - * Type representing the context for resolving a Python environment. - */ -export type ResolveEnvironmentContext = Uri; - -export interface QuickCreateConfig { - /** - * The description of the quick create step. - */ - readonly description: string; - - /** - * The detail of the quick create step. - */ - readonly detail?: string; -} - -/** - * Interface representing an environment manager. - */ -export interface EnvironmentManager { - /** - * The name of the environment manager. Allowed characters (a-z, A-Z, 0-9, -, _). - */ - readonly name: string; - - /** - * The display name of the environment manager. - */ - readonly displayName?: string; - - /** - * The preferred package manager ID for the environment manager. This is a combination - * of publisher id, extension id, and {@link EnvironmentManager.name package manager name}. - * `.:` - * - * @example - * 'ms-python.python:pip' - */ - readonly preferredPackageManagerId: string; - - /** - * The description of the environment manager. - */ - readonly description?: string; - - /** - * The tooltip for the environment manager, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString | undefined; - - /** - * The icon path for the environment manager, which can be a string, Uri, or an object with light and dark theme paths. - */ - readonly iconPath?: IconPath; - - /** - * The log output channel for the environment manager. - */ - readonly log?: LogOutputChannel; - - /** - * The quick create details for the environment manager. Having this method also enables the quick create feature - * for the environment manager. Should Implement {@link EnvironmentManager.create} to support quick create. - */ - quickCreateConfig?(): QuickCreateConfig | undefined; - - /** - * Creates a new Python environment within the specified scope. Create should support adding a .gitignore file if it creates a folder within the workspace. If a manager does not support environment creation, do not implement this method; the UI disables "create" options when `this.manager.create === undefined`. - * @param scope - The scope within which to create the environment. - * @param options - Optional parameters for creating the Python environment. - * @returns A promise that resolves to the created Python environment, or undefined if creation failed. - */ - create?(scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions): Promise; - - /** - * Removes the specified Python environment. - * @param environment - The Python environment to remove. - * @returns A promise that resolves when the environment is removed. - */ - remove?(environment: PythonEnvironment): Promise; - - /** - * Refreshes the list of Python environments within the specified scope. - * @param scope - The scope within which to refresh environments. - * @returns A promise that resolves when the refresh is complete. - */ - refresh(scope: RefreshEnvironmentsScope): Promise; - - /** - * Retrieves a list of Python environments within the specified scope. - * @param scope - The scope within which to retrieve environments. - * @returns A promise that resolves to an array of Python environments. - */ - getEnvironments(scope: GetEnvironmentsScope): Promise; - - /** - * Event that is fired when the list of Python environments changes. - */ - onDidChangeEnvironments?: Event; - - /** - * Sets the current Python environment within the specified scope. - * @param scope - The scope within which to set the environment. - * @param environment - The Python environment to set. If undefined, the environment is unset. - * @returns A promise that resolves when the environment is set. - */ - set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise; - - /** - * Retrieves the current Python environment within the specified scope. - * @param scope - The scope within which to retrieve the environment. - * @returns A promise that resolves to the current Python environment, or undefined if none is set. - */ - get(scope: GetEnvironmentScope): Promise; - - /** - * Event that is fired when the current Python environment changes. - */ - onDidChangeEnvironment?: Event; - - /** - * Resolves the specified Python environment. The environment can be either a {@link PythonEnvironment} or a {@link Uri} context. - * - * This method is used to obtain a fully detailed {@link PythonEnvironment} object. The input can be: - * - A {@link PythonEnvironment} object, which might be missing key details such as {@link PythonEnvironment.execInfo}. - * - A {@link Uri} object, which typically represents either: - * - A folder that contains the Python environment. - * - The path to a Python executable. - * - * @param context - The context for resolving the environment, which can be a {@link PythonEnvironment} or a {@link Uri}. - * @returns A promise that resolves to the fully detailed {@link PythonEnvironment}, or `undefined` if the environment cannot be resolved. - */ - resolve(context: ResolveEnvironmentContext): Promise; - - /** - * Clears the environment manager's cache. - * - * @returns A promise that resolves when the cache is cleared. - */ - clearCache?(): Promise; -} - -/** - * Interface representing a package ID. - */ -export interface PackageId { - /** - * The ID of the package. - */ - id: string; - - /** - * The ID of the package manager. - */ - managerId: string; - - /** - * The ID of the environment in which the package is installed. - */ - environmentId: string; -} - -/** - * Interface representing package information. - */ -export interface PackageInfo { - /** - * The name of the package. - */ - readonly name: string; - - /** - * The display name of the package. - */ - readonly displayName: string; - - /** - * The version of the package. - */ - readonly version?: string; - - /** - * The description of the package. - */ - readonly description?: string; - - /** - * The tooltip for the package, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString | undefined; - - /** - * The icon path for the package, which can be a string, Uri, or an object with light and dark theme paths. - */ - readonly iconPath?: IconPath; - - /** - * The URIs associated with the package. - */ - readonly uris?: readonly Uri[]; -} - -/** - * Interface representing a package. - */ -export interface Package extends PackageInfo { - /** - * The ID of the package. - */ - readonly pkgId: PackageId; -} - -/** - * Enum representing the kinds of package changes. - */ -export enum PackageChangeKind { - /** - * Indicates that a package was added. - */ - add = 'add', - - /** - * Indicates that a package was removed. - */ - remove = 'remove', -} - -/** - * Event arguments for when packages change. - */ -export interface DidChangePackagesEventArgs { - /** - * The Python environment in which the packages changed. - */ - environment: PythonEnvironment; - - /** - * The package manager responsible for the changes. - */ - manager: PackageManager; - - /** - * The list of changes, each containing the kind of change and the package affected. - */ - changes: { kind: PackageChangeKind; pkg: Package }[]; -} - -/** - * Interface representing a package manager. - */ -export interface PackageManager { - /** - * The name of the package manager. Allowed characters (a-z, A-Z, 0-9, -, _). - */ - name: string; - - /** - * The display name of the package manager. - */ - displayName?: string; - - /** - * The description of the package manager. - */ - description?: string; - - /** - * The tooltip for the package manager, which can be a string or a Markdown string. - */ - tooltip?: string | MarkdownString | undefined; - - /** - * The icon path for the package manager, which can be a string, Uri, or an object with light and dark theme paths. - */ - iconPath?: IconPath; - - /** - * The log output channel for the package manager. - */ - log?: LogOutputChannel; - - /** - * Installs/Uninstall packages in the specified Python environment. - * @param environment - The Python environment in which to install packages. - * @param options - Options for managing packages. - * @returns A promise that resolves when the installation is complete. - */ - manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise; - - /** - * Refreshes the package list for the specified Python environment. - * @param environment - The Python environment for which to refresh the package list. - * @returns A promise that resolves when the refresh is complete. - */ - refresh(environment: PythonEnvironment): Promise; - - /** - * Retrieves the list of packages for the specified Python environment. - * @param environment - The Python environment for which to retrieve packages. - * @returns An array of packages, or undefined if the packages could not be retrieved. - */ - getPackages(environment: PythonEnvironment): Promise; - - /** - * Event that is fired when packages change. - */ - onDidChangePackages?: Event; - - /** - * Clears the package manager's cache. - * @returns A promise that resolves when the cache is cleared. - */ - clearCache?(): Promise; -} - -/** - * Interface representing a Python project. - */ -export interface PythonProject { - /** - * The name of the Python project. - */ - readonly name: string; - - /** - * The URI of the Python project. - */ - readonly uri: Uri; - - /** - * The description of the Python project. - */ - readonly description?: string; - - /** - * The tooltip for the Python project, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString; -} - -/** - * Options for creating a Python project. - */ -export interface PythonProjectCreatorOptions { - /** - * The name of the Python project. - */ - name: string; - - /** - * Path provided as the root for the project. - */ - rootUri: Uri; - - /** - * Boolean indicating whether the project should be created without any user input. - */ - quickCreate?: boolean; -} - -/** - * Interface representing a creator for Python projects. - */ -export interface PythonProjectCreator { - /** - * The name of the Python project creator. - */ - readonly name: string; - - /** - * The display name of the Python project creator. - */ - readonly displayName?: string; - - /** - * The description of the Python project creator. - */ - readonly description?: string; - - /** - * The tooltip for the Python project creator, which can be a string or a Markdown string. - */ - readonly tooltip?: string | MarkdownString; - - /** - * Creates a new Python project(s) or, if files are not a project, returns Uri(s) to the created files. - * Anything that needs its own python environment constitutes a project. - * @param options Optional parameters for creating the Python project. - * @returns A promise that resolves to one of the following: - * - PythonProject or PythonProject[]: when a single or multiple projects are created. - * - Uri or Uri[]: when files are created that do not constitute a project. - * - undefined: if project creation fails. - */ - create(options?: PythonProjectCreatorOptions): Promise; - - /** - * A flag indicating whether the project creator supports quick create where no user input is required. - */ - readonly supportsQuickCreate?: boolean; -} - -/** - * Event arguments for when Python projects change. - */ -export interface DidChangePythonProjectsEventArgs { - /** - * The list of Python projects that were added. - */ - added: PythonProject[]; - - /** - * The list of Python projects that were removed. - */ - removed: PythonProject[]; -} - -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; - -/** - * Options for creating a Python environment. - */ -export interface CreateEnvironmentOptions { - /** - * Provides some context about quick create based on user input. - * - if true, the environment should be created without any user input or prompts. - * - if false, the environment creation can show user input or prompts. - * This also means user explicitly skipped the quick create option. - * - if undefined, the environment creation can show user input or prompts. - * You can show quick create option to the user if you support it. - */ - quickCreate?: boolean; - /** - * Packages to install in addition to the automatically picked packages as a part of creating environment. - */ - additionalPackages?: string[]; -} - -/** - * Object representing the process started using run in background API. - */ -export interface PythonProcess { - /** - * The process ID of the Python process. - */ - readonly pid?: number; - - /** - * The standard input of the Python process. - */ - readonly stdin: NodeJS.WritableStream; - - /** - * The standard output of the Python process. - */ - readonly stdout: NodeJS.ReadableStream; - - /** - * The standard error of the Python process. - */ - readonly stderr: NodeJS.ReadableStream; - - /** - * Kills the Python process. - */ - kill(): void; - - /** - * Event that is fired when the Python process exits. - */ - onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; -} - -export interface PythonEnvironmentManagerRegistrationApi { - /** - * Register an environment manager implementation. - * - * @param manager Environment Manager implementation to register. - * @returns A disposable that can be used to unregister the environment manager. - * @see {@link EnvironmentManager} - */ - registerEnvironmentManager(manager: EnvironmentManager): Disposable; -} - -export interface PythonEnvironmentItemApi { - /** - * Create a Python environment item from the provided environment info. This item is used to interact - * with the environment. - * - * @param info Some details about the environment like name, version, etc. needed to interact with the environment. - * @param manager The environment manager to associate with the environment. - * @returns The Python environment. - */ - createPythonEnvironmentItem(info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment; -} - -export interface PythonEnvironmentManagementApi { - /** - * Create a Python environment using environment manager associated with the scope. - * - * @param scope Where the environment is to be created. - * @param options Optional parameters for creating the Python environment. - * @returns The Python environment created. `undefined` if not created. - */ - createEnvironment( - scope: CreateEnvironmentScope, - options?: CreateEnvironmentOptions, - ): Promise; - - /** - * Remove a Python environment. - * - * @param environment The Python environment to remove. - * @returns A promise that resolves when the environment has been removed. - */ - removeEnvironment(environment: PythonEnvironment): Promise; -} - -export interface PythonEnvironmentsApi { - /** - * Initiates a refresh of Python environments within the specified scope. - * @param scope - The scope within which to search for environments. - * @returns A promise that resolves when the search is complete. - */ - refreshEnvironments(scope: RefreshEnvironmentsScope): Promise; - - /** - * Retrieves a list of Python environments within the specified scope. - * @param scope - The scope within which to retrieve environments. - * @returns A promise that resolves to an array of Python environments. - */ - getEnvironments(scope: GetEnvironmentsScope): Promise; - - /** - * Event that is fired when the list of Python environments changes. - * @see {@link DidChangeEnvironmentsEventArgs} - */ - onDidChangeEnvironments: Event; - - /** - * This method is used to get the details missing from a PythonEnvironment. Like - * {@link PythonEnvironment.execInfo} and other details. - * - * @param context : The PythonEnvironment or Uri for which details are required. - */ - resolveEnvironment(context: ResolveEnvironmentContext): Promise; -} - -export interface PythonProjectEnvironmentApi { - /** - * Sets the current Python environment within the specified scope. - * @param scope - The scope within which to set the environment. - * @param environment - The Python environment to set. If undefined, the environment is unset. - */ - setEnvironment(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise; - - /** - * Retrieves the current Python environment within the specified scope. - * @param scope - The scope within which to retrieve the environment. - * @returns A promise that resolves to the current Python environment, or undefined if none is set. - */ - getEnvironment(scope: GetEnvironmentScope): Promise; - - /** - * Event that is fired when the selected Python environment changes for Project, Folder or File. - * @see {@link DidChangeEnvironmentEventArgs} - */ - onDidChangeEnvironment: Event; -} - -export interface PythonEnvironmentManagerApi - extends PythonEnvironmentManagerRegistrationApi, - PythonEnvironmentItemApi, - PythonEnvironmentManagementApi, - PythonEnvironmentsApi, - PythonProjectEnvironmentApi {} - -export interface PythonPackageManagerRegistrationApi { - /** - * Register a package manager implementation. - * - * @param manager Package Manager implementation to register. - * @returns A disposable that can be used to unregister the package manager. - * @see {@link PackageManager} - */ - registerPackageManager(manager: PackageManager): Disposable; -} - -export interface PythonPackageGetterApi { - /** - * Refresh the list of packages in a Python Environment. - * - * @param environment The Python Environment for which the list of packages is to be refreshed. - * @returns A promise that resolves when the list of packages has been refreshed. - */ - refreshPackages(environment: PythonEnvironment): Promise; - - /** - * Get the list of packages in a Python Environment. - * - * @param environment The Python Environment for which the list of packages is required. - * @returns The list of packages in the Python Environment. - */ - getPackages(environment: PythonEnvironment): Promise; - - /** - * Event raised when the list of packages in a Python Environment changes. - * @see {@link DidChangePackagesEventArgs} - */ - onDidChangePackages: Event; -} - -export interface PythonPackageItemApi { - /** - * Create a package item from the provided package info. - * - * @param info The package info. - * @param environment The Python Environment in which the package is installed. - * @param manager The package manager that installed the package. - * @returns The package item. - */ - createPackageItem(info: PackageInfo, environment: PythonEnvironment, manager: PackageManager): Package; -} - -export interface PythonPackageManagementApi { - /** - * Install/Uninstall packages into a Python Environment. - * - * @param environment The Python Environment into which packages are to be installed. - * @param packages The packages to install. - * @param options Options for installing packages. - */ - managePackages(environment: PythonEnvironment, options: PackageManagementOptions): Promise; -} - -export interface PythonPackageManagerApi - extends PythonPackageManagerRegistrationApi, - PythonPackageGetterApi, - PythonPackageManagementApi, - PythonPackageItemApi {} - -export interface PythonProjectCreationApi { - /** - * Register a Python project creator. - * - * @param creator The project creator to register. - * @returns A disposable that can be used to unregister the project creator. - * @see {@link PythonProjectCreator} - */ - registerPythonProjectCreator(creator: PythonProjectCreator): Disposable; -} -export interface PythonProjectGetterApi { - /** - * Get all python projects. - */ - getPythonProjects(): readonly PythonProject[]; - - /** - * Get the python project for a given URI. - * - * @param uri The URI of the project - * @returns The project or `undefined` if not found. - */ - getPythonProject(uri: Uri): PythonProject | undefined; -} - -export interface PythonProjectModifyApi { - /** - * Add a python project or projects to the list of projects. - * - * @param projects The project or projects to add. - */ - addPythonProject(projects: PythonProject | PythonProject[]): void; - - /** - * Remove a python project from the list of projects. - * - * @param project The project to remove. - */ - removePythonProject(project: PythonProject): void; - - /** - * Event raised when python projects are added or removed. - * @see {@link DidChangePythonProjectsEventArgs} - */ - onDidChangePythonProjects: Event; -} - -/** - * The API for interacting with Python projects. A project in python is any folder or file that is a contained - * in some manner. For example, a PEP-723 compliant file can be treated as a project. A folder with a `pyproject.toml`, - * or just python files can be treated as a project. All this allows you to do is set a python environment for that project. - * - * By default all `vscode.workspace.workspaceFolders` are treated as projects. - */ -export interface PythonProjectApi extends PythonProjectCreationApi, PythonProjectGetterApi, PythonProjectModifyApi {} - -export interface PythonTerminalCreateOptions extends TerminalOptions { - /** - * Whether to disable activation on create. - */ - disableActivation?: boolean; -} - -export interface PythonTerminalCreateApi { - /** - * Creates a terminal and activates any (activatable) environment for the terminal. - * - * @param environment The Python environment to activate. - * @param options Options for creating the terminal. - * - * Note: Non-activatable environments have no effect on the terminal. - */ - createTerminal(environment: PythonEnvironment, options: PythonTerminalCreateOptions): Promise; -} - -/** - * Options for running a Python script or module in a terminal. - * - * Example: - * * Running Script: `python myscript.py --arg1` - * ```typescript - * { - * args: ["myscript.py", "--arg1"] - * } - * ``` - * * Running a module: `python -m my_module --arg1` - * ```typescript - * { - * args: ["-m", "my_module", "--arg1"] - * } - * ``` - */ -export interface PythonTerminalExecutionOptions { - /** - * Current working directory for the terminal. This in only used to create the terminal. - */ - cwd: string | Uri; - - /** - * Arguments to pass to the python executable. - */ - args?: string[]; - - /** - * Set `true` to show the terminal. - */ - show?: boolean; -} - -export interface PythonTerminalRunApi { - /** - * Runs a Python script or module in a terminal. This API will create a terminal if one is not available to use. - * If a terminal is available, it will be used to run the script or module. - * - * Note: - * - If you restart VS Code, this will create a new terminal, this is a limitation of VS Code. - * - If you close the terminal, this will create a new terminal. - * - In cases of multi-root/project scenario, it will create a separate terminal for each project. - */ - runInTerminal(environment: PythonEnvironment, options: PythonTerminalExecutionOptions): Promise; - - /** - * Runs a Python script or module in a dedicated terminal. This API will create a terminal if one is not available to use. - * If a terminal is available, it will be used to run the script or module. This terminal will be dedicated to the script, - * and selected based on the `terminalKey`. - * - * @param terminalKey A unique key to identify the terminal. For scripts you can use the Uri of the script file. - */ - runInDedicatedTerminal( - terminalKey: Uri | string, - environment: PythonEnvironment, - options: PythonTerminalExecutionOptions, - ): Promise; -} - -/** - * Options for running a Python task. - * - * Example: - * * Running Script: `python myscript.py --arg1` - * ```typescript - * { - * args: ["myscript.py", "--arg1"] - * } - * ``` - * * Running a module: `python -m my_module --arg1` - * ```typescript - * { - * args: ["-m", "my_module", "--arg1"] - * } - * ``` - */ -export interface PythonTaskExecutionOptions { - /** - * Name of the task to run. - */ - name: string; - - /** - * Arguments to pass to the python executable. - */ - args: string[]; - - /** - * The Python project to use for the task. - */ - project?: PythonProject; - - /** - * Current working directory for the task. Default is the project directory for the script being run. - */ - cwd?: string; - - /** - * Environment variables to set for the task. - */ - env?: { [key: string]: string }; -} - -export interface PythonTaskRunApi { - /** - * Run a Python script or module as a task. - * - */ - runAsTask(environment: PythonEnvironment, options: PythonTaskExecutionOptions): Promise; -} - -/** - * Options for running a Python script or module in the background. - */ -export interface PythonBackgroundRunOptions { - /** - * The Python environment to use for running the script or module. - */ - args: string[]; - - /** - * Current working directory for the script or module. Default is the project directory for the script being run. - */ - cwd?: string; - - /** - * Environment variables to set for the script or module. - */ - env?: { [key: string]: string | undefined }; -} -export interface PythonBackgroundRunApi { - /** - * Run a Python script or module in the background. This API will create a new process to run the script or module. - */ - runInBackground(environment: PythonEnvironment, options: PythonBackgroundRunOptions): Promise; -} - -export interface PythonExecutionApi - extends PythonTerminalCreateApi, - PythonTerminalRunApi, - PythonTaskRunApi, - PythonBackgroundRunApi {} - -/** - * Event arguments for when the monitored `.env` files or any other sources change. - */ -export interface DidChangeEnvironmentVariablesEventArgs { - /** - * The URI of the file that changed. No `Uri` means a non-file source of environment variables changed. - */ - uri?: Uri; - - /** - * The type of change that occurred. - */ - changeType: FileChangeType; -} - -export interface PythonEnvironmentVariablesApi { - /** - * Get environment variables for a workspace. This picks up `.env` file from the root of the - * workspace. - * - * Order of overrides: - * 1. `baseEnvVar` if given or `process.env` - * 2. `.env` file from the "python.envFile" setting in the workspace. - * 3. `.env` file at the root of the python project. - * 4. `overrides` in the order provided. - * - * @param uri The URI of the project, workspace or a file in a for which environment variables are required.If not provided, - * it fetches the environment variables for the global scope. - * @param overrides Additional environment variables to override the defaults. - * @param baseEnvVar The base environment variables that should be used as a starting point. - */ - getEnvironmentVariables( - uri: Uri | undefined, - overrides?: ({ [key: string]: string | undefined } | Uri)[], - baseEnvVar?: { [key: string]: string | undefined }, - ): Promise<{ [key: string]: string | undefined }>; - - /** - * Event raised when `.env` file changes or any other monitored source of env variable changes. - */ - onDidChangeEnvironmentVariables: Event; -} - -/** - * The API for interacting with Python environments, package managers, and projects. - */ -export interface PythonEnvironmentApi - extends PythonEnvironmentManagerApi, - PythonPackageManagerApi, - PythonProjectApi, - PythonExecutionApi, - PythonEnvironmentVariablesApi {} diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index be74235263b9..859548956232 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -6,10 +6,16 @@ import { Uri } from 'vscode'; import { ProjectAdapter } from './projectAdapter'; import { PythonProject } from '../../../envExt/types'; +/** + * Separator used between project ID and test path in project-scoped test IDs. + * Using | instead of :: to avoid conflicts with pytest's :: syntax for test paths. + */ +export const PROJECT_ID_SEPARATOR = '|'; + /** * Generates a unique project ID by hashing the PythonProject object. * This ensures consistent IDs across extension reloads for the same project. - * + * * @param pythonProject The PythonProject object from the environment API * @returns A unique string identifier for the project */ @@ -19,7 +25,7 @@ export function generateProjectId(pythonProject: PythonProject): string { name: pythonProject.name, uri: pythonProject.uri.toString(), }); - + // Generate a hash to create a shorter, unique ID const hash = crypto.createHash('sha256').update(projectString).digest('hex'); return `project-${hash.substring(0, 12)}`; @@ -27,31 +33,31 @@ export function generateProjectId(pythonProject: PythonProject): string { /** * Creates a project-scoped VS Code test item ID. - * Format: "{projectId}::{testPath}" - * + * Format: "{projectId}|{testPath}" + * * @param projectId The unique project identifier * @param testPath The test path (e.g., "/workspace/test.py::test_func") * @returns The project-scoped VS Code test ID */ export function createProjectScopedVsId(projectId: string, testPath: string): string { - return `${projectId}::${testPath}`; + return `${projectId}${PROJECT_ID_SEPARATOR}${testPath}`; } /** * Parses a project-scoped VS Code test ID to extract the project ID and test path. - * + * * @param vsId The VS Code test item ID * @returns Object containing projectId and testPath, or null if invalid */ export function parseProjectScopedVsId(vsId: string): { projectId: string; testPath: string } | null { - const separatorIndex = vsId.indexOf('::'); + const separatorIndex = vsId.indexOf(PROJECT_ID_SEPARATOR); if (separatorIndex === -1) { return null; } - + return { projectId: vsId.substring(0, separatorIndex), - testPath: vsId.substring(separatorIndex + 2), + testPath: vsId.substring(separatorIndex + PROJECT_ID_SEPARATOR.length), }; } @@ -59,7 +65,7 @@ export function parseProjectScopedVsId(vsId: string): { projectId: string; testP * Checks if a test file path is within a nested project's directory. * This is used to determine when to query the API for ownership even if * only one project discovered the file. - * + * * @param testFilePath Absolute path to the test file * @param allProjects All projects in the workspace * @param excludeProject Optional project to exclude from the check (typically the discoverer) @@ -70,17 +76,13 @@ export function hasNestedProjectForPath( allProjects: ProjectAdapter[], excludeProject?: ProjectAdapter, ): boolean { - return allProjects.some( - (p) => - p !== excludeProject && - testFilePath.startsWith(p.projectUri.fsPath), - ); + return allProjects.some((p) => p !== excludeProject && testFilePath.startsWith(p.projectUri.fsPath)); } /** * Finds the project that owns a specific test file based on project URI. * This is typically used after the API returns ownership information. - * + * * @param projectUri The URI of the owning project (from API) * @param allProjects All projects to search * @returns The ProjectAdapter with matching URI, or undefined if not found @@ -92,7 +94,7 @@ export function findProjectByUri(projectUri: Uri, allProjects: ProjectAdapter[]) /** * Creates a display name for a project including Python version. * Format: "{projectName} (Python {version})" - * + * * @param projectName The name of the project * @param pythonVersion The Python version string (e.g., "3.11.2") * @returns Formatted display name @@ -101,6 +103,6 @@ export function createProjectDisplayName(projectName: string, pythonVersion: str // Extract major.minor version if full version provided const versionMatch = pythonVersion.match(/^(\d+\.\d+)/); const shortVersion = versionMatch ? versionMatch[1] : pythonVersion; - + return `${projectName} (Python ${shortVersion})`; } diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index 7cd4352c7de4..acb2d083aa32 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -28,7 +28,7 @@ export class PythonResultResolver implements ITestResultResolver { /** * Optional project ID for scoping test IDs. - * When set, all test IDs are prefixed with "{projectId}::" for project-based testing. + * When set, all test IDs are prefixed with "{projectId}|" for project-based testing. * When undefined, uses legacy workspace-level IDs for backward compatibility. */ private projectId?: string; diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index cadbdf1eb1d1..8af48a203680 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -10,6 +10,7 @@ import { Testing } from '../../../common/utils/localize'; import { createErrorTestItem } from './testItemUtilities'; import { buildErrorNodeOptions, populateTestTree } from './utils'; import { TestItemIndex } from './testItemIndex'; +import { PROJECT_ID_SEPARATOR } from './projectUtils'; /** * Stateless handler for processing discovery payloads and building/updating the TestItem tree. @@ -43,7 +44,7 @@ export class TestDiscoveryHandler { } else { // remove error node only if no errors exist. const errorNodeId = projectId - ? `${projectId}::DiscoveryError:${workspacePath}` + ? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}` : `DiscoveryError:${workspacePath}`; testController.items.delete(errorNodeId); } @@ -90,7 +91,7 @@ export class TestDiscoveryHandler { traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); const errorNodeId = projectId - ? `${projectId}::DiscoveryError:${workspacePath}` + ? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}` : `DiscoveryError:${workspacePath}`; let errorNode = testController.items.get(errorNodeId); const message = util.format( diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 86d5cc9063bd..20bb6e08cd37 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -18,6 +18,7 @@ import { import { Deferred, createDeferred } from '../../../common/utils/async'; import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes'; import { EXTENSION_ROOT_DIR } from '../../../constants'; +import { PROJECT_ID_SEPARATOR } from './projectUtils'; export function fixLogLinesNoTrailing(content: string): string { const lines = content.split(/\r?\n/g); @@ -216,7 +217,7 @@ export function populateTestTree( // If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller. if (!testRoot) { // Create project-scoped ID if projectId is provided - const rootId = projectId ? `${projectId}::${testTreeData.path}` : testTreeData.path; + const rootId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${testTreeData.path}` : testTreeData.path; testRoot = testController.createTestItem(rootId, testTreeData.name, Uri.file(testTreeData.path)); testRoot.canResolveChildren = true; @@ -230,7 +231,7 @@ export function populateTestTree( if (!token?.isCancellationRequested) { if (isTestItem(child)) { // Create project-scoped vsId - const vsId = projectId ? `${projectId}::${child.id_}` : child.id_; + const vsId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_; const testItem = testController.createTestItem(vsId, child.name, Uri.file(child.path)); testItem.tags = [RunTestTag, DebugTestTag]; @@ -259,7 +260,7 @@ export function populateTestTree( if (!node) { // Create project-scoped ID for non-test nodes - const nodeId = projectId ? `${projectId}::${child.id_}` : child.id_; + const nodeId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_; node = testController.createTestItem(nodeId, child.name, Uri.file(child.path)); node.canResolveChildren = true; diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 57b4e47c55b7..0cd8483b0cee 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -29,7 +29,7 @@ import { IConfigurationService, IDisposableRegistry, Resource } from '../../comm import { DelayedTrigger, IDelayedTrigger } from '../../common/utils/delayTrigger'; import { noop } from '../../common/utils/misc'; import { IInterpreterService } from '../../interpreter/contracts'; -import { traceError, traceVerbose } from '../../logging'; +import { traceError, traceInfo, traceVerbose } from '../../logging'; import { IEventNamePropertyMapping, sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; @@ -54,7 +54,7 @@ import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter'; import { generateProjectId, createProjectDisplayName } from './common/projectUtils'; -import { PythonEnvironmentApi, PythonProject, PythonEnvironment } from '../../envExt/types'; +import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. @@ -73,12 +73,16 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Map of workspace URI -> Map of project ID -> ProjectAdapter private readonly workspaceProjects: Map> = new Map(); - // Fast lookup maps for test execution + // Fast lookup maps for execution + // @ts-expect-error - used when useProjectBasedTesting=true private readonly vsIdToProject: Map = new Map(); + // @ts-expect-error - used when useProjectBasedTesting=true private readonly fileUriToProject: Map = new Map(); + // @ts-expect-error - used when useProjectBasedTesting=true private readonly projectToVsIds: Map> = new Map(); // Temporary discovery state (created during discovery, cleared after) + // @ts-expect-error - used when useProjectBasedTesting=true private readonly workspaceDiscoveryState: Map = new Map(); // Flag to enable/disable project-based testing @@ -180,14 +184,65 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }; } + /** + * Creates test adapters (discovery and execution) for a given test provider. + * Centralizes adapter creation to reduce code duplication. + */ + private createTestAdapters( + testProvider: TestProvider, + resultResolver: PythonResultResolver, + ): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { + if (testProvider === UNITTEST_PROVIDER) { + return { + discoveryAdapter: new UnittestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ), + executionAdapter: new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ), + }; + } + + return { + discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService), + executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService), + }; + } + + /** + * Determines the test provider (pytest or unittest) based on workspace settings. + */ + private getTestProvider(workspaceUri: Uri): TestProvider { + const settings = this.configSettings.getSettings(workspaceUri); + return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; + } + + /** + * Sets up file watchers for test discovery triggers. + */ + private setupFileWatchers(workspace: WorkspaceFolder): void { + const settings = this.configSettings.getSettings(workspace.uri); + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } + } + public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; // Try to use project-based testing if enabled if (this.useProjectBasedTesting) { + traceInfo('[test-by-project] Activating project-based testing mode'); try { await Promise.all( Array.from(workspaces).map(async (workspace) => { + traceInfo(`[test-by-project] Processing workspace: ${workspace.uri.fsPath}`); try { // Discover projects in this workspace const projects = await this.discoverWorkspaceProjects(workspace.uri); @@ -200,8 +255,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.workspaceProjects.set(workspace.uri, projectsMap); - traceVerbose( - `Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, + traceInfo( + `[test-by-project] Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, ); // Set up file watchers if auto-discovery is enabled @@ -212,7 +267,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.watchForTestContentChangeOnSave(); } } catch (error) { - traceError(`Failed to activate project-based testing for ${workspace.uri.fsPath}:`, error); + traceError( + `[test-by-project] Failed to activate project-based testing for ${workspace.uri.fsPath}:`, + error, + ); + traceInfo('[test-by-project] Falling back to legacy mode for this workspace'); // Fall back to legacy mode for this workspace await this.activateLegacyWorkspace(workspace); } @@ -220,7 +279,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc ); return; } catch (error) { - traceError('Failed to activate project-based testing, falling back to legacy mode:', error); + traceError( + '[test-by-project] Failed to activate project-based testing, falling back to legacy mode:', + error, + ); + traceInfo('[test-by-project] Disabling project-based testing for this session'); this.useProjectBasedTesting = false; } } @@ -236,40 +299,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc * Used for backward compatibility when project-based testing is disabled or unavailable. */ private activateLegacyWorkspace(workspace: WorkspaceFolder): void { - const settings = this.configSettings.getSettings(workspace.uri); - - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; - - if (settings.testing.unittestEnabled) { - testProvider = UNITTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - testProvider = PYTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new PytestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } + const testProvider = this.getTestProvider(workspace.uri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); const workspaceTestAdapter = new WorkspaceTestAdapter( testProvider, @@ -280,12 +312,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc ); this.testAdapters.set(workspace.uri, workspaceTestAdapter); - - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); - } + this.setupFileWatchers(workspace); } /** @@ -293,27 +320,31 @@ export class PythonTestController implements ITestController, IExtensionSingleAc * Falls back to creating a single default project if API is unavailable or returns no projects. */ private async discoverWorkspaceProjects(workspaceUri: Uri): Promise { + traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`); try { // Check if we should use the environment extension if (!useEnvExtension()) { - traceVerbose('Python Environments extension not enabled, using single project mode'); + traceInfo('[test-by-project] Python Environments extension not enabled, using single project mode'); return [await this.createDefaultProject(workspaceUri)]; } // Get the environment API const envExtApi = await getEnvExtApi(); - + traceInfo('[test-by-project] Successfully retrieved Python Environments API'); + // Query for all Python projects in this workspace const pythonProjects = envExtApi.getPythonProjects(); - + traceInfo(`[test-by-project] Found ${pythonProjects.length} total Python projects from API`); + // Filter projects to only those in this workspace - const workspaceProjects = pythonProjects.filter( - (project) => project.uri.fsPath.startsWith(workspaceUri.fsPath), + const workspaceProjects = pythonProjects.filter((project) => + project.uri.fsPath.startsWith(workspaceUri.fsPath), ); + traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); if (workspaceProjects.length === 0) { - traceVerbose( - `No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`, + traceInfo( + `[test-by-project] No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`, ); return [await this.createDefaultProject(workspaceUri)]; } @@ -325,19 +356,26 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); projectAdapters.push(adapter); } catch (error) { - traceError(`Failed to create project adapter for ${pythonProject.uri.fsPath}:`, error); + traceError( + `[test-by-project] Failed to create project adapter for ${pythonProject.uri.fsPath}:`, + error, + ); // Continue with other projects } } if (projectAdapters.length === 0) { - traceVerbose('All project adapters failed to create, falling back to default project'); + traceInfo('[test-by-project] All project adapters failed to create, falling back to default project'); return [await this.createDefaultProject(workspaceUri)]; } + traceInfo(`[test-by-project] Successfully created ${projectAdapters.length} project adapter(s)`); return projectAdapters; } catch (error) { - traceError('Failed to discover workspace projects, falling back to single project mode:', error); + traceError( + '[test-by-project] Failed to discover workspace projects, falling back to single project mode:', + error, + ); return [await this.createDefaultProject(workspaceUri)]; } } @@ -345,10 +383,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Creates a ProjectAdapter from a PythonProject object. */ - private async createProjectAdapter( - pythonProject: PythonProject, - workspaceUri: Uri, - ): Promise { + private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { + traceInfo( + `[test-by-project] Creating project adapter for: ${pythonProject.name} at ${pythonProject.uri.fsPath}`, + ); // Generate unique project ID const projectId = generateProjectId(pythonProject); @@ -360,53 +398,20 @@ export class PythonTestController implements ITestController, IExtensionSingleAc throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`); } - // Get workspace settings (shared by all projects in workspace) - const settings = this.configSettings.getSettings(workspaceUri); + // Get test provider and create resolver + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); - // Determine test provider - const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; - - // Create result resolver with project ID - const resultResolver = new PythonResultResolver( - this.testController, - testProvider, - workspaceUri, - projectId, - ); - - // Create discovery and execution adapters - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - - if (testProvider === UNITTEST_PROVIDER) { - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - discoveryAdapter = new PytestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } + // Create adapters + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); // Create display name with Python version const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + traceInfo(`[test-by-project] Created project adapter: ${projectName} (ID: ${projectId})`); + // Create project adapter - const projectAdapter: ProjectAdapter = { + return { projectId, projectName, projectUri: pythonProject.uri, @@ -420,8 +425,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc isDiscovering: false, isExecuting: false, }; - - return projectAdapter; } /** @@ -429,39 +432,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc * Used for backward compatibility when environment API is unavailable. */ private async createDefaultProject(workspaceUri: Uri): Promise { - const settings = this.configSettings.getSettings(workspaceUri); - const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; - - // Create result resolver WITHOUT project ID (legacy mode) + traceInfo(`[test-by-project] Creating default project for workspace: ${workspaceUri.fsPath}`); + // Get test provider and create resolver (WITHOUT project ID for legacy mode) + const testProvider = this.getTestProvider(workspaceUri); const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); - // Create discovery and execution adapters - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - - if (testProvider === UNITTEST_PROVIDER) { - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - discoveryAdapter = new PytestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } + // Create adapters + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); // Get active interpreter const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); @@ -495,7 +472,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Use workspace URI as project ID for default project const projectId = `default-${workspaceUri.fsPath}`; - const projectAdapter: ProjectAdapter = { + return { projectId, projectName: pythonProject.name, projectUri: workspaceUri, @@ -509,8 +486,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc isDiscovering: false, isExecuting: false, }; - - return projectAdapter; } public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { From 133d97e94445d1c26447679fb056ee13f83f3472 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:28:23 -0800 Subject: [PATCH 06/25] formatting --- .../testing/testController/common/projectAdapter.ts | 8 +++++++- src/client/testing/testController/workspaceTestAdapter.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts index 6e388acb31a6..35b17abc2b0d 100644 --- a/src/client/testing/testController/common/projectAdapter.ts +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -3,7 +3,13 @@ import { TestItem, Uri } from 'vscode'; import { TestProvider } from '../../types'; -import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver, DiscoveredTestPayload, DiscoveredTestNode } from './types'; +import { + ITestDiscoveryAdapter, + ITestExecutionAdapter, + ITestResultResolver, + DiscoveredTestPayload, + DiscoveredTestNode, +} from './types'; import { PythonEnvironment, PythonProject } from '../../../envExt/types'; /** diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index 3e0dd98b5a7a..75b9489f708e 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -62,7 +62,7 @@ export class WorkspaceTestAdapter { // first fetch all the individual test Items that we necessarily want includes.forEach((t) => { const nodes = getTestCaseNodes(t); - testCaseNodes.push(...nodes); + testCaseNodes.push(...nodes); }); // iterate through testItems nodes and fetch their unittest runID to pass in as argument testCaseNodes.forEach((node) => { From aba01834ea332f4dc9d9b46acfed554fb93033b4 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:30:14 -0800 Subject: [PATCH 07/25] remove doc on design --- docs/project-based-testing-design.md | 1022 -------------------------- 1 file changed, 1022 deletions(-) delete mode 100644 docs/project-based-testing-design.md diff --git a/docs/project-based-testing-design.md b/docs/project-based-testing-design.md deleted file mode 100644 index e1427266695d..000000000000 --- a/docs/project-based-testing-design.md +++ /dev/null @@ -1,1022 +0,0 @@ -# Project-Based Testing Architecture Design - -## Overview - -This document describes the architecture for supporting multiple Python projects within a single VS Code workspace, where each project has its own Python executable and test configuration. - -**Key Concepts:** -- **Project**: A combination of a Python executable + URI (folder/file) -- **Workspace**: Contains one or more projects -- **Test Ownership**: Determined by PythonProject API, not discovery results -- **ID Scoping**: All test IDs are project-scoped to prevent collisions - ---- - -## Architecture Diagram - -``` -VS Code Workspace - └─ PythonTestController (singleton) - ├─ TestController (VS Code API, shared) - ├─ workspaceProjects: Map> - ├─ vsIdToProject: Map (persistent) - └─ Workspace1 - ├─ ProjectA - │ ├─ pythonExecutable: /workspace1/backend/.venv/bin/python - │ ├─ projectUri: /workspace1/backend - │ ├─ discoveryAdapter - │ ├─ executionAdapter - │ └─ resultResolver - │ ├─ runIdToVSid: Map - │ ├─ vsIdToRunId: Map - │ └─ runIdToTestItem: Map - └─ ProjectB - ├─ pythonExecutable: /workspace1/frontend/.venv/bin/python - └─ ... (same structure) -``` - ---- - -## Core Objects - -### 1. PythonTestController (Extension Singleton) - -```typescript -class PythonTestController { - // VS Code shared test controller - testController: TestController - - // === PERSISTENT STATE === - // Workspace → Projects - workspaceProjects: Map> - - // Fast lookups for execution - vsIdToProject: Map - fileUriToProject: Map - projectToVsIds: Map> - - // === TEMPORARY STATE (DISCOVERY ONLY) === - workspaceDiscoveryState: Map - - // === METHODS === - activate() - refreshTestData(uri) - runTests(request, token) - discoverWorkspaceProjects(workspaceUri) -} -``` - -### 2. ProjectAdapter (Per Project) - -```typescript -interface ProjectAdapter { - // === IDENTITY === - projectId: string // Hash of PythonProject object - projectName: string // Display name - projectUri: Uri // Project root folder/file - workspaceUri: Uri // Parent workspace - - // === API OBJECTS (from vscode-python-environments extension) === - pythonProject: PythonProject // From pythonEnvApi.projects.getProjects() - pythonEnvironment: PythonEnvironment // From pythonEnvApi.resolveEnvironment() - // Note: pythonEnvironment.execInfo contains execution details - // pythonEnvironment.sysPrefix contains sys.prefix for the environment - - // === TEST INFRASTRUCTURE === - testProvider: TestProvider // 'pytest' | 'unittest' - discoveryAdapter: ITestDiscoveryAdapter - executionAdapter: ITestExecutionAdapter - resultResolver: PythonResultResolver - - // === DISCOVERY STATE === - rawDiscoveryData: DiscoveredTestPayload // Before filtering (ALL discovered tests) - ownedTests: DiscoveredTestNode // After filtering (API-confirmed owned tests) - // ownedTests is the filtered tree structure that will be passed to populateTestTree() - // It's the root node containing only this project's tests after overlap resolution - - // === LIFECYCLE === - isDiscovering: boolean - isExecuting: boolean - projectRootTestItem: TestItem -} -``` - -### 3. PythonResultResolver (Per Project) - -```typescript -class PythonResultResolver { - projectId: string - workspaceUri: Uri - testProvider: TestProvider - - // === TEST ID MAPPINGS (per-test entries) === - runIdToTestItem: Map - runIdToVSid: Map - vsIdToRunId: Map - - // === COVERAGE === - detailedCoverageMap: Map - - // === METHODS === - resolveDiscovery(payload, token) - resolveExecution(payload, runInstance) - cleanupStaleReferences() -} -``` - -### 4. WorkspaceDiscoveryState (Temporary) - -```typescript -interface WorkspaceDiscoveryState { - workspaceUri: Uri - - // Overlap detection - fileToProjects: Map> - - // API resolution results (maps to actual PythonProject from API) - fileOwnership: Map - // Value is the ProjectAdapter whose pythonProject.uri matches API response - // e.g., await pythonEnvApi.projects.getPythonProject(filePath) returns PythonProject, - // then we find the ProjectAdapter with matching pythonProject.uri - - // Progress tracking (NEW - not in current multi-workspace design) - projectsCompleted: Set - totalProjects: number - isComplete: boolean - // Advantage: Allows parallel discovery with proper completion tracking - // Current design discovers workspaces sequentially; this enables: - // 1. All projects discover in parallel - // 2. Overlap resolution waits for ALL projects to complete - // 3. Can show progress UI ("Discovering 3/5 projects...") -} -``` - ---- - -## ID System - -### ID Types - -| ID Type | Format | Scope | Purpose | Example | -|---------|--------|-------|---------|---------| -| **workspaceUri** | VS Code Uri | Global | Workspace identification | `Uri("/workspace1")` | -| **projectId** | Hash string | Unique per project | Project identification | `"project-abc123"` | -| **vsId** | `{projectId}::{path}::{testName}` | Global (unique) | VS Code TestItem.id | `"project-abc123::/ws/alice/test_alice.py::test_alice1"` | -| **runId** | Framework-specific | Per-project | Python subprocess | `"test_alice.py::test_alice1"` | - -**Workspace Tracking:** -- `workspaceProjects: Map>` - outer key is workspaceUri -- Each ProjectAdapter stores `workspaceUri` for reverse lookup -- TestItem.uri contains file path, workspace determined via `workspaceService.getWorkspaceFolder(uri)` - -### ID Conversion Flow - -``` -Discovery: runId (from Python) → create vsId → store in maps → create TestItem -Execution: TestItem.id (vsId) → lookup vsId → get runId → pass to Python -``` - ---- - -## State Management - -### Per-Workspace State - -```typescript -// Created during workspace activation -workspaceProjects: { - Uri("/workspace1"): { - "project-abc123": ProjectAdapter {...}, - "project-def456": ProjectAdapter {...} - } -} - -// Created during discovery, cleared after -workspaceDiscoveryState: { - Uri("/workspace1"): { - fileToProjects: Map {...}, - fileOwnership: Map {...} - } -} -``` - -### Per-Project State (Persistent) - -Using example structure: -``` - ← workspace root - ← ProjectA (project-alice) - - - - ← ProjectB (project-bob, nested) - - -``` - -```typescript -// ProjectA (alice) -ProjectAdapter { - projectId: "project-alice", - projectUri: Uri("/workspace/tests-plus-projects/alice"), - pythonEnvironment: { execInfo: { run: { executable: "/alice/.venv/bin/python" }}}, - resultResolver: { - runIdToVSid: { - "test_alice.py::test_alice1": "project-alice::/workspace/alice/test_alice.py::test_alice1", - "test_alice.py::test_alice2": "project-alice::/workspace/alice/test_alice.py::test_alice2" - } - } -} - -// ProjectB (bob) - nested project -ProjectAdapter { - projectId: "project-bob", - projectUri: Uri("/workspace/tests-plus-projects/alice/bob"), - pythonEnvironment: { execInfo: { run: { executable: "/alice/bob/.venv/bin/python" }}}, - resultResolver: { - runIdToVSid: { - "test_bob.py::test_bob1": "project-bob::/workspace/alice/bob/test_bob.py::test_bob1", - "test_bob.py::test_bob2": "project-bob::/workspace/alice/bob/test_bob.py::test_bob2" - } - } -} -``` - -### Per-Test State - -```typescript -// ProjectA's resolver - only alice tests -runIdToTestItem["test_alice.py::test_alice1"] → TestItem -runIdToVSid["test_alice.py::test_alice1"] → "project-alice::/workspace/alice/test_alice.py::test_alice1" -vsIdToRunId["project-alice::/workspace/alice/test_alice.py::test_alice1"] → "test_alice.py::test_alice1" - -// ProjectB's resolver - only bob tests -runIdToTestItem["test_bob.py::test_bob1"] → TestItem -runIdToVSid["test_bob.py::test_bob1"] → "project-bob::/workspace/alice/bob/test_bob.py::test_bob1" -vsIdToRunId["project-bob::/workspace/alice/bob/test_bob.py::test_bob1"] → "test_bob.py::test_bob1" -``` - ---- - -## Discovery Flow - -### Phase 1: Discover Projects - -```typescript -async function activate() { - for workspace in workspaceService.workspaceFolders { - projects = await discoverWorkspaceProjects(workspace.uri) - - for project in projects { - projectAdapter = createProjectAdapter(project) - workspaceProjects[workspace.uri][project.id] = projectAdapter - } - } -} - -async function discoverWorkspaceProjects(workspaceUri) { - // Use PythonEnvironmentApi to get all projects in workspace - pythonProjects = await pythonEnvApi.projects.getProjects(workspaceUri) - - return Promise.all(pythonProjects.map(async (pythonProject) => { - // Resolve full environment details - pythonEnv = await pythonEnvApi.resolveEnvironment(pythonProject.uri) - - return { - projectId: hash(pythonProject), // Hash the entire PythonProject object - projectName: pythonProject.name, - projectUri: pythonProject.uri, - pythonProject: pythonProject, // Store API object - pythonEnvironment: pythonEnv, // Store resolved environment - workspaceUri: workspaceUri - } - })) -} -``` - -### Phase 2: Run Discovery Per Project - -```typescript -async function refreshTestData(uri) { - workspace = getWorkspaceFolder(uri) - projects = workspaceProjects[workspace.uri].values() - - // Initialize discovery state - discoveryState = new WorkspaceDiscoveryState() - workspaceDiscoveryState[workspace.uri] = discoveryState - - // Run discovery for all projects in parallel - await Promise.all( - projects.map(p => discoverProject(p, discoveryState)) - ) - - // Resolve overlaps and assign tests - await resolveOverlapsAndAssignTests(workspace.uri) - - // Clear temporary state - workspaceDiscoveryState.delete(workspace.uri) - // Removes WorkspaceDiscoveryState for this workspace, which includes: - // - fileToProjects map (no longer needed after ownership determined) - // - fileOwnership map (results already used to filter ownedTests) - // - projectsCompleted tracking (discovery finished) - // This reduces memory footprint; persistent mappings (vsIdToProject, etc.) remain -} -``` - -### Phase 3: Detect Overlaps - -```typescript -async function discoverProject(project, discoveryState) { - // Run Python discovery subprocess - rawData = await project.discoveryAdapter.discoverTests( - project.projectUri, - executionFactory, - token, - project.pythonExecutable - ) - - project.rawDiscoveryData = rawData - - // Track which projects discovered which files - for testFile in rawData.testFiles { - if (!discoveryState.fileToProjects.has(testFile.path)) { - discoveryState.fileToProjects[testFile.path] = new Set() - } - discoveryState.fileToProjects[testFile.path].add(project) - } -} -``` - -### Phase 4: Resolve Ownership - -**Time Complexity:** O(F × P) where F = files discovered, P = projects per workspace -**Optimized to:** O(F_overlap × API_cost) where F_overlap = overlapping files only - -```typescript -async function resolveOverlapsAndAssignTests(workspaceUri) { - discoveryState = workspaceDiscoveryState[workspaceUri] - projects = workspaceProjects[workspaceUri].values() - - // Query API only for overlaps or nested projects - for [filePath, projectSet] in discoveryState.fileToProjects { - if (projectSet.size > 1) { - // OVERLAP - query API - apiProject = await pythonEnvApi.projects.getPythonProject(filePath) - discoveryState.fileOwnership[filePath] = findProject(apiProject.uri) - } - else if (hasNestedProjectForPath(filePath, projects)) { - // Nested project exists - verify with API - apiProject = await pythonEnvApi.projects.getPythonProject(filePath) - discoveryState.fileOwnership[filePath] = findProject(apiProject.uri) - } - else { - // No overlap - assign to only discoverer - discoveryState.fileOwnership[filePath] = [...projectSet][0] - } - } - - // Filter each project's raw data to only owned tests - for project in projects { - project.ownedTests = project.rawDiscoveryData.tests.filter(test => - discoveryState.fileOwnership[test.filePath] === project - ) - - // Create TestItems and build mappings - await finalizeProjectDiscovery(project) - } -} -``` -// NOTE: can you add in the time complexity for this larger functions - -### Phase 5: Create TestItems and Mappings - -**Time Complexity:** O(T) where T = tests owned by project - -```typescript -async function finalizeProjectDiscovery(project) { - // Pass filtered data to resolver - project.resultResolver.resolveDiscovery(project.ownedTests, token) - - // Create TestItems in TestController - testItems = await populateTestTree( - testController, - project.ownedTests, - project.projectRootTestItem, - project.resultResolver, - project.projectId - ) - - // Build persistent mappings - for testItem in testItems { - vsId = testItem.id - - // Global mappings for execution - vsIdToProject[vsId] = project - fileUriToProject[testItem.uri.fsPath] = project - - if (!projectToVsIds.has(project.projectId)) { - projectToVsIds[project.projectId] = new Set() - } - projectToVsIds[project.projectId].add(vsId) - } -} -``` - ---- - -## Execution Flow - -### Phase 1: Group Tests by Project - -**Time Complexity:** O(T) where T = tests in run request - -**Note:** Similar to existing `getTestItemsForWorkspace()` in controller.ts but groups by project instead of workspace - -```typescript -async function runTests(request: TestRunRequest, token) { - testItems = request.include || getAllTestItems() - - // Group by project using persistent mapping (similar pattern to getTestItemsForWorkspace) - testsByProject = new Map() - - for testItem in testItems { - vsId = testItem.id - project = vsIdToProject[vsId] // O(1) lookup - - if (!testsByProject.has(project)) { - testsByProject[project] = [] - } - testsByProject[project].push(testItem) - } - - // Execute each project - runInstance = testController.createTestRun(request, ...) - - await Promise.all( - [...testsByProject].map(([project, tests]) => - runTestsForProject(project, tests, runInstance, token) - ) - ) - - runInstance.end() -} -``` -// NOTE: there is already an existing function that does this but instead for workspaces for multiroot ones, see getTestItemsForWorkspace in controller.ts - -### Phase 2: Convert vsId → runId - -**Time Complexity:** O(T_project) where T_project = tests for this specific project - -```typescript -async function runTestsForProject(project, testItems, runInstance, token) { - runIds = [] - - for testItem in testItems { - vsId = testItem.id - - // Use project's resolver to get runId - runId = project.resultResolver.vsIdToRunId[vsId] - if (runId) { - runIds.push(runId) - runInstance.started(testItem) - } - } - - // Execute with project's Python executable - await project.executionAdapter.runTests( - project.projectUri, - runIds, // Pass to Python subprocess - runInstance, - executionFactory, - token, - project.pythonExecutable - ) -} -``` - -### Phase 3: Report Results - -```typescript -// Python subprocess sends results back with runIds -async function handleTestResult(payload, runInstance, project) { - // Resolver converts runId → TestItem - testItem = project.resultResolver.runIdToTestItem[payload.testId] - - if (payload.outcome === "passed") { - runInstance.passed(testItem) - } else if (payload.outcome === "failed") { - runInstance.failed(testItem, message) - } -} -``` - ---- - -## Key Algorithms - -### Overlap Detection - -```typescript -function hasNestedProjectForPath(testFilePath, allProjects, excludeProject) { - return allProjects.some(p => - p !== excludeProject && - testFilePath.startsWith(p.projectUri.fsPath) - ) -} -``` - -### Project Cleanup/Refresh - -```typescript -async function refreshProject(project) { - // 1. Get all vsIds for this project - vsIds = projectToVsIds[project.projectId] || new Set() - - // 2. Remove old mappings - for vsId in vsIds { - vsIdToProject.delete(vsId) - - testItem = project.resultResolver.runIdToTestItem[vsId] - if (testItem) { - fileUriToProject.delete(testItem.uri.fsPath) - } - } - projectToVsIds.delete(project.projectId) - - // 3. Clear project's resolver - project.resultResolver.testItemIndex.clear() - - // 4. Clear TestItems from TestController - if (project.projectRootTestItem) { - childIds = [...project.projectRootTestItem.children].map(c => c.id) - for id in childIds { - project.projectRootTestItem.children.delete(id) - } - } - - // 5. Re-run discovery - await discoverProject(project, ...) - await finalizeProjectDiscovery(project) -} -``` - -### File Change Handling - -```typescript -function onDidSaveTextDocument(doc) { - fileUri = doc.uri.fsPath - - // Find owning project - project = fileUriToProject[fileUri] - - if (project) { - // Refresh only this project - refreshProject(project) - } -} -``` - ---- - -## Critical Design Decisions - -### 1. Project-Scoped vsIds -**Decision**: Include projectId in every vsId -**Rationale**: Prevents collisions, enables fast project lookup, clear ownership - -### 2. One Resolver Per Project -**Decision**: Each project has its own ResultResolver -**Rationale**: Clean isolation, no cross-project contamination, independent lifecycles - -### 3. Overlap Resolution Before Mapping -**Decision**: Filter tests before resolver processes them -**Rationale**: Resolvers only see owned tests, no orphaned mappings, simpler state - -### 4. Persistent Execution Mappings -**Decision**: Maintain vsIdToProject map permanently -**Rationale**: Fast execution grouping, avoid vsId parsing, support file watches - -### 5. Temporary Discovery State -**Decision**: Build fileToProjects during discovery, clear after -**Rationale**: Only needed for overlap detection, reduce memory footprint - ---- - -## Migration from Current Architecture - -### Current (Workspace-Level) -``` -Workspace → WorkspaceTestAdapter → ResultResolver → Tests -``` - -### New (Project-Level) -``` -Workspace → [ProjectAdapter₁, ProjectAdapter₂, ...] → ResultResolver → Tests - ↓ ↓ - pythonExec₁ pythonExec₂ -``` - -### Backward Compatibility -- Workspaces without multiple projects: Single ProjectAdapter created automatically -- Existing tests: Assigned to default project based on workspace interpreter -- Settings: Read per-project from pythonProject.uri - ---- - -## Open Questions / Future Considerations - -1. **Project Discovery**: How often to re-scan for new projects? - don't rescan until discovery is re-triggered. -2. **Project Changes**: Handle pyproject.toml changes triggering project re-initialization - no this will be handled by the api and done later -3. **UI**: Show project name in test tree? Collapsible project nodes? - show project notes -4. **Performance**: Cache API queries for file ownership? - not right now -5. **Multi-root Workspaces**: Each workspace root as separate entity? - yes as you see it right now - ---- - -## Summary - -This architecture enables multiple Python projects per workspace by: -1. Creating a ProjectAdapter for each Python executable + URI combination -2. Running independent test discovery per project -3. Using PythonProject API to resolve overlapping test ownership -4. Maintaining project-scoped ID mappings for clean separation -5. Grouping tests by project during execution -6. Preserving current test adapter patterns at project level - -**Key Principle**: Each project is an isolated testing context with its own Python environment, discovery, execution, and result tracking. - ---- - -## Implementation Details & Decisions - -### 1. TestItem Hierarchy - -Following VS Code TestController API, projects are top-level items: - -```typescript -// TestController.items structure -testController.items = [ - ProjectA_RootItem { - id: "project-alice::/workspace/alice", - label: "alice (Python 3.11)", - children: [test files...] - }, - ProjectB_RootItem { - id: "project-bob::/workspace/alice/bob", - label: "bob (Python 3.9)", - children: [test files...] - } -] -``` - -**Creation timing:** `projectRootTestItem` created during `createProjectAdapter()` in activate phase, before discovery runs. - ---- - -### 2. Error Handling Strategy - -**Principle:** Simple and transparent - show errors to users, iterate based on feedback. - -| Failure Scenario | Behavior | -|------------------|----------| -| API `getPythonProject()` fails/timeout | Assign to discovering project (first in set), log warning | -| Project discovery fails | Call `traceError()` with details, show error node in test tree | -| ALL projects fail | Show error nodes for each, user sees all failures | -| API returns `undefined` | Assign to discovering project, log warning | -| No projects found | Create single default project using workspace interpreter | - -```typescript -try { - apiProject = await pythonEnvApi.projects.getPythonProject(filePath) -} catch (error) { - traceError(`Failed to resolve ownership for ${filePath}: ${error}`) - // Fallback: assign to first discovering project - discoveryState.fileOwnership[filePath] = [...projectSet][0] -} -``` - ---- - -### 3. Settings & Configuration - -**Decision:** Settings are per-workspace, shared by all projects in that workspace. - -```typescript -// All projects in workspace1 use same settings -const settings = this.configSettings.getSettings(workspace.uri) - -projectA.testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest' -projectB.testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest' -``` - -**Limitations:** -- Cannot have pytest project and unittest project in same workspace -- All projects share `pytestArgs`, `cwd`, etc. -- Future: Per-project settings via API - -**pytest.ini discovery:** Each project's Python subprocess discovers its own pytest.ini when running from `project.projectUri` - ---- - -### 4. Backwards Compatibility - -**Decision:** Graceful degradation if python-environments extension not available. - -```typescript -async function discoverWorkspaceProjects(workspaceUri) { - try { - pythonProjects = await pythonEnvApi.projects.getProjects(workspaceUri) - - if (pythonProjects.length === 0) { - // Fallback: create single default project - return [createDefaultProject(workspaceUri)] - } - - return pythonProjects.map(...) - } catch (error) { - traceError('Python environments API not available, using single project mode') - // Fallback: single project with workspace interpreter - return [createDefaultProject(workspaceUri)] - } -} - -function createDefaultProject(workspaceUri) { - const interpreter = await interpreterService.getActiveInterpreter(workspaceUri) - return { - projectId: hash(workspaceUri), - projectUri: workspaceUri, - pythonEnvironment: { execInfo: { run: { executable: interpreter.path }}}, - // ... rest matches current workspace behavior - } -} -``` - -**Workspaces Without Environment Extension:** - -When the Python Environments extension is not available or returns no projects: - -1. **Detection**: `discoverWorkspaceProjects()` catches API errors or empty results -2. **Fallback Strategy**: Calls `createDefaultProject(workspaceUri)` which: - - Uses the workspace's active interpreter via `interpreterService.getActiveInterpreter()` - - Creates a mock `PythonProject` with workspace URI as project root - - Generates a project ID from the workspace URI: `default-{workspaceUri.fsPath}` - - Mimics the legacy single-workspace behavior, but wrapped in `ProjectAdapter` structure - -3. **Key Characteristics**: - - Single project per workspace (legacy behavior preserved) - - No project scoping in test IDs (projectId is optional in resolver) - - Uses workspace-level Python interpreter settings - - All tests belong to this single "default" project - - Fully compatible with existing test discovery/execution flows - -4. **Graceful Upgrade Path**: - - When user later installs the Python Environments extension, next discovery will: - - Detect actual Python projects in the workspace - - Replace the default project with real project adapters - - Rebuild test tree with proper project scoping - - No data migration needed - discovery rebuilds from scratch - -This design ensures zero functional degradation for users without the new extension, while providing an instant upgrade path when they adopt it. -``` - ---- - -### 5. Project Discovery Triggers - -**Decision:** Triggered on file save (inefficient but follows current pattern). - -```typescript -// CURRENT BEHAVIOR: Triggers on any test file save -watchForTestContentChangeOnSave() { - onDidSaveTextDocument(async (doc) => { - if (matchesTestPattern(doc.uri)) { - // NOTE: This is inefficient - re-discovers ALL projects in workspace - // even though only one file changed. Future optimization: only refresh - // affected project using fileUriToProject mapping - await refreshTestData(doc.uri) - } - }) -} - -// FUTURE OPTIMIZATION (commented out for now): -// watchForTestContentChangeOnSave() { -// onDidSaveTextDocument(async (doc) => { -// project = fileUriToProject.get(doc.uri.fsPath) -// if (project) { -// await refreshProject(project) // Only refresh one project -// } -// }) -// } -``` - -**Trigger points:** -1. ✅ `activate()` - discovers all projects on startup -2. ✅ File save matching test pattern - full workspace refresh -3. ✅ Settings file change - full workspace refresh -4. ❌ `onDidChangeProjects` event - not implemented yet (future) - ---- - -### 6. Cancellation & Timeouts - -**Decision:** Single cancellation token cancels all project discoveries/executions (kill switch). - -```typescript -// Discovery cancellation -async function refreshTestData(uri) { - // One cancellation token for ALL projects in workspace - const token = this.refreshCancellation.token - - await Promise.all( - projects.map(p => discoverProject(p, discoveryState, token)) - ) - // If token.isCancellationRequested, ALL projects stop -} - -// Execution cancellation -async function runTests(request, token) { - // If token cancelled, ALL project executions stop - await Promise.all( - [...testsByProject].map(([project, tests]) => - runTestsForProject(project, tests, runInstance, token) - ) - ) -} -``` - -**No per-project timeouts** - keep simple, complexity added later if needed. - ---- - -### 7. Path Normalization - -**Decision:** Absolute paths used everywhere, no relative path handling. - -```typescript -// Python subprocess returns absolute paths -rawData = { - tests: [{ - path: "/workspace/alice/test_alice.py", // ← absolute - id: "test_alice.py::test_alice1" - }] -} - -// vsId constructed with absolute path -vsId = `${projectId}::/workspace/alice/test_alice.py::test_alice1` - -// TestItem.uri is absolute -testItem.uri = Uri.file("/workspace/alice/test_alice.py") -``` - -**Path conversion responsibility:** Python adapters (pytest/unittest) ensure paths are absolute before returning to controller. - ---- - -### 8. Resolver Initialization - -**Decision:** Resolver created with ProjectAdapter, empty until discovery populates it. - -```typescript -function createProjectAdapter(pythonProject) { - const resultResolver = new PythonResultResolver( - this.testController, - testProvider, - pythonProject.uri, - projectId // Pass project ID for scoping - ) - - return { - projectId, - resultResolver, // ← Empty maps, will be filled during discovery - // ... - } -} - -// During discovery, resolver is populated -await project.resultResolver.resolveDiscovery(project.ownedTests, token) -``` - ---- - -### 9. Debug Integration - -**Decision:** Debug launcher is project-aware, uses project's Python executable. - -```typescript -async function executeTestsForProvider(project, testItems, ...) { - await project.executionAdapter.runTests( - project.projectUri, - runIds, - runInstance, - this.pythonExecFactory, - token, - request.profile?.kind, - this.debugLauncher, // ← Launcher handles project executable - project.pythonEnvironment // ← Pass project's Python, not workspace - ) -} - -// In executionAdapter -async function runTests(..., debugLauncher, pythonEnvironment) { - if (isDebugging) { - await debugLauncher.launchDebugger({ - testIds: runIds, - interpreter: pythonEnvironment.execInfo.run.executable // ← Project-specific - }) - } -} -``` - ---- - -### 10. State Persistence - -**Decision:** No persistence - everything rebuilds on VS Code reload. - -- ✅ Rebuild `workspaceProjects` map during `activate()` -- ✅ Rebuild `vsIdToProject` map during discovery -- ✅ Rebuild TestItems during discovery -- ✅ Clear `rawDiscoveryData` after filtering (not persisted) - -**Rationale:** Simpler, avoids stale state issues. Performance acceptable for typical workspaces (<100ms per project). - ---- - -### 11. File Watching - -**Decision:** Watchers are per-workspace (shared by all projects). - -```typescript -// Single watcher for workspace, all projects react -watchForSettingsChanges(workspace) { - pattern = new RelativePattern(workspace, "**/{settings.json,pytest.ini,...}") - watcher = this.workspaceService.createFileSystemWatcher(pattern) - - watcher.onDidChange((uri) => { - // NOTE: Inefficient - refreshes ALL projects in workspace - // even if only one project's pytest.ini changed - this.refreshTestData(uri) - }) -} -``` - -**Not per-project** because settings are per-workspace (see #3). - ---- - -### 12. Empty/Loading States - -**Decision:** Match current behavior - blank test explorer, then populate. - -- Before first discovery: Empty test explorer (no items) -- During discovery: No loading indicators (happens fast enough) -- After discovery failure: Error nodes shown in tree - -**No special UI** for loading states in initial implementation. - ---- - -### 13. Coverage Integration - -**Decision:** Push to future implementation - out of scope for initial release. - -Coverage display questions deferred: -- Merging coverage from multiple projects -- Per-project coverage percentages -- Overlapping file coverage - -Current `detailedCoverageMap` remains per-project; UI integration TBD. - ---- - -## Implementation Notes - -### Dynamic Adapter Management - -**Current Issue:** testAdapters are created only during `activate()` and require extension reload to change. - -**Required Changes:** -1. **Add Project Detection Service:** Listen to `pythonEnvApi.projects.onDidChangeProjects` event -2. **Dynamic Creation:** Create ProjectAdapter on-demand when new PythonProject detected -3. **Dynamic Removal:** Clean up ProjectAdapter when PythonProject removed: - ```typescript - async function removeProject(project: ProjectAdapter) { - // 1. Remove from workspaceProjects map - // 2. Clear all vsIdToProject entries - // 3. Remove TestItems from TestController - // 4. Dispose adapters and resolver - } - ``` -4. **Hot Reload:** Trigger discovery for new projects without full extension restart - -### Unittest Support - -**Current Scope:** Focus on pytest-based projects initially. - -**Future Work:** Unittest will use same ProjectAdapter pattern but: -- Different `discoveryAdapter` (UnittestTestDiscoveryAdapter) -- Different `executionAdapter` (UnittestTestExecutionAdapter) -- Same ownership resolution and ID mapping patterns -- Already supported in current architecture via `testProvider` field - -**Not in Scope:** Mixed pytest/unittest within same project (projects are single-framework) From 61745de3e43fb9fb06af40c8f505bd97edc90a51 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:41:12 -0800 Subject: [PATCH 08/25] remove unneeded --- .../testController/common/projectUtils.ts | 68 ------------------- .../testing/testController/controller.ts | 9 +-- 2 files changed, 2 insertions(+), 75 deletions(-) diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index 859548956232..32b6d63b29d1 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -2,16 +2,8 @@ // Licensed under the MIT License. import * as crypto from 'crypto'; -import { Uri } from 'vscode'; -import { ProjectAdapter } from './projectAdapter'; import { PythonProject } from '../../../envExt/types'; -/** - * Separator used between project ID and test path in project-scoped test IDs. - * Using | instead of :: to avoid conflicts with pytest's :: syntax for test paths. - */ -export const PROJECT_ID_SEPARATOR = '|'; - /** * Generates a unique project ID by hashing the PythonProject object. * This ensures consistent IDs across extension reloads for the same project. @@ -31,66 +23,6 @@ export function generateProjectId(pythonProject: PythonProject): string { return `project-${hash.substring(0, 12)}`; } -/** - * Creates a project-scoped VS Code test item ID. - * Format: "{projectId}|{testPath}" - * - * @param projectId The unique project identifier - * @param testPath The test path (e.g., "/workspace/test.py::test_func") - * @returns The project-scoped VS Code test ID - */ -export function createProjectScopedVsId(projectId: string, testPath: string): string { - return `${projectId}${PROJECT_ID_SEPARATOR}${testPath}`; -} - -/** - * Parses a project-scoped VS Code test ID to extract the project ID and test path. - * - * @param vsId The VS Code test item ID - * @returns Object containing projectId and testPath, or null if invalid - */ -export function parseProjectScopedVsId(vsId: string): { projectId: string; testPath: string } | null { - const separatorIndex = vsId.indexOf(PROJECT_ID_SEPARATOR); - if (separatorIndex === -1) { - return null; - } - - return { - projectId: vsId.substring(0, separatorIndex), - testPath: vsId.substring(separatorIndex + PROJECT_ID_SEPARATOR.length), - }; -} - -/** - * Checks if a test file path is within a nested project's directory. - * This is used to determine when to query the API for ownership even if - * only one project discovered the file. - * - * @param testFilePath Absolute path to the test file - * @param allProjects All projects in the workspace - * @param excludeProject Optional project to exclude from the check (typically the discoverer) - * @returns True if the file is within any nested project's directory - */ -export function hasNestedProjectForPath( - testFilePath: string, - allProjects: ProjectAdapter[], - excludeProject?: ProjectAdapter, -): boolean { - return allProjects.some((p) => p !== excludeProject && testFilePath.startsWith(p.projectUri.fsPath)); -} - -/** - * Finds the project that owns a specific test file based on project URI. - * This is typically used after the API returns ownership information. - * - * @param projectUri The URI of the owning project (from API) - * @param allProjects All projects to search - * @returns The ProjectAdapter with matching URI, or undefined if not found - */ -export function findProjectByUri(projectUri: Uri, allProjects: ProjectAdapter[]): ProjectAdapter | undefined { - return allProjects.find((p) => p.projectUri.fsPath === projectUri.fsPath); -} - /** * Creates a display name for a project including Python version. * Format: "{projectName} (Python {version})" diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 0cd8483b0cee..1a2bc902af0c 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -85,9 +85,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // @ts-expect-error - used when useProjectBasedTesting=true private readonly workspaceDiscoveryState: Map = new Map(); - // Flag to enable/disable project-based testing - private useProjectBasedTesting = false; - private readonly triggerTypes: TriggerType[] = []; private readonly testController: TestController; @@ -236,8 +233,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - // Try to use project-based testing if enabled - if (this.useProjectBasedTesting) { + // Try to use project-based testing if environment extension is enabled + if (useEnvExtension()) { traceInfo('[test-by-project] Activating project-based testing mode'); try { await Promise.all( @@ -283,8 +280,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc '[test-by-project] Failed to activate project-based testing, falling back to legacy mode:', error, ); - traceInfo('[test-by-project] Disabling project-based testing for this session'); - this.useProjectBasedTesting = false; } } From 3b7cbf999cd57a81e08b1c229e3c9ebdcb58fc89 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:09:26 -0800 Subject: [PATCH 09/25] adding tests for helpers --- .../testController/common/projectUtils.ts | 29 +- .../testing/testController/controller.ts | 106 +++--- .../common/projectUtils.unit.test.ts | 330 ++++++++++++++++++ 3 files changed, 406 insertions(+), 59 deletions(-) create mode 100644 src/test/testing/testController/common/projectUtils.unit.test.ts diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index 32b6d63b29d1..510883aae2e0 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -4,23 +4,48 @@ import * as crypto from 'crypto'; import { PythonProject } from '../../../envExt/types'; +/** + * Separator used to scope test IDs to a specific project. + * Format: {projectId}{SEPARATOR}{testPath} + * Example: "project-abc123def456::test_file.py::test_name" + */ +export const PROJECT_ID_SEPARATOR = '::'; + /** * Generates a unique project ID by hashing the PythonProject object. * This ensures consistent IDs across extension reloads for the same project. + * Uses 16 characters of the hash to reduce collision probability. * * @param pythonProject The PythonProject object from the environment API * @returns A unique string identifier for the project */ export function generateProjectId(pythonProject: PythonProject): string { // Create a stable string representation of the project + // Use URI as the primary identifier (stable across renames) const projectString = JSON.stringify({ - name: pythonProject.name, uri: pythonProject.uri.toString(), + name: pythonProject.name, }); // Generate a hash to create a shorter, unique ID + // Using 16 chars (64 bits) instead of 12 (48 bits) for better collision resistance const hash = crypto.createHash('sha256').update(projectString).digest('hex'); - return `project-${hash.substring(0, 12)}`; + return `project-${hash.substring(0, 16)}`; +} + +/** + * Parses a project-scoped vsId back into its components. + * + * @param vsId The VS Code test item ID to parse + * @returns A tuple of [projectId, runId]. If the ID is not project-scoped, + * returns [undefined, vsId] (legacy format) + */ +export function parseVsId(vsId: string): [string | undefined, string] { + const separatorIndex = vsId.indexOf(PROJECT_ID_SEPARATOR); + if (separatorIndex === -1) { + return [undefined, vsId]; // Legacy ID without project scope + } + return [vsId.substring(0, separatorIndex), vsId.substring(separatorIndex + PROJECT_ID_SEPARATOR.length)]; } /** diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 1a2bc902af0c..c919b649afff 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,7 +52,7 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter'; +import { ProjectAdapter } from './common/projectAdapter'; import { generateProjectId, createProjectDisplayName } from './common/projectUtils'; import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; @@ -73,17 +73,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Map of workspace URI -> Map of project ID -> ProjectAdapter private readonly workspaceProjects: Map> = new Map(); - // Fast lookup maps for execution - // @ts-expect-error - used when useProjectBasedTesting=true - private readonly vsIdToProject: Map = new Map(); - // @ts-expect-error - used when useProjectBasedTesting=true - private readonly fileUriToProject: Map = new Map(); - // @ts-expect-error - used when useProjectBasedTesting=true - private readonly projectToVsIds: Map> = new Map(); - - // Temporary discovery state (created during discovery, cleared after) - // @ts-expect-error - used when useProjectBasedTesting=true - private readonly workspaceDiscoveryState: Map = new Map(); + // TODO: Phase 3-4 - Add these maps when implementing discovery and execution: + // - vsIdToProject: Map - Fast lookup for test execution + // - fileUriToProject: Map - File watching and change detection + // - projectToVsIds: Map> - Project cleanup and refresh + // - workspaceDiscoveryState: Map - Temporary overlap detection private readonly triggerTypes: TriggerType[] = []; @@ -236,51 +230,49 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Try to use project-based testing if environment extension is enabled if (useEnvExtension()) { traceInfo('[test-by-project] Activating project-based testing mode'); - try { - await Promise.all( - Array.from(workspaces).map(async (workspace) => { - traceInfo(`[test-by-project] Processing workspace: ${workspace.uri.fsPath}`); - try { - // Discover projects in this workspace - const projects = await this.discoverWorkspaceProjects(workspace.uri); - - // Create map for this workspace - const projectsMap = new Map(); - projects.forEach((project) => { - projectsMap.set(project.projectId, project); - }); - - this.workspaceProjects.set(workspace.uri, projectsMap); - - traceInfo( - `[test-by-project] Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, - ); - - // Set up file watchers if auto-discovery is enabled - const settings = this.configSettings.getSettings(workspace.uri); - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); - } - } catch (error) { - traceError( - `[test-by-project] Failed to activate project-based testing for ${workspace.uri.fsPath}:`, - error, - ); - traceInfo('[test-by-project] Falling back to legacy mode for this workspace'); - // Fall back to legacy mode for this workspace - await this.activateLegacyWorkspace(workspace); - } - }), - ); - return; - } catch (error) { - traceError( - '[test-by-project] Failed to activate project-based testing, falling back to legacy mode:', - error, - ); - } + + // Use Promise.allSettled to allow partial success in multi-root workspaces + const results = await Promise.allSettled( + Array.from(workspaces).map(async (workspace) => { + traceInfo(`[test-by-project] Processing workspace: ${workspace.uri.fsPath}`); + + // Discover projects in this workspace + const projects = await this.discoverWorkspaceProjects(workspace.uri); + + // Create map for this workspace + const projectsMap = new Map(); + projects.forEach((project) => { + projectsMap.set(project.projectId, project); + }); + + traceInfo( + `[test-by-project] Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, + ); + + return { workspace, projectsMap }; + }), + ); + + // Handle results individually - allows partial success + results.forEach((result, index) => { + const workspace = workspaces[index]; + if (result.status === 'fulfilled') { + this.workspaceProjects.set(workspace.uri, result.value.projectsMap); + traceInfo( + `[test-by-project] Successfully activated ${result.value.projectsMap.size} project(s) for ${workspace.uri.fsPath}`, + ); + this.setupFileWatchers(workspace); + } else { + traceError( + `[test-by-project] Failed to activate project-based testing for ${workspace.uri.fsPath}:`, + result.reason, + ); + traceInfo('[test-by-project] Falling back to legacy mode for this workspace'); + // Fall back to legacy mode for this workspace only + this.activateLegacyWorkspace(workspace); + } + }); + return; } // Legacy activation (backward compatibility) diff --git a/src/test/testing/testController/common/projectUtils.unit.test.ts b/src/test/testing/testController/common/projectUtils.unit.test.ts new file mode 100644 index 000000000000..8e1a25187f67 --- /dev/null +++ b/src/test/testing/testController/common/projectUtils.unit.test.ts @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { Uri } from 'vscode'; +import { + generateProjectId, + createProjectDisplayName, + parseVsId, + PROJECT_ID_SEPARATOR, +} from '../../../../client/testing/testController/common/projectUtils'; +import { PythonProject } from '../../../../client/envExt/types'; + +suite('Project Utils Tests', () => { + suite('generateProjectId', () => { + test('should generate consistent IDs for same project', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project'), + }; + + const id1 = generateProjectId(project); + const id2 = generateProjectId(project); + + expect(id1).to.equal(id2); + }); + + test('should generate different IDs for different URIs', () => { + const project1: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project1'), + }; + + const project2: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project2'), + }; + + const id1 = generateProjectId(project1); + const id2 = generateProjectId(project2); + + expect(id1).to.not.equal(id2); + }); + + test('should generate different IDs for different names with same URI', () => { + const uri = Uri.file('/workspace/project'); + + const project1: PythonProject = { + name: 'project-a', + uri, + }; + + const project2: PythonProject = { + name: 'project-b', + uri, + }; + + const id1 = generateProjectId(project1); + const id2 = generateProjectId(project2); + + expect(id1).to.not.equal(id2); + }); + + test('should generate ID with correct format', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project'), + }; + + const id = generateProjectId(project); + + expect(id).to.match(/^project-[a-f0-9]{16}$/); + }); + + test('should use 16 character hash for collision resistance', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project'), + }; + + const id = generateProjectId(project); + const hashPart = id.substring('project-'.length); + + expect(hashPart).to.have.lengthOf(16); + }); + + test('should handle Windows paths correctly', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('C:\\workspace\\project'), + }; + + const id = generateProjectId(project); + + expect(id).to.match(/^project-[a-f0-9]{16}$/); + }); + + test('should handle project names with special characters', () => { + const project: PythonProject = { + name: 'test-project!@#$%^&*()', + uri: Uri.file('/workspace/project'), + }; + + const id = generateProjectId(project); + + expect(id).to.match(/^project-[a-f0-9]{16}$/); + }); + + test('should handle empty project name', () => { + const project: PythonProject = { + name: '', + uri: Uri.file('/workspace/project'), + }; + + const id = generateProjectId(project); + + expect(id).to.match(/^project-[a-f0-9]{16}$/); + }); + + test('should generate stable IDs across multiple calls', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project'), + }; + + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateProjectId(project)); + } + + expect(ids.size).to.equal(1, 'Should generate same ID consistently'); + }); + }); + + suite('createProjectDisplayName', () => { + test('should format name with major.minor version', () => { + const result = createProjectDisplayName('MyProject', '3.11.2'); + + expect(result).to.equal('MyProject (Python 3.11)'); + }); + + test('should handle version with patch and pre-release', () => { + const result = createProjectDisplayName('MyProject', '3.12.0rc1'); + + expect(result).to.equal('MyProject (Python 3.12)'); + }); + + test('should handle version with only major.minor', () => { + const result = createProjectDisplayName('MyProject', '3.10'); + + expect(result).to.equal('MyProject (Python 3.10)'); + }); + + test('should handle invalid version format gracefully', () => { + const result = createProjectDisplayName('MyProject', 'invalid-version'); + + expect(result).to.equal('MyProject (Python invalid-version)'); + }); + + test('should handle empty version string', () => { + const result = createProjectDisplayName('MyProject', ''); + + expect(result).to.equal('MyProject (Python )'); + }); + + test('should handle version with single digit', () => { + const result = createProjectDisplayName('MyProject', '3'); + + expect(result).to.equal('MyProject (Python 3)'); + }); + + test('should handle project name with special characters', () => { + const result = createProjectDisplayName('My-Project_123', '3.11.5'); + + expect(result).to.equal('My-Project_123 (Python 3.11)'); + }); + + test('should handle empty project name', () => { + const result = createProjectDisplayName('', '3.11.2'); + + expect(result).to.equal(' (Python 3.11)'); + }); + }); + + suite('parseVsId', () => { + test('should parse project-scoped ID correctly', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle legacy ID without project scope', () => { + const vsId = 'test_file.py'; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.be.undefined; + expect(runId).to.equal('test_file.py'); + }); + + test('should handle runId containing separator', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}test_file.py::test_class::test_method`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('test_file.py::test_class::test_method'); + }); + + test('should handle empty project ID', () => { + const vsId = `${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal(''); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle empty runId', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal(''); + }); + + test('should handle ID with file path', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}/workspace/tests/test_file.py`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('/workspace/tests/test_file.py'); + }); + + test('should handle Windows file paths', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}C:\\workspace\\tests\\test_file.py`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('C:\\workspace\\tests\\test_file.py'); + }); + + test('should roundtrip with generateProjectId', () => { + const project: PythonProject = { + name: 'test-project', + uri: Uri.file('/workspace/project'), + }; + const runId = 'test_file.py::test_name'; + + const projectId = generateProjectId(project); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; + const [parsedProjectId, parsedRunId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(parsedRunId).to.equal(runId); + }); + }); + + suite('Integration Tests', () => { + test('should generate unique IDs for multiple projects', () => { + const projects: PythonProject[] = [ + { name: 'project-a', uri: Uri.file('/workspace/a') }, + { name: 'project-b', uri: Uri.file('/workspace/b') }, + { name: 'project-c', uri: Uri.file('/workspace/c') }, + { name: 'project-d', uri: Uri.file('/workspace/d') }, + { name: 'project-e', uri: Uri.file('/workspace/e') }, + ]; + + const ids = projects.map((p) => generateProjectId(p)); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).to.equal(projects.length, 'All IDs should be unique'); + }); + + test('should handle nested project paths', () => { + const parentProject: PythonProject = { + name: 'parent', + uri: Uri.file('/workspace/parent'), + }; + + const childProject: PythonProject = { + name: 'child', + uri: Uri.file('/workspace/parent/child'), + }; + + const parentId = generateProjectId(parentProject); + const childId = generateProjectId(childProject); + + expect(parentId).to.not.equal(childId); + }); + + test('should create complete vsId and parse it back', () => { + const project: PythonProject = { + name: 'MyProject', + uri: Uri.file('/workspace/myproject'), + }; + + const projectId = generateProjectId(project); + const runId = 'tests/test_module.py::TestClass::test_method'; + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; + + const [parsedProjectId, parsedRunId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(parsedRunId).to.equal(runId); + }); + + test('should handle collision probability with many projects', () => { + // Generate 1000 projects and ensure no collisions + const projects: PythonProject[] = []; + for (let i = 0; i < 1000; i++) { + projects.push({ + name: `project-${i}`, + uri: Uri.file(`/workspace/project-${i}`), + }); + } + + const ids = projects.map((p) => generateProjectId(p)); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).to.equal(projects.length, 'Should have no collisions even with 1000 projects'); + }); + }); +}); From cf2e75ccf8c4e7dac5ee86c00f98b64a1839adae Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:34:42 -0800 Subject: [PATCH 10/25] testing and refinement --- .../testController/common/projectAdapter.ts | 3 +- .../testController/common/projectUtils.ts | 31 +-- .../testing/testController/controller.ts | 29 ++- .../common/projectUtils.unit.test.ts | 229 ++++++------------ 4 files changed, 101 insertions(+), 191 deletions(-) diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts index 35b17abc2b0d..00e62fff5e09 100644 --- a/src/client/testing/testController/common/projectAdapter.ts +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -15,11 +15,12 @@ import { PythonEnvironment, PythonProject } from '../../../envExt/types'; /** * Represents a single Python project with its own test infrastructure. * A project is defined as a combination of a Python executable + URI (folder/file). + * Projects are keyed by projectUri.toString() */ export interface ProjectAdapter { // === IDENTITY === /** - * Unique identifier for this project, generated by hashing the PythonProject object. + * Project identifier, which is the string representation of the project URI. */ projectId: string; diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index 510883aae2e0..a66ab31c2da3 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -1,36 +1,25 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as crypto from 'crypto'; -import { PythonProject } from '../../../envExt/types'; +import { Uri } from 'vscode'; /** * Separator used to scope test IDs to a specific project. * Format: {projectId}{SEPARATOR}{testPath} - * Example: "project-abc123def456::test_file.py::test_name" + * Example: "file:///workspace/project||test_file.py::test_name" */ -export const PROJECT_ID_SEPARATOR = '::'; +export const PROJECT_ID_SEPARATOR = '||'; /** - * Generates a unique project ID by hashing the PythonProject object. - * This ensures consistent IDs across extension reloads for the same project. - * Uses 16 characters of the hash to reduce collision probability. + * Gets the project ID from a project URI. + * The project ID is simply the string representation of the URI, matching how + * the Python Environments extension stores projects in Map. * - * @param pythonProject The PythonProject object from the environment API - * @returns A unique string identifier for the project + * @param projectUri The project URI + * @returns The project ID (URI as string) */ -export function generateProjectId(pythonProject: PythonProject): string { - // Create a stable string representation of the project - // Use URI as the primary identifier (stable across renames) - const projectString = JSON.stringify({ - uri: pythonProject.uri.toString(), - name: pythonProject.name, - }); - - // Generate a hash to create a shorter, unique ID - // Using 16 chars (64 bits) instead of 12 (48 bits) for better collision resistance - const hash = crypto.createHash('sha256').update(projectString).digest('hex'); - return `project-${hash.substring(0, 16)}`; +export function getProjectId(projectUri: Uri): string { + return projectUri.toString(); } /** diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index c919b649afff..dd91524732ec 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -53,7 +53,7 @@ import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { ProjectAdapter } from './common/projectAdapter'; -import { generateProjectId, createProjectDisplayName } from './common/projectUtils'; +import { getProjectId, createProjectDisplayName } from './common/projectUtils'; import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; @@ -66,11 +66,19 @@ type TriggerType = EventPropertyType[TriggerKeyType]; export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + /** + * Feature flag for project-based testing. + * Set to true to enable multi-project testing support (Phases 2-4 must be complete). + * Default: false (use legacy single-workspace mode) + */ + private readonly useProjectBasedTesting = false; + // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); // === NEW: PROJECT-BASED STATE === - // Map of workspace URI -> Map of project ID -> ProjectAdapter + // Map of workspace URI -> Map of project URI string -> ProjectAdapter + // Note: Project URI strings match Python Environments extension's Map keys private readonly workspaceProjects: Map> = new Map(); // TODO: Phase 3-4 - Add these maps when implementing discovery and execution: @@ -227,8 +235,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - // Try to use project-based testing if environment extension is enabled - if (useEnvExtension()) { + // Try to use project-based testing if feature flag is enabled AND environment extension is available + if (this.useProjectBasedTesting && useEnvExtension()) { traceInfo('[test-by-project] Activating project-based testing mode'); // Use Promise.allSettled to allow partial success in multi-root workspaces @@ -239,10 +247,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Discover projects in this workspace const projects = await this.discoverWorkspaceProjects(workspace.uri); - // Create map for this workspace + // Create map for this workspace, keyed by project URI (matches Python Environments extension) const projectsMap = new Map(); projects.forEach((project) => { - projectsMap.set(project.projectId, project); + const projectKey = getProjectId(project.projectUri); + projectsMap.set(projectKey, project); }); traceInfo( @@ -374,8 +383,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc traceInfo( `[test-by-project] Creating project adapter for: ${pythonProject.name} at ${pythonProject.uri.fsPath}`, ); - // Generate unique project ID - const projectId = generateProjectId(pythonProject); + // Use project URI as the project ID (no hashing needed) + const projectId = getProjectId(pythonProject.uri); // Resolve the Python environment const envExtApi = await getEnvExtApi(); @@ -456,8 +465,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc uri: workspaceUri, }; - // Use workspace URI as project ID for default project - const projectId = `default-${workspaceUri.fsPath}`; + // Use workspace URI as the project ID + const projectId = getProjectId(workspaceUri); return { projectId, diff --git a/src/test/testing/testController/common/projectUtils.unit.test.ts b/src/test/testing/testController/common/projectUtils.unit.test.ts index 8e1a25187f67..75f399e89fc0 100644 --- a/src/test/testing/testController/common/projectUtils.unit.test.ts +++ b/src/test/testing/testController/common/projectUtils.unit.test.ts @@ -4,131 +4,68 @@ import { expect } from 'chai'; import { Uri } from 'vscode'; import { - generateProjectId, + getProjectId, createProjectDisplayName, parseVsId, PROJECT_ID_SEPARATOR, } from '../../../../client/testing/testController/common/projectUtils'; -import { PythonProject } from '../../../../client/envExt/types'; suite('Project Utils Tests', () => { - suite('generateProjectId', () => { - test('should generate consistent IDs for same project', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project'), - }; - - const id1 = generateProjectId(project); - const id2 = generateProjectId(project); - - expect(id1).to.equal(id2); - }); - - test('should generate different IDs for different URIs', () => { - const project1: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project1'), - }; - - const project2: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project2'), - }; - - const id1 = generateProjectId(project1); - const id2 = generateProjectId(project2); - - expect(id1).to.not.equal(id2); - }); - - test('should generate different IDs for different names with same URI', () => { + suite('getProjectId', () => { + test('should return URI string representation', () => { const uri = Uri.file('/workspace/project'); - const project1: PythonProject = { - name: 'project-a', - uri, - }; - - const project2: PythonProject = { - name: 'project-b', - uri, - }; + const id = getProjectId(uri); - const id1 = generateProjectId(project1); - const id2 = generateProjectId(project2); - - expect(id1).to.not.equal(id2); - }); - - test('should generate ID with correct format', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project'), - }; - - const id = generateProjectId(project); - - expect(id).to.match(/^project-[a-f0-9]{16}$/); + expect(id).to.equal(uri.toString()); }); - test('should use 16 character hash for collision resistance', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project'), - }; + test('should be consistent for same URI', () => { + const uri = Uri.file('/workspace/project'); - const id = generateProjectId(project); - const hashPart = id.substring('project-'.length); + const id1 = getProjectId(uri); + const id2 = getProjectId(uri); - expect(hashPart).to.have.lengthOf(16); + expect(id1).to.equal(id2); }); - test('should handle Windows paths correctly', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('C:\\workspace\\project'), - }; + test('should be different for different URIs', () => { + const uri1 = Uri.file('/workspace/project1'); + const uri2 = Uri.file('/workspace/project2'); - const id = generateProjectId(project); + const id1 = getProjectId(uri1); + const id2 = getProjectId(uri2); - expect(id).to.match(/^project-[a-f0-9]{16}$/); + expect(id1).to.not.equal(id2); }); - test('should handle project names with special characters', () => { - const project: PythonProject = { - name: 'test-project!@#$%^&*()', - uri: Uri.file('/workspace/project'), - }; + test('should handle Windows paths', () => { + const uri = Uri.file('C:\\workspace\\project'); - const id = generateProjectId(project); + const id = getProjectId(uri); - expect(id).to.match(/^project-[a-f0-9]{16}$/); + expect(id).to.be.a('string'); + expect(id).to.have.length.greaterThan(0); }); - test('should handle empty project name', () => { - const project: PythonProject = { - name: '', - uri: Uri.file('/workspace/project'), - }; + test('should handle nested project paths', () => { + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); - const id = generateProjectId(project); + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); - expect(id).to.match(/^project-[a-f0-9]{16}$/); + expect(parentId).to.not.equal(childId); }); - test('should generate stable IDs across multiple calls', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project'), - }; + test('should match Python Environments extension format', () => { + const uri = Uri.file('/workspace/project'); - const ids = new Set(); - for (let i = 0; i < 100; i++) { - ids.add(generateProjectId(project)); - } + const id = getProjectId(uri); - expect(ids.size).to.equal(1, 'Should generate same ID consistently'); + // Should match how Python Environments extension keys projects + expect(id).to.equal(uri.toString()); + expect(typeof id).to.equal('string'); }); }); @@ -184,11 +121,13 @@ suite('Project Utils Tests', () => { suite('parseVsId', () => { test('should parse project-scoped ID correctly', () => { - const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_name`; - const [projectId, runId] = parseVsId(vsId); + const [parsedProjectId, runId] = parseVsId(vsId); - expect(projectId).to.equal('project-abc123def456'); + expect(parsedProjectId).to.equal(projectId); expect(runId).to.equal('test_file.py::test_name'); }); @@ -202,11 +141,13 @@ suite('Project Utils Tests', () => { }); test('should handle runId containing separator', () => { - const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}test_file.py::test_class::test_method`; + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_class::test_method`; - const [projectId, runId] = parseVsId(vsId); + const [parsedProjectId, runId] = parseVsId(vsId); - expect(projectId).to.equal('project-abc123def456'); + expect(parsedProjectId).to.equal(projectId); expect(runId).to.equal('test_file.py::test_class::test_method'); }); @@ -238,70 +179,46 @@ suite('Project Utils Tests', () => { }); test('should handle Windows file paths', () => { - const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}C:\\workspace\\tests\\test_file.py`; - - const [projectId, runId] = parseVsId(vsId); + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}C:\\workspace\\tests\\test_file.py`; - expect(projectId).to.equal('project-abc123def456'); - expect(runId).to.equal('C:\\workspace\\tests\\test_file.py'); - }); - - test('should roundtrip with generateProjectId', () => { - const project: PythonProject = { - name: 'test-project', - uri: Uri.file('/workspace/project'), - }; - const runId = 'test_file.py::test_name'; - - const projectId = generateProjectId(project); - const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; - const [parsedProjectId, parsedRunId] = parseVsId(vsId); + const [parsedProjectId, runId] = parseVsId(vsId); expect(parsedProjectId).to.equal(projectId); - expect(parsedRunId).to.equal(runId); + expect(runId).to.equal('C:\\workspace\\tests\\test_file.py'); }); }); suite('Integration Tests', () => { - test('should generate unique IDs for multiple projects', () => { - const projects: PythonProject[] = [ - { name: 'project-a', uri: Uri.file('/workspace/a') }, - { name: 'project-b', uri: Uri.file('/workspace/b') }, - { name: 'project-c', uri: Uri.file('/workspace/c') }, - { name: 'project-d', uri: Uri.file('/workspace/d') }, - { name: 'project-e', uri: Uri.file('/workspace/e') }, + test('should generate unique IDs for different URIs', () => { + const uris = [ + Uri.file('/workspace/a'), + Uri.file('/workspace/b'), + Uri.file('/workspace/c'), + Uri.file('/workspace/d'), + Uri.file('/workspace/e'), ]; - const ids = projects.map((p) => generateProjectId(p)); + const ids = uris.map((uri) => getProjectId(uri)); const uniqueIds = new Set(ids); - expect(uniqueIds.size).to.equal(projects.length, 'All IDs should be unique'); + expect(uniqueIds.size).to.equal(uris.length, 'All IDs should be unique'); }); test('should handle nested project paths', () => { - const parentProject: PythonProject = { - name: 'parent', - uri: Uri.file('/workspace/parent'), - }; + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); - const childProject: PythonProject = { - name: 'child', - uri: Uri.file('/workspace/parent/child'), - }; - - const parentId = generateProjectId(parentProject); - const childId = generateProjectId(childProject); + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); expect(parentId).to.not.equal(childId); }); test('should create complete vsId and parse it back', () => { - const project: PythonProject = { - name: 'MyProject', - uri: Uri.file('/workspace/myproject'), - }; - - const projectId = generateProjectId(project); + const projectUri = Uri.file('/workspace/myproject'); + const projectId = getProjectId(projectUri); const runId = 'tests/test_module.py::TestClass::test_method'; const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; @@ -311,20 +228,14 @@ suite('Project Utils Tests', () => { expect(parsedRunId).to.equal(runId); }); - test('should handle collision probability with many projects', () => { - // Generate 1000 projects and ensure no collisions - const projects: PythonProject[] = []; - for (let i = 0; i < 1000; i++) { - projects.push({ - name: `project-${i}`, - uri: Uri.file(`/workspace/project-${i}`), - }); - } - - const ids = projects.map((p) => generateProjectId(p)); - const uniqueIds = new Set(ids); + test('should match Python Environments extension URI format', () => { + const uri = Uri.file('/workspace/project'); + + const projectId = getProjectId(uri); - expect(uniqueIds.size).to.equal(projects.length, 'Should have no collisions even with 1000 projects'); + // Should be string representation of URI + expect(projectId).to.equal(uri.toString()); + expect(typeof projectId).to.equal('string'); }); }); }); From 28b34dc03acd7bd516a76d43423ae76c2b0380ac Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:52:16 -0800 Subject: [PATCH 11/25] tests for controller --- .../testController/controller.unit.test.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/test/testing/testController/controller.unit.test.ts diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts new file mode 100644 index 000000000000..838b96c17eab --- /dev/null +++ b/src/test/testing/testController/controller.unit.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import type { TestController, Uri } from 'vscode'; + +// We must mutate the actual mocked vscode module export (not an __importStar copy), +// otherwise `tests.createTestController` will still be undefined inside the controller module. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const vscodeApi = require('vscode') as typeof import('vscode'); + +import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; +import * as envExtApiInternal from '../../../client/envExt/api.internal'; +import { getProjectId } from '../../../client/testing/testController/common/projectUtils'; + +function createStubTestController(): TestController { + const disposable = { dispose: () => undefined }; + + const controller = ({ + items: { + forEach: sinon.stub(), + get: sinon.stub(), + add: sinon.stub(), + replace: sinon.stub(), + delete: sinon.stub(), + size: 0, + [Symbol.iterator]: sinon.stub(), + }, + createRunProfile: sinon.stub().returns(disposable), + createTestItem: sinon.stub(), + dispose: sinon.stub(), + resolveHandler: undefined, + refreshHandler: undefined, + } as unknown) as TestController; + + return controller; +} + +function ensureVscodeTestsNamespace(): void { + const vscodeAny = vscodeApi as any; + if (!vscodeAny.tests) { + vscodeAny.tests = {}; + } + if (!vscodeAny.tests.createTestController) { + vscodeAny.tests.createTestController = () => createStubTestController(); + } +} + +// NOTE: +// `PythonTestController` calls `vscode.tests.createTestController(...)` in its constructor. +// In unit tests, `vscode` is a mocked module (see `src/test/vscode-mock.ts`) and it does not +// provide the `tests` namespace by default. If we import the controller normally, the module +// will be evaluated before this file runs (ES imports are hoisted), and construction will +// crash with `tests`/`createTestController` being undefined. +// +// To keep this test isolated (without changing production code), we: +// 1) Patch the mocked vscode export to provide `tests.createTestController`. +// 2) Require the controller module *after* patching so the constructor can run safely. +ensureVscodeTestsNamespace(); + +// Dynamically require AFTER the vscode.tests namespace exists. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PythonTestController } = require('../../../client/testing/testController/controller'); + +suite('PythonTestController', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createController(options?: { unittestEnabled?: boolean; interpreter?: any }): any { + const unittestEnabled = options?.unittestEnabled ?? false; + const interpreter = + options?.interpreter ?? + ({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + } as any); + + const workspaceService = ({ workspaceFolders: [] } as unknown) as any; + const configSettings = ({ + getSettings: sandbox.stub().returns({ + testing: { + unittestEnabled, + autoTestDiscoverOnSaveEnabled: false, + }, + }), + } as unknown) as any; + + const pytest = ({} as unknown) as any; + const unittest = ({} as unknown) as any; + const disposables: any[] = []; + const interpreterService = ({ + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as unknown) as any; + + const commandManager = ({ + registerCommand: sandbox.stub().returns({ dispose: () => undefined }), + } as unknown) as any; + const pythonExecFactory = ({} as unknown) as any; + const debugLauncher = ({} as unknown) as any; + const envVarsService = ({} as unknown) as any; + + return new PythonTestController( + workspaceService, + configSettings, + pytest, + unittest, + disposables, + interpreterService, + commandManager, + pythonExecFactory, + debugLauncher, + envVarsService, + ); + } + + suite('getTestProvider', () => { + test('returns unittest when enabled', () => { + const controller = createController({ unittestEnabled: true }); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, UNITTEST_PROVIDER); + }); + + test('returns pytest when unittest not enabled', () => { + const controller = createController({ unittestEnabled: false }); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, PYTEST_PROVIDER); + }); + }); + + suite('createDefaultProject', () => { + test('creates a single default project using active interpreter', async () => { + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/myws'); + const interpreter = { + displayName: 'My Python', + path: '/opt/py/bin/python', + version: { raw: '3.12.1' }, + sysPrefix: '/opt/py', + }; + + const controller = createController({ unittestEnabled: false, interpreter }); + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox + .stub(controller as any, 'createTestAdapters') + .returns({ discoveryAdapter: fakeDiscoveryAdapter, executionAdapter: fakeExecutionAdapter }); + + const project = await (controller as any).createDefaultProject(workspaceUri); + + assert.strictEqual(project.workspaceUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectId, getProjectId(workspaceUri)); + assert.strictEqual(project.projectName, 'myws'); + + assert.strictEqual(project.testProvider, PYTEST_PROVIDER); + assert.strictEqual(project.discoveryAdapter, fakeDiscoveryAdapter); + assert.strictEqual(project.executionAdapter, fakeExecutionAdapter); + + assert.strictEqual(project.pythonProject.uri.toString(), workspaceUri.toString()); + assert.strictEqual(project.pythonProject.name, 'myws'); + + assert.strictEqual(project.pythonEnvironment.displayName, 'My Python'); + assert.strictEqual(project.pythonEnvironment.version, '3.12.1'); + assert.strictEqual(project.pythonEnvironment.execInfo.run.executable, '/opt/py/bin/python'); + }); + }); + + suite('discoverWorkspaceProjects', () => { + test('respects useEnvExtension() == false and falls back to single default project', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/a'); + + const defaultProject = { projectId: 'default', projectUri: workspaceUri }; + const createDefaultProjectStub = sandbox + .stub(controller as any, 'createDefaultProject') + .resolves(defaultProject as any); + + const useEnvExtensionStub = sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + const getEnvExtApiStub = sandbox.stub(envExtApiInternal, 'getEnvExtApi'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + assert.strictEqual(useEnvExtensionStub.called, true); + assert.strictEqual(getEnvExtApiStub.notCalled, true); + assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); + assert.deepStrictEqual(projects, [defaultProject]); + }); + + test('filters Python projects to workspace and creates adapters for each', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + + const pythonProjects = [ + { name: 'p1', uri: vscodeApi.Uri.file('/workspace/root/p1') }, + { name: 'p2', uri: vscodeApi.Uri.file('/workspace/root/nested/p2') }, + { name: 'other', uri: vscodeApi.Uri.file('/other/root/p3') }, + ]; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => pythonProjects, + } as any); + + const createdAdapters = [ + { projectId: 'p1', projectUri: pythonProjects[0].uri }, + { projectId: 'p2', projectUri: pythonProjects[1].uri }, + ]; + + const createProjectAdapterStub = sandbox + .stub(controller as any, 'createProjectAdapter') + .onFirstCall() + .resolves(createdAdapters[0] as any) + .onSecondCall() + .resolves(createdAdapters[1] as any); + + const createDefaultProjectStub = sandbox.stub(controller as any, 'createDefaultProject'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + // Should only create adapters for the 2 projects in the workspace. + assert.strictEqual(createProjectAdapterStub.callCount, 2); + assert.strictEqual(createProjectAdapterStub.firstCall.args[0].uri.fsPath, '/workspace/root/p1'); + assert.strictEqual(createProjectAdapterStub.secondCall.args[0].uri.fsPath, '/workspace/root/nested/p2'); + + assert.strictEqual(createDefaultProjectStub.notCalled, true); + assert.deepStrictEqual(projects, createdAdapters); + }); + + test('falls back to default project when no projects are in the workspace', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [{ name: 'other', uri: vscodeApi.Uri.file('/other/root/p3') }], + } as any); + + const defaultProject = { projectId: 'default', projectUri: workspaceUri }; + const createDefaultProjectStub = sandbox + .stub(controller as any, 'createDefaultProject') + .resolves(defaultProject as any); + + const createProjectAdapterStub = sandbox.stub(controller as any, 'createProjectAdapter'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + assert.strictEqual(createProjectAdapterStub.notCalled, true); + assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); + assert.deepStrictEqual(projects, [defaultProject]); + }); + }); +}); From 7b81f07d9fc686622a9638832b9bee6cf05c06a6 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:06:46 -0800 Subject: [PATCH 12/25] separators and update api calls --- src/client/testing/testController/controller.ts | 13 ++++++++----- .../testing/testController/controller.unit.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index dd91524732ec..ced7acf52701 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -4,6 +4,7 @@ import { inject, injectable, named } from 'inversify'; import { uniq } from 'lodash'; import * as minimatch from 'minimatch'; +import * as path from 'path'; import { CancellationToken, TestController, @@ -56,6 +57,7 @@ import { ProjectAdapter } from './common/projectAdapter'; import { getProjectId, createProjectDisplayName } from './common/projectUtils'; import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; +import { isParentPath } from '../../common/platform/fs-paths'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -332,9 +334,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const pythonProjects = envExtApi.getPythonProjects(); traceInfo(`[test-by-project] Found ${pythonProjects.length} total Python projects from API`); - // Filter projects to only those in this workspace + // Filter projects to only those in this workspace TODO; check this const workspaceProjects = pythonProjects.filter((project) => - project.uri.fsPath.startsWith(workspaceUri.fsPath), + isParentPath(project.uri.fsPath, workspaceUri.fsPath), ); traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); @@ -384,11 +386,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc `[test-by-project] Creating project adapter for: ${pythonProject.name} at ${pythonProject.uri.fsPath}`, ); // Use project URI as the project ID (no hashing needed) - const projectId = getProjectId(pythonProject.uri); + const projectId = pythonProject.uri.fsPath; // Resolve the Python environment const envExtApi = await getEnvExtApi(); - const pythonEnvironment = await envExtApi.resolveEnvironment(pythonProject.uri); + const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); if (!pythonEnvironment) { throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`); @@ -461,7 +463,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Create a mock PythonProject const pythonProject: PythonProject = { - name: workspaceUri.fsPath.split('/').pop() || 'workspace', + // Do not assume path separators (fsPath is platform-specific). + name: path.basename(workspaceUri.fsPath) || 'workspace', uri: workspaceUri, }; diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index 838b96c17eab..2916e383605b 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -235,8 +235,14 @@ suite('PythonTestController', () => { // Should only create adapters for the 2 projects in the workspace. assert.strictEqual(createProjectAdapterStub.callCount, 2); - assert.strictEqual(createProjectAdapterStub.firstCall.args[0].uri.fsPath, '/workspace/root/p1'); - assert.strictEqual(createProjectAdapterStub.secondCall.args[0].uri.fsPath, '/workspace/root/nested/p2'); + assert.strictEqual( + createProjectAdapterStub.firstCall.args[0].uri.toString(), + pythonProjects[0].uri.toString(), + ); + assert.strictEqual( + createProjectAdapterStub.secondCall.args[0].uri.toString(), + pythonProjects[1].uri.toString(), + ); assert.strictEqual(createDefaultProjectStub.notCalled, true); assert.deepStrictEqual(projects, createdAdapters); From b2a3a8e500218b674644f60d984cce8c22b4e0ea Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:01:00 -0800 Subject: [PATCH 13/25] checkpoint- project test nodes --- python_files/vscode_pytest/__init__.py | 45 ++++-- .../testing/testController/common/types.ts | 2 + .../testing/testController/controller.ts | 148 +++++++++++++++++- .../pytest/pytestDiscoveryAdapter.ts | 13 ++ .../unittest/testDiscoveryAdapter.ts | 13 ++ 5 files changed, 206 insertions(+), 15 deletions(-) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 89565dab1264..5a56d8697d64 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -77,6 +77,9 @@ def __init__(self, message): map_id_to_path = {} collected_tests_so_far = set() TEST_RUN_PIPE = os.getenv("TEST_RUN_PIPE") +PROJECT_ROOT_PATH = os.getenv( + "PROJECT_ROOT_PATH" +) # Path to project root for multi-project workspaces SYMLINK_PATH = None INCLUDE_BRANCHES = False @@ -86,6 +89,20 @@ def __init__(self, message): _CACHED_CWD: pathlib.Path | None = None +def get_test_root_path() -> pathlib.Path: + """Get the root path for the test tree. + + For project-based testing, this returns PROJECT_ROOT_PATH (the project root). + For legacy mode, this returns the current working directory. + + Returns: + pathlib.Path: The root path to use for the test tree. + """ + if PROJECT_ROOT_PATH: + return pathlib.Path(PROJECT_ROOT_PATH) + return pathlib.Path.cwd() + + def pytest_load_initial_conftests(early_config, parser, args): # noqa: ARG001 has_pytest_cov = early_config.pluginmanager.hasplugin( "pytest_cov" @@ -409,21 +426,23 @@ def pytest_sessionfinish(session, exitstatus): Exit code 4: pytest command line usage error Exit code 5: No tests were collected """ - cwd = pathlib.Path.cwd() + # Get the root path for the test tree structure (not the CWD for test execution) + # This is PROJECT_ROOT_PATH in project-based mode, or cwd in legacy mode + test_root_path = get_test_root_path() if SYMLINK_PATH: - print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting cwd.") - cwd = pathlib.Path(SYMLINK_PATH) + print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting test root path.") + test_root_path = pathlib.Path(SYMLINK_PATH) if IS_DISCOVERY: if not (exitstatus == 0 or exitstatus == 1 or exitstatus == 5): error_node: TestNode = { "name": "", - "path": cwd, + "path": test_root_path, "type_": "error", "children": [], "id_": "", } - send_discovery_message(os.fsdecode(cwd), error_node) + send_discovery_message(os.fsdecode(test_root_path), error_node) try: session_node: TestNode | None = build_test_tree(session) if not session_node: @@ -431,19 +450,19 @@ def pytest_sessionfinish(session, exitstatus): "Something went wrong following pytest finish, \ no session node was created" ) - send_discovery_message(os.fsdecode(cwd), session_node) + send_discovery_message(os.fsdecode(test_root_path), session_node) except Exception as e: ERRORS.append( f"Error Occurred, traceback: {(traceback.format_exc() if e.__traceback__ else '')}" ) error_node: TestNode = { "name": "", - "path": cwd, + "path": test_root_path, "type_": "error", "children": [], "id_": "", } - send_discovery_message(os.fsdecode(cwd), error_node) + send_discovery_message(os.fsdecode(test_root_path), error_node) else: if exitstatus == 0 or exitstatus == 1: exitstatus_bool = "success" @@ -454,7 +473,7 @@ def pytest_sessionfinish(session, exitstatus): exitstatus_bool = "error" send_execution_message( - os.fsdecode(cwd), + os.fsdecode(test_root_path), exitstatus_bool, None, ) @@ -540,7 +559,7 @@ def pytest_sessionfinish(session, exitstatus): payload: CoveragePayloadDict = CoveragePayloadDict( coverage=True, - cwd=os.fspath(cwd), + cwd=os.fspath(test_root_path), result=file_coverage_map, error=None, ) @@ -832,7 +851,11 @@ def create_session_node(session: pytest.Session) -> TestNode: Keyword arguments: session -- the pytest session. """ - node_path = get_node_path(session) + # Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use session path (legacy) + if PROJECT_ROOT_PATH: + node_path = pathlib.Path(PROJECT_ROOT_PATH) + else: + node_path = get_node_path(session) return { "name": node_path.name, "path": node_path, diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 6121b3e24442..db7adfd92ee2 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -16,6 +16,7 @@ import { import { ITestDebugLauncher } from '../../common/types'; import { IPythonExecutionFactory } from '../../../common/process/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; +import { ProjectAdapter } from './projectAdapter'; export enum TestDataKinds { Workspace, @@ -160,6 +161,7 @@ export interface ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise; } diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index ced7acf52701..cea1bd2ac5b4 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -53,7 +53,7 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { ProjectAdapter } from './common/projectAdapter'; +import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter'; import { getProjectId, createProjectDisplayName } from './common/projectUtils'; import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; @@ -73,7 +73,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc * Set to true to enable multi-project testing support (Phases 2-4 must be complete). * Default: false (use legacy single-workspace mode) */ - private readonly useProjectBasedTesting = false; + private readonly useProjectBasedTesting = true; // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); @@ -83,11 +83,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Note: Project URI strings match Python Environments extension's Map keys private readonly workspaceProjects: Map> = new Map(); - // TODO: Phase 3-4 - Add these maps when implementing discovery and execution: + // Temporary state for tracking overlaps during discovery (created/destroyed per refresh) + private readonly workspaceDiscoveryState: Map = new Map(); + + // TODO: Phase 3-4 - Add these maps when implementing execution: // - vsIdToProject: Map - Fast lookup for test execution // - fileUriToProject: Map - File watching and change detection // - projectToVsIds: Map> - Project cleanup and refresh - // - workspaceDiscoveryState: Map - Temporary overlap detection private readonly triggerTypes: TriggerType[] = []; @@ -551,6 +553,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Ensure we send test telemetry if it gets disabled again this.sendTestDisabledTelemetry = true; + // Branch: Use project-based discovery if feature flag enabled and projects exist + if (this.useProjectBasedTesting && this.workspaceProjects.has(workspace.uri)) { + await this.refreshWorkspaceProjects(workspace.uri); + return; + } + + // Legacy mode: Single workspace adapter if (settings.testing.pytestEnabled) { await this.discoverTestsForProvider(workspace.uri, 'pytest'); } else if (settings.testing.unittestEnabled) { @@ -560,6 +569,137 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + /** + * Phase 2: Discovers tests for all projects within a workspace (project-based testing). + * Runs discovery in parallel for all projects and tracks file overlaps for Phase 3. + * Each project populates its TestItems independently using the existing discovery flow. + */ + private async refreshWorkspaceProjects(workspaceUri: Uri): Promise { + const projectsMap = this.workspaceProjects.get(workspaceUri); + if (!projectsMap || projectsMap.size === 0) { + traceError(`[test-by-project] No projects found for workspace: ${workspaceUri.fsPath}`); + return; + } + + const projects = Array.from(projectsMap.values()); + traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`); + + // Initialize discovery state for overlap tracking + const discoveryState: WorkspaceDiscoveryState = { + workspaceUri, + fileToProjects: new Map(), + fileOwnership: new Map(), + projectsCompleted: new Set(), + totalProjects: projects.length, + isComplete: false, + }; + this.workspaceDiscoveryState.set(workspaceUri, discoveryState); + + try { + // Run discovery for all projects in parallel + // Each project will populate TestItems independently via existing flow + await Promise.all(projects.map((project) => this.discoverProject(project, discoveryState))); + + // Mark discovery complete + discoveryState.isComplete = true; + traceInfo( + `[test-by-project] Discovery complete: ${discoveryState.projectsCompleted.size}/${projects.length} projects succeeded`, + ); + + // Log overlap information for debugging + const overlappingFiles = Array.from(discoveryState.fileToProjects.entries()).filter( + ([, projects]) => projects.size > 1, + ); + if (overlappingFiles.length > 0) { + traceInfo(`[test-by-project] Found ${overlappingFiles.length} file(s) discovered by multiple projects`); + } + + // TODO: Phase 3 - Resolve overlaps and rebuild test tree with proper ownership + // await this.resolveOverlapsAndAssignTests(workspaceUri); + } finally { + // Clean up temporary discovery state + this.workspaceDiscoveryState.delete(workspaceUri); + } + } + + /** + * Phase 2: Runs test discovery for a single project. + * Uses the existing discovery flow which populates TestItems automatically. + * Tracks which files were discovered for overlap detection in Phase 3. + */ + private async discoverProject(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): Promise { + try { + traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); + project.isDiscovering = true; + + // Run discovery using project's adapter with project's interpreter + // This will call the existing discovery flow which populates TestItems via result resolver + // Note: The adapter expects the legacy PythonEnvironment type, but for now we can pass + // the environment from the API. The adapters internally use execInfo which both types have. + // + // Pass the ProjectAdapter so discovery adapters can extract project.projectUri.fsPath + // and set PROJECT_ROOT_PATH environment variable. This tells Python subprocess where to + // trim the test tree, keeping test paths relative to project root instead of workspace root, + // while preserving CWD for user's test configurations. + // + // TODO: Symlink consideration - If project.projectUri.fsPath contains symlinks, + // Python's path resolution may differ from Node.js. Discovery adapters should consider + // using fs.promises.realpath() to resolve symlinks before passing PROJECT_ROOT_PATH to Python, + // similar to handleSymlinkAndRootDir() in pytest. This ensures PROJECT_ROOT_PATH matches + // the resolved path Python will use. + await project.discoveryAdapter.discoverTests( + project.projectUri, + this.pythonExecFactory, + this.refreshCancellation.token, + project.pythonEnvironment as any, // Type cast needed - API type vs legacy type + project, // Pass project for access to projectUri and other project-specific data + ); + + // Track which files this project discovered by inspecting created TestItems + // This data will be used in Phase 3 for overlap resolution + this.trackProjectDiscoveredFiles(project, discoveryState); + + // Mark project as completed + discoveryState.projectsCompleted.add(project.projectId); + traceInfo(`[test-by-project] Project ${project.projectName} discovery completed`); + } catch (error) { + traceError(`[test-by-project] Discovery failed for project ${project.projectName}:`, error); + // Individual project failures don't block others + discoveryState.projectsCompleted.add(project.projectId); // Still mark as completed + } finally { + project.isDiscovering = false; + } + } + + /** + * Tracks which files a project discovered by inspecting its TestItems. + * Populates the fileToProjects map for overlap detection in Phase 3. + */ + private trackProjectDiscoveredFiles(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): void { + // Get all test items for this project from its result resolver + const testItems = project.resultResolver.runIdToTestItem; + + // Extract unique file paths from test items + const filePaths = new Set(); + testItems.forEach((testItem) => { + if (testItem.uri) { + filePaths.add(testItem.uri.fsPath); + } + }); + + // Track which projects discovered each file + filePaths.forEach((filePath) => { + if (!discoveryState.fileToProjects.has(filePath)) { + discoveryState.fileToProjects.set(filePath, new Set()); + } + discoveryState.fileToProjects.get(filePath)!.add(project); + }); + + traceVerbose( + `[test-by-project] Project ${project.projectName} discovered ${filePaths.size} file(s) with ${testItems.size} test(s)`, + ); + } + /** * Discovers tests for all workspaces in the workspace folders. */ diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index 7ad69c71fa0e..46d00052c3a3 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -19,6 +19,7 @@ import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { useEnvExtension, getEnvironment, runInBackground } from '../../../envExt/api.internal'; import { buildPytestEnv as configureSubprocessEnv, handleSymlinkAndRootDir } from './pytestHelpers'; import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers'; +import { ProjectAdapter } from '../common/projectAdapter'; /** * Configures the subprocess environment for pytest discovery. @@ -53,6 +54,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { // Setup discovery pipe and cancellation const { @@ -84,6 +86,17 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Configure subprocess environment const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); + // Set PROJECT_ROOT_PATH for project-based testing + // This tells Python where to trim the test tree, keeping test paths relative to project root + // instead of workspace root, while preserving CWD for user's test configurations. + // Using fsPath for cross-platform compatibility (handles Windows vs Unix paths). + // TODO: Symlink consideration - PROJECT_ROOT_PATH may contain symlinks. If handleSymlinkAndRootDir() + // resolves the CWD to a different path, PROJECT_ROOT_PATH might not match. Consider resolving + // PROJECT_ROOT_PATH symlinks before passing, or adjust Python-side logic to handle both paths. + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + } + // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('pytest', uri, cwd, this.resultResolver, deferredTillExecClose, [5]); diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 7c986e95a449..bb21a8065f65 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -18,6 +18,7 @@ import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { createTestingDeferred } from '../common/utils'; import { buildDiscoveryCommand, buildUnittestEnv as configureSubprocessEnv } from './unittestHelpers'; import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers'; +import { ProjectAdapter } from '../common/projectAdapter'; /** * Configures the subprocess environment for unittest discovery. @@ -51,6 +52,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { // Setup discovery pipe and cancellation const { @@ -78,6 +80,17 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Configure subprocess environment const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); + // Set PROJECT_ROOT_PATH for project-based testing + // This tells Python where to trim the test tree, keeping test paths relative to project root + // instead of workspace root, while preserving CWD for user's test configurations. + // Using fsPath for cross-platform compatibility (handles Windows vs Unix paths). + // TODO: Symlink consideration - If CWD or PROJECT_ROOT_PATH contain symlinks, path matching + // in Python may fail. Consider resolving symlinks before comparison, or using os.path.realpath() + // on the Python side to normalize paths before building test tree. + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + } + // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); From 45675a43386d637da8e8b77a5a424e437017d0a8 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:00:08 -0800 Subject: [PATCH 14/25] second checkpoint- ignore implemented --- python_files/unittestadapter/pvsc_utils.py | 45 +++++++++++ .../testController/common/projectAdapter.ts | 7 ++ .../testing/testController/controller.ts | 75 ++++++++++++++++++- .../pytest/pytestDiscoveryAdapter.ts | 19 +++++ .../unittest/testDiscoveryAdapter.ts | 12 +++ 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index d6920592a4d4..18b68ac5915f 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -166,6 +166,44 @@ def get_child_node(name: str, path: str, type_: TestNodeTypeEnum, root: TestNode return result # type:ignore +# TODO: Unittest nested project exclusion - commented out for now, focusing on pytest first +# def should_exclude_file(test_path: str) -> bool: +# """Check if a test file should be excluded due to nested project ownership. +# +# Reads NESTED_PROJECTS_TO_IGNORE environment variable (JSON array of paths) +# and checks if test_path is under any of those nested project directories. +# +# Args: +# test_path: Absolute path to the test file +# +# Returns: +# True if the file should be excluded, False otherwise +# """ +# nested_projects_json = os.getenv("NESTED_PROJECTS_TO_IGNORE") +# if not nested_projects_json: +# return False +# +# try: +# nested_paths = json.loads(nested_projects_json) +# test_path_obj = pathlib.Path(test_path).resolve() +# +# # Check if test file is under any nested project path +# for nested_path in nested_paths: +# nested_path_obj = pathlib.Path(nested_path).resolve() +# try: +# test_path_obj.relative_to(nested_path_obj) +# # If relative_to succeeds, test_path is under nested_path +# return True +# except ValueError: +# # test_path is not under nested_path +# continue +# +# return False +# except Exception: +# # On any error, don't exclude (safer to show tests than hide them) +# return False + + def build_test_tree( suite: unittest.TestSuite, top_level_directory: str ) -> Tuple[Union[TestNode, None], List[str]]: @@ -251,6 +289,13 @@ def build_test_tree( # Find/build file node. path_components = [top_level_directory, *folders, py_filename] file_path = os.fsdecode(pathlib.PurePath("/".join(path_components))) + + # PHASE 4: Check if file should be excluded (nested project ownership) + # TODO: Commented out for now - focusing on pytest implementation first + # if should_exclude_file(file_path): + # # Skip this test - it belongs to a nested project + # continue + current_node = get_child_node( py_filename, file_path, TestNodeTypeEnum.file, current_node ) diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts index 00e62fff5e09..7f5616947f8e 100644 --- a/src/client/testing/testController/common/projectAdapter.ts +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -85,6 +85,13 @@ export interface ProjectAdapter { */ ownedTests?: DiscoveredTestNode; + /** + * Absolute paths of nested projects to ignore during discovery. + * Used to pass --ignore flags to pytest or exclusion filters to unittest. + * Only populated for parent projects that contain nested child projects. + */ + nestedProjectPathsToIgnore?: string[]; + // === LIFECYCLE === /** * Whether discovery is currently running for this project. diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index cea1bd2ac5b4..c67d9bcb9393 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -569,6 +569,62 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + /** + * Phase 3: Identifies which projects are nested within other projects in the same workspace. + * Returns a map of parent project ID -> array of nested child project paths to ignore. + * + * Example: If ProjectB (alice/bob/) is nested in ProjectA (alice/), + * returns: { "projectA-id": ["alice/bob"] } + * + * Uses simple path prefix matching - a project is nested if its path starts with + * another project's path followed by a path separator. + */ + private computeNestedProjectIgnores(workspaceUri: Uri): Map { + const projectIgnores = new Map(); + const projects = this.workspaceProjects.get(workspaceUri); + + if (!projects || projects.size === 0) { + return projectIgnores; + } + + const projectArray = Array.from(projects.values()); + + // For each project, find all other projects nested within it + for (const parentProject of projectArray) { + const nestedPaths: string[] = []; + + for (const potentialChild of projectArray) { + if (parentProject.projectId === potentialChild.projectId) { + continue; // Skip self + } + + // Check if child is nested under parent + const parentPath = parentProject.projectUri.fsPath; + const childPath = potentialChild.projectUri.fsPath; + + // Use path.sep for cross-platform compatibility (/ on Unix, \\ on Windows) + if (childPath.startsWith(parentPath + path.sep)) { + // Child is nested - add its path for ignoring + nestedPaths.push(childPath); + traceVerbose( + `[test-by-project] Detected nested project: ${potentialChild.projectName} ` + + `(${childPath}) under ${parentProject.projectName} (${parentPath})`, + ); + } + } + + if (nestedPaths.length > 0) { + projectIgnores.set(parentProject.projectId, nestedPaths); + traceInfo( + `[test-by-project] Project ${parentProject.projectName} will ignore ` + + `${nestedPaths.length} nested project(s)`, + ); + } + } + + return projectIgnores; + } + /** * Phase 2: Discovers tests for all projects within a workspace (project-based testing). * Runs discovery in parallel for all projects and tracks file overlaps for Phase 3. @@ -596,7 +652,24 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.workspaceDiscoveryState.set(workspaceUri, discoveryState); try { - // Run discovery for all projects in parallel + // PHASE 3: Compute nested project relationships BEFORE discovery + const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); + + // Populate each project's ignore list by iterating through projects array directly + for (const project of projects) { + const ignorePaths = projectIgnores.get(project.projectId); + if (ignorePaths && ignorePaths.length > 0) { + project.nestedProjectPathsToIgnore = ignorePaths; + traceInfo( + `[test-by-project] Project ${project.projectName} configured to ignore ${ignorePaths.length} nested project(s): ` + + `${ignorePaths.join(', ')}`, + ); + } else { + traceVerbose(`[test-by-project] Project ${project.projectName} has no nested projects to ignore`); + } + } + + // Run discovery for all projects in parallel (now with ignore lists populated) // Each project will populate TestItems independently via existing flow await Promise.all(projects.map((project) => this.discoverProject(project, discoveryState))); diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index 46d00052c3a3..93eee9dfbb61 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -78,6 +78,25 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { let { pytestArgs } = settings.testing; const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; pytestArgs = await handleSymlinkAndRootDir(cwd, pytestArgs); + + // PHASE 4: Add --ignore flags for nested projects + traceVerbose( + `[test-by-project] Checking for nested projects to ignore. Project: ${project?.projectName}, ` + + `nestedProjectPathsToIgnore length: ${project?.nestedProjectPathsToIgnore?.length ?? 0}`, + ); + if (project?.nestedProjectPathsToIgnore?.length) { + const ignoreArgs = project.nestedProjectPathsToIgnore.map((nestedPath) => `--ignore=${nestedPath}`); + pytestArgs = [...pytestArgs, ...ignoreArgs]; + traceInfo( + `[test-by-project] Project ${project.projectName} ignoring ${ignoreArgs.length} ` + + `nested project(s): ${ignoreArgs.join(' ')}`, + ); + } else { + traceVerbose( + `[test-by-project] No nested projects to ignore for project: ${project?.projectName ?? 'unknown'}`, + ); + } + const commandArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs); traceVerbose( `Running pytest discovery with command: ${commandArgs.join(' ')} for workspace ${uri.fsPath}.`, diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index bb21a8065f65..2aca3ea73a4e 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -91,6 +91,18 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; } + // PHASE 4: Pass exclusion list via environment variable for unittest + // TODO: unittest doesn't have a built-in --ignore flag like pytest, so we'll need to pass the + // nested project paths via environment and handle filtering in Python-side discovery.py + // Commenting out for now - focusing on pytest implementation first + // if (project?.nestedProjectPathsToIgnore?.length) { + // mutableEnv.NESTED_PROJECTS_TO_IGNORE = JSON.stringify(project.nestedProjectPathsToIgnore); + // traceInfo( + // `[test-by-project] Project ${project.projectName} will exclude ${project.nestedProjectPathsToIgnore.length} ` + + // `nested project(s) in Python-side unittest discovery` + // ); + // } + // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); From 267007baa55d7dc4cfd68403629c578bafda0452 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:14:14 -0800 Subject: [PATCH 15/25] cleanup cleanup everybody everywhere --- .vscode/settings.json | 108 ++++++++++++++++-- .../testController/common/projectAdapter.ts | 59 +--------- .../testing/testController/controller.ts | 83 ++------------ 3 files changed, 114 insertions(+), 136 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 01de0d907706..74d444e253b2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,7 +28,7 @@ "source.fixAll.eslint": "explicit", "source.organizeImports.isort": "explicit" }, - "editor.defaultFormatter": "charliermarsh.ruff", + "editor.defaultFormatter": "charliermarsh.ruff" }, "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", @@ -67,12 +67,106 @@ "git.pullBeforeCheckout": true, // Open merge editor for resolving conflicts. "git.mergeEditor": true, - "python.testing.pytestArgs": [ - "python_files/tests" - ], + "python.testing.pytestArgs": ["python_files/tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "rust-analyzer.linkedProjects": [ - ".\\python-env-tools\\Cargo.toml" - ] + "rust-analyzer.linkedProjects": [".\\python-env-tools\\Cargo.toml"], + "chat.tools.terminal.autoApprove": { + "cd": true, + "echo": true, + "ls": true, + "pwd": true, + "cat": true, + "head": true, + "tail": true, + "findstr": true, + "wc": true, + "tr": true, + "cut": true, + "cmp": true, + "which": true, + "basename": true, + "dirname": true, + "realpath": true, + "readlink": true, + "stat": true, + "file": true, + "du": true, + "df": true, + "sleep": true, + "nl": true, + "grep": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/": true, + "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*-(d|D|m|M|-delete|-force)\\b/": false, + "Get-ChildItem": true, + "Get-Content": true, + "Get-Date": true, + "Get-Random": true, + "Get-Location": true, + "Write-Host": true, + "Write-Output": true, + "Out-String": true, + "Split-Path": true, + "Join-Path": true, + "Start-Sleep": true, + "Where-Object": true, + "/^Select-[a-z0-9]/i": true, + "/^Measure-[a-z0-9]/i": true, + "/^Compare-[a-z0-9]/i": true, + "/^Format-[a-z0-9]/i": true, + "/^Sort-[a-z0-9]/i": true, + "column": true, + "/^column\\b.*-c\\s+[0-9]{4,}/": false, + "date": true, + "/^date\\b.*(-s|--set)\\b/": false, + "find": true, + "/^find\\b.*-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/": false, + "rg": true, + "/^rg\\b.*(--pre|--hostname-bin)\\b/": false, + "sed": true, + "/^sed\\b.*(-[a-zA-Z]*(e|i|I|f)[a-zA-Z]*|--expression|--file|--in-place)\\b/": false, + "/^sed\\b.*(/e|/w|;W)/": false, + "sort": true, + "/^sort\\b.*-(o|S)\\b/": false, + "tree": true, + "/^tree\\b.*-o\\b/": false, + "rm": false, + "rmdir": false, + "del": false, + "Remove-Item": false, + "ri": false, + "rd": false, + "erase": false, + "dd": false, + "kill": false, + "ps": false, + "top": false, + "Stop-Process": false, + "spps": false, + "taskkill": false, + "taskkill.exe": false, + "curl": false, + "wget": false, + "Invoke-RestMethod": false, + "Invoke-WebRequest": false, + "irm": false, + "iwr": false, + "chmod": false, + "chown": false, + "Set-ItemProperty": false, + "sp": false, + "Set-Acl": false, + "jq": false, + "xargs": false, + "eval": false, + "Invoke-Expression": false, + "iex": false, + "npx tsc": true + } } diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts index 7f5616947f8e..091de2582579 100644 --- a/src/client/testing/testController/common/projectAdapter.ts +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -3,13 +3,7 @@ import { TestItem, Uri } from 'vscode'; import { TestProvider } from '../../types'; -import { - ITestDiscoveryAdapter, - ITestExecutionAdapter, - ITestResultResolver, - DiscoveredTestPayload, - DiscoveredTestNode, -} from './types'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './types'; import { PythonEnvironment, PythonProject } from '../../../envExt/types'; /** @@ -72,19 +66,6 @@ export interface ProjectAdapter { */ resultResolver: ITestResultResolver; - // === DISCOVERY STATE === - /** - * Raw discovery data before filtering (all discovered tests). - * Cleared after ownership resolution to save memory. - */ - rawDiscoveryData?: DiscoveredTestPayload; - - /** - * Filtered tests that this project owns (after API verification). - * This is the tree structure passed to populateTestTree(). - */ - ownedTests?: DiscoveredTestNode; - /** * Absolute paths of nested projects to ignore during discovery. * Used to pass --ignore flags to pytest or exclusion filters to unittest. @@ -109,41 +90,3 @@ export interface ProjectAdapter { */ projectRootTestItem?: TestItem; } - -/** - * Temporary state used during workspace-wide test discovery. - * Created at the start of discovery and cleared after ownership resolution. - */ -export interface WorkspaceDiscoveryState { - /** - * The workspace being discovered. - */ - workspaceUri: Uri; - - /** - * Maps test file paths to the set of projects that discovered them. - * Used to detect overlapping discovery. - */ - fileToProjects: Map>; - - /** - * Maps test file paths to their owning project (after API resolution). - * Value is the ProjectAdapter whose pythonProject.uri matches API response. - */ - fileOwnership: Map; - - /** - * Progress tracking for parallel discovery. - */ - projectsCompleted: Set; - - /** - * Total number of projects in this workspace. - */ - totalProjects: number; - - /** - * Whether all projects have completed discovery. - */ - isComplete: boolean; -} diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index c67d9bcb9393..11a191343a75 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -53,7 +53,7 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter'; +import { ProjectAdapter } from './common/projectAdapter'; import { getProjectId, createProjectDisplayName } from './common/projectUtils'; import { PythonProject, PythonEnvironment } from '../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; @@ -83,9 +83,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Note: Project URI strings match Python Environments extension's Map keys private readonly workspaceProjects: Map> = new Map(); - // Temporary state for tracking overlaps during discovery (created/destroyed per refresh) - private readonly workspaceDiscoveryState: Map = new Map(); - // TODO: Phase 3-4 - Add these maps when implementing execution: // - vsIdToProject: Map - Fast lookup for test execution // - fileUriToProject: Map - File watching and change detection @@ -640,17 +637,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const projects = Array.from(projectsMap.values()); traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`); - // Initialize discovery state for overlap tracking - const discoveryState: WorkspaceDiscoveryState = { - workspaceUri, - fileToProjects: new Map(), - fileOwnership: new Map(), - projectsCompleted: new Set(), - totalProjects: projects.length, - isComplete: false, - }; - this.workspaceDiscoveryState.set(workspaceUri, discoveryState); - try { // PHASE 3: Compute nested project relationships BEFORE discovery const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); @@ -669,38 +655,26 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + // Track completion for progress logging + const projectsCompleted = new Set(); + // Run discovery for all projects in parallel (now with ignore lists populated) // Each project will populate TestItems independently via existing flow - await Promise.all(projects.map((project) => this.discoverProject(project, discoveryState))); + await Promise.all(projects.map((project) => this.discoverProject(project, projectsCompleted))); - // Mark discovery complete - discoveryState.isComplete = true; traceInfo( - `[test-by-project] Discovery complete: ${discoveryState.projectsCompleted.size}/${projects.length} projects succeeded`, - ); - - // Log overlap information for debugging - const overlappingFiles = Array.from(discoveryState.fileToProjects.entries()).filter( - ([, projects]) => projects.size > 1, + `[test-by-project] Discovery complete: ${projectsCompleted.size}/${projects.length} projects succeeded`, ); - if (overlappingFiles.length > 0) { - traceInfo(`[test-by-project] Found ${overlappingFiles.length} file(s) discovered by multiple projects`); - } - - // TODO: Phase 3 - Resolve overlaps and rebuild test tree with proper ownership - // await this.resolveOverlapsAndAssignTests(workspaceUri); - } finally { - // Clean up temporary discovery state - this.workspaceDiscoveryState.delete(workspaceUri); + } catch (error) { + traceError(`[test-by-project] Discovery failed for workspace ${workspaceUri.fsPath}:`, error); } } /** - * Phase 2: Runs test discovery for a single project. + * Runs test discovery for a single project. * Uses the existing discovery flow which populates TestItems automatically. - * Tracks which files were discovered for overlap detection in Phase 3. */ - private async discoverProject(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): Promise { + private async discoverProject(project: ProjectAdapter, projectsCompleted: Set): Promise { try { traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); project.isDiscovering = true; @@ -728,51 +702,18 @@ export class PythonTestController implements ITestController, IExtensionSingleAc project, // Pass project for access to projectUri and other project-specific data ); - // Track which files this project discovered by inspecting created TestItems - // This data will be used in Phase 3 for overlap resolution - this.trackProjectDiscoveredFiles(project, discoveryState); - // Mark project as completed - discoveryState.projectsCompleted.add(project.projectId); + projectsCompleted.add(project.projectId); traceInfo(`[test-by-project] Project ${project.projectName} discovery completed`); } catch (error) { traceError(`[test-by-project] Discovery failed for project ${project.projectName}:`, error); // Individual project failures don't block others - discoveryState.projectsCompleted.add(project.projectId); // Still mark as completed + projectsCompleted.add(project.projectId); // Still mark as completed } finally { project.isDiscovering = false; } } - /** - * Tracks which files a project discovered by inspecting its TestItems. - * Populates the fileToProjects map for overlap detection in Phase 3. - */ - private trackProjectDiscoveredFiles(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): void { - // Get all test items for this project from its result resolver - const testItems = project.resultResolver.runIdToTestItem; - - // Extract unique file paths from test items - const filePaths = new Set(); - testItems.forEach((testItem) => { - if (testItem.uri) { - filePaths.add(testItem.uri.fsPath); - } - }); - - // Track which projects discovered each file - filePaths.forEach((filePath) => { - if (!discoveryState.fileToProjects.has(filePath)) { - discoveryState.fileToProjects.set(filePath, new Set()); - } - discoveryState.fileToProjects.get(filePath)!.add(project); - }); - - traceVerbose( - `[test-by-project] Project ${project.projectName} discovered ${filePaths.size} file(s) with ${testItems.size} test(s)`, - ); - } - /** * Discovers tests for all workspaces in the workspace folders. */ From 2abfbbe8a9fb22ef07bdd064cf6e33a2c435504f Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:31:40 -0800 Subject: [PATCH 16/25] remove comments --- python_files/unittestadapter/pvsc_utils.py | 44 ----------------- .../testing/testController/controller.ts | 47 ++++--------------- .../pytest/pytestDiscoveryAdapter.ts | 23 ++------- .../unittest/testDiscoveryAdapter.ts | 20 +------- 4 files changed, 16 insertions(+), 118 deletions(-) diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index 18b68ac5915f..bdb47bd558f9 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -166,44 +166,6 @@ def get_child_node(name: str, path: str, type_: TestNodeTypeEnum, root: TestNode return result # type:ignore -# TODO: Unittest nested project exclusion - commented out for now, focusing on pytest first -# def should_exclude_file(test_path: str) -> bool: -# """Check if a test file should be excluded due to nested project ownership. -# -# Reads NESTED_PROJECTS_TO_IGNORE environment variable (JSON array of paths) -# and checks if test_path is under any of those nested project directories. -# -# Args: -# test_path: Absolute path to the test file -# -# Returns: -# True if the file should be excluded, False otherwise -# """ -# nested_projects_json = os.getenv("NESTED_PROJECTS_TO_IGNORE") -# if not nested_projects_json: -# return False -# -# try: -# nested_paths = json.loads(nested_projects_json) -# test_path_obj = pathlib.Path(test_path).resolve() -# -# # Check if test file is under any nested project path -# for nested_path in nested_paths: -# nested_path_obj = pathlib.Path(nested_path).resolve() -# try: -# test_path_obj.relative_to(nested_path_obj) -# # If relative_to succeeds, test_path is under nested_path -# return True -# except ValueError: -# # test_path is not under nested_path -# continue -# -# return False -# except Exception: -# # On any error, don't exclude (safer to show tests than hide them) -# return False - - def build_test_tree( suite: unittest.TestSuite, top_level_directory: str ) -> Tuple[Union[TestNode, None], List[str]]: @@ -290,12 +252,6 @@ def build_test_tree( path_components = [top_level_directory, *folders, py_filename] file_path = os.fsdecode(pathlib.PurePath("/".join(path_components))) - # PHASE 4: Check if file should be excluded (nested project ownership) - # TODO: Commented out for now - focusing on pytest implementation first - # if should_exclude_file(file_path): - # # Skip this test - it belongs to a nested project - # continue - current_node = get_child_node( py_filename, file_path, TestNodeTypeEnum.file, current_node ) diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 11a191343a75..df9a95cc40c5 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -70,24 +70,17 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Feature flag for project-based testing. - * Set to true to enable multi-project testing support (Phases 2-4 must be complete). - * Default: false (use legacy single-workspace mode) + * When true, discovers and manages tests per-project using Python Environments API. + * When false, uses legacy single-workspace mode. */ private readonly useProjectBasedTesting = true; // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); - // === NEW: PROJECT-BASED STATE === - // Map of workspace URI -> Map of project URI string -> ProjectAdapter - // Note: Project URI strings match Python Environments extension's Map keys + // Project-based testing: Maps workspace URI -> project ID -> ProjectAdapter private readonly workspaceProjects: Map> = new Map(); - // TODO: Phase 3-4 - Add these maps when implementing execution: - // - vsIdToProject: Map - Fast lookup for test execution - // - fileUriToProject: Map - File watching and change detection - // - projectToVsIds: Map> - Project cleanup and refresh - private readonly triggerTypes: TriggerType[] = []; private readonly testController: TestController; @@ -333,7 +326,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const pythonProjects = envExtApi.getPythonProjects(); traceInfo(`[test-by-project] Found ${pythonProjects.length} total Python projects from API`); - // Filter projects to only those in this workspace TODO; check this + // Filter projects to only those in this workspace const workspaceProjects = pythonProjects.filter((project) => isParentPath(project.uri.fsPath, workspaceUri.fsPath), ); @@ -567,14 +560,11 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } /** - * Phase 3: Identifies which projects are nested within other projects in the same workspace. + * Identifies which projects are nested within other projects in the same workspace. * Returns a map of parent project ID -> array of nested child project paths to ignore. * * Example: If ProjectB (alice/bob/) is nested in ProjectA (alice/), * returns: { "projectA-id": ["alice/bob"] } - * - * Uses simple path prefix matching - a project is nested if its path starts with - * another project's path followed by a path separator. */ private computeNestedProjectIgnores(workspaceUri: Uri): Map { const projectIgnores = new Map(); @@ -623,9 +613,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } /** - * Phase 2: Discovers tests for all projects within a workspace (project-based testing). - * Runs discovery in parallel for all projects and tracks file overlaps for Phase 3. - * Each project populates its TestItems independently using the existing discovery flow. + * Discovers tests for all projects within a workspace. + * Runs discovery in parallel and configures nested project exclusions. */ private async refreshWorkspaceProjects(workspaceUri: Uri): Promise { const projectsMap = this.workspaceProjects.get(workspaceUri); @@ -638,7 +627,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`); try { - // PHASE 3: Compute nested project relationships BEFORE discovery + // Compute nested project relationships BEFORE discovery const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); // Populate each project's ignore list by iterating through projects array directly @@ -672,34 +661,18 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Runs test discovery for a single project. - * Uses the existing discovery flow which populates TestItems automatically. */ private async discoverProject(project: ProjectAdapter, projectsCompleted: Set): Promise { try { traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); project.isDiscovering = true; - // Run discovery using project's adapter with project's interpreter - // This will call the existing discovery flow which populates TestItems via result resolver - // Note: The adapter expects the legacy PythonEnvironment type, but for now we can pass - // the environment from the API. The adapters internally use execInfo which both types have. - // - // Pass the ProjectAdapter so discovery adapters can extract project.projectUri.fsPath - // and set PROJECT_ROOT_PATH environment variable. This tells Python subprocess where to - // trim the test tree, keeping test paths relative to project root instead of workspace root, - // while preserving CWD for user's test configurations. - // - // TODO: Symlink consideration - If project.projectUri.fsPath contains symlinks, - // Python's path resolution may differ from Node.js. Discovery adapters should consider - // using fs.promises.realpath() to resolve symlinks before passing PROJECT_ROOT_PATH to Python, - // similar to handleSymlinkAndRootDir() in pytest. This ensures PROJECT_ROOT_PATH matches - // the resolved path Python will use. await project.discoveryAdapter.discoverTests( project.projectUri, this.pythonExecFactory, this.refreshCancellation.token, - project.pythonEnvironment as any, // Type cast needed - API type vs legacy type - project, // Pass project for access to projectUri and other project-specific data + project.pythonEnvironment as any, + project, ); // Mark project as completed diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index 93eee9dfbb61..f363821371cb 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -79,21 +79,14 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; pytestArgs = await handleSymlinkAndRootDir(cwd, pytestArgs); - // PHASE 4: Add --ignore flags for nested projects - traceVerbose( - `[test-by-project] Checking for nested projects to ignore. Project: ${project?.projectName}, ` + - `nestedProjectPathsToIgnore length: ${project?.nestedProjectPathsToIgnore?.length ?? 0}`, - ); + // Add --ignore flags for nested projects to prevent duplicate discovery if (project?.nestedProjectPathsToIgnore?.length) { const ignoreArgs = project.nestedProjectPathsToIgnore.map((nestedPath) => `--ignore=${nestedPath}`); pytestArgs = [...pytestArgs, ...ignoreArgs]; traceInfo( - `[test-by-project] Project ${project.projectName} ignoring ${ignoreArgs.length} ` + - `nested project(s): ${ignoreArgs.join(' ')}`, - ); - } else { - traceVerbose( - `[test-by-project] No nested projects to ignore for project: ${project?.projectName ?? 'unknown'}`, + `[test-by-project] Project ${project.projectName} ignoring nested project(s): ${ignoreArgs.join( + ' ', + )}`, ); } @@ -105,13 +98,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Configure subprocess environment const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); - // Set PROJECT_ROOT_PATH for project-based testing - // This tells Python where to trim the test tree, keeping test paths relative to project root - // instead of workspace root, while preserving CWD for user's test configurations. - // Using fsPath for cross-platform compatibility (handles Windows vs Unix paths). - // TODO: Symlink consideration - PROJECT_ROOT_PATH may contain symlinks. If handleSymlinkAndRootDir() - // resolves the CWD to a different path, PROJECT_ROOT_PATH might not match. Consider resolving - // PROJECT_ROOT_PATH symlinks before passing, or adjust Python-side logic to handle both paths. + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) if (project) { mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; } diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 2aca3ea73a4e..3e9503f3df4c 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -80,29 +80,11 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Configure subprocess environment const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); - // Set PROJECT_ROOT_PATH for project-based testing - // This tells Python where to trim the test tree, keeping test paths relative to project root - // instead of workspace root, while preserving CWD for user's test configurations. - // Using fsPath for cross-platform compatibility (handles Windows vs Unix paths). - // TODO: Symlink consideration - If CWD or PROJECT_ROOT_PATH contain symlinks, path matching - // in Python may fail. Consider resolving symlinks before comparison, or using os.path.realpath() - // on the Python side to normalize paths before building test tree. + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) if (project) { mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; } - // PHASE 4: Pass exclusion list via environment variable for unittest - // TODO: unittest doesn't have a built-in --ignore flag like pytest, so we'll need to pass the - // nested project paths via environment and handle filtering in Python-side discovery.py - // Commenting out for now - focusing on pytest implementation first - // if (project?.nestedProjectPathsToIgnore?.length) { - // mutableEnv.NESTED_PROJECTS_TO_IGNORE = JSON.stringify(project.nestedProjectPathsToIgnore); - // traceInfo( - // `[test-by-project] Project ${project.projectName} will exclude ${project.nestedProjectPathsToIgnore.length} ` + - // `nested project(s) in Python-side unittest discovery` - // ); - // } - // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); From 4e7a325c1699ffe81f0db007c3289e1c2233c79b Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:24:27 -0800 Subject: [PATCH 17/25] refinement --- .../common/testProjectRegistry.ts | 326 ++++++++++++++++ .../testing/testController/controller.ts | 365 +++--------------- 2 files changed, 386 insertions(+), 305 deletions(-) create mode 100644 src/client/testing/testController/common/testProjectRegistry.ts diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts new file mode 100644 index 000000000000..2bf1f798a2d1 --- /dev/null +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { TestController, Uri } from 'vscode'; +import { IConfigurationService } from '../../../common/types'; +import { IInterpreterService } from '../../../interpreter/contracts'; +import { traceError, traceInfo, traceVerbose } from '../../../logging'; +import { UNITTEST_PROVIDER } from '../../common/constants'; +import { TestProvider } from '../../types'; +import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; +import { PythonProject, PythonEnvironment } from '../../../envExt/types'; +import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal'; +import { isParentPath } from '../../../common/platform/fs-paths'; +import { ProjectAdapter } from './projectAdapter'; +import { getProjectId, createProjectDisplayName } from './projectUtils'; +import { PythonResultResolver } from './resultResolver'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter } from './types'; +import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter'; +import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter'; +import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter'; +import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter'; + +/** + * Registry for Python test projects within workspaces. + * + * Manages the lifecycle of test projects including: + * - Discovering Python projects via Python Environments API + * - Creating and storing ProjectAdapter instances per workspace + * - Computing nested project relationships for ignore lists + * - Fallback to default "legacy" project when API unavailable + * + * Key concepts: + * - Workspace: A VS Code workspace folder (may contain multiple projects) + * - Project: A Python project within a workspace (has its own pyproject.toml, etc.) + * - Each project gets its own test tree root and Python environment + */ +export class TestProjectRegistry { + /** + * Map of workspace URI -> Map of project ID -> ProjectAdapter + * Project IDs match Python Environments extension's Map keys + */ + private readonly workspaceProjects: Map> = new Map(); + + constructor( + private readonly testController: TestController, + private readonly configSettings: IConfigurationService, + private readonly interpreterService: IInterpreterService, + private readonly envVarsService: IEnvironmentVariablesProvider, + ) {} + + /** + * Checks if project-based testing is available (Python Environments API). + */ + public isProjectBasedTestingAvailable(): boolean { + return useEnvExtension(); + } + + /** + * Gets the projects map for a workspace, if it exists. + */ + public getWorkspaceProjects(workspaceUri: Uri): Map | undefined { + return this.workspaceProjects.get(workspaceUri); + } + + /** + * Checks if a workspace has been initialized with projects. + */ + public hasProjects(workspaceUri: Uri): boolean { + return this.workspaceProjects.has(workspaceUri); + } + + /** + * Gets all projects for a workspace as an array. + */ + public getProjectsArray(workspaceUri: Uri): ProjectAdapter[] { + const projectsMap = this.workspaceProjects.get(workspaceUri); + return projectsMap ? Array.from(projectsMap.values()) : []; + } + + /** + * Discovers and registers all Python projects for a workspace. + * Returns the discovered projects for the caller to use. + */ + public async discoverAndRegisterProjects(workspaceUri: Uri): Promise { + traceInfo(`[ProjectManager] Discovering projects for workspace: ${workspaceUri.fsPath}`); + + const projects = await this.discoverProjects(workspaceUri); + + // Create map for this workspace, keyed by project URI + const projectsMap = new Map(); + projects.forEach((project) => { + projectsMap.set(getProjectId(project.projectUri), project); + }); + + this.workspaceProjects.set(workspaceUri, projectsMap); + traceInfo(`[ProjectManager] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`); + + return projects; + } + + /** + * Computes and populates nested project ignore lists for all projects in a workspace. + * Must be called before discovery to ensure parent projects ignore nested children. + */ + public configureNestedProjectIgnores(workspaceUri: Uri): void { + const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); + const projects = this.getProjectsArray(workspaceUri); + + for (const project of projects) { + const ignorePaths = projectIgnores.get(project.projectId); + if (ignorePaths && ignorePaths.length > 0) { + project.nestedProjectPathsToIgnore = ignorePaths; + traceInfo(`[ProjectManager] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`); + } + } + } + + /** + * Clears all projects for a workspace. + */ + public clearWorkspace(workspaceUri: Uri): void { + this.workspaceProjects.delete(workspaceUri); + } + + // ====== Private Methods ====== + + /** + * Discovers Python projects in a workspace using the Python Environment API. + * Falls back to creating a single default project if API is unavailable. + */ + private async discoverProjects(workspaceUri: Uri): Promise { + try { + if (!useEnvExtension()) { + traceInfo('[ProjectManager] Python Environments API not available, using default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + const envExtApi = await getEnvExtApi(); + const allProjects = envExtApi.getPythonProjects(); + traceInfo(`[ProjectManager] Found ${allProjects.length} total Python projects from API`); + + // Filter to projects within this workspace + const workspaceProjects = allProjects.filter((project) => + isParentPath(project.uri.fsPath, workspaceUri.fsPath), + ); + traceInfo(`[ProjectManager] Filtered to ${workspaceProjects.length} projects in workspace`); + + if (workspaceProjects.length === 0) { + traceInfo('[ProjectManager] No projects found, creating default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + // Create ProjectAdapter for each discovered project + const adapters: ProjectAdapter[] = []; + for (const pythonProject of workspaceProjects) { + try { + const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); + adapters.push(adapter); + } catch (error) { + traceError(`[ProjectManager] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error); + } + } + + if (adapters.length === 0) { + traceInfo('[ProjectManager] All adapters failed, falling back to default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + return adapters; + } catch (error) { + traceError('[ProjectManager] Discovery failed, using default project:', error); + return [await this.createDefaultProject(workspaceUri)]; + } + } + + /** + * Creates a ProjectAdapter from a PythonProject. + */ + private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { + const projectId = pythonProject.uri.fsPath; + traceInfo(`[ProjectManager] Creating adapter for: ${pythonProject.name} at ${projectId}`); + + // Resolve Python environment + const envExtApi = await getEnvExtApi(); + const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); + if (!pythonEnvironment) { + throw new Error(`No Python environment found for project ${projectId}`); + } + + // Create test infrastructure + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); + const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver); + + const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + + return { + projectId, + projectName, + projectUri: pythonProject.uri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + } + + /** + * Creates a default project for legacy/fallback mode. + */ + private async createDefaultProject(workspaceUri: Uri): Promise { + traceInfo(`[ProjectManager] Creating default project for: ${workspaceUri.fsPath}`); + + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); + const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver); + + const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); + + const pythonEnvironment: PythonEnvironment = { + name: 'default', + displayName: interpreter?.displayName || 'Python', + shortDisplayName: interpreter?.displayName || 'Python', + displayPath: interpreter?.path || 'python', + version: interpreter?.version?.raw || '3.x', + environmentPath: Uri.file(interpreter?.path || 'python'), + sysPrefix: interpreter?.sysPrefix || '', + execInfo: { run: { executable: interpreter?.path || 'python' } }, + envId: { id: 'default', managerId: 'default' }, + }; + + const pythonProject: PythonProject = { + name: path.basename(workspaceUri.fsPath) || 'workspace', + uri: workspaceUri, + }; + + return { + projectId: getProjectId(workspaceUri), + projectName: pythonProject.name, + projectUri: workspaceUri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + } + + /** + * Identifies nested projects and returns ignore paths for parent projects. + */ + private computeNestedProjectIgnores(workspaceUri: Uri): Map { + const ignoreMap = new Map(); + const projects = this.getProjectsArray(workspaceUri); + + if (projects.length === 0) return ignoreMap; + + for (const parent of projects) { + const nestedPaths: string[] = []; + + for (const child of projects) { + if (parent.projectId === child.projectId) continue; + + const parentPath = parent.projectUri.fsPath; + const childPath = child.projectUri.fsPath; + + if (childPath.startsWith(parentPath + path.sep)) { + nestedPaths.push(childPath); + traceVerbose(`[ProjectManager] Nested: ${child.projectName} under ${parent.projectName}`); + } + } + + if (nestedPaths.length > 0) { + ignoreMap.set(parent.projectId, nestedPaths); + } + } + + return ignoreMap; + } + + /** + * Determines the test provider based on workspace settings. + */ + private getTestProvider(workspaceUri: Uri): TestProvider { + const settings = this.configSettings.getSettings(workspaceUri); + return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : 'pytest'; + } + + /** + * Creates discovery and execution adapters for a test provider. + */ + private createAdapters( + testProvider: TestProvider, + resultResolver: PythonResultResolver, + ): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { + if (testProvider === UNITTEST_PROVIDER) { + return { + discoveryAdapter: new UnittestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ), + executionAdapter: new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ), + }; + } + + return { + discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService), + executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService), + }; + } +} diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index df9a95cc40c5..6480fac0a351 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -4,7 +4,6 @@ import { inject, injectable, named } from 'inversify'; import { uniq } from 'lodash'; import * as minimatch from 'minimatch'; -import * as path from 'path'; import { CancellationToken, TestController, @@ -54,10 +53,7 @@ import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { ProjectAdapter } from './common/projectAdapter'; -import { getProjectId, createProjectDisplayName } from './common/projectUtils'; -import { PythonProject, PythonEnvironment } from '../../envExt/types'; -import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; -import { isParentPath } from '../../common/platform/fs-paths'; +import { TestProjectRegistry } from './common/testProjectRegistry'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -78,8 +74,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); - // Project-based testing: Maps workspace URI -> project ID -> ProjectAdapter - private readonly workspaceProjects: Map> = new Map(); + // Registry for multi-project testing (one registry instance manages all projects across workspaces) + private readonly projectRegistry: TestProjectRegistry; private readonly triggerTypes: TriggerType[] = []; @@ -122,6 +118,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.testController = tests.createTestController('python-tests', 'Python Tests'); this.disposables.push(this.testController); + // Initialize project registry for multi-project testing support + this.projectRegistry = new TestProjectRegistry( + this.testController, + this.configSettings, + this.interpreterService, + this.envVarsService, + ); + const delayTrigger = new DelayedTrigger( (uri: Uri, invalidate: boolean) => { this.refreshTestDataInternal(uri); @@ -226,59 +230,51 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + /** + * Activates the test controller for all workspaces. + * + * Two activation modes: + * 1. **Project-based mode** (when Python Environments API available): + * 2. **Legacy mode** (fallback): + * + * Uses `Promise.allSettled` for resilient multi-workspace activation: + */ public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - // Try to use project-based testing if feature flag is enabled AND environment extension is available - if (this.useProjectBasedTesting && useEnvExtension()) { + // PROJECT-BASED MODE: Uses Python Environments API to discover projects + // Each project becomes its own test tree root with its own Python environment + if (this.useProjectBasedTesting && this.projectRegistry.isProjectBasedTestingAvailable()) { traceInfo('[test-by-project] Activating project-based testing mode'); - // Use Promise.allSettled to allow partial success in multi-root workspaces + // Discover projects in parallel across all workspaces + // Promise.allSettled ensures one workspace failure doesn't block others const results = await Promise.allSettled( Array.from(workspaces).map(async (workspace) => { - traceInfo(`[test-by-project] Processing workspace: ${workspace.uri.fsPath}`); - - // Discover projects in this workspace - const projects = await this.discoverWorkspaceProjects(workspace.uri); - - // Create map for this workspace, keyed by project URI (matches Python Environments extension) - const projectsMap = new Map(); - projects.forEach((project) => { - const projectKey = getProjectId(project.projectUri); - projectsMap.set(projectKey, project); - }); - - traceInfo( - `[test-by-project] Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, - ); - - return { workspace, projectsMap }; + // Queries Python Environments API and creates ProjectAdapter instances + const projects = await this.projectRegistry.discoverAndRegisterProjects(workspace.uri); + return { workspace, projectCount: projects.length }; }), ); - // Handle results individually - allows partial success + // Process results: successful workspaces get file watchers, failed ones fall back to legacy results.forEach((result, index) => { const workspace = workspaces[index]; if (result.status === 'fulfilled') { - this.workspaceProjects.set(workspace.uri, result.value.projectsMap); traceInfo( - `[test-by-project] Successfully activated ${result.value.projectsMap.size} project(s) for ${workspace.uri.fsPath}`, + `[test-by-project] Activated ${result.value.projectCount} project(s) for ${workspace.uri.fsPath}`, ); this.setupFileWatchers(workspace); } else { - traceError( - `[test-by-project] Failed to activate project-based testing for ${workspace.uri.fsPath}:`, - result.reason, - ); - traceInfo('[test-by-project] Falling back to legacy mode for this workspace'); - // Fall back to legacy mode for this workspace only + // Graceful degradation: if project discovery fails, use legacy single-adapter mode + traceError(`[test-by-project] Failed for ${workspace.uri.fsPath}:`, result.reason); this.activateLegacyWorkspace(workspace); } }); return; } - // Legacy activation (backward compatibility) + // LEGACY MODE: Single WorkspaceTestAdapter per workspace (backward compatibility) workspaces.forEach((workspace) => { this.activateLegacyWorkspace(workspace); }); @@ -305,180 +301,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.setupFileWatchers(workspace); } - /** - * Discovers Python projects in a workspace using the Python Environment API. - * Falls back to creating a single default project if API is unavailable or returns no projects. - */ - private async discoverWorkspaceProjects(workspaceUri: Uri): Promise { - traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`); - try { - // Check if we should use the environment extension - if (!useEnvExtension()) { - traceInfo('[test-by-project] Python Environments extension not enabled, using single project mode'); - return [await this.createDefaultProject(workspaceUri)]; - } - - // Get the environment API - const envExtApi = await getEnvExtApi(); - traceInfo('[test-by-project] Successfully retrieved Python Environments API'); - - // Query for all Python projects in this workspace - const pythonProjects = envExtApi.getPythonProjects(); - traceInfo(`[test-by-project] Found ${pythonProjects.length} total Python projects from API`); - - // Filter projects to only those in this workspace - const workspaceProjects = pythonProjects.filter((project) => - isParentPath(project.uri.fsPath, workspaceUri.fsPath), - ); - traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); - - if (workspaceProjects.length === 0) { - traceInfo( - `[test-by-project] No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`, - ); - return [await this.createDefaultProject(workspaceUri)]; - } - - // Create ProjectAdapter for each Python project - const projectAdapters: ProjectAdapter[] = []; - for (const pythonProject of workspaceProjects) { - try { - const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); - projectAdapters.push(adapter); - } catch (error) { - traceError( - `[test-by-project] Failed to create project adapter for ${pythonProject.uri.fsPath}:`, - error, - ); - // Continue with other projects - } - } - - if (projectAdapters.length === 0) { - traceInfo('[test-by-project] All project adapters failed to create, falling back to default project'); - return [await this.createDefaultProject(workspaceUri)]; - } - - traceInfo(`[test-by-project] Successfully created ${projectAdapters.length} project adapter(s)`); - return projectAdapters; - } catch (error) { - traceError( - '[test-by-project] Failed to discover workspace projects, falling back to single project mode:', - error, - ); - return [await this.createDefaultProject(workspaceUri)]; - } - } - - /** - * Creates a ProjectAdapter from a PythonProject object. - */ - private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { - traceInfo( - `[test-by-project] Creating project adapter for: ${pythonProject.name} at ${pythonProject.uri.fsPath}`, - ); - // Use project URI as the project ID (no hashing needed) - const projectId = pythonProject.uri.fsPath; - - // Resolve the Python environment - const envExtApi = await getEnvExtApi(); - const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); - - if (!pythonEnvironment) { - throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`); - } - - // Get test provider and create resolver - const testProvider = this.getTestProvider(workspaceUri); - const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); - - // Create adapters - const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); - - // Create display name with Python version - const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); - - traceInfo(`[test-by-project] Created project adapter: ${projectName} (ID: ${projectId})`); - - // Create project adapter - return { - projectId, - projectName, - projectUri: pythonProject.uri, - workspaceUri, - pythonProject, - pythonEnvironment, - testProvider, - discoveryAdapter, - executionAdapter, - resultResolver, - isDiscovering: false, - isExecuting: false, - }; - } - - /** - * Creates a default project adapter using the workspace interpreter. - * Used for backward compatibility when environment API is unavailable. - */ - private async createDefaultProject(workspaceUri: Uri): Promise { - traceInfo(`[test-by-project] Creating default project for workspace: ${workspaceUri.fsPath}`); - // Get test provider and create resolver (WITHOUT project ID for legacy mode) - const testProvider = this.getTestProvider(workspaceUri); - const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); - - // Create adapters - const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); - - // Get active interpreter - const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); - - // Create a mock PythonEnvironment from the interpreter - const pythonEnvironment: PythonEnvironment = { - name: 'default', - displayName: interpreter?.displayName || 'Python', - shortDisplayName: interpreter?.displayName || 'Python', - displayPath: interpreter?.path || 'python', - version: interpreter?.version?.raw || '3.x', - environmentPath: Uri.file(interpreter?.path || 'python'), - sysPrefix: interpreter?.sysPrefix || '', - execInfo: { - run: { - executable: interpreter?.path || 'python', - }, - }, - envId: { - id: 'default', - managerId: 'default', - }, - }; - - // Create a mock PythonProject - const pythonProject: PythonProject = { - // Do not assume path separators (fsPath is platform-specific). - name: path.basename(workspaceUri.fsPath) || 'workspace', - uri: workspaceUri, - }; - - // Use workspace URI as the project ID - const projectId = getProjectId(workspaceUri); - - return { - projectId, - projectName: pythonProject.name, - projectUri: workspaceUri, - workspaceUri, - pythonProject, - pythonEnvironment, - testProvider, - discoveryAdapter, - executionAdapter, - resultResolver, - isDiscovering: false, - isExecuting: false, - }; - } - public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { if (options?.forceRefresh) { if (uri === undefined) { @@ -518,9 +340,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.refreshingStartedEvent.fire(); try { if (uri) { - await this.refreshSingleWorkspace(uri); + await this.discoverTestsInWorkspace(uri); } else { - await this.refreshAllWorkspaces(); + await this.discoverTestsInAllWorkspaces(); } } finally { this.refreshingCompletedEvent.fire(); @@ -529,8 +351,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Discovers tests for a single workspace. + * Delegates to project-based discovery or legacy mode based on configuration. */ - private async refreshSingleWorkspace(uri: Uri): Promise { + private async discoverTestsInWorkspace(uri: Uri): Promise { const workspace = this.workspaceService.getWorkspaceFolder(uri); if (!workspace?.uri) { traceError('Unable to find workspace for given file'); @@ -543,113 +366,44 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Ensure we send test telemetry if it gets disabled again this.sendTestDisabledTelemetry = true; - // Branch: Use project-based discovery if feature flag enabled and projects exist - if (this.useProjectBasedTesting && this.workspaceProjects.has(workspace.uri)) { - await this.refreshWorkspaceProjects(workspace.uri); + // Use project-based discovery if applicable + if (this.useProjectBasedTesting && this.projectRegistry.hasProjects(workspace.uri)) { + await this.discoverAllProjectsInWorkspace(workspace.uri); return; } // Legacy mode: Single workspace adapter if (settings.testing.pytestEnabled) { - await this.discoverTestsForProvider(workspace.uri, 'pytest'); + await this.discoverWorkspaceTestsLegacy(workspace.uri, 'pytest'); } else if (settings.testing.unittestEnabled) { - await this.discoverTestsForProvider(workspace.uri, 'unittest'); + await this.discoverWorkspaceTestsLegacy(workspace.uri, 'unittest'); } else { await this.handleNoTestProviderEnabled(workspace); } } /** - * Identifies which projects are nested within other projects in the same workspace. - * Returns a map of parent project ID -> array of nested child project paths to ignore. - * - * Example: If ProjectB (alice/bob/) is nested in ProjectA (alice/), - * returns: { "projectA-id": ["alice/bob"] } + * Discovers tests for all projects within a workspace (project-based mode). + * Runs discovery in parallel for all registered projects and configures nested exclusions. */ - private computeNestedProjectIgnores(workspaceUri: Uri): Map { - const projectIgnores = new Map(); - const projects = this.workspaceProjects.get(workspaceUri); - - if (!projects || projects.size === 0) { - return projectIgnores; - } - - const projectArray = Array.from(projects.values()); - - // For each project, find all other projects nested within it - for (const parentProject of projectArray) { - const nestedPaths: string[] = []; - - for (const potentialChild of projectArray) { - if (parentProject.projectId === potentialChild.projectId) { - continue; // Skip self - } - - // Check if child is nested under parent - const parentPath = parentProject.projectUri.fsPath; - const childPath = potentialChild.projectUri.fsPath; - - // Use path.sep for cross-platform compatibility (/ on Unix, \\ on Windows) - if (childPath.startsWith(parentPath + path.sep)) { - // Child is nested - add its path for ignoring - nestedPaths.push(childPath); - traceVerbose( - `[test-by-project] Detected nested project: ${potentialChild.projectName} ` + - `(${childPath}) under ${parentProject.projectName} (${parentPath})`, - ); - } - } - - if (nestedPaths.length > 0) { - projectIgnores.set(parentProject.projectId, nestedPaths); - traceInfo( - `[test-by-project] Project ${parentProject.projectName} will ignore ` + - `${nestedPaths.length} nested project(s)`, - ); - } - } - - return projectIgnores; - } - - /** - * Discovers tests for all projects within a workspace. - * Runs discovery in parallel and configures nested project exclusions. - */ - private async refreshWorkspaceProjects(workspaceUri: Uri): Promise { - const projectsMap = this.workspaceProjects.get(workspaceUri); - if (!projectsMap || projectsMap.size === 0) { + private async discoverAllProjectsInWorkspace(workspaceUri: Uri): Promise { + const projects = this.projectRegistry.getProjectsArray(workspaceUri); + if (projects.length === 0) { traceError(`[test-by-project] No projects found for workspace: ${workspaceUri.fsPath}`); return; } - const projects = Array.from(projectsMap.values()); traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`); try { - // Compute nested project relationships BEFORE discovery - const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); - - // Populate each project's ignore list by iterating through projects array directly - for (const project of projects) { - const ignorePaths = projectIgnores.get(project.projectId); - if (ignorePaths && ignorePaths.length > 0) { - project.nestedProjectPathsToIgnore = ignorePaths; - traceInfo( - `[test-by-project] Project ${project.projectName} configured to ignore ${ignorePaths.length} nested project(s): ` + - `${ignorePaths.join(', ')}`, - ); - } else { - traceVerbose(`[test-by-project] Project ${project.projectName} has no nested projects to ignore`); - } - } + // Configure nested project exclusions before discovery + this.projectRegistry.configureNestedProjectIgnores(workspaceUri); // Track completion for progress logging const projectsCompleted = new Set(); - // Run discovery for all projects in parallel (now with ignore lists populated) - // Each project will populate TestItems independently via existing flow - await Promise.all(projects.map((project) => this.discoverProject(project, projectsCompleted))); + // Run discovery for all projects in parallel + await Promise.all(projects.map((project) => this.discoverTestsForProject(project, projectsCompleted))); traceInfo( `[test-by-project] Discovery complete: ${projectsCompleted.size}/${projects.length} projects succeeded`, @@ -660,9 +414,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } /** - * Runs test discovery for a single project. + * Discovers tests for a single project (project-based mode). + * Creates test tree items rooted at the project's directory. */ - private async discoverProject(project: ProjectAdapter, projectsCompleted: Set): Promise { + private async discoverTestsForProject(project: ProjectAdapter, projectsCompleted: Set): Promise { try { traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); project.isDiscovering = true; @@ -688,9 +443,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } /** - * Discovers tests for all workspaces in the workspace folders. + * Discovers tests across all workspace folders. + * Iterates each workspace and triggers discovery. */ - private async refreshAllWorkspaces(): Promise { + private async discoverTestsInAllWorkspaces(): Promise { traceVerbose('Testing: Refreshing all test data'); const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; @@ -702,16 +458,15 @@ export class PythonTestController implements ITestController, IExtensionSingleAc .then(noop, noop); return; } - await this.refreshSingleWorkspace(workspace.uri); + await this.discoverTestsInWorkspace(workspace.uri); }), ); } /** - * Discovers tests for a specific test provider (pytest or unittest). - * Validates that the adapter's provider matches the expected provider. + * Discovers tests for a workspace using legacy single-adapter mode. */ - private async discoverTestsForProvider(workspaceUri: Uri, expectedProvider: TestProvider): Promise { + private async discoverWorkspaceTestsLegacy(workspaceUri: Uri, expectedProvider: TestProvider): Promise { const testAdapter = this.testAdapters.get(workspaceUri); if (!testAdapter) { From 225ff12f1aa08a15da6882ac38ae64ac4c4972fa Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:28:37 -0800 Subject: [PATCH 18/25] remove unittest refs --- .vscode/settings.json | 108 ++---------------- python_files/unittestadapter/pvsc_utils.py | 1 - .../unittest/testDiscoveryAdapter.ts | 7 -- 3 files changed, 7 insertions(+), 109 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 74d444e253b2..01de0d907706 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,7 +28,7 @@ "source.fixAll.eslint": "explicit", "source.organizeImports.isort": "explicit" }, - "editor.defaultFormatter": "charliermarsh.ruff" + "editor.defaultFormatter": "charliermarsh.ruff", }, "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", @@ -67,106 +67,12 @@ "git.pullBeforeCheckout": true, // Open merge editor for resolving conflicts. "git.mergeEditor": true, - "python.testing.pytestArgs": ["python_files/tests"], + "python.testing.pytestArgs": [ + "python_files/tests" + ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "rust-analyzer.linkedProjects": [".\\python-env-tools\\Cargo.toml"], - "chat.tools.terminal.autoApprove": { - "cd": true, - "echo": true, - "ls": true, - "pwd": true, - "cat": true, - "head": true, - "tail": true, - "findstr": true, - "wc": true, - "tr": true, - "cut": true, - "cmp": true, - "which": true, - "basename": true, - "dirname": true, - "realpath": true, - "readlink": true, - "stat": true, - "file": true, - "du": true, - "df": true, - "sleep": true, - "nl": true, - "grep": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/": true, - "/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*-(d|D|m|M|-delete|-force)\\b/": false, - "Get-ChildItem": true, - "Get-Content": true, - "Get-Date": true, - "Get-Random": true, - "Get-Location": true, - "Write-Host": true, - "Write-Output": true, - "Out-String": true, - "Split-Path": true, - "Join-Path": true, - "Start-Sleep": true, - "Where-Object": true, - "/^Select-[a-z0-9]/i": true, - "/^Measure-[a-z0-9]/i": true, - "/^Compare-[a-z0-9]/i": true, - "/^Format-[a-z0-9]/i": true, - "/^Sort-[a-z0-9]/i": true, - "column": true, - "/^column\\b.*-c\\s+[0-9]{4,}/": false, - "date": true, - "/^date\\b.*(-s|--set)\\b/": false, - "find": true, - "/^find\\b.*-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/": false, - "rg": true, - "/^rg\\b.*(--pre|--hostname-bin)\\b/": false, - "sed": true, - "/^sed\\b.*(-[a-zA-Z]*(e|i|I|f)[a-zA-Z]*|--expression|--file|--in-place)\\b/": false, - "/^sed\\b.*(/e|/w|;W)/": false, - "sort": true, - "/^sort\\b.*-(o|S)\\b/": false, - "tree": true, - "/^tree\\b.*-o\\b/": false, - "rm": false, - "rmdir": false, - "del": false, - "Remove-Item": false, - "ri": false, - "rd": false, - "erase": false, - "dd": false, - "kill": false, - "ps": false, - "top": false, - "Stop-Process": false, - "spps": false, - "taskkill": false, - "taskkill.exe": false, - "curl": false, - "wget": false, - "Invoke-RestMethod": false, - "Invoke-WebRequest": false, - "irm": false, - "iwr": false, - "chmod": false, - "chown": false, - "Set-ItemProperty": false, - "sp": false, - "Set-Acl": false, - "jq": false, - "xargs": false, - "eval": false, - "Invoke-Expression": false, - "iex": false, - "npx tsc": true - } + "rust-analyzer.linkedProjects": [ + ".\\python-env-tools\\Cargo.toml" + ] } diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index bdb47bd558f9..d6920592a4d4 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -251,7 +251,6 @@ def build_test_tree( # Find/build file node. path_components = [top_level_directory, *folders, py_filename] file_path = os.fsdecode(pathlib.PurePath("/".join(path_components))) - current_node = get_child_node( py_filename, file_path, TestNodeTypeEnum.file, current_node ) diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 3e9503f3df4c..7c986e95a449 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -18,7 +18,6 @@ import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { createTestingDeferred } from '../common/utils'; import { buildDiscoveryCommand, buildUnittestEnv as configureSubprocessEnv } from './unittestHelpers'; import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers'; -import { ProjectAdapter } from '../common/projectAdapter'; /** * Configures the subprocess environment for unittest discovery. @@ -52,7 +51,6 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, - project?: ProjectAdapter, ): Promise { // Setup discovery pipe and cancellation const { @@ -80,11 +78,6 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Configure subprocess environment const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); - // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) - if (project) { - mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; - } - // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); From 29533cf16880f29817dfa4d2a99590761bc869c0 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:38:10 -0800 Subject: [PATCH 19/25] cleanup --- .../testing_feature_area.instructions.md | 49 ++ .../testController/common/projectUtils.ts | 38 ++ .../common/testDiscoveryHandler.ts | 2 +- .../common/testProjectRegistry.ts | 75 +-- .../testing/testController/common/types.ts | 10 +- .../testing/testController/common/utils.ts | 12 +- .../testing/testController/controller.ts | 53 +- .../common/testProjectRegistry.unit.test.ts | 459 ++++++++++++++++++ 8 files changed, 599 insertions(+), 99 deletions(-) create mode 100644 src/test/testing/testController/common/testProjectRegistry.unit.test.ts diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index 038dc1025ea5..be946e798dff 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -26,6 +26,10 @@ This document maps the testing support in the extension: discovery, execution (r - `src/client/testing/serviceRegistry.ts` — DI/wiring for testing services. - Workspace orchestration - `src/client/testing/testController/workspaceTestAdapter.ts` — `WorkspaceTestAdapter` (provider-agnostic entry used by controller). +- **Project-based testing (multi-project workspaces)** + - `src/client/testing/testController/common/testProjectRegistry.ts` — `TestProjectRegistry` (manages project lifecycle, discovery, and nested project handling). + - `src/client/testing/testController/common/projectAdapter.ts` — `ProjectAdapter` interface (represents a single Python project with its own test infrastructure). + - `src/client/testing/testController/common/projectUtils.ts` — utilities for project ID generation, display names, and shared adapter creation. - Provider adapters - Unittest - `src/client/testing/testController/unittest/testDiscoveryAdapter.ts` @@ -151,6 +155,51 @@ The adapters in the extension don't implement test discovery/run logic themselve - Settings are consumed by `src/client/testing/common/testConfigurationManager.ts`, `src/client/testing/configurationFactory.ts`, and adapters under `src/client/testing/testController/*` which read settings to build CLI args and env for subprocesses. - The setting definitions and descriptions are in `package.json` and localized strings in `package.nls.json`. +## Project-based testing (multi-project workspaces) + +Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment. + +> **⚠️ Note: unittest support for project-based testing is NOT yet implemented.** Project-based testing currently only works with pytest. unittest support will be added in a future PR. + +### Architecture + +- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that: + + - Discovers Python projects via the Python Environments API + - Creates and manages `ProjectAdapter` instances per workspace + - Computes nested project relationships and configures ignore lists + - Falls back to "legacy" single-adapter mode when API unavailable + +- **ProjectAdapter** (`projectAdapter.ts`): Interface representing a single project with: + - Project identity (ID, name, URI from Python Environments API) + - Python environment with execution details + - Test framework adapters (discovery/execution) + - Nested project ignore paths (for parent projects) + +### How it works + +1. **Activation**: When the extension activates, `PythonTestController` checks if the Python Environments API is available. +2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace. +3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists. +4. **Test discovery**: For each project, the controller calls `project.discoveryAdapter.discoverTests()` with the project's URI. The adapter sets `PROJECT_ROOT_PATH` environment variable for the Python runner. +5. **Python side**: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`. +6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `||` separator. + +### Logging prefix + +All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel. + +### Key files + +- Python side: `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable. +- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters. + +### Tests + +- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests +- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests +- `python_files/tests/pytestadapter/test_get_test_root_path.py` — Python-side get_test_root_path() tests + ## Coverage support (how it works) - Coverage is supported by running the Python helper scripts with coverage enabled and then collecting a coverage payload from the runner. diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index a66ab31c2da3..fb7a4e1d8d1a 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -2,6 +2,15 @@ // Licensed under the MIT License. import { Uri } from 'vscode'; +import { IConfigurationService } from '../../../common/types'; +import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; +import { UNITTEST_PROVIDER } from '../../common/constants'; +import { TestProvider } from '../../types'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './types'; +import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter'; +import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter'; +import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter'; +import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter'; /** * Separator used to scope test IDs to a specific project. @@ -52,3 +61,32 @@ export function createProjectDisplayName(projectName: string, pythonVersion: str return `${projectName} (Python ${shortVersion})`; } + +/** + * Creates test adapters (discovery and execution) for a given test provider. + * Centralizes adapter creation to avoid code duplication across Controller and TestProjectRegistry. + * + * @param testProvider The test framework provider ('pytest' | 'unittest') + * @param resultResolver The result resolver to use for test results + * @param configSettings The configuration service + * @param envVarsService The environment variables provider + * @returns An object containing the discovery and execution adapters + */ +export function createTestAdapters( + testProvider: TestProvider, + resultResolver: ITestResultResolver, + configSettings: IConfigurationService, + envVarsService: IEnvironmentVariablesProvider, +): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { + if (testProvider === UNITTEST_PROVIDER) { + return { + discoveryAdapter: new UnittestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService), + executionAdapter: new UnittestTestExecutionAdapter(configSettings, resultResolver, envVarsService), + }; + } + + return { + discoveryAdapter: new PytestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService), + executionAdapter: new PytestTestExecutionAdapter(configSettings, resultResolver, envVarsService), + }; +} diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index 8af48a203680..212818bc070f 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -67,7 +67,7 @@ export class TestDiscoveryHandler { runIdToTestItem: testItemIndex.runIdToTestItemMap, runIdToVSid: testItemIndex.runIdToVSidMap, vsIdToRunId: testItemIndex.vsIdToRunIdMap, - } as any, + }, token, projectId, ); diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index 2bf1f798a2d1..dc8012624f99 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -13,13 +13,8 @@ import { PythonProject, PythonEnvironment } from '../../../envExt/types'; import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal'; import { isParentPath } from '../../../common/platform/fs-paths'; import { ProjectAdapter } from './projectAdapter'; -import { getProjectId, createProjectDisplayName } from './projectUtils'; +import { getProjectId, createProjectDisplayName, createTestAdapters } from './projectUtils'; import { PythonResultResolver } from './resultResolver'; -import { ITestDiscoveryAdapter, ITestExecutionAdapter } from './types'; -import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter'; -import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter'; -import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter'; -import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter'; /** * Registry for Python test projects within workspaces. @@ -83,7 +78,7 @@ export class TestProjectRegistry { * Returns the discovered projects for the caller to use. */ public async discoverAndRegisterProjects(workspaceUri: Uri): Promise { - traceInfo(`[ProjectManager] Discovering projects for workspace: ${workspaceUri.fsPath}`); + traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`); const projects = await this.discoverProjects(workspaceUri); @@ -94,7 +89,7 @@ export class TestProjectRegistry { }); this.workspaceProjects.set(workspaceUri, projectsMap); - traceInfo(`[ProjectManager] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`); + traceInfo(`[test-by-project] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`); return projects; } @@ -111,7 +106,7 @@ export class TestProjectRegistry { const ignorePaths = projectIgnores.get(project.projectId); if (ignorePaths && ignorePaths.length > 0) { project.nestedProjectPathsToIgnore = ignorePaths; - traceInfo(`[ProjectManager] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`); + traceInfo(`[test-by-project] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`); } } } @@ -132,22 +127,22 @@ export class TestProjectRegistry { private async discoverProjects(workspaceUri: Uri): Promise { try { if (!useEnvExtension()) { - traceInfo('[ProjectManager] Python Environments API not available, using default project'); + traceInfo('[test-by-project] Python Environments API not available, using default project'); return [await this.createDefaultProject(workspaceUri)]; } const envExtApi = await getEnvExtApi(); const allProjects = envExtApi.getPythonProjects(); - traceInfo(`[ProjectManager] Found ${allProjects.length} total Python projects from API`); + traceInfo(`[test-by-project] Found ${allProjects.length} total Python projects from API`); // Filter to projects within this workspace const workspaceProjects = allProjects.filter((project) => isParentPath(project.uri.fsPath, workspaceUri.fsPath), ); - traceInfo(`[ProjectManager] Filtered to ${workspaceProjects.length} projects in workspace`); + traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); if (workspaceProjects.length === 0) { - traceInfo('[ProjectManager] No projects found, creating default project'); + traceInfo('[test-by-project] No projects found, creating default project'); return [await this.createDefaultProject(workspaceUri)]; } @@ -158,18 +153,18 @@ export class TestProjectRegistry { const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); adapters.push(adapter); } catch (error) { - traceError(`[ProjectManager] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error); + traceError(`[test-by-project] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error); } } if (adapters.length === 0) { - traceInfo('[ProjectManager] All adapters failed, falling back to default project'); + traceInfo('[test-by-project] All adapters failed, falling back to default project'); return [await this.createDefaultProject(workspaceUri)]; } return adapters; } catch (error) { - traceError('[ProjectManager] Discovery failed, using default project:', error); + traceError('[test-by-project] Discovery failed, using default project:', error); return [await this.createDefaultProject(workspaceUri)]; } } @@ -179,7 +174,7 @@ export class TestProjectRegistry { */ private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { const projectId = pythonProject.uri.fsPath; - traceInfo(`[ProjectManager] Creating adapter for: ${pythonProject.name} at ${projectId}`); + traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`); // Resolve Python environment const envExtApi = await getEnvExtApi(); @@ -191,7 +186,12 @@ export class TestProjectRegistry { // Create test infrastructure const testProvider = this.getTestProvider(workspaceUri); const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); - const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); @@ -215,11 +215,16 @@ export class TestProjectRegistry { * Creates a default project for legacy/fallback mode. */ private async createDefaultProject(workspaceUri: Uri): Promise { - traceInfo(`[ProjectManager] Creating default project for: ${workspaceUri.fsPath}`); + traceInfo(`[test-by-project] Creating default project for: ${workspaceUri.fsPath}`); const testProvider = this.getTestProvider(workspaceUri); const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); - const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); @@ -276,7 +281,7 @@ export class TestProjectRegistry { if (childPath.startsWith(parentPath + path.sep)) { nestedPaths.push(childPath); - traceVerbose(`[ProjectManager] Nested: ${child.projectName} under ${parent.projectName}`); + traceVerbose(`[test-by-project] Nested: ${child.projectName} under ${parent.projectName}`); } } @@ -295,32 +300,4 @@ export class TestProjectRegistry { const settings = this.configSettings.getSettings(workspaceUri); return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : 'pytest'; } - - /** - * Creates discovery and execution adapters for a test provider. - */ - private createAdapters( - testProvider: TestProvider, - resultResolver: PythonResultResolver, - ): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { - if (testProvider === UNITTEST_PROVIDER) { - return { - discoveryAdapter: new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ), - executionAdapter: new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ), - }; - } - - return { - discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService), - executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService), - }; - } } diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index db7adfd92ee2..6af18c8422c9 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -143,10 +143,18 @@ export type TestCommandOptions = { // triggerRunDataReceivedEvent(data: DataReceivedEvent): void; // triggerDiscoveryDataReceivedEvent(data: DataReceivedEvent): void; // } -export interface ITestResultResolver { + +/** + * Test item mapping interface used by populateTestTree. + * Contains only the maps needed for building the test tree. + */ +export interface ITestItemMappings { runIdToVSid: Map; runIdToTestItem: Map; vsIdToRunId: Map; +} + +export interface ITestResultResolver extends ITestItemMappings { detailedCoverageMap: Map; resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void; diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 20bb6e08cd37..dd7e396ecf24 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -13,7 +13,7 @@ import { DiscoveredTestNode, DiscoveredTestPayload, ExecutionTestPayload, - ITestResultResolver, + ITestItemMappings, } from './types'; import { Deferred, createDeferred } from '../../../common/utils/async'; import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes'; @@ -210,7 +210,7 @@ export function populateTestTree( testController: TestController, testTreeData: DiscoveredTestNode, testRoot: TestItem | undefined, - resultResolver: ITestResultResolver, + testItemMappings: ITestItemMappings, token?: CancellationToken, projectId?: string, ): void { @@ -252,9 +252,9 @@ export function populateTestTree( testRoot!.children.add(testItem); // add to our map - use runID as key, vsId as value - resultResolver.runIdToTestItem.set(child.runID, testItem); - resultResolver.runIdToVSid.set(child.runID, vsId); - resultResolver.vsIdToRunId.set(vsId, child.runID); + testItemMappings.runIdToTestItem.set(child.runID, testItem); + testItemMappings.runIdToVSid.set(child.runID, vsId); + testItemMappings.vsIdToRunId.set(vsId, child.runID); } else { let node = testController.items.get(child.path); @@ -282,7 +282,7 @@ export function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, resultResolver, token, projectId); + populateTestTree(testController, child, node, testItemMappings, token, projectId); } } }); diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 6480fac0a351..1029c099114b 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -36,17 +36,7 @@ import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; import { TestProvider } from '../types'; import { createErrorTestItem, DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities'; import { buildErrorNodeOptions } from './common/utils'; -import { - ITestController, - ITestDiscoveryAdapter, - ITestFrameworkController, - TestRefreshOptions, - ITestExecutionAdapter, -} from './common/types'; -import { UnittestTestDiscoveryAdapter } from './unittest/testDiscoveryAdapter'; -import { UnittestTestExecutionAdapter } from './unittest/testExecutionAdapter'; -import { PytestTestDiscoveryAdapter } from './pytest/pytestDiscoveryAdapter'; -import { PytestTestExecutionAdapter } from './pytest/pytestExecutionAdapter'; +import { ITestController, ITestFrameworkController, TestRefreshOptions } from './common/types'; import { WorkspaceTestAdapter } from './workspaceTestAdapter'; import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; @@ -54,6 +44,7 @@ import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { ProjectAdapter } from './common/projectAdapter'; import { TestProjectRegistry } from './common/testProjectRegistry'; +import { createTestAdapters } from './common/projectUtils'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -181,35 +172,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }; } - /** - * Creates test adapters (discovery and execution) for a given test provider. - * Centralizes adapter creation to reduce code duplication. - */ - private createTestAdapters( - testProvider: TestProvider, - resultResolver: PythonResultResolver, - ): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { - if (testProvider === UNITTEST_PROVIDER) { - return { - discoveryAdapter: new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ), - executionAdapter: new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ), - }; - } - - return { - discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService), - executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService), - }; - } - /** * Determines the test provider (pytest or unittest) based on workspace settings. */ @@ -287,7 +249,12 @@ export class PythonTestController implements ITestController, IExtensionSingleAc private activateLegacyWorkspace(workspace: WorkspaceFolder): void { const testProvider = this.getTestProvider(workspace.uri); const resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); const workspaceTestAdapter = new WorkspaceTestAdapter( testProvider, @@ -422,11 +389,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); project.isDiscovering = true; + // In project-based mode, the discovery adapter uses the Python Environments API + // to get the environment directly, so we don't need to pass the interpreter await project.discoveryAdapter.discoverTests( project.projectUri, this.pythonExecFactory, this.refreshCancellation.token, - project.pythonEnvironment as any, + undefined, // Interpreter not needed; adapter uses Python Environments API project, ); diff --git a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts new file mode 100644 index 000000000000..ecd163a905b9 --- /dev/null +++ b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { TestController, Uri } from 'vscode'; +import { IConfigurationService } from '../../../../client/common/types'; +import { IEnvironmentVariablesProvider } from '../../../../client/common/variables/types'; +import { IInterpreterService } from '../../../../client/interpreter/contracts'; +import { TestProjectRegistry } from '../../../../client/testing/testController/common/testProjectRegistry'; +import * as envExtApiInternal from '../../../../client/envExt/api.internal'; +import { PythonProject, PythonEnvironment } from '../../../../client/envExt/types'; + +suite('TestProjectRegistry', () => { + let sandbox: sinon.SinonSandbox; + let testController: TestController; + let configSettings: IConfigurationService; + let interpreterService: IInterpreterService; + let envVarsService: IEnvironmentVariablesProvider; + let registry: TestProjectRegistry; + + setup(() => { + sandbox = sinon.createSandbox(); + + // Create mock test controller + testController = ({ + items: { + get: sandbox.stub(), + add: sandbox.stub(), + delete: sandbox.stub(), + forEach: sandbox.stub(), + }, + createTestItem: sandbox.stub(), + dispose: sandbox.stub(), + } as unknown) as TestController; + + // Create mock config settings + configSettings = ({ + getSettings: sandbox.stub().returns({ + testing: { + pytestEnabled: true, + unittestEnabled: false, + }, + }), + } as unknown) as IConfigurationService; + + // Create mock interpreter service + interpreterService = ({ + getActiveInterpreter: sandbox.stub().resolves({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }), + } as unknown) as IInterpreterService; + + // Create mock env vars service + envVarsService = ({ + getEnvironmentVariables: sandbox.stub().resolves({}), + } as unknown) as IEnvironmentVariablesProvider; + + registry = new TestProjectRegistry(testController, configSettings, interpreterService, envVarsService); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('isProjectBasedTestingAvailable', () => { + test('should return true when useEnvExtension returns true', () => { + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + + const result = registry.isProjectBasedTestingAvailable(); + + expect(result).to.be.true; + }); + + test('should return false when useEnvExtension returns false', () => { + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const result = registry.isProjectBasedTestingAvailable(); + + expect(result).to.be.false; + }); + }); + + suite('hasProjects', () => { + test('should return false for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.hasProjects(workspaceUri); + + expect(result).to.be.false; + }); + + test('should return true after projects are registered', async () => { + const workspaceUri = Uri.file('/workspace'); + + // Mock useEnvExtension to return false to use default project path + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.hasProjects(workspaceUri); + + expect(result).to.be.true; + }); + }); + + suite('getProjectsArray', () => { + test('should return empty array for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.getProjectsArray(workspaceUri); + + expect(result).to.be.an('array').that.is.empty; + }); + + test('should return projects after registration', async () => { + const workspaceUri = Uri.file('/workspace'); + + // Mock useEnvExtension to return false to use default project path + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.getProjectsArray(workspaceUri); + + expect(result).to.be.an('array').with.length(1); + expect(result[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + }); + + suite('discoverAndRegisterProjects', () => { + test('should create default project when env extension not available', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + expect(projects[0].testProvider).to.equal('pytest'); + }); + + test('should use unittest when configured', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + (configSettings.getSettings as sinon.SinonStub).returns({ + testing: { + pytestEnabled: false, + unittestEnabled: true, + }, + }); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].testProvider).to.equal('unittest'); + }); + + test('should discover projects from Python Environments API', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectUri = Uri.file('/workspace/project1'); + + const mockPythonProject: PythonProject = { + name: 'project1', + uri: projectUri, + }; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [mockPythonProject], + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectName).to.include('project1'); + expect(projects[0].pythonEnvironment).to.deep.equal(mockPythonEnv); + }); + + test('should filter projects to current workspace', async () => { + const workspaceUri = Uri.file('/workspace1'); + const projectInWorkspace = Uri.file('/workspace1/project1'); + const projectOutsideWorkspace = Uri.file('/workspace2/project2'); + + const mockProjects: PythonProject[] = [ + { name: 'project1', uri: projectInWorkspace }, + { name: 'project2', uri: projectOutsideWorkspace }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(projectInWorkspace.fsPath); + }); + + test('should fallback to default project when no projects found', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [], + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + + test('should fallback to default project on API error', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').rejects(new Error('API error')); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + }); + + suite('configureNestedProjectIgnores', () => { + test('should not set ignores when no nested projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectUri = Uri.file('/workspace/project1'); + + const mockPythonProject: PythonProject = { + name: 'project1', + uri: projectUri, + }; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [mockPythonProject], + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + expect(projects[0].nestedProjectPathsToIgnore).to.be.undefined; + }); + + test('should configure ignore paths for nested projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const parentProjectUri = Uri.file('/workspace/parent'); + const childProjectUri = Uri.file(path.join('/workspace/parent', 'child')); + + const mockProjects: PythonProject[] = [ + { name: 'parent', uri: parentProjectUri }, + { name: 'child', uri: childProjectUri }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + const parentProject = projects.find((p) => p.projectUri.fsPath === parentProjectUri.fsPath); + + expect(parentProject?.nestedProjectPathsToIgnore).to.include(childProjectUri.fsPath); + }); + + test('should not set child project as ignored for sibling projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const project1Uri = Uri.file('/workspace/project1'); + const project2Uri = Uri.file('/workspace/project2'); + + const mockProjects: PythonProject[] = [ + { name: 'project1', uri: project1Uri }, + { name: 'project2', uri: project2Uri }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + projects.forEach((project) => { + expect(project.nestedProjectPathsToIgnore).to.be.undefined; + }); + }); + }); + + suite('clearWorkspace', () => { + test('should remove all projects for a workspace', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + expect(registry.hasProjects(workspaceUri)).to.be.true; + + registry.clearWorkspace(workspaceUri); + + expect(registry.hasProjects(workspaceUri)).to.be.false; + expect(registry.getProjectsArray(workspaceUri)).to.be.empty; + }); + + test('should not affect other workspaces', async () => { + const workspace1Uri = Uri.file('/workspace1'); + const workspace2Uri = Uri.file('/workspace2'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspace1Uri); + await registry.discoverAndRegisterProjects(workspace2Uri); + + registry.clearWorkspace(workspace1Uri); + + expect(registry.hasProjects(workspace1Uri)).to.be.false; + expect(registry.hasProjects(workspace2Uri)).to.be.true; + }); + }); + + suite('getWorkspaceProjects', () => { + test('should return undefined for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.getWorkspaceProjects(workspaceUri); + + expect(result).to.be.undefined; + }); + + test('should return map after registration', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.getWorkspaceProjects(workspaceUri); + + expect(result).to.be.instanceOf(Map); + expect(result?.size).to.equal(1); + }); + }); + + suite('ProjectAdapter properties', () => { + test('should create adapter with correct test infrastructure', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + + expect(project.projectId).to.be.a('string'); + expect(project.projectName).to.be.a('string'); + expect(project.projectUri.fsPath).to.equal(workspaceUri.fsPath); + expect(project.workspaceUri.fsPath).to.equal(workspaceUri.fsPath); + expect(project.testProvider).to.equal('pytest'); + expect(project.discoveryAdapter).to.exist; + expect(project.executionAdapter).to.exist; + expect(project.resultResolver).to.exist; + expect(project.isDiscovering).to.be.false; + expect(project.isExecuting).to.be.false; + }); + + test('should include python environment details', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + + expect(project.pythonEnvironment).to.exist; + expect(project.pythonProject).to.exist; + expect(project.pythonProject.name).to.equal('myproject'); + }); + }); +}); From ca140687a212e8df8916db55d47a6f8890d99554 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:53:56 -0800 Subject: [PATCH 20/25] pytest tests --- .../expected_discovery_test_output.py | 198 ++++++++++++++++++ .../tests/pytestadapter/test_discovery.py | 42 ++++ 2 files changed, 240 insertions(+) diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index b6f0779cf982..77328a77b33e 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1870,3 +1870,201 @@ ], "id_": TEST_DATA_PATH_STR, } + +# ===================================================================================== +# PROJECT_ROOT_PATH environment variable tests +# These test the project-based testing feature where PROJECT_ROOT_PATH changes +# the test tree root from cwd to the specified project path. +# ===================================================================================== + +# This is the expected output for unittest_folder when PROJECT_ROOT_PATH is set to unittest_folder. +# The root of the tree is unittest_folder (not .data), simulating project-based testing. +# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH) +# ├── test_add.py +# │ └── TestAddFunction +# │ ├── test_add_negative_numbers +# │ └── test_add_positive_numbers +# │ └── TestDuplicateFunction +# │ └── test_dup_a +# └── test_subtract.py +# └── TestSubtractFunction +# ├── test_subtract_negative_numbers +# └── test_subtract_positive_numbers +# └── TestDuplicateFunction +# └── test_dup_s +# +# Note: This reuses the unittest_folder paths defined earlier in this file. +project_root_unittest_folder_expected_output = { + "name": "unittest_folder", + "path": os.fspath(unittest_folder_path), + "type_": "folder", + "children": [ + { + "name": "test_add.py", + "path": os.fspath(test_add_path), + "type_": "file", + "id_": os.fspath(test_add_path), + "children": [ + { + "name": "TestAddFunction", + "path": os.fspath(test_add_path), + "type_": "class", + "children": [ + { + "name": "test_add_negative_numbers", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_add_negative_numbers", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), + }, + { + "name": "test_add_positive_numbers", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_add_positive_numbers", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction", + test_add_path, + ), + "lineno": find_class_line_number("TestAddFunction", test_add_path), + }, + { + "name": "TestDuplicateFunction", + "path": os.fspath(test_add_path), + "type_": "class", + "children": [ + { + "name": "test_dup_a", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_dup_a", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_add.py::TestDuplicateFunction", + test_add_path, + ), + "lineno": find_class_line_number("TestDuplicateFunction", test_add_path), + }, + ], + }, + { + "name": "test_subtract.py", + "path": os.fspath(test_subtract_path), + "type_": "file", + "id_": os.fspath(test_subtract_path), + "children": [ + { + "name": "TestSubtractFunction", + "path": os.fspath(test_subtract_path), + "type_": "class", + "children": [ + { + "name": "test_subtract_negative_numbers", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_subtract_negative_numbers", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), + }, + { + "name": "test_subtract_positive_numbers", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_subtract_positive_numbers", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction", + test_subtract_path, + ), + "lineno": find_class_line_number("TestSubtractFunction", test_subtract_path), + }, + { + "name": "TestDuplicateFunction", + "path": os.fspath(test_subtract_path), + "type_": "class", + "children": [ + { + "name": "test_dup_s", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_dup_s", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction", + test_subtract_path, + ), + "lineno": find_class_line_number("TestDuplicateFunction", test_subtract_path), + }, + ], + }, + ], + "id_": os.fspath(unittest_folder_path), +} diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 842ee3c7c707..0eddc7b99cab 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -386,3 +386,45 @@ def test_plugin_collect(file, expected_const, extra_arg): ), ( f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_const, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" ) + + +def test_project_root_path_env_var(): + """Test pytest discovery with PROJECT_ROOT_PATH environment variable set. + + This simulates project-based testing where the test tree root should be + the project root (PROJECT_ROOT_PATH) rather than the workspace cwd. + + When PROJECT_ROOT_PATH is set: + - The test tree root (name, path, id_) should match PROJECT_ROOT_PATH + - The cwd in the response should match PROJECT_ROOT_PATH + - Test files should be direct children of the root (not nested under a subfolder) + """ + # Use unittest_folder as our "project" subdirectory + project_path = helpers.TEST_DATA_PATH / "unittest_folder" + + actual = helpers.runner_with_cwd_env( + [os.fspath(project_path), "--collect-only"], + helpers.TEST_DATA_PATH, # cwd is parent of project + {"PROJECT_ROOT_PATH": os.fspath(project_path)}, # Set project root + ) + + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + + assert all(item in actual_item for item in ("status", "cwd", "error")) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + # cwd in response should be PROJECT_ROOT_PATH + assert actual_item.get("cwd") == os.fspath(project_path), ( + f"Expected cwd '{os.fspath(project_path)}', got '{actual_item.get('cwd')}'" + ) + assert is_same_tree( + actual_item.get("tests"), + expected_discovery_test_output.project_root_unittest_folder_expected_output, + ["id_", "lineno", "name", "runID"], + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.project_root_unittest_folder_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) From 34965e3d106d2993527126053499359406cd1b8d Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:00:31 -0800 Subject: [PATCH 21/25] test fixes --- .../testing-workflow.instructions.md | 1 + .../testController/controller.unit.test.ts | 197 +++++++++++++----- 2 files changed, 147 insertions(+), 51 deletions(-) diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 948886a59635..844946404328 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -578,3 +578,4 @@ envConfig.inspect - When mocking `testController.createTestItem()` in unit tests, use `typemoq.It.isAny()` for parameters when testing handler behavior (not ID/label generation logic), but consider using specific matchers (e.g., `It.is((id: string) => id.startsWith('_error_'))`) when the actual values being passed are important for correctness - this balances test precision with maintainability (2) - Remove unused variables from test code immediately - leftover tracking variables like `validationCallCount` that aren't referenced indicate dead code that should be simplified (1) - Use `Uri.file(path).fsPath` for both sides of path comparisons in tests to ensure cross-platform compatibility - Windows converts forward slashes to backslashes automatically (1) +- When tests fail with "Cannot stub non-existent property", the method likely moved to a different class during refactoring - find the class that owns the method and test that class directly instead of stubbing on the original class (1) diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index 2916e383605b..4c9a3f3df1db 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -13,6 +13,7 @@ const vscodeApi = require('vscode') as typeof import('vscode'); import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; import * as envExtApiInternal from '../../../client/envExt/api.internal'; import { getProjectId } from '../../../client/testing/testController/common/projectUtils'; +import * as projectUtils from '../../../client/testing/testController/common/projectUtils'; function createStubTestController(): TestController { const disposable = { dispose: () => undefined }; @@ -62,6 +63,8 @@ ensureVscodeTestsNamespace(); // Dynamically require AFTER the vscode.tests namespace exists. // eslint-disable-next-line @typescript-eslint/no-var-requires const { PythonTestController } = require('../../../client/testing/testController/controller'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { TestProjectRegistry } = require('../../../client/testing/testController/common/testProjectRegistry'); suite('PythonTestController', () => { let sandbox: sinon.SinonSandbox; @@ -143,7 +146,7 @@ suite('PythonTestController', () => { }); }); - suite('createDefaultProject', () => { + suite('createDefaultProject (via TestProjectRegistry)', () => { test('creates a single default project using active interpreter', async () => { const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/myws'); const interpreter = { @@ -153,16 +156,40 @@ suite('PythonTestController', () => { sysPrefix: '/opt/py', }; - const controller = createController({ unittestEnabled: false, interpreter }); - const fakeDiscoveryAdapter = { kind: 'discovery' }; const fakeExecutionAdapter = { kind: 'execution' }; - sandbox - .stub(controller as any, 'createTestAdapters') - .returns({ discoveryAdapter: fakeDiscoveryAdapter, executionAdapter: fakeExecutionAdapter }); + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + // Stub useEnvExtension to return false so createDefaultProject is called + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); - const project = await (controller as any).createDefaultProject(workspaceUri); + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + assert.strictEqual(projects.length, 1); assert.strictEqual(project.workspaceUri.toString(), workspaceUri.toString()); assert.strictEqual(project.projectUri.toString(), workspaceUri.toString()); assert.strictEqual(project.projectId, getProjectId(workspaceUri)); @@ -181,29 +208,54 @@ suite('PythonTestController', () => { }); }); - suite('discoverWorkspaceProjects', () => { + suite('discoverWorkspaceProjects (via TestProjectRegistry)', () => { test('respects useEnvExtension() == false and falls back to single default project', async () => { - const controller = createController(); const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/a'); - const defaultProject = { projectId: 'default', projectUri: workspaceUri }; - const createDefaultProjectStub = sandbox - .stub(controller as any, 'createDefaultProject') - .resolves(defaultProject as any); - const useEnvExtensionStub = sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); const getEnvExtApiStub = sandbox.stub(envExtApiInternal, 'getEnvExtApi'); - const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); assert.strictEqual(useEnvExtensionStub.called, true); assert.strictEqual(getEnvExtApiStub.notCalled, true); - assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); - assert.deepStrictEqual(projects, [defaultProject]); + assert.strictEqual(projects.length, 1); + assert.strictEqual(projects[0].projectUri.toString(), workspaceUri.toString()); }); test('filters Python projects to workspace and creates adapters for each', async () => { - const controller = createController(); const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); const pythonProjects = [ @@ -215,41 +267,57 @@ suite('PythonTestController', () => { sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ getPythonProjects: () => pythonProjects, + getEnvironment: sandbox.stub().resolves({ + name: 'env', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: vscodeApi.Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'test', managerId: 'test' }, + }), } as any); - const createdAdapters = [ - { projectId: 'p1', projectUri: pythonProjects[0].uri }, - { projectId: 'p2', projectUri: pythonProjects[1].uri }, - ]; + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); - const createProjectAdapterStub = sandbox - .stub(controller as any, 'createProjectAdapter') - .onFirstCall() - .resolves(createdAdapters[0] as any) - .onSecondCall() - .resolves(createdAdapters[1] as any); + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(null), + } as any; - const createDefaultProjectStub = sandbox.stub(controller as any, 'createDefaultProject'); + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; - const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + const testController = createStubTestController(); + const envVarsService = {} as any; - // Should only create adapters for the 2 projects in the workspace. - assert.strictEqual(createProjectAdapterStub.callCount, 2); - assert.strictEqual( - createProjectAdapterStub.firstCall.args[0].uri.toString(), - pythonProjects[0].uri.toString(), - ); - assert.strictEqual( - createProjectAdapterStub.secondCall.args[0].uri.toString(), - pythonProjects[1].uri.toString(), + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, ); - assert.strictEqual(createDefaultProjectStub.notCalled, true); - assert.deepStrictEqual(projects, createdAdapters); + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + // Should only create adapters for the 2 projects in the workspace (not 'other') + assert.strictEqual(projects.length, 2); + const projectUris = projects.map((p) => p.projectUri.fsPath); + assert.ok(projectUris.includes('/workspace/root/p1')); + assert.ok(projectUris.includes('/workspace/root/nested/p2')); + assert.ok(!projectUris.includes('/other/root/p3')); }); test('falls back to default project when no projects are in the workspace', async () => { - const controller = createController(); const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); @@ -257,18 +325,45 @@ suite('PythonTestController', () => { getPythonProjects: () => [{ name: 'other', uri: vscodeApi.Uri.file('/other/root/p3') }], } as any); - const defaultProject = { projectId: 'default', projectUri: workspaceUri }; - const createDefaultProjectStub = sandbox - .stub(controller as any, 'createDefaultProject') - .resolves(defaultProject as any); + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + const interpreter = { + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }; + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; - const createProjectAdapterStub = sandbox.stub(controller as any, 'createProjectAdapter'); + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); - const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + const projects = await registry.discoverAndRegisterProjects(workspaceUri); - assert.strictEqual(createProjectAdapterStub.notCalled, true); - assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); - assert.deepStrictEqual(projects, [defaultProject]); + // Should fall back to default project since no projects are in the workspace + assert.strictEqual(projects.length, 1); + assert.strictEqual(projects[0].projectUri.toString(), workspaceUri.toString()); }); }); }); From 7c3c8790c59d32bb3db92ed17054aa9e41e7d382 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:10:16 -0800 Subject: [PATCH 22/25] fix --- src/test/testing/testController/controller.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index 4c9a3f3df1db..c829e4f0fe49 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -311,7 +311,7 @@ suite('PythonTestController', () => { // Should only create adapters for the 2 projects in the workspace (not 'other') assert.strictEqual(projects.length, 2); - const projectUris = projects.map((p) => p.projectUri.fsPath); + const projectUris = projects.map((p: { projectUri: { fsPath: string } }) => p.projectUri.fsPath); assert.ok(projectUris.includes('/workspace/root/p1')); assert.ok(projectUris.includes('/workspace/root/nested/p2')); assert.ok(!projectUris.includes('/other/root/p3')); From 42cd0117138d9df5df54c71d7068fc4c138c6cd3 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:53:24 -0800 Subject: [PATCH 23/25] address comments --- src/client/testing/testController/common/projectUtils.ts | 4 ++-- .../testing/testController/common/testProjectRegistry.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index fb7a4e1d8d1a..31cb45b1f3f1 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -15,9 +15,9 @@ import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter'; /** * Separator used to scope test IDs to a specific project. * Format: {projectId}{SEPARATOR}{testPath} - * Example: "file:///workspace/project||test_file.py::test_name" + * Example: "file:///workspace/project@@PROJECT@@test_file.py::test_name" */ -export const PROJECT_ID_SEPARATOR = '||'; +export const PROJECT_ID_SEPARATOR = '@@vsc@@'; /** * Gets the project ID from a project URI. diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index dc8012624f99..dc8c421831d9 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -173,7 +173,7 @@ export class TestProjectRegistry { * Creates a ProjectAdapter from a PythonProject. */ private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { - const projectId = pythonProject.uri.fsPath; + const projectId = getProjectId(pythonProject.uri); traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`); // Resolve Python environment From 145ccc8ca7f6bedadabd37615609df133d198569 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:55:06 -0800 Subject: [PATCH 24/25] fixes --- python_files/vscode_pytest/__init__.py | 13 +++++-------- .../base/locators/common/nativePythonFinder.ts | 7 +++++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 5a56d8697d64..be4e3daaa843 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -207,7 +207,7 @@ def pytest_exception_interact(node, call, report): send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) @@ -331,7 +331,7 @@ def pytest_report_teststatus(report, config): # noqa: ARG001 send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) yield @@ -365,7 +365,7 @@ def pytest_runtest_protocol(item, nextitem): # noqa: ARG001 send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) yield @@ -852,10 +852,7 @@ def create_session_node(session: pytest.Session) -> TestNode: session -- the pytest session. """ # Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use session path (legacy) - if PROJECT_ROOT_PATH: - node_path = pathlib.Path(PROJECT_ROOT_PATH) - else: - node_path = get_node_path(session) + node_path = pathlib.Path(PROJECT_ROOT_PATH) if PROJECT_ROOT_PATH else get_node_path(session) return { "name": node_path.name, "path": node_path, @@ -1047,7 +1044,7 @@ def get_node_path( except Exception as e: raise VSCodePytestError( f"Error occurred while calculating symlink equivalent from node path: {e}" - f"\n SYMLINK_PATH: {SYMLINK_PATH}, \n node path: {node_path}, \n cwd: {_CACHED_CWD if _CACHED_CWD else pathlib.Path.cwd()}" + f"\n SYMLINK_PATH: {SYMLINK_PATH}, \n node path: {node_path}, \n cwd: {_CACHED_CWD or pathlib.Path.cwd()}" ) from e else: result = node_path diff --git a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index ea0d63cd7552..e45deb696a2a 100644 --- a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -521,6 +521,9 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython }, }; } + if (_finder && isFinderDisposed(_finder)) { + _finder = undefined; + } if (!_finder) { const cacheDirectory = context ? getCacheDirectory(context) : undefined; _finder = new NativePythonFinderImpl(cacheDirectory, context); @@ -531,6 +534,10 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython return _finder; } +function isFinderDisposed(finder: NativePythonFinder): boolean { + return 'isDisposed' in finder && Boolean((finder as { isDisposed?: boolean }).isDisposed); +} + export function getCacheDirectory(context: IExtensionContext): Uri { return Uri.joinPath(context.globalStorageUri, 'pythonLocator'); } From ef32ac206888da24ab787c2663b69cf105d504f7 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:25:38 -0800 Subject: [PATCH 25/25] testing logging --- .../locators/common/nativePythonFinder.ts | 40 ++++++++++++++++++- .../nativePythonFinder.unit.test.ts | 9 ++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index e45deb696a2a..933862081e6e 100644 --- a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -114,6 +114,16 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde private readonly suppressErrorNotification: IPersistentStorage; + /** + * Tracks whether the internal JSON-RPC connection has been closed. + * This can happen independently of the finder being disposed. + */ + private _connectionClosed = false; + + public get isConnectionClosed(): boolean { + return this._connectionClosed; + } + constructor(private readonly cacheDirectory?: Uri, private readonly context?: IExtensionContext) { super(); this.suppressErrorNotification = this.context @@ -135,14 +145,21 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde } async *refresh(options?: NativePythonEnvironmentKind | Uri[]): AsyncIterable { + this.outputChannel.info( + `refresh() called: firstRefreshResults=${!!this.firstRefreshResults}, connectionClosed=${ + this._connectionClosed + }, isDisposed=${this.isDisposed}`, + ); if (this.firstRefreshResults) { // If this is the first time we are refreshing, // Then get the results from the first refresh. // Those would have started earlier and cached in memory. + this.outputChannel.info('Using firstRefreshResults'); const results = this.firstRefreshResults(); this.firstRefreshResults = undefined; yield* results; } else { + this.outputChannel.info('Calling doRefresh'); const result = this.doRefresh(options); let completed = false; void result.completed.finally(() => { @@ -298,6 +315,8 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde sendNativeTelemetry(data, this.initialRefreshMetrics), ), connection.onClose(() => { + this.outputChannel.info('JSON-RPC connection closed, marking connection as closed'); + this._connectionClosed = true; disposables.forEach((d) => d.dispose()); }), ); @@ -535,7 +554,15 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython } function isFinderDisposed(finder: NativePythonFinder): boolean { - return 'isDisposed' in finder && Boolean((finder as { isDisposed?: boolean }).isDisposed); + const finderImpl = finder as { isDisposed?: boolean; isConnectionClosed?: boolean }; + const disposed = Boolean(finderImpl.isDisposed); + const connectionClosed = Boolean(finderImpl.isConnectionClosed); + if (disposed || connectionClosed) { + traceError( + `[NativePythonFinder] Finder needs recreation: isDisposed=${disposed}, isConnectionClosed=${connectionClosed}`, + ); + } + return disposed || connectionClosed; } export function getCacheDirectory(context: IExtensionContext): Uri { @@ -546,3 +573,14 @@ export async function clearCacheDirectory(context: IExtensionContext): Promise { let getWorkspaceFolderPathsStub: sinon.SinonStub; setup(() => { + // Clear singleton before each test to ensure fresh state + clearNativePythonFinder(); + createLogOutputChannelStub = sinon.stub(windowsApis, 'createLogOutputChannel'); createLogOutputChannelStub.returns(new MockOutputChannel('locator')); @@ -41,11 +45,14 @@ suite('Native Python Finder', () => { }); teardown(() => { + // Clean up finder before restoring stubs to avoid issues with mock references + clearNativePythonFinder(); sinon.restore(); }); suiteTeardown(() => { - finder.dispose(); + // Final cleanup (finder may already be disposed by teardown) + clearNativePythonFinder(); }); test('Refresh should return python environments', async () => {