diff --git a/pythonFiles/tests/unittestadapter/test_discovery.py b/pythonFiles/tests/unittestadapter/test_discovery.py index 7e626b1fc4e2..30ccb7ef4079 100644 --- a/pythonFiles/tests/unittestadapter/test_discovery.py +++ b/pythonFiles/tests/unittestadapter/test_discovery.py @@ -10,9 +10,8 @@ DEFAULT_PORT, discover_tests, parse_discovery_cli_args, - parse_unittest_discovery_args, ) -from unittestadapter.utils import TestNodeTypeEnum +from unittestadapter.utils import TestNodeTypeEnum, parse_unittest_args from .helpers import TEST_DATA_PATH, is_same_tree @@ -68,7 +67,7 @@ def test_parse_unittest_args(args: List[str], expected: List[str]) -> None: """The parse_unittest_args function should return values for the start_dir, pattern, and top_level_dir arguments when passed as command-line options, and ignore unrecognized arguments. """ - actual = parse_unittest_discovery_args(args) + actual = parse_unittest_args(args) assert actual == expected @@ -132,7 +131,6 @@ def test_simple_discovery() -> None: actual = discover_tests(start_dir, pattern, None, uuid) assert actual["status"] == "success" - assert actual["uuid"] == uuid assert is_same_tree(actual.get("tests"), expected) assert "errors" not in actual @@ -148,7 +146,6 @@ def test_empty_discovery() -> None: actual = discover_tests(start_dir, pattern, None, uuid) assert actual["status"] == "success" - assert actual["uuid"] == uuid assert "tests" not in actual assert "errors" not in actual @@ -215,6 +212,5 @@ def test_error_discovery() -> None: actual = discover_tests(start_dir, pattern, None, uuid) assert actual["status"] == "error" - assert actual["uuid"] == uuid assert is_same_tree(expected, actual.get("tests")) assert len(actual.get("errors", [])) == 1 diff --git a/pythonFiles/tests/unittestadapter/test_utils.py b/pythonFiles/tests/unittestadapter/test_utils.py index 05c745e05e86..a3bc1dd7693c 100644 --- a/pythonFiles/tests/unittestadapter/test_utils.py +++ b/pythonFiles/tests/unittestadapter/test_utils.py @@ -195,6 +195,7 @@ def test_build_simple_tree() -> None: "type_": TestNodeTypeEnum.test, "lineno": "13", "id_": file_path + "\\" + "TreeOne" + "\\" + "test_one", + "runID": "utils_simple_tree.TreeOne.test_one", }, { "name": "test_two", @@ -202,6 +203,7 @@ def test_build_simple_tree() -> None: "type_": TestNodeTypeEnum.test, "lineno": "16", "id_": file_path + "\\" + "TreeOne" + "\\" + "test_two", + "runID": "utils_simple_tree.TreeOne.test_two", }, ], "id_": file_path + "\\" + "TreeOne", @@ -253,6 +255,7 @@ def test_build_decorated_tree() -> None: "type_": TestNodeTypeEnum.test, "lineno": "24", "id_": file_path + "\\" + "TreeOne" + "\\" + "test_one", + "runID": "utils_decorated_tree.TreeOne.test_one", }, { "name": "test_two", @@ -260,6 +263,7 @@ def test_build_decorated_tree() -> None: "type_": TestNodeTypeEnum.test, "lineno": "28", "id_": file_path + "\\" + "TreeOne" + "\\" + "test_two", + "runID": "utils_decorated_tree.TreeOne.test_two", }, ], "id_": file_path + "\\" + "TreeOne", diff --git a/pythonFiles/unittestadapter/discovery.py b/pythonFiles/unittestadapter/discovery.py index 396f99f7996c..0be09e986ca8 100644 --- a/pythonFiles/unittestadapter/discovery.py +++ b/pythonFiles/unittestadapter/discovery.py @@ -17,7 +17,7 @@ from testing_tools import socket_manager # If I use from utils then there will be an import error in test_discovery.py. -from unittestadapter.utils import TestNode, build_test_tree +from unittestadapter.utils import TestNode, build_test_tree, parse_unittest_args # Add the lib path to sys.path to find the typing_extensions module. sys.path.insert(0, os.path.join(PYTHON_FILES, "lib", "python")) @@ -44,35 +44,8 @@ def parse_discovery_cli_args(args: List[str]) -> Tuple[int, Union[str, None]]: return int(parsed_args.port), parsed_args.uuid -def parse_unittest_discovery_args(args: List[str]) -> Tuple[str, str, Union[str, None]]: - """Parse command-line arguments that should be forwarded to unittest to perform discovery. - - Valid unittest arguments are: -v, -s, -p, -t and their long-form counterparts, - however we only care about the last three. - - The returned tuple contains the following items - - start_directory: The directory where to start discovery, defaults to . - - pattern: The pattern to match test files, defaults to test*.py - - top_level_directory: The top-level directory of the project, defaults to None, and unittest will use start_directory behind the scenes. - """ - - arg_parser = argparse.ArgumentParser() - arg_parser.add_argument("--start-directory", "-s", default=".") - arg_parser.add_argument("--pattern", "-p", default="test*.py") - arg_parser.add_argument("--top-level-directory", "-t", default=None) - - parsed_args, _ = arg_parser.parse_known_args(args) - - return ( - parsed_args.start_directory, - parsed_args.pattern, - parsed_args.top_level_directory, - ) - - class PayloadDict(TypedDict): cwd: str - uuid: Union[str, None] status: Literal["success", "error"] tests: NotRequired[TestNode] errors: NotRequired[List[str]] @@ -112,7 +85,7 @@ def discover_tests( } """ cwd = os.path.abspath(start_dir) - payload: PayloadDict = {"cwd": cwd, "status": "success", "uuid": uuid} + payload: PayloadDict = {"cwd": cwd, "status": "success"} tests = None errors: List[str] = [] @@ -140,7 +113,7 @@ def discover_tests( argv = sys.argv[1:] index = argv.index("--udiscovery") - start_dir, pattern, top_level_dir = parse_unittest_discovery_args(argv[index + 1 :]) + start_dir, pattern, top_level_dir = parse_unittest_args(argv[index + 1 :]) # Perform test discovery. port, uuid = parse_discovery_cli_args(argv[:index]) @@ -154,6 +127,7 @@ def discover_tests( Host: localhost:{port} Content-Length: {len(data)} Content-Type: application/json +Request-uuid: {uuid} {data}""" result = s.socket.sendall(request.encode("utf-8")) # type: ignore diff --git a/pythonFiles/unittestadapter/execution.py b/pythonFiles/unittestadapter/execution.py index e4b8393ef4ef..a016ff1af9ec 100644 --- a/pythonFiles/unittestadapter/execution.py +++ b/pythonFiles/unittestadapter/execution.py @@ -11,18 +11,14 @@ from types import TracebackType from typing import Dict, List, Optional, Tuple, Type, TypeAlias, TypedDict -from typing_extensions import NotRequired - -from .discovery import parse_unittest_discovery_args - # Add the path to pythonFiles to sys.path to find testing_tools.socket_manager. PYTHON_FILES = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, PYTHON_FILES) - -# from testing_tools import socket_manager - # Add the lib path to sys.path to find the typing_extensions module. sys.path.insert(0, os.path.join(PYTHON_FILES, "lib", "python")) +from testing_tools import socket_manager +from typing_extensions import NotRequired +from unittestadapter.utils import parse_unittest_args DEFAULT_PORT = "45454" @@ -41,15 +37,13 @@ def parse_execution_cli_args(args: List[str]) -> Tuple[int, str | None, List[str arg_parser = argparse.ArgumentParser() arg_parser.add_argument("--port", default=DEFAULT_PORT) arg_parser.add_argument("--uuid") - arg_parser.add_argument("--testids") + arg_parser.add_argument("--testids", nargs="+") parsed_args, _ = arg_parser.parse_known_args(args) - test_ids: List[str] = parsed_args.testids.split(",") if parsed_args.testids else [] - - return (int(parsed_args.port), parsed_args.uuid, test_ids) + return (int(parsed_args.port), parsed_args.uuid, parsed_args.testids) -ErrorType: TypeAlias = ( +ErrorType = ( Tuple[Type[BaseException], BaseException, TracebackType] | Tuple[None, None, None] ) @@ -66,6 +60,7 @@ class TestOutcomeEnum(str, enum.Enum): class UnittestTestResult(unittest.TextTestResult): + formatted: Dict[str, Dict[str, str | None]] = dict() def startTest(self, test: unittest.TestCase): @@ -152,7 +147,6 @@ class TestExecutionStatus(str, enum.Enum): class PayloadDict(TypedDict): cwd: str - uuid: str | None status: TestExecutionStatus result: NotRequired[TestResultTypeAlias] not_found: NotRequired[List[str]] @@ -167,14 +161,14 @@ class PayloadDict(TypedDict): def run_tests( start_dir: str, test_ids: List[str], - pattern: Optional[str], + pattern: str, top_level_dir: Optional[str], uuid: Optional[str], ) -> PayloadDict: cwd = os.path.abspath(start_dir) - status = TestExecutionStatus.success + status = TestExecutionStatus.error error = None - payload: PayloadDict = {"cwd": cwd, "uuid": uuid, "status": status} + payload: PayloadDict = {"cwd": cwd, "status": status} try: # If it's a file, split path and file name. @@ -191,20 +185,17 @@ def run_tests( "pattern": pattern, "top_level_dir": top_level_dir, } - suite = loader.discover(**{k: v for k, v in args.items() if v is not None}) + suite = loader.discover(start_dir, pattern, top_level_dir) # Run tests. runner = unittest.TextTestRunner(resultclass=UnittestTestResult) - result: UnittestTestResult = runner.run(suite) # type: ignore + # lets try to tailer our own suite so we can figure out running only the ones we want + loader = unittest.TestLoader() + tailor: unittest.TestSuite = loader.loadTestsFromNames(test_ids) + result: UnittestTestResult = runner.run(tailor) # type: ignore - # Filter tests by id. - filtered_results = {k: v for k, v in result.formatted.items() if k in test_ids} - payload["result"] = filtered_results + payload["result"] = result.formatted - # Add a payload entry with the list of test ids for tests that weren't found. - not_found = set(test_ids) - set(filtered_results.keys()) - if not_found: - payload["not_found"] = list(not_found) except Exception: status = TestExecutionStatus.error error = traceback.format_exc() @@ -214,7 +205,7 @@ def run_tests( payload["status"] = status - print(f"payload: \n{json.dumps(payload, indent=4)}") + # print(f"payload: \n{json.dumps(payload, indent=4)}") return payload @@ -224,29 +215,21 @@ def run_tests( argv = sys.argv[1:] index = argv.index("--udiscovery") - start_dir, pattern, top_level_dir = parse_unittest_discovery_args(argv[index + 1 :]) - - # start_path = pathlib.Path.home() / "Documents" / "Sandbox" / "unittest-subtest" - # test_ids = [ - # "subfolder.test_two.TestClassTwo.test_two_two", - # "test_one.TestClassOne.test_func_one", - # "test_eight.TestClassEight.test_func_eight", - # ] - # uuid = "abcd" + start_dir, pattern, top_level_dir = parse_unittest_args(argv[index + 1 :]) # Perform test execution. - port, uuid, test_ids = parse_execution_cli_args(argv[:index]) - run_tests(start_dir, test_ids, pattern, top_level_dir, uuid) - - -# # Build the request data (it has to be a POST request or the Node side will not process it), and send it. -# addr = ("localhost", port) -# with socket_manager.SocketManager(addr) as s: -# data = json.dumps(payload) -# request = f"""POST / HTTP/1.1 -# Host: localhost:{port} -# Content-Length: {len(data)} -# Content-Type: application/json - -# {data}""" -# result = s.socket.sendall(request.encode("utf-8")) # type: ignore + port, uuid, testids = parse_execution_cli_args(argv[:index]) + payload = run_tests(start_dir, testids, pattern, top_level_dir, uuid) + + # Build the request data (it has to be a POST request or the Node side will not process it), and send it. + addr = ("localhost", port) + with socket_manager.SocketManager(addr) as s: + data = json.dumps(payload) + request = f"""POST / HTTP/1.1 +Host: localhost:{port} +Content-Length: {len(data)} +Content-Type: application/json +Request-uuid: {uuid} + +{data}""" + result = s.socket.sendall(request.encode("utf-8")) # type: ignore diff --git a/pythonFiles/unittestadapter/utils.py b/pythonFiles/unittestadapter/utils.py index 44638071520d..568ff30ee92d 100644 --- a/pythonFiles/unittestadapter/utils.py +++ b/pythonFiles/unittestadapter/utils.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import argparse import enum import inspect import os @@ -28,6 +29,7 @@ class TestData(TypedDict): class TestItem(TestData): lineno: str + runID: str class TestNode(TestData): @@ -190,6 +192,7 @@ def build_test_tree( "lineno": lineno, "type_": TestNodeTypeEnum.test, "id_": file_path + "\\" + class_name + "\\" + function_name, + "runID": test_id, } # concatenate class name and function test name current_node["children"].append(test_node) @@ -197,3 +200,29 @@ def build_test_tree( root = None return root, errors + + +def parse_unittest_args(args: List[str]) -> Tuple[str, str, Union[str, None]]: + """Parse command-line arguments that should be forwarded to unittest to perform discovery. + + Valid unittest arguments are: -v, -s, -p, -t and their long-form counterparts, + however we only care about the last three. + + The returned tuple contains the following items + - start_directory: The directory where to start discovery, defaults to . + - pattern: The pattern to match test files, defaults to test*.py + - top_level_directory: The top-level directory of the project, defaults to None, and unittest will use start_directory behind the scenes. + """ + + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument("--start-directory", "-s", default=".") + arg_parser.add_argument("--pattern", "-p", default="test*.py") + arg_parser.add_argument("--top-level-directory", "-t", default=None) + + parsed_args, _ = arg_parser.parse_known_args(args) + + return ( + parsed_args.start_directory, + parsed_args.pattern, + parsed_args.top_level_directory, + ) diff --git a/src/client/common/process/internal/scripts/index.ts b/src/client/common/process/internal/scripts/index.ts index fc275209a55a..e0749cc18c67 100644 --- a/src/client/common/process/internal/scripts/index.ts +++ b/src/client/common/process/internal/scripts/index.ts @@ -138,6 +138,13 @@ export function visualstudio_py_testlauncher(testArgs: string[]): string[] { return [script, ...testArgs]; } +// execution.py +// eslint-disable-next-line camelcase +export function execution_py_testlauncher(testArgs: string[]): string[] { + const script = path.join(SCRIPTS_DIR, 'unittestadapter', 'execution.py'); + return [script, ...testArgs]; +} + // tensorboard_launcher.py export function tensorboardLauncher(args: string[]): string[] { diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 5690e64bcfce..5e667d023a09 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -514,6 +514,11 @@ export namespace Testing { ); export const errorUnittestDiscovery = localize('Testing.errorUnittestDiscovery', 'Unittest test discovery error'); export const seePythonOutput = localize('Testing.seePythonOutput', '(see Output > Python)'); + export const cancelUnittestExecution = localize( + 'Testing.cancelUnittestExecution', + 'Canceled unittest test execution', + ); + export const errorUnittestExecution = localize('Testing.errorUnittestExecution', 'Unittest test execution error'); } export namespace OutdatedDebugger { diff --git a/src/client/testing/common/debugLauncher.ts b/src/client/testing/common/debugLauncher.ts index b5213db56ea2..5c8bfd537f79 100644 --- a/src/client/testing/common/debugLauncher.ts +++ b/src/client/testing/common/debugLauncher.ts @@ -211,7 +211,8 @@ export class DebugLauncher implements ITestDebugLauncher { private getTestLauncherScript(testProvider: TestProvider) { switch (testProvider) { case 'unittest': { - return internalScripts.visualstudio_py_testlauncher; + return internalScripts.visualstudio_py_testlauncher; // old way unittest execution, debugger + // return internalScripts.execution_py_testlauncher; // this is the new way to run unittest execution, debugger } case 'pytest': { return internalScripts.testlauncher; diff --git a/src/client/testing/testController/common/server.ts b/src/client/testing/testController/common/server.ts index 9193275a1a21..8e6d2fac3829 100644 --- a/src/client/testing/testController/common/server.ts +++ b/src/client/testing/testController/common/server.ts @@ -13,6 +13,8 @@ import { import { traceLog } from '../../../logging'; import { DataReceivedEvent, ITestServer, TestCommandOptions } from './types'; import { DEFAULT_TEST_PORT } from './utils'; +import { ITestDebugLauncher, LaunchOptions } from '../../common/types'; +import { UNITTEST_PROVIDER } from '../../common/constants'; export class PythonTestServer implements ITestServer, Disposable { private _onDataReceived: EventEmitter = new EventEmitter(); @@ -23,7 +25,7 @@ export class PythonTestServer implements ITestServer, Disposable { public port: number; - constructor(private executionFactory: IPythonExecutionFactory) { + constructor(private executionFactory: IPythonExecutionFactory, private debugLauncher: ITestDebugLauncher) { this.uuids = new Map(); this.port = DEFAULT_TEST_PORT; @@ -37,11 +39,12 @@ export class PythonTestServer implements ITestServer, Disposable { } const data = Buffer.concat(buffers).toString(); - + // grab the uuid from the header + const indexRequestuuid = request.rawHeaders.indexOf('Request-uuid'); + const uuid = request.rawHeaders[indexRequestuuid + 1]; response.end(); - const { uuid } = JSON.parse(data); - + JSON.parse(data); // Check if the uuid we received exists in the list of active ones. // If yes, process the response, if not, ignore it. const cwd = this.uuids.get(uuid); @@ -50,7 +53,7 @@ export class PythonTestServer implements ITestServer, Disposable { this.uuids.delete(uuid); } } catch (ex) { - traceLog(`Error processing test server request: ${ex}`); + traceLog(`Error processing test server request: ${ex} observe`); this._onDataReceived.fire({ cwd: '', data: '' }); } }; @@ -88,16 +91,43 @@ export class PythonTestServer implements ITestServer, Disposable { const execService = await this.executionFactory.createActivatedEnvironment(creationOptions); // Add the generated UUID to the data to be sent (expecting to receive it back). - const args = [options.command.script, '--port', this.port.toString(), '--uuid', uuid].concat( - options.command.args, - ); + // first check if we have testIds passed in (in case of execution) and + // insert appropriate flag and test id array + let args = []; + if (options.testIds) { + args = [ + options.command.script, + '--port', + this.port.toString(), + '--uuid', + uuid, + '--testids', + ...options.testIds, + ].concat(options.command.args); + } else { + // if not case of execution, go with the normal args + args = [options.command.script, '--port', this.port.toString(), '--uuid', uuid].concat( + options.command.args, + ); + } if (options.outChannel) { options.outChannel.appendLine(`python ${args.join(' ')}`); } try { - await execService.exec(args, spawnOptions); + if (options.debugBool) { + const launchOptions: LaunchOptions = { + cwd: options.cwd, + args, + token: options.token, + testProvider: UNITTEST_PROVIDER, + }; + + await this.debugLauncher!.launchDebugger(launchOptions); + } else { + await execService.exec(args, spawnOptions); + } } catch (ex) { this.uuids.delete(uuid); this._onDataReceived.fire({ diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 7437bcde5524..5a7a168b146e 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -136,12 +136,19 @@ export type TestDiscoveryCommand = { args: string[]; }; +export type TestExecutionCommand = { + script: string; + args: string[]; +}; + export type TestCommandOptions = { workspaceFolder: Uri; cwd: string; - command: TestDiscoveryCommand; + command: TestDiscoveryCommand | TestExecutionCommand; token?: CancellationToken; outChannel?: OutputChannel; + debugBool?: boolean; + testIds?: string[]; }; /** @@ -159,6 +166,11 @@ export interface ITestDiscoveryAdapter { discoverTests(uri: Uri): Promise; } +// interface for execution/runner adapter +export interface ITestExecutionAdapter { + runTests(uri: Uri, testIds: string[], debugBool?: boolean): Promise; +} + // Same types as in pythonFiles/unittestadapter/utils.py export type DiscoveredTestType = 'folder' | 'file' | 'class' | 'test'; @@ -172,6 +184,7 @@ export type DiscoveredTestCommon = { export type DiscoveredTestItem = DiscoveredTestCommon & { lineno: number; + runID: string; }; export type DiscoveredTestNode = DiscoveredTestCommon & { @@ -184,3 +197,19 @@ export type DiscoveredTestPayload = { status: 'success' | 'error'; errors?: string[]; }; + +export type ExecutionTestPayload = { + cwd: string; + status: 'success' | 'error'; + result?: { + [testRunID: string]: { + test?: string; + outcome?: string; + message?: string; + traceback?: string; + subtest?: string; + }; + }; + notFound?: string[]; + error: string; +}; diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 03101f9bd684..1e4321e9ac7c 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -31,9 +31,17 @@ import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; import { TestProvider } from '../types'; import { PythonTestServer } from './common/server'; import { DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities'; -import { ITestController, ITestDiscoveryAdapter, ITestFrameworkController, TestRefreshOptions } from './common/types'; +import { + ITestController, + ITestDiscoveryAdapter, + ITestFrameworkController, + TestRefreshOptions, + ITestExecutionAdapter, +} from './common/types'; import { UnittestTestDiscoveryAdapter } from './unittest/testDiscoveryAdapter'; import { WorkspaceTestAdapter } from './workspaceTestAdapter'; +import { UnittestTestExecutionAdapter } from './unittest/testExecutionAdapter'; +import { ITestDebugLauncher } from '../common/types'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -78,9 +86,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc @inject(ITestFrameworkController) @named(PYTEST_PROVIDER) private readonly pytest: ITestFrameworkController, @inject(ITestFrameworkController) @named(UNITTEST_PROVIDER) private readonly unittest: ITestFrameworkController, @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, - @inject(IPythonExecutionFactory) private readonly pythonExecFactory: IPythonExecutionFactory, @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, @inject(ICommandManager) private readonly commandManager: ICommandManager, + @inject(IPythonExecutionFactory) private readonly pythonExecFactory: IPythonExecutionFactory, + @inject(ITestDebugLauncher) private readonly debugLauncher: ITestDebugLauncher, ) { this.refreshCancellation = new CancellationTokenSource(); @@ -133,7 +142,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc return this.refreshTestData(undefined, { forceRefresh: true }); }; - this.pythonTestServer = new PythonTestServer(this.pythonExecFactory); + // this.pythonTestServer = new PythonTestServer(this.pythonExecFactory); // old way where debugLauncher did not have to be passed + this.pythonTestServer = new PythonTestServer(this.pythonExecFactory, this.debugLauncher); } public async activate(): Promise { @@ -143,18 +153,26 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const settings = this.configSettings.getSettings(workspace.uri); let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; let testProvider: TestProvider; if (settings.testing.unittestEnabled) { discoveryAdapter = new UnittestTestDiscoveryAdapter(this.pythonTestServer, this.configSettings); + executionAdapter = new UnittestTestExecutionAdapter(this.pythonTestServer, this.configSettings); testProvider = UNITTEST_PROVIDER; } else { // TODO: PYTEST DISCOVERY ADAPTER // this is a placeholder for now discoveryAdapter = new UnittestTestDiscoveryAdapter(this.pythonTestServer, { ...this.configSettings }); + executionAdapter = new UnittestTestExecutionAdapter(this.pythonTestServer, this.configSettings); testProvider = PYTEST_PROVIDER; } - const workspaceTestAdapter = new WorkspaceTestAdapter(testProvider, discoveryAdapter, workspace.uri); + const workspaceTestAdapter = new WorkspaceTestAdapter( + testProvider, + discoveryAdapter, + executionAdapter, + workspace.uri, + ); this.testAdapters.set(workspace.uri, workspaceTestAdapter); @@ -311,6 +329,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc `Running Tests for Workspace(s): ${workspaces.map((w) => w.uri.fsPath).join(';')}`, true, ); + const dispose = token.onCancellationRequested(() => { runInstance.end(); }); @@ -355,10 +374,25 @@ export class PythonTestController implements ITestController, IExtensionSingleAc ); } if (settings.testing.unittestEnabled) { + // potentially sqeeze in the new exeuction way here? sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { tool: 'unittest', debugging: request.profile?.kind === TestRunProfileKind.Debug, }); + // new execution runner/adapter + // const testAdapter = + // this.testAdapters.get(workspace.uri) || + // (this.testAdapters.values().next().value as WorkspaceTestAdapter); + // return testAdapter.executeTests( + // this.testController, + // runInstance, + // testItems, + // token, + // request.profile?.kind === TestRunProfileKind.Debug, + // ); + + // below is old way of running unittest execution + return this.unittest.runTests( { includes: testItems, @@ -383,7 +417,6 @@ export class PythonTestController implements ITestController, IExtensionSingleAc runInstance.appendOutput(`Finished running tests!\r\n`); runInstance.end(); dispose.dispose(); - if (unconfiguredWorkspaces.length > 0) { this.runWithoutConfigurationEvent.fire(unconfiguredWorkspaces); } diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts new file mode 100644 index 000000000000..d71dea9fea36 --- /dev/null +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Uri } from 'vscode'; +import { IConfigurationService } from '../../../common/types'; +import { createDeferred, Deferred } from '../../../common/utils/async'; +import { EXTENSION_ROOT_DIR } from '../../../constants'; +import { + DataReceivedEvent, + ExecutionTestPayload, + ITestExecutionAdapter, + ITestServer, + TestCommandOptions, + TestExecutionCommand, +} from '../common/types'; + +/** + * Wrapper Class for unittest test execution. This is where we call `runTestCommand`? + */ + +export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { + private deferred: Deferred | undefined; + + private cwd: string | undefined; + + constructor(public testServer: ITestServer, public configSettings: IConfigurationService) { + testServer.onDataReceived(this.onDataReceivedHandler, this); + } + + public onDataReceivedHandler({ cwd, data }: DataReceivedEvent): void { + if (this.deferred && cwd === this.cwd) { + const testData: ExecutionTestPayload = JSON.parse(data); + + this.deferred.resolve(testData); + this.deferred = undefined; + } + } + + public async runTests(uri: Uri, testIds: string[], debugBool?: boolean): Promise { + if (!this.deferred) { + const settings = this.configSettings.getSettings(uri); + const { unittestArgs } = settings.testing; + + const command = buildExecutionCommand(unittestArgs); + this.cwd = uri.fsPath; + + const options: TestCommandOptions = { + workspaceFolder: uri, + command, + cwd: this.cwd, + debugBool, + testIds, + }; + + this.deferred = createDeferred(); + + // send test command to server + // server fire onDataReceived event once it gets response + this.testServer.sendCommand(options); + } + return this.deferred.promise; + } +} + +function buildExecutionCommand(args: string[]): TestExecutionCommand { + const executionScript = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'unittestadapter', 'execution.py'); + + return { + script: executionScript, + args: ['--udiscovery', ...args], + }; +} diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index bb9e084886ac..0ecab7649745 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -3,15 +3,38 @@ import * as path from 'path'; import * as util from 'util'; -import { CancellationToken, Position, Range, TestController, TestItem, Uri } from 'vscode'; +import { + CancellationToken, + Position, + Range, + TestController, + TestItem, + TestMessage, + TestRun, + Uri, + Location, +} from 'vscode'; import { createDeferred, Deferred } from '../../common/utils/async'; import { Testing } from '../../common/utils/localize'; import { traceError } from '../../logging'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { TestProvider } from '../types'; -import { createErrorTestItem, DebugTestTag, ErrorTestItemOptions, RunTestTag } from './common/testItemUtilities'; -import { DiscoveredTestItem, DiscoveredTestNode, DiscoveredTestType, ITestDiscoveryAdapter } from './common/types'; +import { + createErrorTestItem, + DebugTestTag, + ErrorTestItemOptions, + getTestCaseNodes, + RunTestTag, +} from './common/testItemUtilities'; +import { + DiscoveredTestItem, + DiscoveredTestNode, + DiscoveredTestType, + ITestDiscoveryAdapter, + ITestExecutionAdapter, +} from './common/types'; +import { fixLogLines } from './common/utils'; /** * This class exposes a test-provider-agnostic way of discovering tests. @@ -23,17 +46,155 @@ import { DiscoveredTestItem, DiscoveredTestNode, DiscoveredTestType, ITestDiscov * and uses them to insert/update/remove items in the `TestController` instance behind the testing UI whenever the `PythonTestController` requests a refresh. */ export class WorkspaceTestAdapter { - private discovering: Deferred | undefined = undefined; + private discovering: Deferred | undefined; - private testData: DiscoveredTestNode | undefined; + private executing: Deferred | undefined; + + runIdToTestItem: Map; + + runIdToVSid: Map; + + vsIdToRunId: Map; constructor( private testProvider: TestProvider, private discoveryAdapter: ITestDiscoveryAdapter, - // TODO: Implement test running - // private runningAdapter: ITestRunningAdapter, + private executionAdapter: ITestExecutionAdapter, private workspaceUri: Uri, - ) {} + ) { + this.runIdToTestItem = new Map(); + this.runIdToVSid = new Map(); + this.vsIdToRunId = new Map(); + } + + public async executeTests( + testController: TestController, + runInstance: TestRun, + includes: TestItem[], + token?: CancellationToken, + debugBool?: boolean, + ): Promise { + if (this.executing) { + return this.executing.promise; + } + + const deferred = createDeferred(); + this.executing = deferred; + + let rawTestExecData; + const testCaseNodes: TestItem[] = []; + const testCaseIds: string[] = []; + try { + // first fetch all the individual test Items that we necessarily want + includes.forEach((t) => { + const nodes = getTestCaseNodes(t); + testCaseNodes.push(...nodes); + }); + // iterate through testItems nodes and fetch their unittest runID to pass in as argument + testCaseNodes.forEach((node) => { + runInstance.started(node); // do the vscode ui test item start here before runtest + const runId = this.vsIdToRunId.get(node.id); + if (runId) { + testCaseIds.push(runId); + } + }); + + // need to get the testItems runIds so that we can pass in here. + rawTestExecData = await this.executionAdapter.runTests(this.workspaceUri, testCaseIds, debugBool); + deferred.resolve(); + } catch (ex) { + // handle token and telemetry here + sendTelemetryEvent(EventName.UNITTEST_RUN_ALL_FAILED, undefined); + + const cancel = token?.isCancellationRequested + ? Testing.cancelUnittestExecution + : Testing.errorUnittestExecution; + traceError(`${cancel}\r\n`, ex); + + // Also report on the test view + const message = util.format(`${cancel} ${Testing.seePythonOutput}\r\n`, ex); + const options = buildErrorNodeOptions(this.workspaceUri, message); + const errorNode = createErrorTestItem(testController, options); + testController.items.add(errorNode); + + deferred.reject(ex as Error); + } finally { + this.executing = undefined; + } + + if (rawTestExecData !== undefined && rawTestExecData.result !== undefined) { + for (const keyTemp of Object.keys(rawTestExecData.result)) { + // check for result and update the UI accordingly. + const testCases: TestItem[] = []; + + // grab leaf level test items + testController.items.forEach((i) => { + const tempArr: TestItem[] = getTestCaseNodes(i); + testCases.push(...tempArr); + }); + + if ( + rawTestExecData.result[keyTemp].outcome === 'failure' || + rawTestExecData.result[keyTemp].outcome === 'subtest-failure' || + rawTestExecData.result[keyTemp].outcome === 'passed-unexpected' + ) { + const traceback = rawTestExecData.result[keyTemp].traceback + ? rawTestExecData.result[keyTemp] + .traceback!.splitLines({ trim: false, removeEmptyEntries: true }) + .join('\r\n') + : ''; + const text = `${rawTestExecData.result[keyTemp].test} failed: ${ + rawTestExecData.result[keyTemp].message ?? rawTestExecData.result[keyTemp].outcome + }\r\n${traceback}\r\n`; + const message = new TestMessage(text); + + // note that keyTemp is a runId for unittest library... + const grabVSid = this.runIdToVSid.get(keyTemp); + // search through freshly built array of testItem to find the failed test and update UI. + testCases.forEach((indiItem) => { + if (indiItem.id === grabVSid) { + if (indiItem.uri && indiItem.range) { + message.location = new Location(indiItem.uri, indiItem.range); + runInstance.failed(indiItem, message); + runInstance.appendOutput(fixLogLines(text)); + } + } + }); + } else if ( + rawTestExecData.result[keyTemp].outcome === 'success' || + rawTestExecData.result[keyTemp].outcome === 'expected-failure' || + rawTestExecData.result[keyTemp].outcome === 'subtest-passed' + ) { + const grabTestItem = this.runIdToTestItem.get(keyTemp); + const grabVSid = this.runIdToVSid.get(keyTemp); + if (grabTestItem !== undefined) { + testCases.forEach((indiItem) => { + if (indiItem.id === grabVSid) { + if (indiItem.uri && indiItem.range) { + runInstance.passed(grabTestItem); + runInstance.appendOutput('Passed here'); + } + } + }); + } + } else if (rawTestExecData.result[keyTemp].outcome === 'skipped') { + const grabTestItem = this.runIdToTestItem.get(keyTemp); + const grabVSid = this.runIdToVSid.get(keyTemp); + if (grabTestItem !== undefined) { + testCases.forEach((indiItem) => { + if (indiItem.id === grabVSid) { + if (indiItem.uri && indiItem.range) { + runInstance.skipped(grabTestItem); + runInstance.appendOutput('Skipped here'); + } + } + }); + } + } + } + } + return Promise.resolve(); + } public async discoverTests( testController: TestController, @@ -140,23 +301,14 @@ export class WorkspaceTestAdapter { children, }; - const workspaceNode = testController.items.get(rootPath); - if (rawTestData.tests) { // 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. - if (workspaceNode) { - updateTestTree(testController, rawTestData.tests, this.testData, workspaceNode, token); - } else { - populateTestTree(testController, rawTestData.tests, undefined, token); - } + populateTestTree(testController, rawTestData.tests, undefined, this, token); } else { // Delete everything from the test controller. testController.items.replace([]); } - - // Save new test data state. - this.testData = rawTestData.tests; } sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { tool: this.testProvider, failed: false }); @@ -168,109 +320,18 @@ function isTestItem(test: DiscoveredTestNode | DiscoveredTestItem): test is Disc return test.type_ === 'test'; } -function deleteTestTree(testController: TestController, root?: TestItem) { - if (root) { - const { children } = root; - - children.forEach((child) => { - deleteTestTree(testController, child); - - const { id } = child; - testController.items.delete(id); - }); - - testController.items.delete(root.id); - } -} - -function updateTestTree( - testController: TestController, - updatedData: DiscoveredTestNode, - localData: DiscoveredTestNode | undefined, - testRoot: TestItem | undefined, - token?: CancellationToken, -): void { - // If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller. - if (!testRoot) { - testRoot = testController.createTestItem(updatedData.path, updatedData.name, Uri.file(updatedData.path)); - testRoot.canResolveChildren = true; - testRoot.tags = [RunTestTag, DebugTestTag]; - - testController.items.add(testRoot); - } - - // Delete existing items if they don't exist in the updated tree. - if (localData) { - localData.children.forEach((local) => { - if (!token?.isCancellationRequested) { - const exists = updatedData.children.find( - (node) => local.name === node.name && local.path === node.path && local.type_ === node.type_, - ); - - if (!exists) { - // Delete this node and all its children. - const testItem = testController.items.get(local.path); - deleteTestTree(testController, testItem); - } - } - }); - } - - // Go through the updated tree, update the existing nodes, and create new ones if necessary. - updatedData.children.forEach((child) => { - if (!token?.isCancellationRequested) { - const root = testController.items.get(child.path); - if (root) { - root.busy = true; - // Update existing test node or item. - if (isTestItem(child)) { - // Update the only property that can be updated. - root.label = child.name; - } else { - const localNode = localData?.children.find( - (node) => child.name === node.name && child.path === node.path && child.type_ === node.type_, - ); - updateTestTree(testController, child, localNode as DiscoveredTestNode, root, token); - } - root.busy = false; - } else { - // Create new test node or item. - let testItem; - if (isTestItem(child)) { - testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); - // testItem = testController.createTestItem(child.uniqueID, child.name, Uri.file(child.path)); - const range = new Range(new Position(child.lineno - 1, 0), new Position(child.lineno, 0)); - - testItem.canResolveChildren = false; - - testItem.tags = [RunTestTag, DebugTestTag]; - testItem.range = range; - - testRoot!.children.add(testItem); - } else { - testItem = testController.createTestItem(child.path, child.name, Uri.file(child.path)); - testItem.canResolveChildren = true; - testItem.tags = [RunTestTag, DebugTestTag]; - - testRoot!.children.add(testItem); - - // Populate the test tree under the newly created node. - populateTestTree(testController, child, testItem, token); - } - } - } - }); -} - +// had to switch the order of the original parameter since required param cannot follow optional. function populateTestTree( testController: TestController, testTreeData: DiscoveredTestNode, testRoot: TestItem | undefined, + wstAdapter: WorkspaceTestAdapter, token?: CancellationToken, ): void { // If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller. if (!testRoot) { testRoot = testController.createTestItem(testTreeData.path, testTreeData.name, Uri.file(testTreeData.path)); + testRoot.canResolveChildren = true; testRoot.tags = [RunTestTag, DebugTestTag]; @@ -280,20 +341,26 @@ function populateTestTree( // Recursively populate the tree with test data. testTreeData.children.forEach((child) => { if (!token?.isCancellationRequested) { - // Try to identify if we fall into TestItem or TestNode? - if (isTestItem(child)) { const testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); - const range = new Range(new Position(child.lineno - 1, 0), new Position(child.lineno, 0)); + testItem.tags = [RunTestTag, DebugTestTag]; + + const range = new Range( + new Position(Number(child.lineno) - 1, 0), + new Position(Number(child.lineno), 0), + ); testItem.canResolveChildren = false; testItem.range = range; testItem.tags = [RunTestTag, DebugTestTag]; testRoot!.children.add(testItem); + // add to our map + wstAdapter.runIdToTestItem.set(child.runID, testItem); + wstAdapter.runIdToVSid.set(child.runID, child.id_); + wstAdapter.vsIdToRunId.set(child.id_, child.runID); } else { let node = testController.items.get(child.path); if (!node) { - // replace child.path with child.id_ (unique) node = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); node.canResolveChildren = true; @@ -301,7 +368,7 @@ function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, token); + populateTestTree(testController, child, node, wstAdapter, token); } } }); diff --git a/src/test/testing/testController/server.unit.test.ts b/src/test/testing/testController/server.unit.test.ts index 56209fbbf554..125500fd0ab7 100644 --- a/src/test/testing/testController/server.unit.test.ts +++ b/src/test/testing/testController/server.unit.test.ts @@ -10,6 +10,7 @@ import { IPythonExecutionFactory, IPythonExecutionService } from '../../../clien import { createDeferred } from '../../../client/common/utils/async'; import { PythonTestServer } from '../../../client/testing/testController/common/server'; import * as logging from '../../../client/logging'; +import { ITestDebugLauncher } from '../../../client/testing/common/types'; suite('Python Test Server', () => { const fakeUuid = 'fake-uuid'; @@ -21,6 +22,7 @@ suite('Python Test Server', () => { let execArgs: string[]; let v4Stub: sinon.SinonStub; let traceLogStub: sinon.SinonStub; + let debugLauncher: ITestDebugLauncher; setup(() => { sandbox = sinon.createSandbox(); @@ -53,7 +55,7 @@ suite('Python Test Server', () => { cwd: '/foo/bar', }; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); await server.sendCommand(options); const { port } = server; @@ -75,7 +77,7 @@ suite('Python Test Server', () => { outChannel, }; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); await server.sendCommand(options); @@ -99,7 +101,7 @@ suite('Python Test Server', () => { cwd: '/foo/bar', }; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); server.onDataReceived(({ data }) => { eventData = JSON.parse(data); }); @@ -120,7 +122,7 @@ suite('Python Test Server', () => { let response; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); server.onDataReceived(({ data }) => { response = data; deferred.resolve(); @@ -134,11 +136,13 @@ suite('Python Test Server', () => { hostname: 'localhost', method: 'POST', port, + headers: { 'Request-uuid': fakeUuid }, }; const request = http.request(requestOptions, (res) => { res.setEncoding('utf8'); }); + const postData = JSON.stringify({ status: 'success', uuid: fakeUuid }); request.write(postData); request.end(); @@ -157,7 +161,7 @@ suite('Python Test Server', () => { let response; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); server.onDataReceived(({ data }) => { response = data; deferred.resolve(); @@ -171,6 +175,7 @@ suite('Python Test Server', () => { hostname: 'localhost', method: 'POST', port, + headers: { 'Request-uuid': fakeUuid }, }; const request = http.request(requestOptions, (res) => { @@ -196,7 +201,7 @@ suite('Python Test Server', () => { let response; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); server.onDataReceived(({ data }) => { response = data; deferred.resolve(); @@ -210,8 +215,9 @@ suite('Python Test Server', () => { hostname: 'localhost', method: 'POST', port, + headers: { 'Request-uuid': fakeUuid }, }; - + // request.hasHeader() const request = http.request(requestOptions, (res) => { res.setEncoding('utf8'); }); @@ -234,7 +240,7 @@ suite('Python Test Server', () => { let response; - server = new PythonTestServer(stubExecutionFactory); + server = new PythonTestServer(stubExecutionFactory, debugLauncher); server.onDataReceived(({ data }) => { response = data; deferred.resolve(); @@ -248,8 +254,14 @@ suite('Python Test Server', () => { hostname: 'localhost', method: 'POST', port, + headers: { 'Request-uuid': 'some-other-uuid' }, + }; + const requestOptions2 = { + hostname: 'localhost', + method: 'POST', + port, + headers: { 'Request-uuid': fakeUuid }, }; - const requestOne = http.request(requestOptions, (res) => { res.setEncoding('utf8'); }); @@ -257,7 +269,7 @@ suite('Python Test Server', () => { requestOne.write(postDataOne); requestOne.end(); - const requestTwo = http.request(requestOptions, (res) => { + const requestTwo = http.request(requestOptions2, (res) => { res.setEncoding('utf8'); }); const postDataTwo = JSON.stringify({ status: 'success', uuid: fakeUuid, payload: 'foo' }); diff --git a/src/test/testing/testController/workspaceTestAdapter.unit.test.ts b/src/test/testing/testController/workspaceTestAdapter.unit.test.ts index 7b94d73aea6d..b6be8d6081de 100644 --- a/src/test/testing/testController/workspaceTestAdapter.unit.test.ts +++ b/src/test/testing/testController/workspaceTestAdapter.unit.test.ts @@ -7,6 +7,7 @@ import * as sinon from 'sinon'; import { TestController, TestItem, Uri } from 'vscode'; import { IConfigurationService } from '../../../client/common/types'; import { UnittestTestDiscoveryAdapter } from '../../../client/testing/testController/unittest/testDiscoveryAdapter'; +import { UnittestTestExecutionAdapter } from '../../../client/testing/testController/unittest/testExecutionAdapter'; // 7/7 import { WorkspaceTestAdapter } from '../../../client/testing/testController/workspaceTestAdapter'; import * as Telemetry from '../../../client/telemetry'; import { EventName } from '../../../client/telemetry/constants'; @@ -109,8 +110,13 @@ suite('Workspace test adapter', () => { discoverTestsStub.resolves(); const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - - const workspaceTestAdapter = new WorkspaceTestAdapter('unittest', testDiscoveryAdapter, Uri.parse('foo')); + const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); // 7/7 + const workspaceTestAdapter = new WorkspaceTestAdapter( + 'unittest', + testDiscoveryAdapter, + testExecutionAdapter, + Uri.parse('foo'), + ); await workspaceTestAdapter.discoverTests(testController); @@ -129,8 +135,13 @@ suite('Workspace test adapter', () => { ); const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - - const workspaceTestAdapter = new WorkspaceTestAdapter('unittest', testDiscoveryAdapter, Uri.parse('foo')); + const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); // 7/7 + const workspaceTestAdapter = new WorkspaceTestAdapter( + 'unittest', + testDiscoveryAdapter, + testExecutionAdapter, + Uri.parse('foo'), + ); // Try running discovery twice const one = workspaceTestAdapter.discoverTests(testController); @@ -145,8 +156,14 @@ suite('Workspace test adapter', () => { discoverTestsStub.resolves({ status: 'success' }); const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); + const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); - const workspaceTestAdapter = new WorkspaceTestAdapter('unittest', testDiscoveryAdapter, Uri.parse('foo')); + const workspaceTestAdapter = new WorkspaceTestAdapter( + 'unittest', + testDiscoveryAdapter, + testExecutionAdapter, + Uri.parse('foo'), + ); await workspaceTestAdapter.discoverTests(testController); @@ -161,8 +178,14 @@ suite('Workspace test adapter', () => { discoverTestsStub.rejects(new Error('foo')); const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); + const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); - const workspaceTestAdapter = new WorkspaceTestAdapter('unittest', testDiscoveryAdapter, Uri.parse('foo')); + const workspaceTestAdapter = new WorkspaceTestAdapter( + 'unittest', + testDiscoveryAdapter, + testExecutionAdapter, + Uri.parse('foo'), + ); await workspaceTestAdapter.discoverTests(testController);