Skip to content

Commit ad3ae47

Browse files
committed
Checkpoint from VS Code for cloud agent session
1 parent e2681d5 commit ad3ae47

File tree

6 files changed

+2587
-1
lines changed

6 files changed

+2587
-1
lines changed
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)