Skip to content

Commit a8b9521

Browse files
committed
Merge fix/test-explorer-exclude-filtering: respect TestRunRequest.exclude (PR microsoft#25743)
2 parents fc70c4c + 2e818df commit a8b9521

File tree

8 files changed

+321
-36
lines changed

8 files changed

+321
-36
lines changed

src/client/testing/testController/common/projectTestExecution.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TestProjectRegistry } from './testProjectRegistry';
1212
import { getProjectId } from './projectUtils';
1313
import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal';
1414
import { isParentPath } from '../../../pythonEnvironments/common/externalDependencies';
15+
import { expandExcludeSet, getTestCaseNodes } from './testItemUtilities';
1516

1617
/** Dependencies for project-based test execution. */
1718
export interface ProjectExecutionDependencies {
@@ -46,6 +47,10 @@ export async function executeTestsForProjects(
4647
const isDebugMode = request.profile?.kind === TestRunProfileKind.Debug;
4748
traceInfo(`[test-by-project] Executing tests across ${testsByProject.size} project(s), debug=${isDebugMode}`);
4849

50+
// Expand exclude set once for all projects
51+
const rawExcludeSet = request.exclude?.length ? new Set(request.exclude) : undefined;
52+
const excludeSet = expandExcludeSet(rawExcludeSet);
53+
4954
// Setup coverage once for all projects (single callback that routes by file path)
5055
if (request.profile?.kind === TestRunProfileKind.Coverage) {
5156
setupCoverageForProjects(request, projects);
@@ -71,7 +76,7 @@ export async function executeTestsForProjects(
7176
});
7277

7378
try {
74-
await executeTestsForProject(project, items, runInstance, request, deps);
79+
await executeTestsForProject(project, items, runInstance, request, deps, excludeSet);
7580
} catch (error) {
7681
// Don't log cancellation as an error
7782
if (!token.isCancellationRequested) {
@@ -216,27 +221,26 @@ export async function executeTestsForProject(
216221
runInstance: TestRun,
217222
request: TestRunRequest,
218223
deps: ProjectExecutionDependencies,
224+
excludeSet?: Set<TestItem>,
219225
): Promise<void> {
220-
const processedTestItemIds = new Set<string>();
221-
const uniqueTestCaseIds = new Set<string>();
226+
const testCaseNodes: TestItem[] = [];
227+
const visitedNodes = new Set<TestItem>();
222228

223-
// Mark items as started and collect test IDs (deduplicated to handle overlapping selections)
229+
// Expand included items to leaf test nodes, respecting exclusions.
230+
// getTestCaseNodes handles visited tracking and exclusion filtering.
224231
for (const item of testItems) {
225-
const testCaseNodes = getTestCaseNodesRecursive(item);
226-
for (const node of testCaseNodes) {
227-
if (processedTestItemIds.has(node.id)) {
228-
continue;
229-
}
230-
processedTestItemIds.add(node.id);
231-
runInstance.started(node);
232-
const runId = project.resultResolver.vsIdToRunId.get(node.id);
233-
if (runId) {
234-
uniqueTestCaseIds.add(runId);
235-
}
236-
}
232+
getTestCaseNodes(item, testCaseNodes, visitedNodes, excludeSet);
237233
}
238234

239-
const testCaseIds = Array.from(uniqueTestCaseIds);
235+
// Mark items as started and collect test IDs
236+
const testCaseIds: string[] = [];
237+
for (const node of testCaseNodes) {
238+
runInstance.started(node);
239+
const runId = project.resultResolver.vsIdToRunId.get(node.id);
240+
if (runId) {
241+
testCaseIds.push(runId);
242+
}
243+
}
240244

241245
if (testCaseIds.length === 0) {
242246
traceVerbose(`[test-by-project] No test IDs found for project ${project.projectName}`);

src/client/testing/testController/common/testItemUtilities.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,52 @@ export async function updateTestItemFromRawData(
498498
item.busy = false;
499499
}
500500

501-
export function getTestCaseNodes(testNode: TestItem, collection: TestItem[] = []): TestItem[] {
501+
/**
502+
* Expands an exclude set to include all descendants of excluded items.
503+
* After expansion, checking if a node is excluded is O(1) - just check set membership.
504+
*/
505+
export function expandExcludeSet(excludeSet: Set<TestItem> | undefined): Set<TestItem> | undefined {
506+
if (!excludeSet || excludeSet.size === 0) {
507+
return excludeSet;
508+
}
509+
const expanded = new Set<TestItem>();
510+
excludeSet.forEach((item) => {
511+
addWithDescendants(item, expanded);
512+
});
513+
return expanded;
514+
}
515+
516+
function addWithDescendants(item: TestItem, set: Set<TestItem>): void {
517+
if (set.has(item)) {
518+
return;
519+
}
520+
set.add(item);
521+
item.children.forEach((child) => addWithDescendants(child, set));
522+
}
523+
524+
export function getTestCaseNodes(
525+
testNode: TestItem,
526+
collection: TestItem[] = [],
527+
visited?: Set<TestItem>,
528+
excludeSet?: Set<TestItem>,
529+
): TestItem[] {
530+
if (visited?.has(testNode)) {
531+
return collection;
532+
}
533+
visited?.add(testNode);
534+
535+
// Skip excluded nodes (excludeSet should be pre-expanded to include descendants)
536+
if (excludeSet?.has(testNode)) {
537+
return collection;
538+
}
539+
502540
if (!testNode.canResolveChildren && testNode.tags.length > 0) {
503541
collection.push(testNode);
504542
}
505543

506544
testNode.children.forEach((c) => {
507545
if (testNode.canResolveChildren) {
508-
getTestCaseNodes(c, collection);
546+
getTestCaseNodes(c, collection, visited, excludeSet);
509547
} else {
510548
collection.push(testNode);
511549
}

src/client/testing/testController/controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
907907
request.profile?.kind,
908908
this.debugLauncher,
909909
await this.interpreterService.getActiveInterpreter(workspace.uri),
910+
request.exclude,
910911
);
911912
}
912913

src/client/testing/testController/workspaceTestAdapter.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { traceError } from '../../logging';
99
import { sendTelemetryEvent } from '../../telemetry';
1010
import { EventName } from '../../telemetry/constants';
1111
import { TestProvider } from '../types';
12-
import { createErrorTestItem, getTestCaseNodes } from './common/testItemUtilities';
12+
import { createErrorTestItem, expandExcludeSet, getTestCaseNodes } from './common/testItemUtilities';
1313
import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './common/types';
1414
import { IPythonExecutionFactory } from '../../common/process/types';
1515
import { ITestDebugLauncher } from '../common/types';
@@ -48,6 +48,7 @@ export class WorkspaceTestAdapter {
4848
profileKind?: boolean | TestRunProfileKind,
4949
debugLauncher?: ITestDebugLauncher,
5050
interpreter?: PythonEnvironment,
51+
excludes?: readonly TestItem[],
5152
project?: ProjectAdapter,
5253
): Promise<void> {
5354
if (this.executing) {
@@ -59,22 +60,24 @@ export class WorkspaceTestAdapter {
5960
this.executing = deferred;
6061

6162
const testCaseNodes: TestItem[] = [];
62-
const testCaseIdsSet = new Set<string>();
63+
const visitedNodes = new Set<TestItem>();
64+
const rawExcludeSet = excludes?.length ? new Set(excludes) : undefined;
65+
const excludeSet = expandExcludeSet(rawExcludeSet);
66+
const testCaseIds: string[] = [];
6367
try {
64-
// first fetch all the individual test Items that we necessarily want
68+
// Expand included items to leaf test nodes.
69+
// getTestCaseNodes handles visited tracking and exclusion filtering.
6570
includes.forEach((t) => {
66-
const nodes = getTestCaseNodes(t);
67-
testCaseNodes.push(...nodes);
71+
getTestCaseNodes(t, testCaseNodes, visitedNodes, excludeSet);
6872
});
69-
// iterate through testItems nodes and fetch their unittest runID to pass in as argument
73+
// Collect runIDs for the test nodes to execute.
7074
testCaseNodes.forEach((node) => {
71-
runInstance.started(node); // do the vscode ui test item start here before runtest
75+
runInstance.started(node);
7276
const runId = this.resultResolver.vsIdToRunId.get(node.id);
7377
if (runId) {
74-
testCaseIdsSet.add(runId);
78+
testCaseIds.push(runId);
7579
}
7680
});
77-
const testCaseIds = Array.from(testCaseIdsSet);
7881
if (executionFactory === undefined) {
7982
throw new Error('Execution factory is required for test execution');
8083
}

src/test/testing/testController/common/projectTestExecution.unit.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,54 @@ suite('Project Test Execution', () => {
438438
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
439439
expect(passedTestIds).to.have.length(2);
440440
});
441+
442+
test('should exclude test items in excludeSet from execution', async () => {
443+
// Mock - class containing two test methods, one excluded
444+
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
445+
const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py');
446+
const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py');
447+
const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]);
448+
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
449+
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
450+
const runMock = createMockTestRun();
451+
const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest;
452+
const deps = createMockDependencies();
453+
454+
// Exclude leaf2
455+
const excludeSet = new Set([leaf2]);
456+
457+
// Run
458+
await executeTestsForProject(project, [classItem], runMock.object, request, deps, excludeSet);
459+
460+
// Assert - only leaf1 should be started and executed, leaf2 should be excluded
461+
runMock.verify((r) => r.started(leaf1), typemoq.Times.once());
462+
runMock.verify((r) => r.started(leaf2), typemoq.Times.never());
463+
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
464+
expect(passedTestIds).to.deep.equal(['runId1']);
465+
});
466+
467+
test('should exclude entire subtree when parent is in excludeSet', async () => {
468+
// Mock - file containing a class with test methods
469+
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
470+
const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py');
471+
const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py');
472+
const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]);
473+
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
474+
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
475+
const runMock = createMockTestRun();
476+
const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest;
477+
const deps = createMockDependencies();
478+
479+
// Exclude the entire class (expandExcludeSet would add children too)
480+
const excludeSet = new Set([classItem, leaf1, leaf2]);
481+
482+
// Run
483+
await executeTestsForProject(project, [classItem], runMock.object, request, deps, excludeSet);
484+
485+
// Assert - nothing should be started or executed
486+
runMock.verify((r) => r.started(typemoq.It.isAny()), typemoq.Times.never());
487+
expect(project.executionAdapterStub.called).to.be.false;
488+
});
441489
});
442490

443491
// ===== executeTestsForProjects Tests =====
@@ -612,6 +660,63 @@ suite('Project Test Execution', () => {
612660
const telemetryProps = telemetryStub.firstCall.args[2];
613661
expect(telemetryProps.debugging).to.be.true;
614662
});
663+
664+
test('should respect request.exclude when executing tests', async () => {
665+
// Mock - project with two test items, one excluded via request.exclude
666+
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
667+
const item1 = createMockTestItem('test1', '/workspace/proj/test.py');
668+
const item2 = createMockTestItem('test2', '/workspace/proj/test.py');
669+
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
670+
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
671+
const runMock = createMockTestRun();
672+
const token = new CancellationTokenSource().token;
673+
// Exclude item2 via request.exclude
674+
const request = ({
675+
profile: { kind: TestRunProfileKind.Run },
676+
exclude: [item2],
677+
} as unknown) as TestRunRequest;
678+
const deps = createMockDependencies();
679+
680+
// Run
681+
await executeTestsForProjects([project], [item1, item2], runMock.object, request, token, deps);
682+
683+
// Assert - only item1 should be executed, item2 should be excluded
684+
runMock.verify((r) => r.started(item1), typemoq.Times.once());
685+
runMock.verify((r) => r.started(item2), typemoq.Times.never());
686+
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
687+
expect(passedTestIds).to.deep.equal(['runId1']);
688+
});
689+
690+
test('should exclude items only from their own project in multi-project execution', async () => {
691+
// Mock - two projects, each with one test item, exclude one item from proj1
692+
const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' });
693+
const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' });
694+
const item1 = createMockTestItem('test1', '/workspace/proj1/test.py');
695+
const item2 = createMockTestItem('test2', '/workspace/proj2/test.py');
696+
proj1.resultResolver.vsIdToRunId.set('test1', 'runId1');
697+
proj2.resultResolver.vsIdToRunId.set('test2', 'runId2');
698+
const runMock = createMockTestRun();
699+
const token = new CancellationTokenSource().token;
700+
// Exclude item1 from proj1 - item2 in proj2 should still run
701+
const request = ({
702+
profile: { kind: TestRunProfileKind.Run },
703+
exclude: [item1],
704+
} as unknown) as TestRunRequest;
705+
const deps = createMockDependencies();
706+
707+
// Run
708+
await executeTestsForProjects([proj1, proj2], [item1, item2], runMock.object, request, token, deps);
709+
710+
// Assert - item1 excluded, item2 still executed
711+
runMock.verify((r) => r.started(item1), typemoq.Times.never());
712+
runMock.verify((r) => r.started(item2), typemoq.Times.once());
713+
// proj1 should not have called runTests (no items left after exclusion)
714+
expect(proj1.executionAdapterStub.called).to.be.false;
715+
// proj2 should have called runTests with item2
716+
expect(proj2.executionAdapterStub.calledOnce).to.be.true;
717+
const proj2TestIds = proj2.executionAdapterStub.firstCall.args[1] as string[];
718+
expect(proj2TestIds).to.deep.equal(['runId2']);
719+
});
615720
});
616721

617722
// ===== setupCoverageForProjects Tests =====

0 commit comments

Comments
 (0)