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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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 () => { From b4563cd4f2050b0366f5ca85da73295ad19a5340 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:05:04 -0800 Subject: [PATCH 26/36] test fix --- .../testing/testController/controller.unit.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index c829e4f0fe49..21b80b636fea 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -312,9 +312,16 @@ 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: { 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')); + const expectedInWorkspace = [ + vscodeApi.Uri.file('/workspace/root/p1').fsPath, + vscodeApi.Uri.file('/workspace/root/nested/p2').fsPath, + ]; + const expectedOutOfWorkspace = vscodeApi.Uri.file('/other/root/p3').fsPath; + + expectedInWorkspace.forEach((expectedPath) => { + assert.ok(projectUris.includes(expectedPath)); + }); + assert.ok(!projectUris.includes(expectedOutOfWorkspace)); }); test('falls back to default project when no projects are in the workspace', async () => { From d472e4b16dc580c077a47402097cee9c0a684f46 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:40:48 -0800 Subject: [PATCH 27/36] remove unneeded edits --- .../locators/common/nativePythonFinder.ts | 45 ------------------- .../nativePythonFinder.unit.test.ts | 9 +--- 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index 933862081e6e..ea0d63cd7552 100644 --- a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -114,16 +114,6 @@ 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 @@ -145,21 +135,14 @@ 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(() => { @@ -315,8 +298,6 @@ 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()); }), ); @@ -540,9 +521,6 @@ 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); @@ -553,18 +531,6 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython return _finder; } -function isFinderDisposed(finder: NativePythonFinder): boolean { - 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 { return Uri.joinPath(context.globalStorageUri, 'pythonLocator'); } @@ -573,14 +539,3 @@ 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')); @@ -45,14 +41,11 @@ suite('Native Python Finder', () => { }); teardown(() => { - // Clean up finder before restoring stubs to avoid issues with mock references - clearNativePythonFinder(); sinon.restore(); }); suiteTeardown(() => { - // Final cleanup (finder may already be disposed by teardown) - clearNativePythonFinder(); + finder.dispose(); }); test('Refresh should return python environments', async () => { From 07189944431b1cb8509838e0eeae207271720e11 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:19:45 -0800 Subject: [PATCH 28/36] lots of fun fixes --- .../expected_discovery_test_output.py | 39 ++-- .../tests/pytestadapter/test_discovery.py | 51 +++++ .../testController/common/projectAdapter.ts | 8 +- .../testController/common/projectUtils.ts | 1 - .../testController/common/resultResolver.ts | 2 +- .../common/testProjectRegistry.ts | 45 ++-- .../testing/testController/controller.ts | 210 ++++++++++++++++-- .../common/testProjectRegistry.unit.test.ts | 19 -- .../testController/controller.unit.test.ts | 68 ++---- src/test/vscode-mock.ts | 27 +++ 10 files changed, 346 insertions(+), 124 deletions(-) diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index 77328a77b33e..047f1c72ad17 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1879,19 +1879,32 @@ # 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 +# +# **Project Configuration:** +# In the VS Code Python extension, projects are defined by the Python Environments extension. +# Each project has a root directory (identified by pyproject.toml, setup.py, etc.). +# When PROJECT_ROOT_PATH is set, pytest uses that path as the test tree root instead of cwd. +# +# **Test Tree Structure:** +# Without PROJECT_ROOT_PATH (legacy mode): +# └── .data (cwd = workspace root) +# └── unittest_folder +# └── test_add.py, test_subtract.py... +# +# With PROJECT_ROOT_PATH set to unittest_folder (project-based mode): +# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH env var) +# ├── 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 = { diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 0eddc7b99cab..48b542a2465c 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -428,3 +428,54 @@ def test_project_root_path_env_var(): ), ( 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)}" ) + + +@pytest.mark.skipif( + sys.platform == "win32", +) +def test_symlink_with_project_root_path(): + """Test pytest discovery with both symlink and PROJECT_ROOT_PATH set. + + This tests the combination of: + 1. A symlinked test directory (--rootdir points to symlink) + 2. PROJECT_ROOT_PATH set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring test IDs and paths are correctly resolved through the symlink. + """ + with helpers.create_symlink(helpers.TEST_DATA_PATH, "root", "symlink_folder") as ( + source, + destination, + ): + assert destination.is_symlink() + + # Run pytest with: + # - cwd being the resolved symlink path (simulating subprocess from node) + # - PROJECT_ROOT_PATH set to the symlink destination + actual = helpers.runner_with_cwd_env( + ["--collect-only", f"--rootdir={os.fspath(destination)}"], + source, # cwd is the resolved (non-symlink) path + {"PROJECT_ROOT_PATH": os.fspath(destination)}, # Project root is the symlink + ) + + expected = expected_discovery_test_output.symlink_expected_discovery_output + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + try: + assert all(item in actual_item for item in ("status", "cwd", "error")), ( + "Required keys are missing" + ) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + # cwd should be the PROJECT_ROOT_PATH (the symlink destination) + assert actual_item.get("cwd") == os.fspath(destination), ( + f"CWD does not match symlink path: expected {os.fspath(destination)}, got {actual_item.get('cwd')}" + ) + assert actual_item.get("tests") == expected, "Tests do not match expected value" + except AssertionError as e: + # Print the actual_item in JSON format if an assertion fails + print(json.dumps(actual_item, indent=4)) + pytest.fail(str(e)) diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts index 091de2582579..cfffbf439ca6 100644 --- a/src/client/testing/testController/common/projectAdapter.ts +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -9,15 +9,10 @@ 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() + * Projects are uniquely identified by their projectUri (use projectUri.toString() for map keys). */ export interface ProjectAdapter { // === IDENTITY === - /** - * Project identifier, which is the string representation of the project URI. - */ - projectId: string; - /** * Display name for the project (e.g., "alice (Python 3.11)"). */ @@ -25,6 +20,7 @@ export interface ProjectAdapter { /** * URI of the project root folder or file. + * This is the unique identifier for the project. */ projectUri: Uri; diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts index 31cb45b1f3f1..b104b7f6842d 100644 --- a/src/client/testing/testController/common/projectUtils.ts +++ b/src/client/testing/testController/common/projectUtils.ts @@ -64,7 +64,6 @@ export function createProjectDisplayName(projectName: string, pythonVersion: str /** * 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 diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index acb2d083aa32..2e843a571095 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}@@vsc@@` 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/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index dc8c421831d9..458ac93ffd08 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -25,15 +25,23 @@ import { PythonResultResolver } from './resultResolver'; * - 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 + * **Key concepts:** + * - **Workspace:** A VS Code workspace folder (may contain multiple projects) + * - **Project:** A Python project within a workspace (identified by pyproject.toml, setup.py, etc.) + * - **ProjectUri:** The unique identifier for a project (the URI of the project root directory) + * - Each project gets its own test tree root, Python environment, and test adapters + * + * **Project identification:** + * Projects are identified and tracked by their URI (projectUri.toString()). This matches + * how the Python Environments extension stores projects in its Map. */ export class TestProjectRegistry { /** - * Map of workspace URI -> Map of project ID -> ProjectAdapter - * Project IDs match Python Environments extension's Map keys + * Map of workspace URI -> Map of project URI string -> ProjectAdapter + * + * Projects are keyed by their URI string (projectUri.toString()) which matches how + * the Python Environments extension identifies projects. This enables O(1) lookups + * when given a project URI. */ private readonly workspaceProjects: Map> = new Map(); @@ -44,13 +52,6 @@ export class TestProjectRegistry { 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. */ @@ -103,7 +104,7 @@ export class TestProjectRegistry { const projects = this.getProjectsArray(workspaceUri); for (const project of projects) { - const ignorePaths = projectIgnores.get(project.projectId); + const ignorePaths = projectIgnores.get(getProjectId(project.projectUri)); if (ignorePaths && ignorePaths.length > 0) { project.nestedProjectPathsToIgnore = ignorePaths; traceInfo(`[test-by-project] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`); @@ -171,6 +172,12 @@ export class TestProjectRegistry { /** * Creates a ProjectAdapter from a PythonProject. + * + * Each project gets its own isolated test infrastructure: + * - **ResultResolver:** Handles mapping test IDs and processing results for this project + * - **DiscoveryAdapter:** Discovers tests scoped to this project's root directory + * - **ExecutionAdapter:** Runs tests for this project using its Python environment + * */ private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { const projectId = getProjectId(pythonProject.uri); @@ -196,7 +203,6 @@ export class TestProjectRegistry { const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); return { - projectId, projectName, projectUri: pythonProject.uri, workspaceUri, @@ -246,7 +252,6 @@ export class TestProjectRegistry { }; return { - projectId: getProjectId(workspaceUri), projectName: pythonProject.name, projectUri: workspaceUri, workspaceUri, @@ -263,6 +268,9 @@ export class TestProjectRegistry { /** * Identifies nested projects and returns ignore paths for parent projects. + * + * **Time complexity:** O(n²) where n is the number of projects in the workspace. + * For each project, checks all other projects to find nested relationships. */ private computeNestedProjectIgnores(workspaceUri: Uri): Map { const ignoreMap = new Map(); @@ -274,7 +282,8 @@ export class TestProjectRegistry { const nestedPaths: string[] = []; for (const child of projects) { - if (parent.projectId === child.projectId) continue; + // Skip self-comparison using URI + if (parent.projectUri.toString() === child.projectUri.toString()) continue; const parentPath = parent.projectUri.fsPath; const childPath = child.projectUri.fsPath; @@ -286,7 +295,7 @@ export class TestProjectRegistry { } if (nestedPaths.length > 0) { - ignoreMap.set(parent.projectId, nestedPaths); + ignoreMap.set(getProjectId(parent.projectUri), nestedPaths); } } diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 1029c099114b..1fc4e406a9e7 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -44,7 +44,9 @@ 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'; +import { createTestAdapters, getProjectId } from './common/projectUtils'; +import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal'; +import { DidChangePythonProjectsEventArgs, PythonProject } from '../../envExt/types'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -55,13 +57,6 @@ type TriggerType = EventPropertyType[TriggerKeyType]; export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; - /** - * Feature flag for project-based testing. - * 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(); @@ -206,7 +201,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // 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()) { + if (useEnvExtension()) { traceInfo('[test-by-project] Activating project-based testing mode'); // Discover projects in parallel across all workspaces @@ -233,6 +228,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.activateLegacyWorkspace(workspace); } }); + // Subscribe to project changes to update test tree when projects are added/removed + await this.subscribeToProjectChanges(); return; } @@ -242,6 +239,166 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }); } + /** + * Subscribes to Python project changes from the Python Environments API. + * When projects are added or removed, updates the test tree accordingly. + */ + private async subscribeToProjectChanges(): Promise { + try { + const envExtApi = await getEnvExtApi(); + this.disposables.push( + envExtApi.onDidChangePythonProjects((event: DidChangePythonProjectsEventArgs) => { + this.handleProjectChanges(event).catch((error) => { + traceError('[test-by-project] Error handling project changes:', error); + }); + }), + ); + traceInfo('[test-by-project] Subscribed to Python project changes'); + } catch (error) { + traceError('[test-by-project] Failed to subscribe to project changes:', error); + } + } + + /** + * Handles changes to Python projects (added or removed). + * Cleans up stale test items and re-discovers projects and tests for affected workspaces. + */ + private async handleProjectChanges(event: DidChangePythonProjectsEventArgs): Promise { + const { added, removed } = event; + + if (added.length === 0 && removed.length === 0) { + return; + } + + traceInfo(`[test-by-project] Project changes detected: ${added.length} added, ${removed.length} removed`); + + // Find all affected workspaces + const affectedWorkspaces = new Set(); + + const findWorkspace = (project: PythonProject): WorkspaceFolder | undefined => { + return this.workspaceService.getWorkspaceFolder(project.uri); + }; + + for (const project of [...added, ...removed]) { + const workspace = findWorkspace(project); + if (workspace) { + affectedWorkspaces.add(workspace); + } + } + + // For each affected workspace, clean up and re-discover + for (const workspace of affectedWorkspaces) { + traceInfo(`[test-by-project] Re-discovering projects for workspace: ${workspace.uri.fsPath}`); + + // Get the current projects before clearing to know what to clean up + const existingProjects = this.projectRegistry.getProjectsArray(workspace.uri); + + // Remove ALL test items for the affected workspace's projects + // This ensures no stale items remain from deleted/changed projects + this.removeWorkspaceProjectTestItems(workspace.uri, existingProjects); + + // Also explicitly remove test items for removed projects (in case they weren't tracked) + for (const project of removed) { + const projectWorkspace = findWorkspace(project); + if (projectWorkspace?.uri.toString() === workspace.uri.toString()) { + this.removeProjectTestItems(project); + } + } + + // Clear registry and re-discover all projects for the workspace + this.projectRegistry.clearWorkspace(workspace.uri); + await this.projectRegistry.discoverAndRegisterProjects(workspace.uri); + + // Re-run test discovery for the workspace to populate fresh test items + await this.discoverTestsInWorkspace(workspace.uri); + } + } + + /** + * Removes all test items associated with projects in a workspace. + * Used to clean up stale items before re-discovery. + */ + private removeWorkspaceProjectTestItems(workspaceUri: Uri, projects: ProjectAdapter[]): void { + const idsToRemove: string[] = []; + + // Collect IDs of test items belonging to any project in this workspace + for (const project of projects) { + const projectIdPrefix = getProjectId(project.projectUri); + const projectFsPath = project.projectUri.fsPath; + + this.testController.items.forEach((item) => { + // Match by project ID prefix (e.g., "file:///path@@vsc@@...") + if (item.id.startsWith(projectIdPrefix)) { + idsToRemove.push(item.id); + } + // Match by fsPath in ID (legacy items might use path directly) + else if (item.id.includes(projectFsPath)) { + idsToRemove.push(item.id); + } + // Match by item URI being within project directory + else if (item.uri && item.uri.fsPath.startsWith(projectFsPath)) { + idsToRemove.push(item.id); + } + }); + } + + // Also remove any items whose URI is within the workspace (catch-all for edge cases) + this.testController.items.forEach((item) => { + if ( + item.uri && + this.workspaceService.getWorkspaceFolder(item.uri)?.uri.toString() === workspaceUri.toString() + ) { + if (!idsToRemove.includes(item.id)) { + idsToRemove.push(item.id); + } + } + }); + + // Remove all collected items + for (const id of idsToRemove) { + this.testController.items.delete(id); + } + + traceInfo( + `[test-by-project] Cleaned up ${idsToRemove.length} test items for workspace: ${workspaceUri.fsPath}`, + ); + } + + /** + * Removes test items associated with a specific project from the test controller. + * Matches items by project ID prefix, fsPath, or URI. + */ + private removeProjectTestItems(project: PythonProject): void { + const projectId = getProjectId(project.uri); + const projectFsPath = project.uri.fsPath; + const idsToRemove: string[] = []; + + // Find all root items that belong to this project + this.testController.items.forEach((item) => { + // Match by project ID prefix (e.g., "file:///path@@vsc@@...") + if (item.id.startsWith(projectId)) { + idsToRemove.push(item.id); + } + // Match by fsPath in ID (items might use path directly without URI prefix) + else if (item.id.startsWith(projectFsPath) || item.id.includes(projectFsPath)) { + idsToRemove.push(item.id); + } + // Match by item URI being within project directory + else if (item.uri && item.uri.fsPath.startsWith(projectFsPath)) { + idsToRemove.push(item.id); + } + }); + + for (const id of idsToRemove) { + this.testController.items.delete(id); + traceVerbose(`[test-by-project] Removed test item: ${id}`); + } + + if (idsToRemove.length > 0) { + traceInfo(`[test-by-project] Removed ${idsToRemove.length} test items for project: ${project.name}`); + } + } + /** * Activates testing for a workspace using the legacy single-adapter approach. * Used for backward compatibility when project-based testing is disabled or unavailable. @@ -318,7 +475,16 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Discovers tests for a single workspace. - * Delegates to project-based discovery or legacy mode based on configuration. + * + * **Discovery flow:** + * 1. If the workspace has registered projects (via Python Environments API), + * uses project-based discovery: each project is discovered independently + * with its own Python environment and test adapters. + * 2. Otherwise, falls back to legacy mode: a single WorkspaceTestAdapter + * discovers all tests in the workspace using the active interpreter. + * + * In project-based mode, the test tree will have separate roots for each project. + * In legacy mode, the workspace folder is the single test tree root. */ private async discoverTestsInWorkspace(uri: Uri): Promise { const workspace = this.workspaceService.getWorkspaceFolder(uri); @@ -334,7 +500,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.sendTestDisabledTelemetry = true; // Use project-based discovery if applicable - if (this.useProjectBasedTesting && this.projectRegistry.hasProjects(workspace.uri)) { + if (this.projectRegistry.hasProjects(workspace.uri)) { await this.discoverAllProjectsInWorkspace(workspace.uri); return; } @@ -351,10 +517,22 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Discovers tests for all projects within a workspace (project-based mode). - * Runs discovery in parallel for all registered projects and configures nested exclusions. + * Re-discovers projects from the Python Environments API before running test discovery. + * This ensures the test tree stays in sync with project changes. */ private async discoverAllProjectsInWorkspace(workspaceUri: Uri): Promise { - const projects = this.projectRegistry.getProjectsArray(workspaceUri); + // Get existing projects before re-discovery for cleanup + const existingProjects = this.projectRegistry.getProjectsArray(workspaceUri); + + // Clean up all existing test items for this workspace + // This ensures stale items from deleted/changed projects are removed + this.removeWorkspaceProjectTestItems(workspaceUri, existingProjects); + + // Re-discover projects from Python Environments API + // This picks up any added/removed projects since last discovery + this.projectRegistry.clearWorkspace(workspaceUri); + const projects = await this.projectRegistry.discoverAndRegisterProjects(workspaceUri); + if (projects.length === 0) { traceError(`[test-by-project] No projects found for workspace: ${workspaceUri.fsPath}`); return; @@ -399,13 +577,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc project, ); - // Mark project as completed - projectsCompleted.add(project.projectId); + // Mark project as completed (use URI string as unique key) + projectsCompleted.add(project.projectUri.toString()); 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 - projectsCompleted.add(project.projectId); // Still mark as completed + projectsCompleted.add(project.projectUri.toString()); // Still mark as completed } finally { project.isDiscovering = false; } diff --git a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts index ecd163a905b9..5d04930d0e88 100644 --- a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts +++ b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts @@ -67,24 +67,6 @@ suite('TestProjectRegistry', () => { 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'); @@ -431,7 +413,6 @@ suite('TestProjectRegistry', () => { 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); diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index 21b80b636fea..feb5f36fc797 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -3,17 +3,14 @@ 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 * as vscode from 'vscode'; +import { TestController, Uri } from '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'; +import { PythonTestController } from '../../../client/testing/testController/controller'; +import { TestProjectRegistry } from '../../../client/testing/testController/common/testProjectRegistry'; function createStubTestController(): TestController { const disposable = { dispose: () => undefined }; @@ -38,34 +35,6 @@ function createStubTestController(): 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'); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { TestProjectRegistry } = require('../../../client/testing/testController/common/testProjectRegistry'); - suite('PythonTestController', () => { let sandbox: sinon.SinonSandbox; @@ -129,7 +98,7 @@ suite('PythonTestController', () => { suite('getTestProvider', () => { test('returns unittest when enabled', () => { const controller = createController({ unittestEnabled: true }); - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + const workspaceUri: Uri = vscode.Uri.file('/workspace'); const provider = (controller as any).getTestProvider(workspaceUri); @@ -138,7 +107,7 @@ suite('PythonTestController', () => { test('returns pytest when unittest not enabled', () => { const controller = createController({ unittestEnabled: false }); - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + const workspaceUri: Uri = vscode.Uri.file('/workspace'); const provider = (controller as any).getTestProvider(workspaceUri); @@ -148,7 +117,7 @@ suite('PythonTestController', () => { suite('createDefaultProject (via TestProjectRegistry)', () => { test('creates a single default project using active interpreter', async () => { - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/myws'); + const workspaceUri: Uri = vscode.Uri.file('/workspace/myws'); const interpreter = { displayName: 'My Python', path: '/opt/py/bin/python', @@ -192,7 +161,6 @@ suite('PythonTestController', () => { 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)); assert.strictEqual(project.projectName, 'myws'); assert.strictEqual(project.testProvider, PYTEST_PROVIDER); @@ -210,7 +178,7 @@ suite('PythonTestController', () => { suite('discoverWorkspaceProjects (via TestProjectRegistry)', () => { test('respects useEnvExtension() == false and falls back to single default project', async () => { - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/a'); + const workspaceUri: Uri = vscode.Uri.file('/workspace/a'); const useEnvExtensionStub = sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); const getEnvExtApiStub = sandbox.stub(envExtApiInternal, 'getEnvExtApi'); @@ -256,12 +224,12 @@ suite('PythonTestController', () => { }); test('filters Python projects to workspace and creates adapters for each', async () => { - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + const workspaceUri: Uri = vscode.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') }, + { name: 'p1', uri: vscode.Uri.file('/workspace/root/p1') }, + { name: 'p2', uri: vscode.Uri.file('/workspace/root/nested/p2') }, + { name: 'other', uri: vscode.Uri.file('/other/root/p3') }, ]; sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); @@ -273,7 +241,7 @@ suite('PythonTestController', () => { shortDisplayName: 'Python 3.11', displayPath: '/usr/bin/python3', version: '3.11.8', - environmentPath: vscodeApi.Uri.file('/usr/bin/python3'), + environmentPath: vscode.Uri.file('/usr/bin/python3'), sysPrefix: '/usr', execInfo: { run: { executable: '/usr/bin/python3' } }, envId: { id: 'test', managerId: 'test' }, @@ -313,10 +281,10 @@ suite('PythonTestController', () => { assert.strictEqual(projects.length, 2); const projectUris = projects.map((p: { projectUri: { fsPath: string } }) => p.projectUri.fsPath); const expectedInWorkspace = [ - vscodeApi.Uri.file('/workspace/root/p1').fsPath, - vscodeApi.Uri.file('/workspace/root/nested/p2').fsPath, + vscode.Uri.file('/workspace/root/p1').fsPath, + vscode.Uri.file('/workspace/root/nested/p2').fsPath, ]; - const expectedOutOfWorkspace = vscodeApi.Uri.file('/other/root/p3').fsPath; + const expectedOutOfWorkspace = vscode.Uri.file('/other/root/p3').fsPath; expectedInWorkspace.forEach((expectedPath) => { assert.ok(projectUris.includes(expectedPath)); @@ -325,11 +293,11 @@ suite('PythonTestController', () => { }); test('falls back to default project when no projects are in the workspace', async () => { - const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + const workspaceUri: Uri = vscode.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') }], + getPythonProjects: () => [{ name: 'other', uri: vscode.Uri.file('/other/root/p3') }], } as any); const fakeDiscoveryAdapter = { kind: 'discovery' }; diff --git a/src/test/vscode-mock.ts b/src/test/vscode-mock.ts index 3e2816afbbde..b7ea2bc549a0 100644 --- a/src/test/vscode-mock.ts +++ b/src/test/vscode-mock.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as vscodeMocks from './mocks/vsc'; import { vscMockTelemetryReporter } from './mocks/vsc/telemetryReporter'; import { anything, instance, mock, when } from 'ts-mockito'; +import { TestItem } from 'vscode'; const Module = require('module'); type VSCode = typeof vscode; @@ -148,3 +149,29 @@ mockedVSCode.TestRunProfileKind = vscodeMocks.TestRunProfileKind; (mockedVSCode as any).StatementCoverage = class StatementCoverage { constructor(public executed: number | boolean, public location: any, public branches?: any) {} }; + +// Mock TestController for vscode.tests namespace +function createMockTestController(): vscode.TestController { + const disposable = { dispose: () => undefined }; + return ({ + items: { + forEach: () => undefined, + get: () => undefined, + add: () => undefined, + replace: () => undefined, + delete: () => undefined, + size: 0, + [Symbol.iterator]: function* () {}, + }, + createRunProfile: () => disposable, + createTestItem: () => ({} as TestItem), + dispose: () => undefined, + resolveHandler: undefined, + refreshHandler: undefined, + } as unknown) as vscode.TestController; +} + +// Add tests namespace with createTestController +(mockedVSCode as any).tests = { + createTestController: (_id: string, _label: string) => createMockTestController(), +}; From 9050b2365f2cdb7a0302f7fdc2d0e97280cb1e6f Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:25:19 -0800 Subject: [PATCH 29/36] fix --- python_files/tests/pytestadapter/test_discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 48b542a2465c..cf777399fed9 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -432,6 +432,7 @@ def test_project_root_path_env_var(): @pytest.mark.skipif( sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", ) def test_symlink_with_project_root_path(): """Test pytest discovery with both symlink and PROJECT_ROOT_PATH set. From 401e8b1041bbf7b7e2e480e0231e07f0594cbc09 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:51:19 -0800 Subject: [PATCH 30/36] phase 1 --- .../tests/unittestadapter/test_discovery.py | 73 ++++++++++++++ python_files/unittestadapter/discovery.py | 24 ++++- .../unittest/testDiscoveryAdapter.ts | 10 ++ .../testDiscoveryAdapter.unit.test.ts | 95 +++++++++++++++++++ 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index a10b5c406680..105bf67db47c 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -325,3 +325,76 @@ def test_simple_django_collect(): assert ( len(actual_item["tests"]["children"][0]["children"][0]["children"][0]["children"]) == 3 ) + + +def test_project_root_path_with_cwd_override() -> None: + """Test unittest discovery with cwd_override parameter. + + This simulates project-based testing where the cwd in the payload should be + the project root (cwd_override) rather than the start_dir. + + When cwd_override is provided: + - The cwd in the response should match cwd_override + - The test tree root should still be built correctly based on top_level_dir + """ + # Use unittest_skip folder as our "project" directory + project_path = TEST_DATA_PATH / "unittest_skip" + start_dir = os.fsdecode(project_path) + pattern = "unittest_*" + + # Call discover_tests with cwd_override to simulate PROJECT_ROOT_PATH + actual = discover_tests(start_dir, pattern, None, cwd_override=start_dir) + + assert actual["status"] == "success" + # cwd in response should match the cwd_override (project root) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert "tests" in actual + # Verify the test tree structure matches expected output + assert is_same_tree( + actual.get("tests"), + expected_discovery_test_output.skip_unittest_folder_discovery_output, + ["id_", "lineno", "name"], + ) + assert "error" not in actual + + +def test_project_root_path_with_different_cwd_and_start_dir() -> None: + """Test unittest discovery where cwd_override differs from start_dir. + + This simulates the scenario where: + - start_dir points to a subfolder where tests are located + - cwd_override (PROJECT_ROOT_PATH) points to the project root + + The cwd in the response should be the project root, while discovery + still runs from the start_dir. + """ + # Use utils_complex_tree as our test case - discovery from a subfolder + project_path = TEST_DATA_PATH / "utils_complex_tree" + start_dir = os.fsdecode( + pathlib.PurePath( + TEST_DATA_PATH, + "utils_complex_tree", + "test_outer_folder", + "test_inner_folder", + ) + ) + pattern = "test_*.py" + top_level_dir = os.fsdecode(project_path) + + # Call discover_tests with cwd_override set to project root + actual = discover_tests(start_dir, pattern, top_level_dir, cwd_override=top_level_dir) + + assert actual["status"] == "success" + # cwd should be the project root (cwd_override), not the start_dir + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert "error" not in actual + # Test tree should still be structured correctly with top_level_dir as root + assert is_same_tree( + actual.get("tests"), + expected_discovery_test_output.complex_tree_expected_output, + ["id_", "lineno", "name"], + ) diff --git a/python_files/unittestadapter/discovery.py b/python_files/unittestadapter/discovery.py index ce8251218743..8f2008d4c4ac 100644 --- a/python_files/unittestadapter/discovery.py +++ b/python_files/unittestadapter/discovery.py @@ -27,12 +27,13 @@ def discover_tests( start_dir: str, pattern: str, top_level_dir: Optional[str], + cwd_override: Optional[str] = None, ) -> DiscoveryPayloadDict: """Returns a dictionary containing details of the discovered tests. The returned dict has the following keys: - - cwd: Absolute path to the test start directory; + - cwd: Absolute path to the test start directory (or cwd_override if provided); - status: Test discovery status, can be "success" or "error"; - tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests; - error: Discovery error if any, not present otherwise. @@ -56,8 +57,15 @@ def discover_tests( "": [list of errors] "status": "error", } + + Args: + start_dir: Directory where test discovery starts + pattern: Pattern to match test files (e.g., "test*.py") + top_level_dir: Top-level directory for the test tree hierarchy + cwd_override: Optional override for the cwd in the response payload + (used for project-based testing to set project root) """ - cwd = os.path.abspath(start_dir) # noqa: PTH100 + cwd = os.path.abspath(cwd_override if cwd_override else start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -133,7 +141,17 @@ def discover_tests( print(error_msg, file=sys.stderr) raise VSCodeUnittestError(error_msg) # noqa: B904 else: + # Check for PROJECT_ROOT_PATH environment variable (project-based testing). + # When set, this overrides top_level_dir to root the test tree at the project directory. + project_root_path = os.environ.get("PROJECT_ROOT_PATH") + if project_root_path: + top_level_dir = project_root_path + print( + f"PROJECT_ROOT_PATH is set, using {project_root_path} as top_level_dir for discovery" + ) + # Perform regular unittest test discovery. - payload = discover_tests(start_dir, pattern, top_level_dir) + # Pass project_root_path as cwd_override so the payload's cwd matches the project root. + payload = discover_tests(start_dir, pattern, top_level_dir, cwd_override=project_root_path) # Post this discovery payload. send_post_request(payload, test_run_pipe) diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 7c986e95a449..48c688839500 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,14 @@ 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; + traceInfo( + `[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for unittest discovery`, + ); + } + // Setup process handlers (shared by both execution paths) const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); diff --git a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index 4dae070bccbe..bb9c030cbe28 100644 --- a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -22,6 +22,7 @@ import { SpawnOptions, } from '../../../../client/common/process/types'; import * as extapi from '../../../../client/envExt/api.internal'; +import { ProjectAdapter } from '../../../../client/testing/testController/common/projectAdapter'; suite('Unittest test discovery adapter', () => { let configService: IConfigurationService; @@ -244,4 +245,98 @@ suite('Unittest test discovery adapter', () => { await discoveryPromise; assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); }); + + test('DiscoverTests should set PROJECT_ROOT_PATH when project is provided', async () => { + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = { + projectId: 'file:///workspace/myproject', + projectUri: Uri.file(projectPath), + projectName: 'myproject', + workspaceUri: Uri.file('/workspace'), + } as ProjectAdapter; + + const adapter = new UnittestTestDiscoveryAdapter(configService); + adapter.discoverTests(uri, execFactory.object, undefined, undefined, mockProject); + const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); + const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; + + // must await until the execObservable is called in order to verify it + await deferred.promise; + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.is>((argsActual) => { + try { + assert.equal(argsActual.length, argsExpected.length); + assert.deepEqual(argsActual, argsExpected); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + typeMoq.It.is((options) => { + try { + // Verify PROJECT_ROOT_PATH is set when project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + projectPath, + 'PROJECT_ROOT_PATH should be set to project URI path', + ); + assert.equal(options.cwd, expectedPath); + assert.equal(options.throwOnStdErr, true); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + ), + typeMoq.Times.once(), + ); + }); + + test('DiscoverTests should NOT set PROJECT_ROOT_PATH when no project is provided', async () => { + const adapter = new UnittestTestDiscoveryAdapter(configService); + adapter.discoverTests(uri, execFactory.object); + const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); + const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; + + // must await until the execObservable is called in order to verify it + await deferred.promise; + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.is>((argsActual) => { + try { + assert.equal(argsActual.length, argsExpected.length); + assert.deepEqual(argsActual, argsExpected); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + typeMoq.It.is((options) => { + try { + // Verify PROJECT_ROOT_PATH is NOT set when no project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + undefined, + 'PROJECT_ROOT_PATH should NOT be set when no project is provided', + ); + assert.equal(options.cwd, expectedPath); + assert.equal(options.throwOnStdErr, true); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + ), + typeMoq.Times.once(), + ); + }); }); From c7b66fe3f0c6ce11425aa2698ec1cea7ad051eed Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:34:51 -0800 Subject: [PATCH 31/36] updates --- .../testing_feature_area.instructions.md | 15 +++--- .../tests/unittestadapter/test_discovery.py | 46 +++++++++++++++++++ .../common/testProjectRegistry.ts | 2 + .../testDiscoveryAdapter.unit.test.ts | 4 +- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index be946e798dff..28af477221d0 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -159,8 +159,6 @@ The adapters in the extension don't implement test discovery/run logic themselve 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: @@ -182,8 +180,10 @@ Project-based testing enables multi-project workspace support where each Python 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. +5. **Python side**: + - For pytest: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`. + - For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `cwd_override` to root the test tree at the project directory. +6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`). ### Logging prefix @@ -191,14 +191,17 @@ All project-based testing logs use the `[test-by-project]` prefix for easy filte ### Key files -- Python side: `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable. +- Python side: + - `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest. + - `python_files/unittestadapter/discovery.py` — `discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest. - 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 +- `python_files/tests/pytestadapter/test_discovery.py` — pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`) +- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH tests ## Coverage support (how it works) diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index 105bf67db47c..2a827e87b043 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -398,3 +398,49 @@ def test_project_root_path_with_different_cwd_and_start_dir() -> None: expected_discovery_test_output.complex_tree_expected_output, ["id_", "lineno", "name"], ) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", +) +def test_symlink_with_project_root_path() -> None: + """Test unittest discovery with both symlink and PROJECT_ROOT_PATH set. + + This tests the combination of: + 1. A symlinked test directory + 2. cwd_override (PROJECT_ROOT_PATH) set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring test IDs and paths are correctly resolved through the symlink. + """ + with helpers.create_symlink(TEST_DATA_PATH, "unittest_skip", "symlink_unittest") as ( + source, + destination, + ): + assert destination.is_symlink() + + # Run discovery with: + # - start_dir pointing to the symlink destination + # - cwd_override set to the symlink destination (simulating PROJECT_ROOT_PATH) + start_dir = os.fsdecode(destination) + pattern = "unittest_*" + + actual = discover_tests(start_dir, pattern, None, cwd_override=start_dir) + + assert actual["status"] == "success", ( + f"Status is not 'success', error is: {actual.get('error')}" + ) + # cwd should be the symlink path (cwd_override) + assert actual["cwd"] == os.fsdecode(destination), ( + f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" + ) + assert "tests" in actual + # The test tree root should be named after the symlink directory + assert actual["tests"]["name"] == "symlink_unittest", ( + f"Expected root name 'symlink_unittest', got '{actual['tests']['name']}'" + ) + # The test tree root path should use the symlink path + assert actual["tests"]["path"] == os.fsdecode(destination), ( + f"Expected root path to be symlink, got '{actual['tests']['path']}'" + ) diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index 458ac93ffd08..379a09ad20eb 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -98,6 +98,8 @@ export class TestProjectRegistry { /** * 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. + * + * **Time complexity:** O(n²) where n is the number of projects (calls computeNestedProjectIgnores). */ public configureNestedProjectIgnores(workspaceUri: Uri): void { const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); diff --git a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index bb9c030cbe28..031f30afba8a 100644 --- a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -248,12 +248,12 @@ suite('Unittest test discovery adapter', () => { test('DiscoverTests should set PROJECT_ROOT_PATH when project is provided', async () => { const projectPath = path.join('/', 'workspace', 'myproject'); - const mockProject = { + const mockProject = ({ projectId: 'file:///workspace/myproject', projectUri: Uri.file(projectPath), projectName: 'myproject', workspaceUri: Uri.file('/workspace'), - } as ProjectAdapter; + } as unknown) as ProjectAdapter; const adapter = new UnittestTestDiscoveryAdapter(configService); adapter.discoverTests(uri, execFactory.object, undefined, undefined, mockProject); From 07d18834253f41cd0a3e787540fed86a26dbc9fd Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:38:48 -0800 Subject: [PATCH 32/36] updates --- python_files/tests/unittestadapter/test_discovery.py | 3 ++- python_files/unittestadapter/discovery.py | 7 ++++--- python_files/unittestadapter/pvsc_utils.py | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index 2a827e87b043..1dbc96edcb27 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -191,7 +191,8 @@ def test_empty_discovery() -> None: actual = discover_tests(start_dir, pattern, None) assert actual["status"] == "success" - assert "tests" in actual + # When no tests are found, the tests key should not be present in the payload + assert "tests" not in actual assert "error" not in actual diff --git a/python_files/unittestadapter/discovery.py b/python_files/unittestadapter/discovery.py index 8f2008d4c4ac..69747f54c042 100644 --- a/python_files/unittestadapter/discovery.py +++ b/python_files/unittestadapter/discovery.py @@ -91,9 +91,10 @@ def discover_tests( except Exception: error.append(traceback.format_exc()) - # Still include the tests in the payload even if there are errors so that the TS - # side can determine if it is from run or discovery. - payload["tests"] = tests if tests is not None else None + # Only include tests in the payload if tests were discovered. + # If no tests found (tests is None), omit the tests key per the docstring contract. + if tests is not None: + payload["tests"] = tests if len(error): payload["status"] = "error" diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index d6920592a4d4..b8c7f7028222 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -281,6 +281,10 @@ def build_test_tree( } # concatenate class name and function test name current_node["children"].append(test_node) + # If no tests were discovered, return None instead of an empty root node + if not root["children"]: + return None, error + return root, error From b0504d17cd16f7be19bdd3e2fbd89404c43b5fd0 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:09:21 -0800 Subject: [PATCH 33/36] updates --- .../testing_feature_area.instructions.md | 26 +++- .../tests/unittestadapter/test_execution.py | 131 ++++++++++++++++++ python_files/unittestadapter/execution.py | 35 ++++- .../testing/testController/common/types.ts | 1 + .../pytest/pytestExecutionAdapter.ts | 11 ++ .../unittest/testExecutionAdapter.ts | 14 ++ .../testController/workspaceTestAdapter.ts | 3 + .../testExecutionAdapter.unit.test.ts | 113 +++++++++++++++ 8 files changed, 329 insertions(+), 5 deletions(-) diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index 28af477221d0..beb75a9a7dac 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -185,6 +185,22 @@ Project-based testing enables multi-project workspace support where each Python - For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `cwd_override` to root the test tree at the project directory. 6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`). +### Nested project handling: pytest vs unittest + +**pytest** supports the `--ignore` flag to exclude paths during test collection. When nested projects are detected, parent projects automatically receive `--ignore` flags for child project paths. This ensures each test appears under exactly one project in the test tree. + +**unittest** does not support path exclusion during `discover()`. Therefore, tests in nested project directories may appear under multiple project roots (both the parent and the child project). This is **expected behavior** for unittest: + +- Each project discovers and displays all tests it finds within its directory structure +- There is no deduplication or collision detection +- Users may see the same test file under multiple project roots if their project structure has nesting + +This approach was chosen because: + +1. unittest's `TestLoader.discover()` has no built-in path exclusion mechanism +2. Implementing custom exclusion would add significant complexity with minimal benefit +3. The existing approach is transparent and predictable - each project shows what it finds + ### Logging prefix All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel. @@ -193,15 +209,19 @@ All project-based testing logs use the `[test-by-project]` prefix for easy filte - Python side: - `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest. - - `python_files/unittestadapter/discovery.py` — `discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest. -- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters. + - `python_files/unittestadapter/discovery.py` — `discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery. + - `python_files/unittestadapter/execution.py` — `run_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest execution. +- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution 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_discovery.py` — pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`) -- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH tests +- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH discovery tests +- `python_files/tests/unittestadapter/test_execution.py` — unittest `cwd_override` / PROJECT_ROOT_PATH execution tests +- `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` — unittest discovery adapter PROJECT_ROOT_PATH tests +- `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` — unittest execution adapter PROJECT_ROOT_PATH tests ## Coverage support (how it works) diff --git a/python_files/tests/unittestadapter/test_execution.py b/python_files/tests/unittestadapter/test_execution.py index f369c6d770b0..4d636920fc6c 100644 --- a/python_files/tests/unittestadapter/test_execution.py +++ b/python_files/tests/unittestadapter/test_execution.py @@ -341,3 +341,134 @@ def test_basic_run_django(): assert id_result["outcome"] == "failure" else: assert id_result["outcome"] == "success" + + +def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution with cwd_override parameter. + + This simulates project-based testing where the cwd in the payload should be + the project root (cwd_override) rather than the start_dir. + + When cwd_override is provided: + - The cwd in the response should match cwd_override + - Test execution should still work correctly with start_dir + """ + # Use unittest_folder as our "project" directory + project_path = TEST_DATA_PATH / "unittest_folder" + start_dir = os.fsdecode(project_path) + pattern = "test_add*" + test_ids = [ + "test_add.TestAddFunction.test_add_positive_numbers", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + # Call run_tests with cwd_override to simulate PROJECT_ROOT_PATH + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + cwd_override=start_dir, + ) + + assert actual["status"] == "success" + # cwd in response should match the cwd_override (project root) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert actual["result"] is not None + assert test_ids[0] in actual["result"] + assert actual["result"][test_ids[0]]["outcome"] == "success" + + +def test_project_root_path_with_different_cwd_and_start_dir() -> None: + """Test unittest execution where cwd_override differs from start_dir. + + This simulates the scenario where: + - start_dir points to a subfolder where tests are located + - cwd_override (PROJECT_ROOT_PATH) points to the project root + + The cwd in the response should be the project root, while execution + still runs from the start_dir. + """ + # Use utils_nested_cases as our test case + project_path = TEST_DATA_PATH / "utils_nested_cases" + start_dir = os.fsdecode(project_path) + pattern = "*" + test_ids = [ + "file_one.CaseTwoFileOne.test_one", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + # Call run_tests with cwd_override set to project root + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + cwd_override=os.fsdecode(project_path), + ) + + assert actual["status"] == "success" + # cwd should be the project root (cwd_override) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert actual["result"] is not None + assert test_ids[0] in actual["result"] + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", +) +def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution with both symlink and cwd_override set. + + This tests the combination of: + 1. A symlinked test directory + 2. cwd_override (PROJECT_ROOT_PATH) set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring execution payloads correctly use the symlink path. + """ + with helpers.create_symlink(TEST_DATA_PATH, "unittest_folder", "symlink_unittest_exec") as ( + _source, + destination, + ): + assert destination.is_symlink() + + # Run execution with: + # - start_dir pointing to the symlink destination + # - cwd_override set to the symlink destination (simulating PROJECT_ROOT_PATH) + start_dir = os.fsdecode(destination) + pattern = "test_add*" + test_ids = [ + "test_add.TestAddFunction.test_add_positive_numbers", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + cwd_override=start_dir, + ) + + assert actual["status"] == "success", ( + f"Status is not 'success', error is: {actual.get('error')}" + ) + # cwd should be the symlink path (cwd_override) + assert actual["cwd"] == os.fsdecode(destination), ( + f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" + ) diff --git a/python_files/unittestadapter/execution.py b/python_files/unittestadapter/execution.py index 951289850884..b5f59fd79849 100644 --- a/python_files/unittestadapter/execution.py +++ b/python_files/unittestadapter/execution.py @@ -36,6 +36,9 @@ ErrorType = Union[Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None]] test_run_pipe = "" START_DIR = "" +# PROJECT_ROOT_PATH: Used for project-based testing to override cwd in payload +# When set, this should be used as the cwd in all execution payloads +PROJECT_ROOT_PATH = None # type: Optional[str] class TestOutcomeEnum(str, enum.Enum): @@ -191,8 +194,22 @@ def run_tests( verbosity: int, failfast: Optional[bool], # noqa: FBT001 locals_: Optional[bool] = None, # noqa: FBT001 + cwd_override: Optional[str] = None, ) -> ExecutionPayloadDict: - cwd = os.path.abspath(start_dir) # noqa: PTH100 + """Run unittests and return the execution payload. + + Args: + start_dir: Directory where test discovery starts + test_ids: List of test IDs to run + pattern: Pattern to match test files + top_level_dir: Top-level directory for test tree hierarchy + verbosity: Verbosity level for test output + failfast: Stop on first failure + locals_: Show local variables in tracebacks + cwd_override: Optional override for the cwd in the response payload + (used for project-based testing to set project root) + """ + cwd = os.path.abspath(cwd_override or start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -259,7 +276,8 @@ def run_tests( def send_run_data(raw_data, test_run_pipe): status = raw_data["outcome"] - cwd = os.path.abspath(START_DIR) # noqa: PTH100 + # Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use START_DIR + cwd = os.path.abspath(PROJECT_ROOT_PATH or START_DIR) # noqa: PTH100 test_id = raw_data["subtest"] or raw_data["test"] test_dict = {} test_dict[test_id] = raw_data @@ -348,7 +366,19 @@ def send_run_data(raw_data, test_run_pipe): args = argv[index + 1 :] or [] django_execution_runner(manage_py_path, test_ids, args) else: + # Check for PROJECT_ROOT_PATH environment variable (project-based testing). + # When set, this overrides the cwd in the payload to match the project root. + project_root_path = os.environ.get("PROJECT_ROOT_PATH") + if project_root_path: + # Update the module-level variable for send_run_data to use + # pylint: disable=global-statement + globals()["PROJECT_ROOT_PATH"] = project_root_path + print( + f"PROJECT_ROOT_PATH is set, using {project_root_path} as cwd for execution payload" + ) + # Perform regular unittest execution. + # Pass project_root_path as cwd_override so the payload's cwd matches the project root. payload = run_tests( start_dir, test_ids, @@ -357,6 +387,7 @@ def send_run_data(raw_data, test_run_pipe): verbosity, failfast, locals_, + cwd_override=project_root_path, ) if is_coverage_run: diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 6af18c8422c9..017c41cf3d97 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -183,6 +183,7 @@ export interface ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise; } diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index 3b2f9f7de33a..6c950ec7e01b 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -21,6 +21,7 @@ import * as utils from '../common/utils'; import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal'; +import { ProjectAdapter } from '../common/projectAdapter'; export class PytestTestExecutionAdapter implements ITestExecutionAdapter { constructor( @@ -37,6 +38,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -71,6 +73,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory, debugLauncher, interpreter, + project, ); } finally { await deferredTillServerClose.promise; @@ -87,6 +90,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { const relativePathToPytest = 'python_files'; const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); @@ -102,6 +106,13 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); mutableEnv.PYTHONPATH = pythonPathCommand; mutableEnv.TEST_RUN_PIPE = resultNamedPipeName; + + // 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; + traceInfo(`[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for pytest execution`); + } + if (profileKind && profileKind === TestRunProfileKind.Coverage) { mutableEnv.COVERAGE_ENABLED = 'True'; } diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts index cbc1d2985f84..85f09cd0b1f5 100644 --- a/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -27,6 +27,7 @@ import { ITestDebugLauncher, LaunchOptions } from '../../common/types'; import { UNITTEST_PROVIDER } from '../../common/constants'; import * as utils from '../common/utils'; import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal'; +import { ProjectAdapter } from '../common/projectAdapter'; /** * Wrapper Class for unittest test execution. This is where we call `runTestCommand`? @@ -46,6 +47,8 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance: TestRun, executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, + _interpreter?: unknown, // Not used - kept for interface compatibility + project?: ProjectAdapter, ): Promise { // deferredTillServerClose awaits named pipe server close const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -80,6 +83,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { profileKind, executionFactory, debugLauncher, + project, ); } catch (error) { traceError(`Error in running unittest tests: ${error}`); @@ -97,6 +101,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { profileKind: boolean | TestRunProfileKind | undefined, executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, + project?: ProjectAdapter, ): Promise { const settings = this.configSettings.getSettings(uri); const { unittestArgs } = settings.testing; @@ -111,6 +116,15 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { const pythonPathCommand = [cwd, ...pythonPathParts].join(path.delimiter); mutableEnv.PYTHONPATH = pythonPathCommand; mutableEnv.TEST_RUN_PIPE = resultNamedPipeName; + + // 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; + traceInfo( + `[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for unittest execution`, + ); + } + if (profileKind && profileKind === TestRunProfileKind.Coverage) { mutableEnv.COVERAGE_ENABLED = cwd; } diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index 75b9489f708e..f17687732f57 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -15,6 +15,7 @@ import { IPythonExecutionFactory } from '../../common/process/types'; import { ITestDebugLauncher } from '../common/types'; import { buildErrorNodeOptions } from './common/utils'; import { PythonEnvironment } from '../../pythonEnvironments/info'; +import { ProjectAdapter } from './common/projectAdapter'; /** * This class exposes a test-provider-agnostic way of discovering tests. @@ -47,6 +48,7 @@ export class WorkspaceTestAdapter { profileKind?: boolean | TestRunProfileKind, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { if (this.executing) { traceError('Test execution already in progress, not starting a new one.'); @@ -84,6 +86,7 @@ export class WorkspaceTestAdapter { executionFactory, debugLauncher, interpreter, + project, ); deferred.resolve(); } catch (ex) { diff --git a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts index ab492736f0ad..df104b01b548 100644 --- a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -22,6 +22,7 @@ import { MockChildProcess } from '../../../mocks/mockChildProcess'; import { traceInfo } from '../../../../client/logging'; import { UnittestTestExecutionAdapter } from '../../../../client/testing/testController/unittest/testExecutionAdapter'; import * as extapi from '../../../../client/envExt/api.internal'; +import { ProjectAdapter } from '../../../../client/testing/testController/common/projectAdapter'; suite('Unittest test execution adapter', () => { let configService: IConfigurationService; @@ -321,4 +322,116 @@ suite('Unittest test execution adapter', () => { typeMoq.Times.once(), ); }); + + test('RunTests should set PROJECT_ROOT_PATH when project is provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = ({ + projectId: 'file:///workspace/myproject', + projectUri: Uri.file(projectPath), + projectName: 'myproject', + workspaceUri: Uri.file('/workspace'), + } as unknown) as ProjectAdapter; + + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + adapter.runTests( + uri, + [], + TestRunProfileKind.Run, + testRun.object, + execFactory.object, + undefined, // debugLauncher + undefined, // interpreter + mockProject, + ); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'python_files'); + const pathToExecutionScript = path.join(pathToPythonFiles, 'unittestadapter', 'execution.py'); + const expectedArgs = [pathToExecutionScript, '--udiscovery', '.']; + + execService.verify( + (x) => + x.execObservable( + expectedArgs, + typeMoq.It.is((options) => { + // Verify PROJECT_ROOT_PATH is set when project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + projectPath, + 'PROJECT_ROOT_PATH should be set to project URI path', + ); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); + + test('RunTests should NOT set PROJECT_ROOT_PATH when no project is provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + adapter.runTests(uri, [], TestRunProfileKind.Run, testRun.object, execFactory.object); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'python_files'); + const pathToExecutionScript = path.join(pathToPythonFiles, 'unittestadapter', 'execution.py'); + const expectedArgs = [pathToExecutionScript, '--udiscovery', '.']; + + execService.verify( + (x) => + x.execObservable( + expectedArgs, + typeMoq.It.is((options) => { + // Verify PROJECT_ROOT_PATH is NOT set when no project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + undefined, + 'PROJECT_ROOT_PATH should NOT be set when no project is provided', + ); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); }); From ce9883dec3e5bc66ab8e7c1fd4f71cb6f9b784e6 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:36:04 -0800 Subject: [PATCH 34/36] project labeling --- .../testing_feature_area.instructions.md | 4 ++++ .../testing/testController/common/resultResolver.ts | 9 +++++++++ .../testController/common/testDiscoveryHandler.ts | 2 ++ .../testController/common/testProjectRegistry.ts | 13 +++++++++---- src/client/testing/testController/common/utils.ts | 7 +++++-- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index beb75a9a7dac..8c43c81ddb20 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -201,6 +201,10 @@ This approach was chosen because: 2. Implementing custom exclusion would add significant complexity with minimal benefit 3. The existing approach is transparent and predictable - each project shows what it finds +### Empty projects and hidden root nodes + +**Important:** If a project discovers zero tests, its root node will **not appear** in the Test Explorer. This is by design - the test tree only shows projects that have actual tests. + ### Logging prefix All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel. diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index 2e843a571095..c126d233de1b 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -33,15 +33,23 @@ export class PythonResultResolver implements ITestResultResolver { */ private projectId?: string; + /** + * Optional project display name for labeling the test tree root. + * When set, the root node label will be "project: {projectName}" instead of the folder name. + */ + private projectName?: string; + constructor( testController: TestController, testProvider: TestProvider, private workspaceUri: Uri, projectId?: string, + projectName?: string, ) { this.testController = testController; this.testProvider = testProvider; this.projectId = projectId; + this.projectName = projectName; // Initialize a new TestItemIndex which will be used to track test items in this workspace/project this.testItemIndex = new TestItemIndex(); } @@ -76,6 +84,7 @@ export class PythonResultResolver implements ITestResultResolver { this.testProvider, token, this.projectId, + this.projectName, ); 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 212818bc070f..97f65123911a 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -29,6 +29,7 @@ export class TestDiscoveryHandler { testProvider: TestProvider, token?: CancellationToken, projectId?: string, + projectName?: string, ): void { if (!payload) { // No test data is available @@ -70,6 +71,7 @@ export class TestDiscoveryHandler { }, token, projectId, + projectName, ); } } diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index 458ac93ffd08..6236bf822c32 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -192,7 +192,14 @@ export class TestProjectRegistry { // Create test infrastructure const testProvider = this.getTestProvider(workspaceUri); - const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); + const projectDisplayName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + const resultResolver = new PythonResultResolver( + this.testController, + testProvider, + workspaceUri, + projectId, + pythonProject.name, // Use simple project name for test tree label (without version) + ); const { discoveryAdapter, executionAdapter } = createTestAdapters( testProvider, resultResolver, @@ -200,10 +207,8 @@ export class TestProjectRegistry { this.envVarsService, ); - const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); - return { - projectName, + projectName: projectDisplayName, projectUri: pythonProject.uri, workspaceUri, pythonProject, diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index dd7e396ecf24..ec26aa6b0c70 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -213,12 +213,15 @@ export function populateTestTree( testItemMappings: ITestItemMappings, token?: CancellationToken, projectId?: string, + projectName?: 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) { // Create project-scoped ID if projectId is provided const rootId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${testTreeData.path}` : testTreeData.path; - testRoot = testController.createTestItem(rootId, testTreeData.name, Uri.file(testTreeData.path)); + // Use "Project: {name}" label for project-based testing, otherwise use folder name + const rootLabel = projectName ? `Project: ${projectName}` : testTreeData.name; + testRoot = testController.createTestItem(rootId, rootLabel, Uri.file(testTreeData.path)); testRoot.canResolveChildren = true; testRoot.tags = [RunTestTag, DebugTestTag]; @@ -282,7 +285,7 @@ export function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, testItemMappings, token, projectId); + populateTestTree(testController, child, node, testItemMappings, token, projectId, projectName); } } }); From bb0e83d8bf218f4f0aaa074de139701156eeb45c Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:03:02 -0800 Subject: [PATCH 35/36] actually move to showing empty node and comment fixes --- .../testing_feature_area.instructions.md | 14 +++---- .../tests/unittestadapter/test_discovery.py | 37 +++++++++---------- .../tests/unittestadapter/test_execution.py | 36 +++++++++--------- python_files/unittestadapter/discovery.py | 23 ++++++------ python_files/unittestadapter/execution.py | 12 +++--- python_files/unittestadapter/pvsc_utils.py | 4 -- 6 files changed, 61 insertions(+), 65 deletions(-) diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index 8c43c81ddb20..a4e11523d7c8 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -182,7 +182,7 @@ Project-based testing enables multi-project workspace support where each Python 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**: - For pytest: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`. - - For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `cwd_override` to root the test tree at the project directory. + - For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `project_root_path` to root the test tree at the project directory. 6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`). ### Nested project handling: pytest vs unittest @@ -201,9 +201,9 @@ This approach was chosen because: 2. Implementing custom exclusion would add significant complexity with minimal benefit 3. The existing approach is transparent and predictable - each project shows what it finds -### Empty projects and hidden root nodes +### Empty projects and root nodes -**Important:** If a project discovers zero tests, its root node will **not appear** in the Test Explorer. This is by design - the test tree only shows projects that have actual tests. +If a project discovers zero tests, its root node will still appear in the Test Explorer as an empty folder. This ensures consistent behavior and makes it clear which projects were discovered, even if they have no tests yet. ### Logging prefix @@ -213,8 +213,8 @@ All project-based testing logs use the `[test-by-project]` prefix for easy filte - Python side: - `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest. - - `python_files/unittestadapter/discovery.py` — `discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery. - - `python_files/unittestadapter/execution.py` — `run_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest execution. + - `python_files/unittestadapter/discovery.py` — `discover_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery. + - `python_files/unittestadapter/execution.py` — `run_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest execution. - TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution adapters. ### Tests @@ -222,8 +222,8 @@ All project-based testing logs use the `[test-by-project]` prefix for easy filte - `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_discovery.py` — pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`) -- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH discovery tests -- `python_files/tests/unittestadapter/test_execution.py` — unittest `cwd_override` / PROJECT_ROOT_PATH execution tests +- `python_files/tests/unittestadapter/test_discovery.py` — unittest `project_root_path` / PROJECT_ROOT_PATH discovery tests +- `python_files/tests/unittestadapter/test_execution.py` — unittest `project_root_path` / PROJECT_ROOT_PATH execution tests - `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` — unittest discovery adapter PROJECT_ROOT_PATH tests - `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` — unittest execution adapter PROJECT_ROOT_PATH tests diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index 1dbc96edcb27..1089da9b1f4e 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -191,8 +191,7 @@ def test_empty_discovery() -> None: actual = discover_tests(start_dir, pattern, None) assert actual["status"] == "success" - # When no tests are found, the tests key should not be present in the payload - assert "tests" not in actual + assert "tests" in actual assert "error" not in actual @@ -329,13 +328,13 @@ def test_simple_django_collect(): def test_project_root_path_with_cwd_override() -> None: - """Test unittest discovery with cwd_override parameter. + """Test unittest discovery with project_root_path parameter. This simulates project-based testing where the cwd in the payload should be - the project root (cwd_override) rather than the start_dir. + the project root (project_root_path) rather than the start_dir. - When cwd_override is provided: - - The cwd in the response should match cwd_override + When project_root_path is provided: + - The cwd in the response should match project_root_path - The test tree root should still be built correctly based on top_level_dir """ # Use unittest_skip folder as our "project" directory @@ -343,11 +342,11 @@ def test_project_root_path_with_cwd_override() -> None: start_dir = os.fsdecode(project_path) pattern = "unittest_*" - # Call discover_tests with cwd_override to simulate PROJECT_ROOT_PATH - actual = discover_tests(start_dir, pattern, None, cwd_override=start_dir) + # Call discover_tests with project_root_path to simulate PROJECT_ROOT_PATH + actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir) assert actual["status"] == "success" - # cwd in response should match the cwd_override (project root) + # cwd in response should match the project_root_path (project root) assert actual["cwd"] == os.fsdecode(project_path), ( f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" ) @@ -362,11 +361,11 @@ def test_project_root_path_with_cwd_override() -> None: def test_project_root_path_with_different_cwd_and_start_dir() -> None: - """Test unittest discovery where cwd_override differs from start_dir. + """Test unittest discovery where project_root_path differs from start_dir. This simulates the scenario where: - start_dir points to a subfolder where tests are located - - cwd_override (PROJECT_ROOT_PATH) points to the project root + - project_root_path (PROJECT_ROOT_PATH) points to the project root The cwd in the response should be the project root, while discovery still runs from the start_dir. @@ -384,11 +383,11 @@ def test_project_root_path_with_different_cwd_and_start_dir() -> None: pattern = "test_*.py" top_level_dir = os.fsdecode(project_path) - # Call discover_tests with cwd_override set to project root - actual = discover_tests(start_dir, pattern, top_level_dir, cwd_override=top_level_dir) + # Call discover_tests with project_root_path set to project root + actual = discover_tests(start_dir, pattern, top_level_dir, project_root_path=top_level_dir) assert actual["status"] == "success" - # cwd should be the project root (cwd_override), not the start_dir + # cwd should be the project root (project_root_path), not the start_dir assert actual["cwd"] == os.fsdecode(project_path), ( f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" ) @@ -410,29 +409,29 @@ def test_symlink_with_project_root_path() -> None: This tests the combination of: 1. A symlinked test directory - 2. cwd_override (PROJECT_ROOT_PATH) set to the symlink path + 2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path This simulates project-based testing where the project root is a symlink, ensuring test IDs and paths are correctly resolved through the symlink. """ with helpers.create_symlink(TEST_DATA_PATH, "unittest_skip", "symlink_unittest") as ( - source, + _source, destination, ): assert destination.is_symlink() # Run discovery with: # - start_dir pointing to the symlink destination - # - cwd_override set to the symlink destination (simulating PROJECT_ROOT_PATH) + # - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH) start_dir = os.fsdecode(destination) pattern = "unittest_*" - actual = discover_tests(start_dir, pattern, None, cwd_override=start_dir) + actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir) assert actual["status"] == "success", ( f"Status is not 'success', error is: {actual.get('error')}" ) - # cwd should be the symlink path (cwd_override) + # cwd should be the symlink path (project_root_path) assert actual["cwd"] == os.fsdecode(destination), ( f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" ) diff --git a/python_files/tests/unittestadapter/test_execution.py b/python_files/tests/unittestadapter/test_execution.py index 4d636920fc6c..cab03f0b5dc4 100644 --- a/python_files/tests/unittestadapter/test_execution.py +++ b/python_files/tests/unittestadapter/test_execution.py @@ -344,13 +344,13 @@ def test_basic_run_django(): def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noqa: ARG001 - """Test unittest execution with cwd_override parameter. + """Test unittest execution with project_root_path parameter. This simulates project-based testing where the cwd in the payload should be - the project root (cwd_override) rather than the start_dir. + the project root (project_root_path) rather than the start_dir. - When cwd_override is provided: - - The cwd in the response should match cwd_override + When project_root_path is provided: + - The cwd in the response should match project_root_path - Test execution should still work correctly with start_dir """ # Use unittest_folder as our "project" directory @@ -363,7 +363,7 @@ def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noq os.environ["TEST_RUN_PIPE"] = "fake" - # Call run_tests with cwd_override to simulate PROJECT_ROOT_PATH + # Call run_tests with project_root_path to simulate PROJECT_ROOT_PATH actual = run_tests( start_dir, test_ids, @@ -371,11 +371,11 @@ def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noq None, 1, None, - cwd_override=start_dir, + project_root_path=start_dir, ) assert actual["status"] == "success" - # cwd in response should match the cwd_override (project root) + # cwd in response should match the project_root_path (project root) assert actual["cwd"] == os.fsdecode(project_path), ( f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" ) @@ -384,12 +384,12 @@ def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noq assert actual["result"][test_ids[0]]["outcome"] == "success" -def test_project_root_path_with_different_cwd_and_start_dir() -> None: - """Test unittest execution where cwd_override differs from start_dir. +def test_project_root_path_with_different_cwd_and_start_dir(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution where project_root_path differs from start_dir. This simulates the scenario where: - start_dir points to a subfolder where tests are located - - cwd_override (PROJECT_ROOT_PATH) points to the project root + - project_root_path (PROJECT_ROOT_PATH) points to the project root The cwd in the response should be the project root, while execution still runs from the start_dir. @@ -404,7 +404,7 @@ def test_project_root_path_with_different_cwd_and_start_dir() -> None: os.environ["TEST_RUN_PIPE"] = "fake" - # Call run_tests with cwd_override set to project root + # Call run_tests with project_root_path set to project root actual = run_tests( start_dir, test_ids, @@ -412,11 +412,11 @@ def test_project_root_path_with_different_cwd_and_start_dir() -> None: None, 1, None, - cwd_override=os.fsdecode(project_path), + project_root_path=os.fsdecode(project_path), ) assert actual["status"] == "success" - # cwd should be the project root (cwd_override) + # cwd should be the project root (project_root_path) assert actual["cwd"] == os.fsdecode(project_path), ( f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" ) @@ -429,11 +429,11 @@ def test_project_root_path_with_different_cwd_and_start_dir() -> None: reason="Symlinks require elevated privileges on Windows", ) def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: ARG001 - """Test unittest execution with both symlink and cwd_override set. + """Test unittest execution with both symlink and project_root_path set. This tests the combination of: 1. A symlinked test directory - 2. cwd_override (PROJECT_ROOT_PATH) set to the symlink path + 2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path This simulates project-based testing where the project root is a symlink, ensuring execution payloads correctly use the symlink path. @@ -446,7 +446,7 @@ def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: AR # Run execution with: # - start_dir pointing to the symlink destination - # - cwd_override set to the symlink destination (simulating PROJECT_ROOT_PATH) + # - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH) start_dir = os.fsdecode(destination) pattern = "test_add*" test_ids = [ @@ -462,13 +462,13 @@ def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: AR None, 1, None, - cwd_override=start_dir, + project_root_path=start_dir, ) assert actual["status"] == "success", ( f"Status is not 'success', error is: {actual.get('error')}" ) - # cwd should be the symlink path (cwd_override) + # cwd should be the symlink path (project_root_path) assert actual["cwd"] == os.fsdecode(destination), ( f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" ) diff --git a/python_files/unittestadapter/discovery.py b/python_files/unittestadapter/discovery.py index 69747f54c042..b3086d92b102 100644 --- a/python_files/unittestadapter/discovery.py +++ b/python_files/unittestadapter/discovery.py @@ -27,13 +27,13 @@ def discover_tests( start_dir: str, pattern: str, top_level_dir: Optional[str], - cwd_override: Optional[str] = None, + project_root_path: Optional[str] = None, ) -> DiscoveryPayloadDict: """Returns a dictionary containing details of the discovered tests. The returned dict has the following keys: - - cwd: Absolute path to the test start directory (or cwd_override if provided); + - cwd: Absolute path to the test start directory (or project_root_path if provided); - status: Test discovery status, can be "success" or "error"; - tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests; - error: Discovery error if any, not present otherwise. @@ -62,10 +62,10 @@ def discover_tests( start_dir: Directory where test discovery starts pattern: Pattern to match test files (e.g., "test*.py") top_level_dir: Top-level directory for the test tree hierarchy - cwd_override: Optional override for the cwd in the response payload - (used for project-based testing to set project root) + project_root_path: Optional project root path for the cwd in the response payload + (used for project-based testing to root test tree at project) """ - cwd = os.path.abspath(cwd_override if cwd_override else start_dir) # noqa: PTH100 + cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -91,10 +91,9 @@ def discover_tests( except Exception: error.append(traceback.format_exc()) - # Only include tests in the payload if tests were discovered. - # If no tests found (tests is None), omit the tests key per the docstring contract. - if tests is not None: - payload["tests"] = tests + # Still include the tests in the payload even if there are errors so that the TS + # side can determine if it is from run or discovery. + payload["tests"] = tests if tests is not None else None if len(error): payload["status"] = "error" @@ -152,7 +151,9 @@ def discover_tests( ) # Perform regular unittest test discovery. - # Pass project_root_path as cwd_override so the payload's cwd matches the project root. - payload = discover_tests(start_dir, pattern, top_level_dir, cwd_override=project_root_path) + # Pass project_root_path so the payload's cwd matches the project root. + payload = discover_tests( + start_dir, pattern, top_level_dir, project_root_path=project_root_path + ) # Post this discovery payload. send_post_request(payload, test_run_pipe) diff --git a/python_files/unittestadapter/execution.py b/python_files/unittestadapter/execution.py index b5f59fd79849..e031138b6f75 100644 --- a/python_files/unittestadapter/execution.py +++ b/python_files/unittestadapter/execution.py @@ -194,7 +194,7 @@ def run_tests( verbosity: int, failfast: Optional[bool], # noqa: FBT001 locals_: Optional[bool] = None, # noqa: FBT001 - cwd_override: Optional[str] = None, + project_root_path: Optional[str] = None, ) -> ExecutionPayloadDict: """Run unittests and return the execution payload. @@ -206,10 +206,10 @@ def run_tests( verbosity: Verbosity level for test output failfast: Stop on first failure locals_: Show local variables in tracebacks - cwd_override: Optional override for the cwd in the response payload - (used for project-based testing to set project root) + project_root_path: Optional project root path for the cwd in the response payload + (used for project-based testing to root test tree at project) """ - cwd = os.path.abspath(cwd_override or start_dir) # noqa: PTH100 + cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -378,7 +378,7 @@ def send_run_data(raw_data, test_run_pipe): ) # Perform regular unittest execution. - # Pass project_root_path as cwd_override so the payload's cwd matches the project root. + # Pass project_root_path so the payload's cwd matches the project root. payload = run_tests( start_dir, test_ids, @@ -387,7 +387,7 @@ def send_run_data(raw_data, test_run_pipe): verbosity, failfast, locals_, - cwd_override=project_root_path, + project_root_path=project_root_path, ) if is_coverage_run: diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index b8c7f7028222..d6920592a4d4 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -281,10 +281,6 @@ def build_test_tree( } # concatenate class name and function test name current_node["children"].append(test_node) - # If no tests were discovered, return None instead of an empty root node - if not root["children"]: - return None, error - return root, error From 0cd96d490dd066f4823545ea18b5a9f4354507dc Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:10:34 -0800 Subject: [PATCH 36/36] fix --- python_files/tests/unittestadapter/test_discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index 1089da9b1f4e..ab028ef176c3 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -436,6 +436,7 @@ def test_symlink_with_project_root_path() -> None: f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" ) assert "tests" in actual + assert actual["tests"] is not None # The test tree root should be named after the symlink directory assert actual["tests"]["name"] == "symlink_unittest", ( f"Expected root name 'symlink_unittest', got '{actual['tests']['name']}'"