|
| 1 | +# Overlapping Projects and Test Ownership Resolution |
| 2 | + |
| 3 | +## Problem Statement |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## Scenario Example |
| 8 | + |
| 9 | +### Project Structure |
| 10 | + |
| 11 | +``` |
| 12 | +root/alice/ ← ProjectA root |
| 13 | +├── .venv/ ← ProjectA's Python environment |
| 14 | +│ └── bin/python |
| 15 | +├── alice_test.py |
| 16 | +│ ├── test: t1 |
| 17 | +│ └── test: t2 |
| 18 | +└── bob/ ← ProjectB root (nested) |
| 19 | + ├── .venv/ ← ProjectB's Python environment |
| 20 | + │ └── bin/python |
| 21 | + └── bob_test.py |
| 22 | + └── test: t1 |
| 23 | +``` |
| 24 | + |
| 25 | +### Project Definitions |
| 26 | + |
| 27 | +| Project | URI | Python Executable | |
| 28 | +|-----------|-------------------|--------------------------------------| |
| 29 | +| ProjectA | `root/alice` | `root/alice/.venv/bin/python` | |
| 30 | +| ProjectB | `root/alice/bob` | `root/alice/bob/.venv/bin/python` | |
| 31 | + |
| 32 | +### Discovery Results |
| 33 | + |
| 34 | +#### ProjectA Discovery (on `root/alice/`) |
| 35 | + |
| 36 | +Discovers 3 tests: |
| 37 | +1. ✓ `root/alice/alice_test.py::t1` |
| 38 | +2. ✓ `root/alice/alice_test.py::t2` |
| 39 | +3. ✓ `root/alice/bob/bob_test.py::t1` ← **Found in subdirectory** |
| 40 | + |
| 41 | +#### ProjectB Discovery (on `root/alice/bob/`) |
| 42 | + |
| 43 | +Discovers 1 test: |
| 44 | +1. ✓ `root/alice/bob/bob_test.py::t1` ← **Same test as ProjectA found!** |
| 45 | + |
| 46 | +### Conflict |
| 47 | + |
| 48 | +**Both ProjectA and ProjectB discovered:** `root/alice/bob/bob_test.py::t1` |
| 49 | + |
| 50 | +Which project should own this test in the Test Explorer? |
| 51 | + |
| 52 | +## Resolution Strategy |
| 53 | + |
| 54 | +### Using PythonProject API as Source of Truth |
| 55 | + |
| 56 | +The `vscode-python-environments` extension provides: |
| 57 | +```typescript |
| 58 | +interface PythonProject { |
| 59 | + readonly name: string; |
| 60 | + readonly uri: Uri; |
| 61 | +} |
| 62 | + |
| 63 | +// Query which project owns a specific URI |
| 64 | +getPythonProject(uri: Uri): Promise<PythonProject | undefined> |
| 65 | +``` |
| 66 | + |
| 67 | +### Resolution Process |
| 68 | + |
| 69 | +For the conflicting test `root/alice/bob/bob_test.py::t1`: |
| 70 | + |
| 71 | +```typescript |
| 72 | +// Query: Which project owns this file? |
| 73 | +const project = await getPythonProject(Uri.file("root/alice/bob/bob_test.py")); |
| 74 | + |
| 75 | +// Result: ProjectB (the most specific/nested project) |
| 76 | +// project.uri = "root/alice/bob" |
| 77 | +``` |
| 78 | + |
| 79 | +### Final Test Ownership |
| 80 | + |
| 81 | +| Test | Discovered By | Owned By | Reason | |
| 82 | +|-----------------------------------|-------------------|------------|-------------------------------------------| |
| 83 | +| `root/alice/alice_test.py::t1` | ProjectA | ProjectA | Only discovered by ProjectA | |
| 84 | +| `root/alice/alice_test.py::t2` | ProjectA | ProjectA | Only discovered by ProjectA | |
| 85 | +| `root/alice/bob/bob_test.py::t1` | ProjectA, ProjectB | **ProjectB** | API returns ProjectB for this URI | |
| 86 | + |
| 87 | +## Implementation Rules |
| 88 | + |
| 89 | +### 1. Discovery Runs Independently |
| 90 | +Each project runs discovery using its own Python executable and configuration, discovering all tests it can find (including subdirectories). |
| 91 | + |
| 92 | +### 2. Detect Overlaps and Query API Only When Needed |
| 93 | +After all projects complete discovery, detect which test files were found by multiple projects: |
| 94 | +```typescript |
| 95 | +// Build map of test file -> projects that discovered it |
| 96 | +const testFileToProjects = new Map<string, Set<string>>(); |
| 97 | +for (const project of allProjects) { |
| 98 | + for (const testFile of project.discoveredTestFiles) { |
| 99 | + if (!testFileToProjects.has(testFile.path)) { |
| 100 | + testFileToProjects.set(testFile.path, new Set()); |
| 101 | + } |
| 102 | + testFileToProjects.get(testFile.path).add(project.id); |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +// Query API only for overlapping tests or tests within nested projects |
| 107 | +for (const [filePath, projectIds] of testFileToProjects) { |
| 108 | + if (projectIds.size > 1) { |
| 109 | + // Multiple projects found it - use API to resolve |
| 110 | + const owner = await getPythonProject(Uri.file(filePath)); |
| 111 | + assignToProject(owner.uri, filePath); |
| 112 | + } else if (hasNestedProjectForPath(filePath, allProjects)) { |
| 113 | + // Only one project found it, but nested project exists - verify with API |
| 114 | + const owner = await getPythonProject(Uri.file(filePath)); |
| 115 | + assignToProject(owner.uri, filePath); |
| 116 | + } else { |
| 117 | + // Unambiguous - assign to the only project that found it |
| 118 | + assignToProject([...projectIds][0], filePath); |
| 119 | + } |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +This optimization reduces API calls significantly since most projects don't have overlapping discovery. |
| 124 | + |
| 125 | +### 3. Filter Discovery Results |
| 126 | +ProjectA's final tests: |
| 127 | +```typescript |
| 128 | +const projectATests = discoveredTests.filter(test => |
| 129 | + getPythonProject(test.uri) === projectA |
| 130 | +); |
| 131 | +// Result: Only alice_test.py tests remain |
| 132 | +``` |
| 133 | + |
| 134 | +ProjectB's final tests: |
| 135 | +```typescript |
| 136 | +const projectBTests = discoveredTests.filter(test => |
| 137 | + getPythonProject(test.uri) === projectB |
| 138 | +); |
| 139 | +// Result: Only bob_test.py tests remain |
| 140 | +``` |
| 141 | + |
| 142 | +### 4. Add to TestController |
| 143 | +Each project only adds tests that the API says it owns: |
| 144 | +```typescript |
| 145 | +// ProjectA adds its filtered tests under ProjectA node |
| 146 | +populateTestTree(testController, projectATests, projectANode, projectAResolver); |
| 147 | + |
| 148 | +// ProjectB adds its filtered tests under ProjectB node |
| 149 | +populateTestTree(testController, projectBTests, projectBNode, projectBResolver); |
| 150 | +``` |
| 151 | + |
| 152 | +## Test Explorer UI Result |
| 153 | + |
| 154 | +``` |
| 155 | +📁 Workspace: root |
| 156 | + 📦 Project: ProjectA (root/alice) |
| 157 | + 📄 alice_test.py |
| 158 | + ✓ t1 |
| 159 | + ✓ t2 |
| 160 | + 📦 Project: ProjectB (root/alice/bob) |
| 161 | + 📄 bob_test.py |
| 162 | + ✓ t1 |
| 163 | +``` |
| 164 | + |
| 165 | +## Edge Cases |
| 166 | + |
| 167 | +### Case 1: No Project Found |
| 168 | +```typescript |
| 169 | +const project = await getPythonProject(testUri); |
| 170 | +if (!project) { |
| 171 | + // File is not part of any project |
| 172 | + // Could belong to workspace-level tests (fallback) |
| 173 | +} |
| 174 | +``` |
| 175 | + |
| 176 | +### Case 2: Project Changed After Discovery |
| 177 | +If a test file's project assignment changes (e.g., user creates new `pyproject.toml`), the next discovery cycle will re-assign ownership correctly. |
| 178 | + |
| 179 | +### Case 3: Deeply Nested Projects |
| 180 | +``` |
| 181 | +root/a/ ← ProjectA |
| 182 | + root/a/b/ ← ProjectB |
| 183 | + root/a/b/c/ ← ProjectC |
| 184 | +``` |
| 185 | + |
| 186 | +API always returns the **most specific** (deepest) project for a given URI. |
| 187 | + |
| 188 | +## Algorithm Summary |
| 189 | + |
| 190 | +```typescript |
| 191 | +async function assignTestsToProjects( |
| 192 | + allProjects: ProjectAdapter[], |
| 193 | + testController: TestController |
| 194 | +): Promise<void> { |
| 195 | + for (const project of allProjects) { |
| 196 | + // 1. Run discovery with project's Python executable |
| 197 | + const discoveredTests = await project.discoverTests(); |
| 198 | + |
| 199 | + // 2. Filter to tests actually owned by this project |
| 200 | + const ownedTests = []; |
| 201 | + for (const test of discoveredTests) { |
| 202 | + const owningProject = await getPythonProject(test.uri); |
| 203 | + // 1. Run discovery for all projects |
| 204 | + await Promise.all(allProjects.map(p => p.discoverTests())); |
| 205 | + |
| 206 | + // 2. Build overlap detection map |
| 207 | + const testFileToProjects = new Map<string, Set<ProjectAdapter>>(); |
| 208 | + for (const project of allProjects) { |
| 209 | + for (const testFile of project.discoveredTestFiles) { |
| 210 | + if (!testFileToProjects.has(testFile.path)) { |
| 211 | + testFileToProjects.set(testFile.path, new Set()); |
| 212 | + } |
| 213 | + testFileToProjects.get(testFile.path).add(project); |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + // 3. Resolve ownership (query API only when needed) |
| 218 | + const testFileToOwner = new Map<string, ProjectAdapter>(); |
| 219 | + for (const [filePath, projects] of testFileToProjects) { |
| 220 | + if (projects.size === 1) { |
| 221 | + // No overlap - assign to only discoverer |
| 222 | + const project = [...projects][0]; |
| 223 | + // Still check if nested project exists for this path |
| 224 | + if (!hasNestedProjectForPath(filePath, allProjects, project)) { |
| 225 | + testFileToOwner.set(filePath, project); |
| 226 | + continue; |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + // Overlap or nested project exists - use API as source of truth |
| 231 | + const owningProject = await getPythonProject(Uri.file(filePath)); |
| 232 | + if (owningProject) { |
| 233 | + const project = allProjects.find(p => p.projectUri.fsPath === owningProject.uri.fsPath); |
| 234 | + if (project) { |
| 235 | + testFileToOwner.set(filePath, project); |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + // 4. Add tests to their owning project's tree |
| 241 | + for (const [filePath, owningProject] of testFileToOwner) { |
| 242 | + const tests = owningProject.discoveredTestFiles.get(filePath); |
| 243 | + populateProjectTestTree(owningProject, tests); |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +function hasNestedProjectForPath( |
| 248 | + testFilePath: string, |
| 249 | + allProjects: ProjectAdapter[], |
| 250 | + excludeProject?: ProjectAdapter |
| 251 | +): boolean { |
| 252 | + return allProjects.some(p => |
| 253 | + p !== excludeProject && |
| 254 | + testFilePath.startsWith(p.projectUri.fsPath) |
| 255 | + );project-based ownership, TestItem IDs must include project context: |
| 256 | +```typescript |
| 257 | +// Instead of: "/root/alice/bob/bob_test.py::t1" |
| 258 | +// Use: "projectB::/root/alice/bob/bob_test.py::t1" |
| 259 | +testItemId = `${projectId}::${testPath}`; |
| 260 | +``` |
| 261 | + |
| 262 | +### Discovery Filtering in populateTestTree |
| 263 | + |
| 264 | +The `populateTestTree` function needs to be project-aware: |
| 265 | +```typescript |
| 266 | +export async function populateTestTree( |
| 267 | + testController: TestController, |
| 268 | + testTreeData: DiscoveredTestNode, |
| 269 | + testRoot: TestItem | undefined, |
| 270 | + resultResolver: ITestResultResolver, |
| 271 | + projectId: string, |
| 272 | + getPythonProject: (uri: Uri) => Promise<PythonProject | undefined>, |
| 273 | + token?: CancellationToken, |
| 274 | +): Promise<void> { |
| 275 | + // For each discovered test, check ownership |
| 276 | + for (const testNode of testTreeData.children) { |
| 277 | + const testFileUri = Uri.file(testNode.path); |
| 278 | + const owningProject = await getPythonProject(testFileUri); |
| 279 | + |
| 280 | + // Only add if this project owns the test |
| 281 | + if (owningProject?.uri.fsPath === projectId.split('::')[0]) { |
| 282 | + // Add test to tree |
| 283 | + addTestItemToTree(testController, testNode, testRoot, projectId); |
| 284 | + } |
| 285 | + } |
| 286 | +} |
| 287 | +``` |
| 288 | +
|
| 289 | +### ResultResolver Scoping |
| 290 | +
|
| 291 | +Each project's ResultResolver maintains mappings only for tests it owns: |
| 292 | +```typescript |
| 293 | +class PythonResultResolver { |
| 294 | + constructor( |
| 295 | + testController: TestController, |
| 296 | + testProvider: TestProvider, |
| 297 | + workspaceUri: Uri, |
| 298 | + projectId: string // Scopes all IDs to this project |
| 299 | + ) { |
| 300 | + this.projectId = projectId; |
| 301 | + } |
| 302 | + |
| 303 | + // Maps include projectId prefix |
| 304 | + runIdToTestItem: Map<string, TestItem> // "projectA::test.py::t1" -> TestItem |
| 305 | + runIdToVSid: Map<string, string> // "projectA::test.py::t1" -> vsCodeId |
| 306 | + vsIdToRunId: Map<string, string> // vsCodeId -> "projectA::test.py::t1" |
| 307 | +} |
| 308 | +``` |
| 309 | +
|
| 310 | +--- |
| 311 | +
|
| 312 | +**Key Takeaway**: Discovery finds tests broadly; the PythonProject API decides ownership narrowly. |
0 commit comments