From 5cb266c0f2189c0f54324e645d0bbfb7ae975043 Mon Sep 17 00:00:00 2001 From: Andrew Lavin Date: Mon, 19 Jan 2026 18:28:48 -0800 Subject: [PATCH] fix(testing): respect TestRunRequest.exclude when running tests The VS Code Test Explorer API specifies that TestRunRequest.exclude contains tests the user has marked as excluded (e.g., via filtering). Per the API contract, "exclusions should apply after inclusions." Previously, the Python extension ignored request.exclude entirely, causing "Run Tests" to run all tests even when the user had filtered the Test Explorer view. This fix adds exclude handling at two levels: 1. In getTestItemsForWorkspace(): Filter out excluded items before passing to the test adapter (checks ancestors for top-level items) 2. In WorkspaceTestAdapter.executeTests(): Pre-expand the exclude set to include all descendants, then pass to getTestCaseNodes() which skips excluded nodes with O(1) set lookups during traversal Also adds a visited set to getTestCaseNodes() to avoid expanding the same node multiple times when includes contains overlapping items. Fixes the issue where filtering tests in Test Explorer (e.g., by tag) and clicking "Run Tests" would still run all tests. Co-Authored-By: Claude Opus 4.5 --- .../common/testItemUtilities.ts | 59 ++++++++++++++++++- .../testing/testController/controller.ts | 7 ++- .../testController/workspaceTestAdapter.ts | 21 ++++--- 3 files changed, 74 insertions(+), 13 deletions(-) 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'); }