diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index b92e7a870f20..da1a516c3360 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -1,60 +1,51 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - CancellationToken, - TestController, - TestItem, - Uri, - TestMessage, - Location, - TestRun, - MarkdownString, - TestCoverageCount, - FileCoverage, - FileCoverageDetail, - StatementCoverage, - Range, -} from 'vscode'; -import * as util from 'util'; -import { - CoveragePayload, - DiscoveredTestPayload, - ExecutionTestPayload, - FileCoverageMetrics, - ITestResultResolver, -} from './types'; +import { CancellationToken, TestController, TestItem, Uri, TestRun, FileCoverageDetail } from 'vscode'; +import { CoveragePayload, DiscoveredTestPayload, ExecutionTestPayload, ITestResultResolver } from './types'; import { TestProvider } from '../../types'; -import { traceError, traceVerbose } from '../../../logging'; -import { Testing } from '../../../common/utils/localize'; -import { clearAllChildren, createErrorTestItem, getTestCaseNodes } from './testItemUtilities'; -import { sendTelemetryEvent } from '../../../telemetry'; -import { EventName } from '../../../telemetry/constants'; -import { splitLines } from '../../../common/stringUtils'; -import { buildErrorNodeOptions, populateTestTree, splitTestNameWithRegex } from './utils'; +import { traceVerbose } from '../../../logging'; +import { TestItemIndex } from './testItemIndex'; +import { TestDiscoveryHandler } from './testDiscoveryHandler'; +import { TestExecutionHandler } from './testExecutionHandler'; +import { TestCoverageHandler } from './testCoverageHandler'; export class PythonResultResolver implements ITestResultResolver { testController: TestController; testProvider: TestProvider; - public runIdToTestItem: Map; + // Per-workspace state managed by TestItemIndex + private testItemIndex: TestItemIndex; - public runIdToVSid: Map; + // Expose maps for backward compatibility (WorkspaceTestAdapter accesses these) + public get runIdToTestItem(): Map { + return this.testItemIndex.runIdToTestItem; + } + + public get runIdToVSid(): Map { + return this.testItemIndex.runIdToVSid; + } - public vsIdToRunId: Map; + public get vsIdToRunId(): Map { + return this.testItemIndex.vsIdToRunId; + } public subTestStats: Map = new Map(); public detailedCoverageMap = new Map(); + // Shared singleton handler instances (stateless, can be shared across all resolvers) + private static discoveryHandler: TestDiscoveryHandler = new TestDiscoveryHandler(); + private static executionHandler: TestExecutionHandler = new TestExecutionHandler(); + private static coverageHandler: TestCoverageHandler = new TestCoverageHandler(); + constructor(testController: TestController, testProvider: TestProvider, private workspaceUri: Uri) { this.testController = testController; this.testProvider = testProvider; - this.runIdToTestItem = new Map(); - this.runIdToVSid = new Map(); - this.vsIdToRunId = new Map(); + // Create the per-workspace TestItemIndex for ID mappings + this.testItemIndex = new TestItemIndex(); } public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { @@ -66,53 +57,19 @@ export class PythonResultResolver implements ITestResultResolver { } public _resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { - const workspacePath = this.workspaceUri.fsPath; - const rawTestData = payload as DiscoveredTestPayload; - // Check if there were any errors in the discovery process. - if (rawTestData.status === 'error') { - const testingErrorConst = - this.testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery; - const { error } = rawTestData; - traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); - - let errorNode = this.testController.items.get(`DiscoveryError:${workspacePath}`); - const message = util.format( - `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, - error?.join('\r\n\r\n') ?? '', - ); - - if (errorNode === undefined) { - const options = buildErrorNodeOptions(this.workspaceUri, message, this.testProvider); - errorNode = createErrorTestItem(this.testController, options); - this.testController.items.add(errorNode); - } - const errorNodeLabel: MarkdownString = new MarkdownString( - `[Show output](command:python.viewOutput) to view error logs`, - ); - errorNodeLabel.isTrusted = true; - errorNode.error = errorNodeLabel; - } else { - // remove error node only if no errors exist. - this.testController.items.delete(`DiscoveryError:${workspacePath}`); - } - if (rawTestData.tests || rawTestData.tests === null) { - // if any tests exist, they should be populated in the test tree, regardless of whether there were errors or not. - // parse and insert test data. - - // Clear existing mappings before rebuilding test tree - this.runIdToTestItem.clear(); - this.runIdToVSid.clear(); - this.vsIdToRunId.clear(); - - // If the test root for this folder exists: Workspace refresh, update its children. - // Otherwise, it is a freshly discovered workspace, and we need to create a new test root and populate the test tree. - populateTestTree(this.testController, rawTestData.tests, undefined, this, token); - } + // Clear the testItemIndex before discovery handler processes the payload + // This ensures a clean state for the new discovery results + this.testItemIndex.clear(); - sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { - tool: this.testProvider, - failed: false, - }); + // Delegate to the stateless discovery handler + PythonResultResolver.discoveryHandler.processDiscovery( + payload, + this.testController, + this, + this.workspaceUri, + this.testProvider, + token, + ); } public resolveExecution(payload: ExecutionTestPayload | CoveragePayload, runInstance: TestRun): void { @@ -126,345 +83,32 @@ export class PythonResultResolver implements ITestResultResolver { } public _resolveCoverage(payload: CoveragePayload, runInstance: TestRun): void { - if (payload.result === undefined) { - return; - } - for (const [key, value] of Object.entries(payload.result)) { - const fileNameStr = key; - const fileCoverageMetrics: FileCoverageMetrics = value; - const linesCovered = fileCoverageMetrics.lines_covered ? fileCoverageMetrics.lines_covered : []; // undefined if no lines covered - const linesMissed = fileCoverageMetrics.lines_missed ? fileCoverageMetrics.lines_missed : []; // undefined if no lines missed - const executedBranches = fileCoverageMetrics.executed_branches; - const totalBranches = fileCoverageMetrics.total_branches; - - const lineCoverageCount = new TestCoverageCount( - linesCovered.length, - linesCovered.length + linesMissed.length, - ); - let fileCoverage: FileCoverage; - const uri = Uri.file(fileNameStr); - if (totalBranches === -1) { - // branch coverage was not enabled and should not be displayed - fileCoverage = new FileCoverage(uri, lineCoverageCount); - } else { - const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches); - fileCoverage = new FileCoverage(uri, lineCoverageCount, branchCoverageCount); - } - runInstance.addCoverage(fileCoverage); - - // create detailed coverage array for each file (only line coverage on detailed, not branch) - const detailedCoverageArray: FileCoverageDetail[] = []; - // go through all covered lines, create new StatementCoverage, and add to detailedCoverageArray - for (const line of linesCovered) { - // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number - // true value means line is covered - const statementCoverage = new StatementCoverage( - true, - new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), - ); - detailedCoverageArray.push(statementCoverage); - } - for (const line of linesMissed) { - // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number - // false value means line is NOT covered - const statementCoverage = new StatementCoverage( - false, - new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), - ); - detailedCoverageArray.push(statementCoverage); - } - - this.detailedCoverageMap.set(uri.fsPath, detailedCoverageArray); - } - } - - /** - * Collect all test case items from the test controller tree. - * Note: This performs full tree traversal - use cached lookups when possible. - */ - private collectAllTestCases(): TestItem[] { - const testCases: TestItem[] = []; - - this.testController.items.forEach((i) => { - const tempArr: TestItem[] = getTestCaseNodes(i); - testCases.push(...tempArr); - }); - - return testCases; - } - - /** - * Find a test item efficiently using cached maps with fallback strategies. - * Uses a three-tier approach: direct lookup, ID mapping, then tree search. - */ - private findTestItemByIdEfficient(keyTemp: string): TestItem | undefined { - // Try direct O(1) lookup first - const directItem = this.runIdToTestItem.get(keyTemp); - if (directItem) { - // Validate the item is still in the test tree - if (this.isTestItemValid(directItem)) { - return directItem; - } else { - // Clean up stale reference - this.runIdToTestItem.delete(keyTemp); - } - } - - // Try vsId mapping as fallback - const vsId = this.runIdToVSid.get(keyTemp); - if (vsId) { - // Search by VS Code ID in the controller - let foundItem: TestItem | undefined; - this.testController.items.forEach((item) => { - if (item.id === vsId) { - foundItem = item; - return; - } - if (!foundItem) { - item.children.forEach((child) => { - if (child.id === vsId) { - foundItem = child; - } - }); - } - }); - - if (foundItem) { - // Cache for future lookups - this.runIdToTestItem.set(keyTemp, foundItem); - return foundItem; - } else { - // Clean up stale mapping - this.runIdToVSid.delete(keyTemp); - this.vsIdToRunId.delete(vsId); - } - } - - // Last resort: full tree search - traceError(`Falling back to tree search for test: ${keyTemp}`); - const testCases = this.collectAllTestCases(); - return testCases.find((item) => item.id === vsId); - } - - /** - * Check if a TestItem is still valid (exists in the TestController tree) - * - * Time Complexity: O(depth) where depth is the maximum nesting level of the test tree. - * In most cases this is O(1) to O(3) since test trees are typically shallow. - */ - private isTestItemValid(testItem: TestItem): boolean { - // Simple validation: check if the item's parent chain leads back to the controller - let current: TestItem | undefined = testItem; - while (current?.parent) { - current = current.parent; - } - - // If we reached a root item, check if it's in the controller - if (current) { - return this.testController.items.get(current.id) === current; - } - - // If no parent chain, check if it's directly in the controller - return this.testController.items.get(testItem.id) === testItem; + // Delegate to the stateless coverage handler + // Store the returned coverage map for backward compatibility + this.detailedCoverageMap = PythonResultResolver.coverageHandler.processCoverage(payload, runInstance); } /** * Clean up stale test item references from the cache maps. - * Validates cached items and removes any that are no longer in the test tree. + * Delegates to TestItemIndex for the actual cleanup logic. */ public cleanupStaleReferences(): void { - const staleRunIds: string[] = []; - - // Check all runId->TestItem mappings - this.runIdToTestItem.forEach((testItem, runId) => { - if (!this.isTestItemValid(testItem)) { - staleRunIds.push(runId); - } - }); - - // Remove stale entries - staleRunIds.forEach((runId) => { - const vsId = this.runIdToVSid.get(runId); - this.runIdToTestItem.delete(runId); - this.runIdToVSid.delete(runId); - if (vsId) { - this.vsIdToRunId.delete(vsId); - } - }); - - if (staleRunIds.length > 0) { - traceVerbose(`Cleaned up ${staleRunIds.length} stale test item references`); - } - } - - /** - * Handle test items that errored during execution. - * Extracts error details, finds the corresponding TestItem, and reports the error to VS Code's Test Explorer. - */ - private handleTestError(keyTemp: string, testItem: any, runInstance: TestRun): void { - const rawTraceback = testItem.traceback ?? ''; - const traceback = splitLines(rawTraceback, { - trim: false, - removeEmptyEntries: true, - }).join('\r\n'); - const text = `${testItem.test} failed with error: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - const message = new TestMessage(text); - - const foundItem = this.findTestItemByIdEfficient(keyTemp); - - if (foundItem?.uri) { - if (foundItem.range) { - message.location = new Location(foundItem.uri, foundItem.range); - } - runInstance.errored(foundItem, message); - } - } - - /** - * Handle test items that failed during execution - */ - private handleTestFailure(keyTemp: string, testItem: any, runInstance: TestRun): void { - const rawTraceback = testItem.traceback ?? ''; - const traceback = splitLines(rawTraceback, { - trim: false, - removeEmptyEntries: true, - }).join('\r\n'); - - const text = `${testItem.test} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - const message = new TestMessage(text); - - const foundItem = this.findTestItemByIdEfficient(keyTemp); - - if (foundItem?.uri) { - if (foundItem.range) { - message.location = new Location(foundItem.uri, foundItem.range); - } - runInstance.failed(foundItem, message); - } - } - - /** - * Handle test items that passed during execution - */ - private handleTestSuccess(keyTemp: string, runInstance: TestRun): void { - const grabTestItem = this.runIdToTestItem.get(keyTemp); - - if (grabTestItem !== undefined) { - const foundItem = this.findTestItemByIdEfficient(keyTemp); - if (foundItem?.uri) { - runInstance.passed(grabTestItem); - } - } - } - - /** - * Handle test items that were skipped during execution - */ - private handleTestSkipped(keyTemp: string, runInstance: TestRun): void { - const grabTestItem = this.runIdToTestItem.get(keyTemp); - - if (grabTestItem !== undefined) { - const foundItem = this.findTestItemByIdEfficient(keyTemp); - if (foundItem?.uri) { - runInstance.skipped(grabTestItem); - } - } - } - - /** - * Handle subtest failures - */ - private handleSubtestFailure(keyTemp: string, testItem: any, runInstance: TestRun): void { - const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp); - const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); - - if (parentTestItem) { - const subtestStats = this.subTestStats.get(parentTestCaseId); - if (subtestStats) { - subtestStats.failed += 1; - } else { - this.subTestStats.set(parentTestCaseId, { - failed: 1, - passed: 0, - }); - clearAllChildren(parentTestItem); - } - - const subTestItem = this.testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); - - if (subTestItem) { - const traceback = testItem.traceback ?? ''; - const text = `${testItem.subtest} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - parentTestItem.children.add(subTestItem); - runInstance.started(subTestItem); - const message = new TestMessage(text); - if (parentTestItem.uri && parentTestItem.range) { - message.location = new Location(parentTestItem.uri, parentTestItem.range); - } - runInstance.failed(subTestItem, message); - } else { - throw new Error('Unable to create new child node for subtest'); - } - } else { - throw new Error('Parent test item not found'); - } - } - - /** - * Handle subtest successes - */ - private handleSubtestSuccess(keyTemp: string, runInstance: TestRun): void { - const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp); - const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); - - if (parentTestItem) { - const subtestStats = this.subTestStats.get(parentTestCaseId); - if (subtestStats) { - subtestStats.passed += 1; - } else { - this.subTestStats.set(parentTestCaseId, { failed: 0, passed: 1 }); - clearAllChildren(parentTestItem); - } - - const subTestItem = this.testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); - - if (subTestItem) { - parentTestItem.children.add(subTestItem); - runInstance.started(subTestItem); - runInstance.passed(subTestItem); - } else { - throw new Error('Unable to create new child node for subtest'); - } - } else { - throw new Error('Parent test item not found'); - } + this.testItemIndex.cleanupStaleReferences(this.testController); } /** * Process test execution results and update VS Code's Test Explorer with outcomes. - * Uses efficient lookup methods to handle large numbers of test results. + * Delegates to the stateless TestExecutionHandler. */ public _resolveExecution(payload: ExecutionTestPayload, runInstance: TestRun): void { - const rawTestExecData = payload as ExecutionTestPayload; - if (rawTestExecData !== undefined && rawTestExecData.result !== undefined) { - for (const keyTemp of Object.keys(rawTestExecData.result)) { - const testItem = rawTestExecData.result[keyTemp]; - - // Delegate to specific outcome handlers using efficient lookups - if (testItem.outcome === 'error') { - this.handleTestError(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'failure' || testItem.outcome === 'passed-unexpected') { - this.handleTestFailure(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'success' || testItem.outcome === 'expected-failure') { - this.handleTestSuccess(keyTemp, runInstance); - } else if (testItem.outcome === 'skipped') { - this.handleTestSkipped(keyTemp, runInstance); - } else if (testItem.outcome === 'subtest-failure') { - this.handleSubtestFailure(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'subtest-success') { - this.handleSubtestSuccess(keyTemp, runInstance); - } - } - } + // Delegate to the stateless execution handler + // Store the returned subtest stats for backward compatibility + this.subTestStats = PythonResultResolver.executionHandler.processExecution( + payload, + runInstance, + this.testItemIndex, + this.testController, + this, + ); } } diff --git a/src/client/testing/testController/common/testCoverageHandler.ts b/src/client/testing/testController/common/testCoverageHandler.ts new file mode 100644 index 000000000000..40e3c131eba0 --- /dev/null +++ b/src/client/testing/testController/common/testCoverageHandler.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + Uri, + TestRun, + TestCoverageCount, + FileCoverage, + FileCoverageDetail, + StatementCoverage, + Range, +} from 'vscode'; +import { CoveragePayload, FileCoverageMetrics } from './types'; + +/** + * TestCoverageHandler - Stateless processor for coverage payloads. + * + * This handler processes coverage payloads and creates coverage objects. + * It is designed to be stateless and shared across all workspaces. + * + * Responsibilities: + * - Parse CoveragePayload and create FileCoverage objects + * - Generate detailed coverage information (line-level coverage) + * - Return coverage data for caller to store/use + * + * The handler returns coverage data rather than storing it, allowing + * the caller (resolver or adapter) to decide how to manage this data. + */ +export class TestCoverageHandler { + /** + * Process coverage payload and update test run with coverage data. + * This is the main entry point for handling coverage results. + * + * @param payload - The coverage payload from the Python subprocess + * @param runInstance - VS Code TestRun to add coverage to + * @returns Map of file paths to detailed coverage information + */ + public processCoverage(payload: CoveragePayload, runInstance: TestRun): Map { + const detailedCoverageMap = new Map(); + + if (payload.result === undefined) { + return detailedCoverageMap; + } + + for (const [key, value] of Object.entries(payload.result)) { + const fileNameStr = key; + const fileCoverageMetrics: FileCoverageMetrics = value; + + // Create file coverage and detailed coverage + const { fileCoverage, detailedCoverageArray } = this.processFileCoverage( + fileNameStr, + fileCoverageMetrics, + ); + + // Add coverage to the test run + runInstance.addCoverage(fileCoverage); + + // Store detailed coverage for the file + detailedCoverageMap.set(fileCoverage.uri.fsPath, detailedCoverageArray); + } + + return detailedCoverageMap; + } + + /** + * Process coverage for a single file. + * + * @param fileNameStr - Path to the file + * @param metrics - Coverage metrics for the file + * @returns Object containing FileCoverage and detailed coverage array + */ + private processFileCoverage( + fileNameStr: string, + metrics: FileCoverageMetrics, + ): { fileCoverage: FileCoverage; detailedCoverageArray: FileCoverageDetail[] } { + // Handle undefined arrays (use empty arrays as defaults) + const linesCovered = metrics.lines_covered ?? []; + const linesMissed = metrics.lines_missed ?? []; + const executedBranches = metrics.executed_branches; + const totalBranches = metrics.total_branches; + + // Create line coverage count + const lineCoverageCount = new TestCoverageCount(linesCovered.length, linesCovered.length + linesMissed.length); + + // Create FileCoverage object + const uri = Uri.file(fileNameStr); + let fileCoverage: FileCoverage; + + if (totalBranches === -1) { + // Branch coverage was not enabled and should not be displayed + fileCoverage = new FileCoverage(uri, lineCoverageCount); + } else { + const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches); + fileCoverage = new FileCoverage(uri, lineCoverageCount, branchCoverageCount); + } + + // Create detailed coverage array for line-level coverage (not branch level) + const detailedCoverageArray = this.createDetailedCoverage(linesCovered, linesMissed); + + return { fileCoverage, detailedCoverageArray }; + } + + /** + * Create detailed coverage array for a file. + * This generates StatementCoverage objects for each covered and missed line. + * + * @param linesCovered - Array of 1-indexed line numbers that were covered + * @param linesMissed - Array of 1-indexed line numbers that were not covered + * @returns Array of FileCoverageDetail (StatementCoverage) objects + */ + private createDetailedCoverage(linesCovered: number[], linesMissed: number[]): FileCoverageDetail[] { + const detailedCoverageArray: FileCoverageDetail[] = []; + + // Process covered lines + for (const line of linesCovered) { + // Line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number + // true value means line is covered + const statementCoverage = new StatementCoverage( + true, + new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), + ); + detailedCoverageArray.push(statementCoverage); + } + + // Process missed lines + for (const line of linesMissed) { + // Line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number + // false value means line is NOT covered + const statementCoverage = new StatementCoverage( + false, + new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), + ); + detailedCoverageArray.push(statementCoverage); + } + + return detailedCoverageArray; + } + + /** + * Create FileCoverage object from metrics. + * This is a convenience method that can be used by callers who need + * to create FileCoverage objects outside of the main processing flow. + * + * @param uri - URI of the file + * @param metrics - Coverage metrics for the file + * @returns FileCoverage object + */ + public createFileCoverage(uri: Uri, metrics: FileCoverageMetrics): FileCoverage { + const linesCovered = metrics.lines_covered ?? []; + const linesMissed = metrics.lines_missed ?? []; + const executedBranches = metrics.executed_branches; + const totalBranches = metrics.total_branches; + + const lineCoverageCount = new TestCoverageCount(linesCovered.length, linesCovered.length + linesMissed.length); + + if (totalBranches === -1) { + return new FileCoverage(uri, lineCoverageCount); + } else { + const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches); + return new FileCoverage(uri, lineCoverageCount, branchCoverageCount); + } + } +} diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts new file mode 100644 index 000000000000..43b799d0c6af --- /dev/null +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, MarkdownString, TestController, TestItem, Uri } from 'vscode'; +import * as util from 'util'; +import { DiscoveredTestPayload, ITestResultResolver } from './types'; +import { TestProvider } from '../../types'; +import { traceError } from '../../../logging'; +import { Testing } from '../../../common/utils/localize'; +import { createErrorTestItem } from './testItemUtilities'; +import { sendTelemetryEvent } from '../../../telemetry'; +import { EventName } from '../../../telemetry/constants'; +import { buildErrorNodeOptions, populateTestTree } from './utils'; + +/** + * TestDiscoveryHandler - Stateless processor for test discovery payloads. + * + * This handler processes discovery payloads and builds/updates the TestItem tree. + * It is designed to be stateless and shared across all workspaces. + * + * Responsibilities: + * - Parse DiscoveredTestPayload and create/update TestItems + * - Handle discovery errors and create error nodes + * - Populate test tree structure + * - Send telemetry events for discovery completion + * + * The handler calls resultResolver methods to register test items in the index + * as it builds the tree, maintaining the bridge between discovery and the + * persistent state needed for execution. + */ +export class TestDiscoveryHandler { + /** + * Process discovery payload and update test tree. + * This is the main entry point for handling discovery results. + * + * @param payload - The discovery payload from the Python subprocess + * @param testController - VS Code TestController to update + * @param resultResolver - Result resolver for registering test items (provides index access) + * @param workspaceUri - URI of the workspace being discovered + * @param testProvider - Test framework provider ('pytest' or 'unittest') + * @param token - Optional cancellation token + */ + public processDiscovery( + payload: DiscoveredTestPayload, + testController: TestController, + resultResolver: ITestResultResolver, + workspaceUri: Uri, + testProvider: TestProvider, + token?: CancellationToken, + ): void { + const workspacePath = workspaceUri.fsPath; + + // Check if there were any errors in the discovery process. + if (payload.status === 'error') { + this.handleDiscoveryError(payload, testController, workspaceUri, workspacePath, testProvider); + } else { + // Remove error node only if no errors exist. + testController.items.delete(`DiscoveryError:${workspacePath}`); + } + + if (payload.tests || payload.tests === null) { + // If any tests exist, they should be populated in the test tree, + // regardless of whether there were errors or not. + // Parse and insert test data. + // Note: The testItemIndex is cleared by the caller (PythonResultResolver._resolveDiscovery) + // before calling this handler, so we don't need to clear it here. + + // If the test root for this folder exists: Workspace refresh, update its children. + // Otherwise, it is a freshly discovered workspace, and we need to create a new test root + // and populate the test tree. + populateTestTree(testController, payload.tests, undefined, resultResolver, token); + } + + sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { + tool: testProvider, + failed: false, + }); + } + + /** + * Handle discovery errors by creating or updating error nodes in the test tree. + * + * @param payload - The discovery payload containing error information + * @param testController - VS Code TestController to update + * @param workspaceUri - URI of the workspace + * @param workspacePath - File system path of the workspace + * @param testProvider - Test framework provider + */ + private handleDiscoveryError( + payload: DiscoveredTestPayload, + testController: TestController, + workspaceUri: Uri, + workspacePath: string, + testProvider: TestProvider, + ): void { + const testingErrorConst = + testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery; + const { error } = payload; + traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); + + let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`); + const message = util.format( + `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, + error?.join('\r\n\r\n') ?? '', + ); + + if (errorNode === undefined) { + const options = buildErrorNodeOptions(workspaceUri, message, testProvider); + errorNode = createErrorTestItem(testController, options); + testController.items.add(errorNode); + } + + const errorNodeLabel: MarkdownString = new MarkdownString( + `[Show output](command:python.viewOutput) to view error logs`, + ); + errorNodeLabel.isTrusted = true; + errorNode.error = errorNodeLabel; + } + + /** + * Create an error node for discovery failures. + * This is a convenience method that can be used by callers who need + * to create error nodes outside of the main discovery flow. + * + * @param testController - VS Code TestController to create the item in + * @param workspaceUri - URI of the workspace + * @param message - Error message to display + * @param testProvider - Test framework provider + * @returns The created error TestItem + */ + public createErrorNode( + testController: TestController, + workspaceUri: Uri, + message: string, + testProvider: TestProvider, + ): TestItem { + const options = buildErrorNodeOptions(workspaceUri, message, testProvider); + const errorNode = createErrorTestItem(testController, options); + testController.items.add(errorNode); + + const errorNodeLabel: MarkdownString = new MarkdownString( + `[Show output](command:python.viewOutput) to view error logs`, + ); + errorNodeLabel.isTrusted = true; + errorNode.error = errorNodeLabel; + + return errorNode; + } +} diff --git a/src/client/testing/testController/common/testExecutionHandler.ts b/src/client/testing/testController/common/testExecutionHandler.ts new file mode 100644 index 000000000000..20446b7c80d4 --- /dev/null +++ b/src/client/testing/testController/common/testExecutionHandler.ts @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestMessage, Location, TestRun } from 'vscode'; +import { ExecutionTestPayload, ITestResultResolver, SubtestStats } from './types'; +import { splitLines } from '../../../common/stringUtils'; +import { splitTestNameWithRegex } from './utils'; +import { clearAllChildren } from './testItemUtilities'; +import { TestItemIndex } from './testItemIndex'; + +// Re-export SubtestStats for backward compatibility +export { SubtestStats } from './types'; + +/** + * TestExecutionHandler - Stateless processor for test execution payloads. + * + * This handler processes execution payloads and updates TestRun instances with results. + * It is designed to be stateless and shared across all workspaces. + * + * Responsibilities: + * - Parse ExecutionTestPayload and update TestRun with results (passed/failed/skipped/errored) + * - Look up TestItems using TestItemIndex + * - Handle subtests (create child TestItems dynamically) + * - Process test outcomes and create TestMessages + * + * The handler returns subtest statistics rather than storing them, allowing + * the caller (resolver or adapter) to decide how to manage this transient state. + */ +export class TestExecutionHandler { + /** + * Process execution payload and update test run. + * This is the main entry point for handling execution results. + * + * @param payload - The execution payload from the Python subprocess + * @param runInstance - VS Code TestRun to update with results + * @param testItemIndex - Index for looking up TestItems by run ID + * @param testController - VS Code TestController for creating subtest items + * @param resultResolver - Result resolver for accessing ID mappings (for subtests) + * @returns Map of subtest statistics for caller to manage + */ + public processExecution( + payload: ExecutionTestPayload, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + resultResolver: ITestResultResolver, + ): Map { + const subtestStats = new Map(); + + if (payload !== undefined && payload.result !== undefined) { + for (const keyTemp of Object.keys(payload.result)) { + const testItem = payload.result[keyTemp]; + + // Route to outcome-specific handlers + this.handleTestOutcome( + keyTemp, + testItem, + runInstance, + testItemIndex, + testController, + resultResolver, + subtestStats, + ); + } + } + + return subtestStats; + } + + /** + * Route test result to appropriate outcome handler based on outcome type. + */ + private handleTestOutcome( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + resultResolver: ITestResultResolver, + subtestStats: Map, + ): void { + if (testItem.outcome === 'error') { + this.handleTestError(runId, testItem, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'failure' || testItem.outcome === 'passed-unexpected') { + this.handleTestFailure(runId, testItem, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'success' || testItem.outcome === 'expected-failure') { + this.handleTestSuccess(runId, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'skipped') { + this.handleTestSkipped(runId, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'subtest-failure') { + this.handleSubtestFailure(runId, testItem, runInstance, testController, resultResolver, subtestStats); + } else if (testItem.outcome === 'subtest-success') { + this.handleSubtestSuccess(runId, runInstance, testController, resultResolver, subtestStats); + } + } + + /** + * Handle test items that errored during execution. + * Extracts error details, finds the corresponding TestItem, and reports the error to VS Code's Test Explorer. + */ + private handleTestError( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const rawTraceback = testItem.traceback ?? ''; + const traceback = splitLines(rawTraceback, { + trim: false, + removeEmptyEntries: true, + }).join('\r\n'); + const text = `${testItem.test} failed with error: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + const message = new TestMessage(text); + + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem?.uri) { + if (foundItem.range) { + message.location = new Location(foundItem.uri, foundItem.range); + } + runInstance.errored(foundItem, message); + } + } + + /** + * Handle test items that failed during execution. + */ + private handleTestFailure( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const rawTraceback = testItem.traceback ?? ''; + const traceback = splitLines(rawTraceback, { + trim: false, + removeEmptyEntries: true, + }).join('\r\n'); + + const text = `${testItem.test} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + const message = new TestMessage(text); + + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem?.uri) { + if (foundItem.range) { + message.location = new Location(foundItem.uri, foundItem.range); + } + runInstance.failed(foundItem, message); + } + } + + /** + * Handle test items that passed during execution. + */ + private handleTestSuccess( + runId: string, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const foundItem = testItemIndex.getTestItem(runId, testController); + if (foundItem?.uri) { + runInstance.passed(foundItem); + } + } + + /** + * Handle test items that were skipped during execution. + */ + private handleTestSkipped( + runId: string, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const foundItem = testItemIndex.getTestItem(runId, testController); + if (foundItem?.uri) { + runInstance.skipped(foundItem); + } + } + + /** + * Handle subtest failures. + * Creates a child TestItem for the subtest and reports it as failed. + */ + private handleSubtestFailure( + runId: string, + testItem: any, + runInstance: TestRun, + testController: TestController, + resultResolver: ITestResultResolver, + subtestStats: Map, + ): void { + const [parentTestCaseId, subtestId] = splitTestNameWithRegex(runId); + const parentTestItem = resultResolver.runIdToTestItem.get(parentTestCaseId); + + if (parentTestItem) { + const stats = subtestStats.get(parentTestCaseId); + if (stats) { + stats.failed += 1; + } else { + subtestStats.set(parentTestCaseId, { + failed: 1, + passed: 0, + }); + clearAllChildren(parentTestItem); + } + + const subTestItem = testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); + + if (subTestItem) { + const traceback = testItem.traceback ?? ''; + const text = `${testItem.subtest} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + const message = new TestMessage(text); + if (parentTestItem.uri && parentTestItem.range) { + message.location = new Location(parentTestItem.uri, parentTestItem.range); + } + runInstance.failed(subTestItem, message); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } + } + + /** + * Handle subtest successes. + * Creates a child TestItem for the subtest and reports it as passed. + */ + private handleSubtestSuccess( + runId: string, + runInstance: TestRun, + testController: TestController, + resultResolver: ITestResultResolver, + subtestStats: Map, + ): void { + const [parentTestCaseId, subtestId] = splitTestNameWithRegex(runId); + const parentTestItem = resultResolver.runIdToTestItem.get(parentTestCaseId); + + if (parentTestItem) { + const stats = subtestStats.get(parentTestCaseId); + if (stats) { + stats.passed += 1; + } else { + subtestStats.set(parentTestCaseId, { failed: 0, passed: 1 }); + clearAllChildren(parentTestItem); + } + + const subTestItem = testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); + + if (subTestItem) { + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + runInstance.passed(subTestItem); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } + } +} diff --git a/src/client/testing/testController/common/testItemIndex.ts b/src/client/testing/testController/common/testItemIndex.ts new file mode 100644 index 000000000000..2b235ec10ebc --- /dev/null +++ b/src/client/testing/testController/common/testItemIndex.ts @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestItem } from 'vscode'; +import { traceError, traceVerbose } from '../../../logging'; + +// Re-export SubtestStats from types for backward compatibility +export { SubtestStats } from './types'; + +/** + * TestItemIndex - Manages persistent ID mappings between Python test IDs and VS Code TestItems. + * + * This is the only stateful component in the new design. It provides: + * - Storage for bidirectional mappings: runId ↔ TestItem, runId ↔ vsId + * - Efficient O(1) lookup methods + * - Stale reference cleanup when tests are removed + * - Validation that TestItem references are still in the tree + * + * Lifecycle: + * - Created: When PythonResultResolver is instantiated (during workspace activation) + * - Populated: During discovery - each discovered test registers its mappings + * - Queried: During execution - to look up TestItems by Python run ID + * - Cleared: When discovery runs again (fresh start) or workspace is disposed + * - Cleaned: Periodically to remove stale references to deleted tests + */ +export class TestItemIndex { + /** + * Map from Python run ID (e.g., "test_file.py::test_example") to VS Code TestItem + * Used for O(1) lookup during execution result processing + */ + private _runIdToTestItem: Map; + + /** + * Map from Python run ID to VS Code ID + * Used as fallback when TestItem reference is stale + */ + private _runIdToVSid: Map; + + /** + * Map from VS Code ID to Python run ID + * Used by WorkspaceTestAdapter.executeTests() to convert selected tests to Python IDs + */ + private _vsIdToRunId: Map; + + constructor() { + this._runIdToTestItem = new Map(); + this._runIdToVSid = new Map(); + this._vsIdToRunId = new Map(); + } + + /** + * Get the runId to TestItem map (read-only access for backward compatibility) + */ + public get runIdToTestItem(): Map { + return this._runIdToTestItem; + } + + /** + * Get the runId to VS Code ID map (read-only access for backward compatibility) + */ + public get runIdToVSid(): Map { + return this._runIdToVSid; + } + + /** + * Get the VS Code ID to runId map (read-only access for backward compatibility) + */ + public get vsIdToRunId(): Map { + return this._vsIdToRunId; + } + + /** + * Register a test item with its Python run ID and VS Code ID. + * Called during DISCOVERY to populate the index. + * + * @param runId - Python run ID (e.g., "test_file.py::test_example") + * @param vsId - VS Code TestItem ID + * @param testItem - The VS Code TestItem reference + */ + public registerTestItem(runId: string, vsId: string, testItem: TestItem): void { + this._runIdToTestItem.set(runId, testItem); + this._runIdToVSid.set(runId, vsId); + this._vsIdToRunId.set(vsId, runId); + } + + /** + * Get TestItem by Python run ID with validation and fallback strategies. + * Called during EXECUTION to look up tests. + * + * Uses a three-tier approach: + * 1. Direct O(1) lookup in runIdToTestItem map + * 2. If stale, try vsId mapping and search controller + * 3. Fall back to full tree search if needed + * + * @param runId - Python run ID to look up + * @param testController - TestController to search if cached reference is stale + * @returns TestItem if found, undefined otherwise + */ + public getTestItem(runId: string, testController: TestController): TestItem | undefined { + // Try direct O(1) lookup first + const directItem = this._runIdToTestItem.get(runId); + if (directItem) { + // Validate the item is still in the test tree + if (this.isTestItemValid(directItem, testController)) { + return directItem; + } else { + // Clean up stale reference + this._runIdToTestItem.delete(runId); + } + } + + // Try vsId mapping as fallback + const vsId = this._runIdToVSid.get(runId); + if (vsId) { + // Search by VS Code ID in the controller + let foundItem: TestItem | undefined; + testController.items.forEach((item) => { + if (item.id === vsId) { + foundItem = item; + return; + } + if (!foundItem) { + item.children.forEach((child) => { + if (child.id === vsId) { + foundItem = child; + } + }); + } + }); + + if (foundItem) { + // Cache for future lookups + this._runIdToTestItem.set(runId, foundItem); + return foundItem; + } else { + // Clean up stale mapping + this._runIdToVSid.delete(runId); + this._vsIdToRunId.delete(vsId); + } + } + + // Last resort: full tree search (logged as it's expensive) + traceError(`Falling back to tree search for test: ${runId}`); + return this.searchTreeForTestItem(vsId, testController); + } + + /** + * Get Python run ID from VS Code ID. + * Called by WorkspaceTestAdapter.executeTests() to convert selected tests to Python IDs. + * + * @param vsId - VS Code TestItem ID + * @returns Python run ID if found, undefined otherwise + */ + public getRunId(vsId: string): string | undefined { + return this._vsIdToRunId.get(vsId); + } + + /** + * Get VS Code ID from Python run ID. + * + * @param runId - Python run ID + * @returns VS Code ID if found, undefined otherwise + */ + public getVSId(runId: string): string | undefined { + return this._runIdToVSid.get(runId); + } + + /** + * Check if a TestItem is still valid (exists in the TestController tree). + * + * Time Complexity: O(depth) where depth is the maximum nesting level of the test tree. + * In most cases this is O(1) to O(3) since test trees are typically shallow. + * + * @param testItem - TestItem to validate + * @param testController - TestController that owns the test tree + * @returns true if the item is valid and in the tree + */ + public isTestItemValid(testItem: TestItem, testController: TestController): boolean { + // Simple validation: check if the item's parent chain leads back to the controller + let current: TestItem | undefined = testItem; + while (current?.parent) { + current = current.parent; + } + + // If we reached a root item, check if it's in the controller + if (current) { + return testController.items.get(current.id) === current; + } + + // If no parent chain, check if it's directly in the controller + return testController.items.get(testItem.id) === testItem; + } + + /** + * Remove all mappings. + * Called at the start of discovery to ensure clean state. + */ + public clear(): void { + this._runIdToTestItem.clear(); + this._runIdToVSid.clear(); + this._vsIdToRunId.clear(); + } + + /** + * Clean up stale references that no longer exist in the test tree. + * Called after test tree modifications. + * + * @param testController - TestController to validate items against + */ + public cleanupStaleReferences(testController: TestController): void { + const staleRunIds: string[] = []; + + // Check all runId->TestItem mappings + this._runIdToTestItem.forEach((testItem, runId) => { + if (!this.isTestItemValid(testItem, testController)) { + staleRunIds.push(runId); + } + }); + + // Remove stale entries + staleRunIds.forEach((runId) => { + const vsId = this._runIdToVSid.get(runId); + this._runIdToTestItem.delete(runId); + this._runIdToVSid.delete(runId); + if (vsId) { + this._vsIdToRunId.delete(vsId); + } + }); + + if (staleRunIds.length > 0) { + traceVerbose(`Cleaned up ${staleRunIds.length} stale test item references`); + } + } + + /** + * Search the entire test tree for a test item by VS Code ID. + * This is expensive and should only be used as a last resort. + * + * @param vsId - VS Code ID to search for + * @param testController - TestController containing the test tree + * @returns TestItem if found, undefined otherwise + */ + private searchTreeForTestItem(vsId: string | undefined, testController: TestController): TestItem | undefined { + if (!vsId) { + return undefined; + } + + const testCases: TestItem[] = []; + testController.items.forEach((item) => { + this.collectTestCasesRecursively(item, testCases); + }); + + return testCases.find((item) => item.id === vsId); + } + + /** + * Recursively collect all test case items from a test item and its children. + * + * @param testItem - Root item to start collecting from + * @param testCases - Array to collect test cases into + */ + private collectTestCasesRecursively(testItem: TestItem, testCases: TestItem[]): void { + if (!testItem.canResolveChildren) { + // This is a leaf node (test case) + testCases.push(testItem); + } + testItem.children.forEach((child) => { + this.collectTestCasesRecursively(child, testCases); + }); + } +} diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 6121b3e24442..b94ec4963376 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -238,3 +238,12 @@ export type ExecutionTestPayload = { notFound?: string[]; error: string; }; + +/** + * Interface defining subtest statistics for a parent test. + * Used to track pass/fail counts for subtests during execution. + */ +export interface SubtestStats { + passed: number; + failed: number; +}