Skip to content

Commit 2e818df

Browse files
andravinclaude
andcommitted
fix(testing): add exclude filtering support to project-based test execution
The new project-based test execution path (executeTestsForProjects) was not respecting TestRunRequest.exclude. This adds exclude filtering by: - Importing expandExcludeSet and getTestCaseNodes from testItemUtilities - Creating expanded exclude set from request.exclude in executeTestsForProjects - Using getTestCaseNodes (with exclude support) instead of getTestCaseNodesRecursive Also fixes testMocks.ts to create realistic mock TestItems: - Set canResolveChildren=true when children are present - Include RunTestTag and DebugTestTag (required for getTestCaseNodes) Adds tests for exclude filtering in project-based execution including multi-project scenarios. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fabb4d6 commit 2e818df

File tree

3 files changed

+131
-19
lines changed

3 files changed

+131
-19
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/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 =====

src/test/testing/testController/testMocks.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ITestDebugLauncher } from '../../../client/testing/common/types';
1414
import { ProjectAdapter } from '../../../client/testing/testController/common/projectAdapter';
1515
import { ProjectExecutionDependencies } from '../../../client/testing/testController/common/projectTestExecution';
1616
import { TestProjectRegistry } from '../../../client/testing/testController/common/testProjectRegistry';
17+
import { RunTestTag, DebugTestTag } from '../../../client/testing/testController/common/testItemUtilities';
1718
import { ITestExecutionAdapter, ITestResultResolver } from '../../../client/testing/testController/common/types';
1819

1920
/**
@@ -42,14 +43,16 @@ export function createMockTestItem(id: string, uriPath: string, children?: TestI
4243
},
4344
} as TestItemCollection;
4445

46+
// Parent nodes (with children) have canResolveChildren=true, leaf nodes have canResolveChildren=false
47+
const hasChildren = children && children.length > 0;
4548
return ({
4649
id,
4750
uri: Uri.file(uriPath),
4851
children: mockChildren,
4952
label: id,
50-
canResolveChildren: false,
53+
canResolveChildren: hasChildren,
5154
busy: false,
52-
tags: [],
55+
tags: [RunTestTag, DebugTestTag],
5356
range: undefined,
5457
error: undefined,
5558
parent: undefined,

0 commit comments

Comments
 (0)