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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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);