diff --git a/src/client/testing/testController/common/testItemUtilities.ts b/src/client/testing/testController/common/testItemUtilities.ts index 43624bba2527..7840794f9ad0 100644 --- a/src/client/testing/testController/common/testItemUtilities.ts +++ b/src/client/testing/testController/common/testItemUtilities.ts @@ -498,14 +498,69 @@ export async function updateTestItemFromRawData( item.busy = false; } -export function getTestCaseNodes(testNode: TestItem, collection: TestItem[] = []): TestItem[] { +/** + * Checks if a test item or any of its ancestors is in the exclude set. + */ +export function isTestItemExcluded(item: TestItem, excludeSet: Set | undefined): boolean { + if (!excludeSet || excludeSet.size === 0) { + return false; + } + let current: TestItem | undefined = item; + while (current) { + if (excludeSet.has(current)) { + return true; + } + current = current.parent; + } + return false; +} + +/** + * Expands an exclude set to include all descendants of excluded items. + * After expansion, checking if a node is excluded is O(1) - just check set membership. + */ +export function expandExcludeSet(excludeSet: Set | undefined): Set | undefined { + if (!excludeSet || excludeSet.size === 0) { + return excludeSet; + } + const expanded = new Set(); + excludeSet.forEach((item) => { + addWithDescendants(item, expanded); + }); + return expanded; +} + +function addWithDescendants(item: TestItem, set: Set): void { + if (set.has(item)) { + return; + } + set.add(item); + item.children.forEach((child) => addWithDescendants(child, set)); +} + +export function getTestCaseNodes( + testNode: TestItem, + collection: TestItem[] = [], + visited?: Set, + excludeSet?: Set, +): TestItem[] { + if (visited?.has(testNode)) { + return collection; + } + visited?.add(testNode); + + // Skip excluded nodes (excludeSet should be pre-expanded to include descendants) + if (excludeSet?.has(testNode)) { + return collection; + } + if (!testNode.canResolveChildren && testNode.tags.length > 0) { collection.push(testNode); } testNode.children.forEach((c) => { if (testNode.canResolveChildren) { - getTestCaseNodes(c, collection); + getTestCaseNodes(c, collection, visited, excludeSet); } else { collection.push(testNode); } diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..d8bb2eb7bd5c 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -34,7 +34,7 @@ import { IEventNamePropertyMapping, sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; import { TestProvider } from '../types'; -import { createErrorTestItem, DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities'; +import { createErrorTestItem, DebugTestTag, getNodeByUri, isTestItemExcluded, RunTestTag } from './common/testItemUtilities'; import { buildErrorNodeOptions } from './common/utils'; import { ITestController, @@ -507,12 +507,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc */ private getTestItemsForWorkspace(workspace: WorkspaceFolder, request: TestRunRequest): TestItem[] { const testItems: TestItem[] = []; + const excludeSet = request.exclude?.length ? new Set(request.exclude) : undefined; // If the run request includes test items then collect only items that belong to // `workspace`. If there are no items in the run request then just run the `workspace` // root test node. Include will be `undefined` in the "run all" scenario. + // Exclusions are applied after inclusions per VS Code API contract. (request.include ?? this.testController.items).forEach((i: TestItem) => { const w = this.workspaceService.getWorkspaceFolder(i.uri); - if (w?.uri.fsPath === workspace.uri.fsPath) { + if (w?.uri.fsPath === workspace.uri.fsPath && !isTestItemExcluded(i, excludeSet)) { testItems.push(i); } }); @@ -566,6 +568,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc request.profile?.kind, this.debugLauncher, await this.interpreterService.getActiveInterpreter(workspace.uri), + request.exclude, ); } diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index 75b9489f708e..071edf8aa0a7 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -9,7 +9,7 @@ import { traceError } from '../../logging'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { TestProvider } from '../types'; -import { createErrorTestItem, getTestCaseNodes } from './common/testItemUtilities'; +import { createErrorTestItem, expandExcludeSet, getTestCaseNodes } from './common/testItemUtilities'; import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './common/types'; import { IPythonExecutionFactory } from '../../common/process/types'; import { ITestDebugLauncher } from '../common/types'; @@ -47,6 +47,7 @@ export class WorkspaceTestAdapter { profileKind?: boolean | TestRunProfileKind, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + excludes?: readonly TestItem[], ): Promise { if (this.executing) { traceError('Test execution already in progress, not starting a new one.'); @@ -57,22 +58,24 @@ export class WorkspaceTestAdapter { this.executing = deferred; const testCaseNodes: TestItem[] = []; - const testCaseIdsSet = new Set(); + const visitedNodes = new Set(); + const rawExcludeSet = excludes?.length ? new Set(excludes) : undefined; + const excludeSet = expandExcludeSet(rawExcludeSet); + const testCaseIds: string[] = []; try { - // first fetch all the individual test Items that we necessarily want + // Expand included items to leaf test nodes. + // getTestCaseNodes handles visited tracking and exclusion filtering. includes.forEach((t) => { - const nodes = getTestCaseNodes(t); - testCaseNodes.push(...nodes); + getTestCaseNodes(t, testCaseNodes, visitedNodes, excludeSet); }); - // iterate through testItems nodes and fetch their unittest runID to pass in as argument + // Collect runIDs for the test nodes to execute. testCaseNodes.forEach((node) => { - runInstance.started(node); // do the vscode ui test item start here before runtest + runInstance.started(node); const runId = this.resultResolver.vsIdToRunId.get(node.id); if (runId) { - testCaseIdsSet.add(runId); + testCaseIds.push(runId); } }); - const testCaseIds = Array.from(testCaseIdsSet); if (executionFactory === undefined) { throw new Error('Execution factory is required for test execution'); }