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/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); } } }); diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..57b4e47c55b7 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; @@ -162,58 +182,335 @@ 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 { + 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) => { - const settings = this.configSettings.getSettings(workspace.uri); + this.activateLegacyWorkspace(workspace); + }); + } - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; + /** + * 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); - 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, + 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); + + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } + } + + /** + * 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)]; } - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, + // 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, + ); + } - this.testAdapters.set(workspace.uri, workspaceTestAdapter); + // 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, + }; - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); - } - }); + 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 { 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) => {