forked from DonJayamanne/pythonVSCode
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathprojectTestExecution.ts
More file actions
300 lines (258 loc) · 11.3 KB
/
projectTestExecution.ts
File metadata and controls
300 lines (258 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { CancellationToken, FileCoverageDetail, TestItem, TestRun, TestRunProfileKind, TestRunRequest } from 'vscode';
import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging';
import { sendTelemetryEvent } from '../../../telemetry';
import { EventName } from '../../../telemetry/constants';
import { IPythonExecutionFactory } from '../../../common/process/types';
import { ITestDebugLauncher } from '../../common/types';
import { ProjectAdapter } from './projectAdapter';
import { TestProjectRegistry } from './testProjectRegistry';
import { getProjectId } from './projectUtils';
import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal';
import { isParentPath } from '../../../pythonEnvironments/common/externalDependencies';
import { expandExcludeSet, getTestCaseNodes } from './testItemUtilities';
/** Dependencies for project-based test execution. */
export interface ProjectExecutionDependencies {
projectRegistry: TestProjectRegistry;
pythonExecFactory: IPythonExecutionFactory;
debugLauncher: ITestDebugLauncher;
}
/** Executes tests for multiple projects, grouping by project and using each project's Python environment. */
export async function executeTestsForProjects(
projects: ProjectAdapter[],
testItems: TestItem[],
runInstance: TestRun,
request: TestRunRequest,
token: CancellationToken,
deps: ProjectExecutionDependencies,
): Promise<void> {
if (projects.length === 0) {
traceError(`[test-by-project] No projects provided for execution`);
return;
}
// Early exit if already cancelled
if (token.isCancellationRequested) {
traceInfo(`[test-by-project] Execution cancelled before starting`);
return;
}
// Group test items by project
const testsByProject = await groupTestItemsByProject(testItems, projects);
const isDebugMode = request.profile?.kind === TestRunProfileKind.Debug;
traceInfo(`[test-by-project] Executing tests across ${testsByProject.size} project(s), debug=${isDebugMode}`);
// Expand exclude set once for all projects
const rawExcludeSet = request.exclude?.length ? new Set(request.exclude) : undefined;
const excludeSet = expandExcludeSet(rawExcludeSet);
// Setup coverage once for all projects (single callback that routes by file path)
if (request.profile?.kind === TestRunProfileKind.Coverage) {
setupCoverageForProjects(request, projects);
}
// Execute tests for each project in parallel
// For debug mode, multiple debug sessions will be launched in parallel
// Each execution respects cancellation via runInstance.token
const executions = Array.from(testsByProject.entries()).map(async ([_projectId, { project, items }]) => {
// Check for cancellation before starting each project
if (token.isCancellationRequested) {
traceInfo(`[test-by-project] Skipping ${project.projectName} - cancellation requested`);
return;
}
if (items.length === 0) return;
traceInfo(`[test-by-project] Executing ${items.length} test item(s) for project: ${project.projectName}`);
sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, {
tool: project.testProvider,
debugging: isDebugMode,
});
try {
await executeTestsForProject(project, items, runInstance, request, deps, excludeSet);
} catch (error) {
// Don't log cancellation as an error
if (!token.isCancellationRequested) {
traceError(`[test-by-project] Execution failed for project ${project.projectName}:`, error);
}
}
});
await Promise.all(executions);
if (token.isCancellationRequested) {
traceInfo(`[test-by-project] Project executions cancelled`);
} else {
traceInfo(`[test-by-project] All project executions completed`);
}
}
/** Lookup context for caching project lookups within a single test run. */
interface ProjectLookupContext {
uriToAdapter: Map<string, ProjectAdapter | undefined>;
projectPathToAdapter: Map<string, ProjectAdapter>;
}
/** Groups test items by owning project using env API or path-based matching as fallback. */
export async function groupTestItemsByProject(
testItems: TestItem[],
projects: ProjectAdapter[],
): Promise<Map<string, { project: ProjectAdapter; items: TestItem[] }>> {
const result = new Map<string, { project: ProjectAdapter; items: TestItem[] }>();
// Initialize entries for all projects
for (const project of projects) {
result.set(getProjectId(project.projectUri), { project, items: [] });
}
// Build lookup context for this run - O(p) one-time setup, enables O(1) lookups per item.
// When tests are from a single project, most lookups hit the cache after the first item.
const lookupContext: ProjectLookupContext = {
uriToAdapter: new Map(),
projectPathToAdapter: new Map(projects.map((p) => [p.projectUri.fsPath, p])),
};
// Assign each test item to its project
for (const item of testItems) {
const project = await findProjectForTestItem(item, projects, lookupContext);
if (project) {
const entry = result.get(getProjectId(project.projectUri));
if (entry) {
entry.items.push(item);
}
} else {
// If no project matches, log it
traceWarn(`[test-by-project] Could not match test item ${item.id} to a project`);
}
}
// Remove projects with no test items
for (const [projectId, entry] of result.entries()) {
if (entry.items.length === 0) {
result.delete(projectId);
}
}
return result;
}
/** Finds the project that owns a test item. */
export async function findProjectForTestItem(
item: TestItem,
projects: ProjectAdapter[],
lookupContext?: ProjectLookupContext,
): Promise<ProjectAdapter | undefined> {
if (!item.uri) return undefined;
const uriPath = item.uri.fsPath;
// Check lookup context first - O(1)
if (lookupContext?.uriToAdapter.has(uriPath)) {
return lookupContext.uriToAdapter.get(uriPath);
}
let result: ProjectAdapter | undefined;
// Try using the Python Environment extension API first.
// Legacy path: when useEnvExtension() is false, this block is skipped and we go
// directly to findProjectByPath() below (path-based matching).
if (useEnvExtension()) {
try {
const envExtApi = await getEnvExtApi();
const pythonProject = envExtApi.getPythonProject(item.uri);
if (pythonProject) {
// Use lookup context for O(1) adapter lookup instead of O(p) linear search
result = lookupContext?.projectPathToAdapter.get(pythonProject.uri.fsPath);
if (!result) {
// Fallback to linear search if lookup context not available
result = projects.find((p) => p.projectUri.fsPath === pythonProject.uri.fsPath);
}
}
} catch (error) {
traceVerbose(`[test-by-project] Failed to use env extension API, falling back to path matching: ${error}`);
}
}
// Fallback: path-based matching when env API unavailable or didn't find a match.
// O(p) time complexity where p = number of projects.
if (!result) {
result = findProjectByPath(item, projects);
}
// Store result for future lookups of same file within this run - O(1)
if (lookupContext) {
lookupContext.uriToAdapter.set(uriPath, result);
}
return result;
}
/** Fallback: finds project using path-based matching. */
function findProjectByPath(item: TestItem, projects: ProjectAdapter[]): ProjectAdapter | undefined {
if (!item.uri) return undefined;
const itemPath = item.uri.fsPath;
let bestMatch: ProjectAdapter | undefined;
let bestMatchLength = 0;
for (const project of projects) {
const projectPath = project.projectUri.fsPath;
// Use isParentPath for safe path-boundary matching (handles separators and case normalization)
if (isParentPath(itemPath, projectPath) && projectPath.length > bestMatchLength) {
bestMatch = project;
bestMatchLength = projectPath.length;
}
}
return bestMatch;
}
/** Executes tests for a single project using the project's Python environment. */
export async function executeTestsForProject(
project: ProjectAdapter,
testItems: TestItem[],
runInstance: TestRun,
request: TestRunRequest,
deps: ProjectExecutionDependencies,
excludeSet?: Set<TestItem>,
): Promise<void> {
const testCaseNodes: TestItem[] = [];
const visitedNodes = new Set<TestItem>();
// Expand included items to leaf test nodes, respecting exclusions.
// getTestCaseNodes handles visited tracking and exclusion filtering.
for (const item of testItems) {
getTestCaseNodes(item, testCaseNodes, visitedNodes, excludeSet);
}
// Mark items as started and collect test IDs
const testCaseIds: string[] = [];
for (const node of testCaseNodes) {
runInstance.started(node);
const runId = project.resultResolver.vsIdToRunId.get(node.id);
if (runId) {
testCaseIds.push(runId);
}
}
if (testCaseIds.length === 0) {
traceVerbose(`[test-by-project] No test IDs found for project ${project.projectName}`);
return;
}
traceInfo(`[test-by-project] Running ${testCaseIds.length} test(s) for project: ${project.projectName}`);
// Execute tests using the project's execution adapter
await project.executionAdapter.runTests(
project.projectUri,
testCaseIds,
request.profile?.kind,
runInstance,
deps.pythonExecFactory,
deps.debugLauncher,
undefined, // interpreter not needed, project has its own environment
project,
);
}
/** Recursively gets all leaf test case nodes from a test item tree. */
export function getTestCaseNodesRecursive(item: TestItem): TestItem[] {
const results: TestItem[] = [];
if (item.children.size === 0) {
// This is a leaf node (test case)
results.push(item);
} else {
// Recursively get children
item.children.forEach((child) => {
results.push(...getTestCaseNodesRecursive(child));
});
}
return results;
}
/** Sets up detailed coverage loading that routes to the correct project by file path. */
export function setupCoverageForProjects(request: TestRunRequest, projects: ProjectAdapter[]): void {
if (request.profile?.kind === TestRunProfileKind.Coverage) {
// Create a single callback that routes to the correct project's coverage map by file path
request.profile.loadDetailedCoverage = (
_testRun: TestRun,
fileCoverage,
_token,
): Thenable<FileCoverageDetail[]> => {
const filePath = fileCoverage.uri.fsPath;
// Find the project that has coverage data for this file
for (const project of projects) {
const details = project.resultResolver.detailedCoverageMap.get(filePath);
if (details) {
return Promise.resolve(details);
}
}
return Promise.resolve([]);
};
}
}