From 027625b075747faa4b408429e0a38b0184b84472 Mon Sep 17 00:00:00 2001 From: Petri Huovinen Date: Wed, 11 Mar 2026 20:36:17 +0200 Subject: [PATCH] feat(discovery) Add fast discovery Add end-to-end fast discovery flow (discover fast) with pre-filter support and fallback behavior for unsupported cases. This feature is only created for VS Code and Cursor. The feature is by default disabled and can be enabled via VS Code settings. The feature is mainly tested with RF 7 and it is not guaranteed to work in older versions. Introduce incremental NDJSON discovery transport and client-side incremental tree updates to reduce payload pressure and improve responsiveness. Stabilize Test Explorer tree mapping/rendering (synthetic-root flattening, robust item lookup, safer refresh/prune behavior) to prevent collapse/churn. Add configurable discovery behavior: runEmptySuite support and fast-timeout override (robotcode.testExplorer.discovery.fastTimeoutMs) with adaptive defaults. Improve CLI/filter handling and diagnostics, including correct short-option parsing (-I vs -i) and clearer timeout/log output. Add/adjust tests and supporting updates across runner/debug/client modules to validate fast discovery behavior. Downgrade VS Code requirements. Cursor uses older VsCode base and RobotCode cannot be used with Cursor without the downgrade. If robotcode.analysis.loadWorkspaceDocuments is set to false, then analysis covers only for open files, not for all workspace files. Show full robot CLI args if robotcode.testExplorer.discovery.logCommandArgs and/or robotcode.debug.logCommandArgs is set to true. Added robotcode.debug.listenerLogTraffic settings flag. If this is set to false, then no log hooks of the RobotCode generate traffic. This is useful in the case the keywords are very verbose. --- package-lock.json | 10 +- package.json | 89 +- .../core/src/robotcode/core/ignore_spec.py | 28 +- .../core/src/robotcode/core/text_document.py | 2 +- .../src/robotcode/debugger/launcher/server.py | 6 + .../src/robotcode/debugger/listeners.py | 59 + .../debugger/src/robotcode/debugger/run.py | 13 + .../common/parts/diagnostics.py | 7 +- .../robotframework/configuration.py | 4 + .../robotframework/parts/robot_workspace.py | 10 + .../robot/diagnostics/imports_manager.py | 8 +- .../robotcode/runner/cli/discover/discover.py | 1291 ++++++++++++++++- .../runner/src/robotcode/runner/cli/robot.py | 131 +- .../common/test_text_document.py | 38 + .../runner/cli/discover/test_discover_fast.py | 253 ++++ vscode-client/extension/debugmanager.ts | 60 +- .../extension/keywordsTreeViewProvider.ts | 13 +- vscode-client/extension/pythonmanger.ts | 244 +++- .../extension/testcontrollermanager.ts | 1270 ++++++++++++++-- 19 files changed, 3365 insertions(+), 171 deletions(-) create mode 100644 tests/robotcode/runner/cli/discover/test_discover_fast.py diff --git a/package-lock.json b/package-lock.json index 01ff0b2fe..00e660883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@jgoz/esbuild-plugin-typecheck": "^4.0.4", "@types/fs-extra": "^11.0.4", "@types/node": "^22.17.0", - "@types/vscode": "^1.108.0", + "@types/vscode": "~1.96.0", "@types/vscode-notebook-renderer": "^1.72.4", "@vscode/python-extension": "^1.0.6", "@vscode/vsce": "^3.9.1", @@ -44,7 +44,7 @@ "typescript-eslint": "^8.60.0" }, "engines": { - "vscode": "^1.108.0" + "vscode": "^1.96.0" } }, "docs": { @@ -2663,9 +2663,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.120.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", - "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "version": "1.96.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", + "integrity": "sha512-qvZbSZo+K4ZYmmDuaodMbAa67Pl6VDQzLKFka6rq+3WUTY4Kro7Bwoi0CuZLO/wema0ygcmpwow7zZfPJTs5jg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 528d4d20c..5d8260d48 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "qna": "https://github.com/robotcodedev/robotcode/discussions/categories/q-a", "engines": { - "vscode": "^1.108.0" + "vscode": "^1.105.0" }, "categories": [ "Programming Languages", @@ -883,6 +883,53 @@ "default": false, "scope": "resource" }, + "robotcode.debug.logCommandArgs": { + "type": "boolean", + "description": "Log full command arguments for debug launcher and robot run commands.", + "default": false, + "scope": "resource" + }, + "robotcode.debug.listenerLogLevel": { + "type": "string", + "enum": [ + "TRACE", + "DEBUG", + "INFO", + "WARN", + "ERROR", + "FAIL", + "OFF" + ], + "enumDescriptions": [ + "Process listener log/message callbacks at TRACE and above.", + "Process listener log/message callbacks at DEBUG and above.", + "Process listener log/message callbacks at INFO and above.", + "Process listener log/message callbacks at WARN and above.", + "Process listener log/message callbacks at ERROR and above.", + "Process listener log/message callbacks at FAIL and above.", + "Disable listener log/message callback processing." + ], + "description": "Minimum Robot Framework message level processed by RobotCode listener log/message hooks during test/debug runs.", + "default": "TRACE", + "scope": "resource" + }, + "robotcode.debug.listenerLogTraffic": { + "type": "string", + "enum": [ + "all", + "warnAndAbove", + "off" + ], + "enumDescriptions": [ + "Process all listener log/message callbacks.", + "Process only WARN/ERROR/FAIL listener log/message callbacks.", + "Disable listener log/message callback processing." + ], + "description": "Controls how much Robot Framework log/message traffic RobotCode listeners process during test/debug runs.", + "default": "all", + "scope": "resource", + "markdownDeprecationMessage": "Deprecated in favor of `robotcode.debug.listenerLogLevel`." + }, "robotcode.debug.useExternalDebugpy": { "type": "boolean", "description": "Use the debugpy in python environment, not from the python extension.", @@ -1286,6 +1333,44 @@ "default": true, "description": "Enable/disable whether Robot Framework tests and tasks are integrated into the VSCode Test/Test Explorer view.", "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.enabled": { + "type": "boolean", + "default": false, + "description": "Experimental optimization for very large workspaces. Prefilters files containing Test Cases/Tasks before full discovery.", + "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.prefilterCommand": { + "type": "string", + "enum": [ + "auto", + "gitGrep", + "ripGrep", + "grep", + "none" + ], + "default": "auto", + "markdownDescription": "Selects prefilter command for fast discovery. `auto` tries `git grep` first, then `ripgrep`, then `grep`, and falls back to normal discovery if unavailable. `none` disables prefiltering.", + "scope": "resource" + }, + "robotcode.testExplorer.fastDiscovery.command.enabled": { + "type": "boolean", + "default": false, + "description": "Use `robotcode discover fast` for workspace discovery when fast discovery prefiltering is enabled. Automatically falls back to full discovery if unsupported options are detected.", + "scope": "resource" + }, + "robotcode.testExplorer.discovery.runEmptySuite": { + "type": "boolean", + "default": true, + "markdownDescription": "Keep empty suites in Test Explorer discovery results. Disable to show only suites with executable tests/tasks.", + "scope": "resource" + }, + "robotcode.testExplorer.discovery.fastTimeoutMs": { + "type": "integer", + "default": 0, + "minimum": 0, + "markdownDescription": "Override fast discovery timeout in milliseconds. `0` uses adaptive timeout based on candidate count.", + "scope": "resource" } } }, @@ -2158,7 +2243,7 @@ "@jgoz/esbuild-plugin-typecheck": "^4.0.4", "@types/fs-extra": "^11.0.4", "@types/node": "^22.17.0", - "@types/vscode": "^1.108.0", + "@types/vscode": "^1.105.0", "@types/vscode-notebook-renderer": "^1.72.4", "@vscode/python-extension": "^1.0.6", "@vscode/vsce": "^3.9.1", diff --git a/packages/core/src/robotcode/core/ignore_spec.py b/packages/core/src/robotcode/core/ignore_spec.py index 82994139c..f14f010bb 100644 --- a/packages/core/src/robotcode/core/ignore_spec.py +++ b/packages/core/src/robotcode/core/ignore_spec.py @@ -313,6 +313,8 @@ def _iter_files( verbose_callback: Optional[Callable[[str], None]] = None, verbose_trace: bool = False, ) -> Iterator[Path]: + ignore_file_names = tuple(ignore_files) + if verbose_callback is not None and verbose_trace: verbose_callback(f"iter_files: {path}") @@ -334,27 +336,29 @@ def _iter_files( parents.insert(0, p) for p in parents: - ignore_file = next((p / f for f in ignore_files if (p / f).is_file()), None) - - if ignore_file is not None: + for ignore_file in (p / f for f in ignore_file_names if (p / f).is_file()): if verbose_callback is not None: verbose_callback(f"using ignore file: '{ignore_file}'") parent_spec = parent_spec + IgnoreSpec.from_gitignore(ignore_file) - ignore_files = [ignore_file.name] - - ignore_file = next((path / f for f in ignore_files if (path / f).is_file()), None) - if ignore_file is not None: + spec = parent_spec + for ignore_file in (path / f for f in ignore_file_names if (path / f).is_file()): if verbose_callback is not None: verbose_callback(f"using ignore file: '{ignore_file}'") - spec = parent_spec + IgnoreSpec.from_gitignore(ignore_file) - ignore_files = [ignore_file.name] - else: - spec = parent_spec + spec = spec + IgnoreSpec.from_gitignore(ignore_file) if not path.is_dir(): if spec is not None and spec.matches(path): return + + parent = path.parent + while True: + if spec is not None and spec.matches(parent): + return + if parent == parent.parent: + break + parent = parent.parent + yield path return @@ -368,7 +372,7 @@ def _iter_files( if p.is_dir(): yield from _iter_files( p, - ignore_files=ignore_files, + ignore_files=ignore_file_names, include_hidden=include_hidden, parent_spec=spec, verbose_callback=verbose_callback, diff --git a/packages/core/src/robotcode/core/text_document.py b/packages/core/src/robotcode/core/text_document.py index ade4107ea..06d735837 100644 --- a/packages/core/src/robotcode/core/text_document.py +++ b/packages/core/src/robotcode/core/text_document.py @@ -344,7 +344,7 @@ def get_cache( e = self._cache[reference] - with e.lock: + with e.lock(timeout=-1): if not e.has_data: e.data = entry(self, *args, **kwargs) e.has_data = True diff --git a/packages/debugger/src/robotcode/debugger/launcher/server.py b/packages/debugger/src/robotcode/debugger/launcher/server.py index 179731fcc..375e0eb0f 100644 --- a/packages/debugger/src/robotcode/debugger/launcher/server.py +++ b/packages/debugger/src/robotcode/debugger/launcher/server.py @@ -125,6 +125,9 @@ async def _launch( outputLog: Optional[bool] = False, # noqa: N803 outputTimestamps: Optional[bool] = False, # noqa: N803 groupOutput: Optional[bool] = False, # noqa: N803 + logCommandArgs: Optional[bool] = False, # noqa: N803 + listenerLogLevel: Optional[str] = "TRACE", # noqa: N803 + listenerLogTraffic: Optional[str] = "all", # noqa: N803 stopOnEntry: Optional[bool] = False, # noqa: N803 dryRun: Optional[bool] = None, # noqa: N803 mode: Optional[str] = None, @@ -239,6 +242,9 @@ async def _launch( run_args.insert(0, "--") env = {k: ("" if v is None else str(v)) for k, v in env.items()} if env else {} + env["ROBOTCODE_DEBUG_LOG_COMMAND_ARGS"] = "1" if logCommandArgs else "0" + env["ROBOTCODE_DEBUG_LISTENER_LOG_LEVEL"] = (listenerLogLevel or "TRACE").strip() + env["ROBOTCODE_DEBUG_LISTENER_LOG_TRAFFIC"] = (listenerLogTraffic or "all").strip() if console in ["integratedTerminal", "externalTerminal"]: await self.send_request_async( diff --git a/packages/debugger/src/robotcode/debugger/listeners.py b/packages/debugger/src/robotcode/debugger/listeners.py index 9f32e9a56..b9b3b1abb 100644 --- a/packages/debugger/src/robotcode/debugger/listeners.py +++ b/packages/debugger/src/robotcode/debugger/listeners.py @@ -1,5 +1,7 @@ +import os import re from dataclasses import dataclass +from functools import lru_cache from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Union, cast @@ -56,6 +58,57 @@ def source_from_attributes(attributes: Dict[str, Any]) -> str: return s or "" +_LISTENER_LOG_LEVEL_VALUES = { + "TRACE": 10, + "DEBUG": 20, + "INFO": 30, + "WARN": 40, + "ERROR": 50, + "FAIL": 60, + "OFF": 100, +} + + +def _normalize_log_level(value: Optional[str], *, default: str) -> str: + normalized = (value or "").strip().upper() + + if normalized in ["WARNING", "WARN"]: + return "WARN" + + if normalized in _LISTENER_LOG_LEVEL_VALUES: + return normalized + + return default + + +def _legacy_listener_log_traffic_to_level() -> str: + value = (os.getenv("ROBOTCODE_DEBUG_LISTENER_LOG_TRAFFIC") or "all").strip().lower() + + if value in ["warnandabove", "warn_and_above", "warn-and-above"]: + return "WARN" + if value == "off": + return "OFF" + + return "TRACE" + + +@lru_cache(maxsize=1) +def _get_listener_log_level_threshold() -> int: + configured_level = os.getenv("ROBOTCODE_DEBUG_LISTENER_LOG_LEVEL") + level_name = _normalize_log_level(configured_level, default=_legacy_listener_log_traffic_to_level()) + return _LISTENER_LOG_LEVEL_VALUES[level_name] + + +def _should_process_listener_log(level: Optional[str]) -> bool: + threshold = _get_listener_log_level_threshold() + if threshold >= _LISTENER_LOG_LEVEL_VALUES["OFF"]: + return False + + message_level = _normalize_log_level(level, default="INFO") + + return _LISTENER_LOG_LEVEL_VALUES[message_level] >= threshold + + class ListenerV2: ROBOT_LISTENER_API_VERSION = "2" @@ -221,6 +274,9 @@ def end_keyword(self, name: str, attributes: Dict[str, Any]) -> None: RE_FILE_LINE_MATCHER = re.compile(r".+\sin\sfile\s'(?P.*)'\son\sline\s(?P\d+):(?P.*)") def log_message(self, message: LogMessage) -> None: + if not _should_process_listener_log(message.get("level")): + return + if message["level"] in ["FAIL", "ERROR", "WARN"]: current_frame = Debugger.instance.full_stack_frames[0] if Debugger.instance.full_stack_frames else None @@ -274,6 +330,9 @@ def log_message(self, message: LogMessage) -> None: Debugger.instance.log_message(message) def message(self, message: LogMessage) -> None: + if not _should_process_listener_log(message.get("level")): + return + if message["level"] in ["FAIL", "ERROR", "WARN"]: current_frame = Debugger.instance.full_stack_frames[0] if Debugger.instance.full_stack_frames else None diff --git a/packages/debugger/src/robotcode/debugger/run.py b/packages/debugger/src/robotcode/debugger/run.py index 34a5fae17..1991cbe75 100644 --- a/packages/debugger/src/robotcode/debugger/run.py +++ b/packages/debugger/src/robotcode/debugger/run.py @@ -102,6 +102,14 @@ def _debug_adapter_server( debugpy_connected = threading.Event() +def _should_log_command_args() -> bool: + value = os.getenv("ROBOTCODE_DEBUG_LOG_COMMAND_ARGS") + if value is None: + return True + + return value.lower() in ["on", "1", "yes", "true"] + + @_logger.call def start_debugpy( app: Application, @@ -167,6 +175,8 @@ def run_debugger( output_timestamps: bool = False, group_output: bool = False, ) -> int: + app.verbose(lambda: f"debug run: initial robot args={args}") + if debug and debugpy and not is_debugpy_installed(): app.warning("Debugpy not installed") @@ -222,6 +232,7 @@ def run_debugger( "robotcode.debugger.listeners.ListenerV2", *args, ] + app.verbose(lambda: f"debug run: robot args with listeners={args}") Debugger.instance.stop_on_entry = stop_on_entry Debugger.instance.output_messages = output_messages @@ -241,6 +252,8 @@ def run_debugger( app.verbose("Start robot") try: + if _should_log_command_args(): + app.echo(f"robot python api argv: {args}") app.verbose(f"Create robot context with args: {args}") robot_ctx = robot.make_context("robot", args, parent=ctx) robot.invoke(robot_ctx) diff --git a/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py b/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py index 0e02ca26c..21afc084b 100644 --- a/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py +++ b/packages/language_server/src/robotcode/language_server/common/parts/diagnostics.py @@ -349,7 +349,12 @@ def run_workspace_diagnostics(self) -> None: self._break_diagnostics_loop_event.clear() documents = sorted( - [doc for doc in self.parent.documents.documents if self._doc_need_update(doc)], + [ + doc + for doc in self.parent.documents.documents + if self._doc_need_update(doc) + and (doc.opened_in_editor or self.get_diagnostics_mode(doc.uri) == DiagnosticsMode.WORKSPACE) + ], key=lambda d: not d.opened_in_editor, ) diff --git a/packages/language_server/src/robotcode/language_server/robotframework/configuration.py b/packages/language_server/src/robotcode/language_server/robotframework/configuration.py index bac036de9..e3406bb4a 100644 --- a/packages/language_server/src/robotcode/language_server/robotframework/configuration.py +++ b/packages/language_server/src/robotcode/language_server/robotframework/configuration.py @@ -60,6 +60,10 @@ class AnalysisConfig(ConfigBase): diagnostic_mode: DiagnosticsMode = DiagnosticsMode.OPENFILESONLY progress_mode: AnalysisProgressMode = AnalysisProgressMode.OFF references_code_lens: bool = False + # Controls whether the language server should eagerly load all workspace + # Robot Framework documents for analysis. This is independent from + # diagnostic_mode to avoid coupling references behavior to diagnostics. + load_workspace_documents: bool = True find_unused_references: bool = False cache: CacheConfig = field(default_factory=CacheConfig) robot: AnalysisRobotConfig = field(default_factory=AnalysisRobotConfig) diff --git a/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py b/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py index 2fef82926..d234801de 100644 --- a/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py +++ b/packages/language_server/src/robotcode/language_server/robotframework/parts/robot_workspace.py @@ -58,6 +58,16 @@ def load_workspace_documents(self, sender: Any) -> None: for folder in self.parent.workspace.workspace_folders: config = self.parent.workspace.get_configuration(RobotCodeConfig, folder.uri) + if not config.analysis.load_workspace_documents: + self._logger.debug( + lambda: ( + f"Skip loading workspace documents for {folder.uri.to_path()} " + f"because analysis.loadWorkspaceDocuments=false" + ), + context_name="load_workspace_documents", + ) + continue + extensions = [ROBOT_FILE_EXTENSION, RESOURCE_FILE_EXTENSION] exclude_patterns = [ diff --git a/packages/robot/src/robotcode/robot/diagnostics/imports_manager.py b/packages/robot/src/robotcode/robot/diagnostics/imports_manager.py index ab941fa1c..6d757211c 100644 --- a/packages/robot/src/robotcode/robot/diagnostics/imports_manager.py +++ b/packages/robot/src/robotcode/robot/diagnostics/imports_manager.py @@ -1571,10 +1571,14 @@ def _run_in_subprocess(self, func: Any, func_args: Tuple[Any, ...], timeout_msg: extensions) and cannot be safely re-imported after on-disk changes. """ executor = ProcessPoolExecutor(max_workers=1, mp_context=mp.get_context("spawn")) + wait_for_shutdown = True try: + future = executor.submit(func, *func_args) try: - return executor.submit(func, *func_args).result(self.load_library_timeout) + return future.result(self.load_library_timeout) except TimeoutError as e: + wait_for_shutdown = False + future.cancel() raise RuntimeError( f"{timeout_msg} " f"timed out after {self.load_library_timeout} seconds. " @@ -1588,7 +1592,7 @@ def _run_in_subprocess(self, func: Any, func_args: Tuple[Any, ...], timeout_msg: self._logger.exception(e) raise finally: - executor.shutdown(wait=True) + executor.shutdown(wait=wait_for_shutdown, cancel_futures=not wait_for_shutdown) def _save_import_cache( self, diff --git a/packages/runner/src/robotcode/runner/cli/discover/discover.py b/packages/runner/src/robotcode/runner/cli/discover/discover.py index 680010566..1fb9a6e9f 100644 --- a/packages/runner/src/robotcode/runner/cli/discover/discover.py +++ b/packages/runner/src/robotcode/runner/cli/discover/discover.py @@ -1,8 +1,12 @@ +import json import os import platform import re import sys +import time from collections import defaultdict +from dataclasses import dataclass +from fnmatch import fnmatchcase from io import IOBase from pathlib import Path from typing import ( @@ -12,15 +16,17 @@ List, MutableMapping, Optional, + Set, Tuple, Union, ) import click import robot.running.model as running_model +from robot.api import Token, get_tokens from robot.conf import RobotSettings from robot.errors import DATA_ERROR, INFO_PRINTED, DataError, Information -from robot.model import ModelModifier, TestCase, TestSuite +from robot.model import ModelModifier, TagPatterns, TestCase, TestSuite from robot.model.visitor import SuiteVisitor from robot.output import LOGGER, Message from robot.running.builder import TestSuiteBuilder @@ -37,7 +43,7 @@ ) from robotcode.core.uri import Uri from robotcode.core.utils.cli import show_hidden_arguments -from robotcode.core.utils.dataclasses import from_json +from robotcode.core.utils.dataclasses import as_dict, as_json from robotcode.core.utils.path import normalized_path from robotcode.plugin import ( Application, @@ -93,6 +99,48 @@ def __init__(self, *args: Any, error_message: str, **kwargs: Any) -> None: _stdin_data: Optional[Dict[Uri, str]] = None +_stdin_candidates: Optional[List[str]] = None +_discover_log_visited_files = os.getenv("ROBOTCODE_DISCOVER_LOG_VISITED_FILES", "").lower() in [ + "on", + "1", + "yes", + "true", +] +_discover_run_empty_suite = True +_FAST_DISCOVERY_UNSUPPORTED_OPTION_PREFIXES = ( + "--parser", + "--prerunmodifier", + "-PARSER", +) +_FAST_DISCOVERY_PROGRESS_INTERVAL = 200 +_FAST_DISCOVERY_SLOW_FILE_THRESHOLD_S = 2.0 + + +def _emit_fast_discovery_log(app: Application, message: str) -> None: + app.verbose(message) + + +def _prune_empty_test_items(items: Optional[List["TestItem"]]) -> List["TestItem"]: + if not items: + return [] + + result: List["TestItem"] = [] + for item in items: + if item.children is not None: + item.children = _prune_empty_test_items(item.children) + + if item.type in ("test", "task", "error"): + result.append(item) + continue + + if item.children: + result.append(item) + continue + + if item.error: + result.append(item) + + return result def _patch() -> None: @@ -231,6 +279,1012 @@ def get_file(self: FileReader, source: Union[str, Path, IOBase], accept_text: bo FileReader._get_file = get_file +def _fast_match(value: str, pattern: str) -> bool: + return fnmatchcase(normalize(value, ignore="_"), normalize(pattern, ignore="_")) + + +def _compose_fast_longname(parent: TestItem, child_name: str) -> str: + if parent.type == "workspace": + return child_name + return f"{parent.longname}.{child_name}" + + +def _get_robot_option_values(robot_options_and_args: Tuple[str, ...], *option_names: str) -> List[str]: + result: List[str] = [] + option_names_set = set(option_names) + i = 0 + while i < len(robot_options_and_args): + arg = robot_options_and_args[i] + if arg in option_names_set: + if i + 1 < len(robot_options_and_args): + result.append(robot_options_and_args[i + 1]) + i += 2 + continue + else: + for name in option_names: + if arg.startswith(f"{name}="): + result.append(arg[len(name) + 1 :]) + break + i += 1 + return result + + +def _has_fast_discovery_unsupported_options( + cmd_options: List[str], + robot_options_and_args: Tuple[str, ...], +) -> Optional[str]: + all_options = [*cmd_options, *robot_options_and_args] + for option in all_options: + option_l = option.lower() + if any( + option_l == prefix.lower() or option_l.startswith(f"{prefix.lower()}=") + for prefix in _FAST_DISCOVERY_UNSUPPORTED_OPTION_PREFIXES + ): + return option + return None + + +def _get_fast_discovery_suffixes(cmd_options: List[str], robot_options_and_args: Tuple[str, ...]) -> Set[str]: + extensions = _get_robot_option_values(tuple([*cmd_options, *robot_options_and_args]), "--extension", "-F") + suffixes = {f".{e.strip().lstrip('.').lower()}" for e in extensions if e.strip()} + if not suffixes: + suffixes = {".robot"} + suffixes.add(".resource") + return suffixes + + +def _get_fast_discovery_tag_patterns( + cmd_options: List[str], robot_options_and_args: Tuple[str, ...] +) -> Tuple[Optional[TagPatterns], Optional[TagPatterns]]: + all_options = tuple([*cmd_options, *robot_options_and_args]) + include_tags = _get_robot_option_values(all_options, "--include", "-i") + exclude_tags = _get_robot_option_values(all_options, "--exclude", "-e") + include_patterns = TagPatterns(include_tags) if include_tags else None + exclude_patterns = TagPatterns(exclude_tags) if exclude_tags else None + return include_patterns, exclude_patterns + + +def _fast_match_tags( + tags: Iterable[str], include_patterns: Optional[TagPatterns], exclude_patterns: Optional[TagPatterns] +) -> bool: + if include_patterns and not include_patterns.match(tags): + return False + if exclude_patterns and exclude_patterns.match(tags): + return False + return True + + +def _resolve_fast_candidate_path(candidate: str, root_folder: Optional[Path]) -> Path: + candidate_path = Path(candidate) + if not candidate_path.is_absolute(): + candidate_path = (root_folder or Path.cwd()) / candidate_path + return normalized_path(candidate_path) + + +def _fast_discovery_path_sort_key(path: Path) -> str: + return path.as_posix().lower() + + +def _is_allowed_fast_candidate(path: Path, root_folder: Optional[Path], app: Application) -> bool: + if not path.is_file(): + return False + + return any( + p == path + for p in iter_files( + path, + root=root_folder, + ignore_files=[ROBOT_IGNORE_FILE, GIT_IGNORE_FILE], + include_hidden=False, + ) + ) + + +def _iter_fast_discovery_files( + app: Application, + root_folder: Optional[Path], + profile: Any, + candidates: Optional[List[str]], + allowed_suffixes: Set[str], +) -> List[Path]: + if candidates: + result = [] + for candidate in candidates: + p = _resolve_fast_candidate_path(candidate, root_folder) + if p.suffix.lower() in allowed_suffixes and _is_allowed_fast_candidate(p, root_folder, app): + result.append(p) + return sorted(set(result), key=_fast_discovery_path_sort_key) + + search_paths = set( + ( + [*(app.config.default_paths if app.config.default_paths else ())] + if profile.paths is None + else profile.paths + if isinstance(profile.paths, list) + else [profile.paths] + ) + ) + if not search_paths: + search_paths = {"."} + + return sorted( + set( + p + for p in iter_files( + (Path(s) for s in search_paths), + root=root_folder, + ignore_files=[ROBOT_IGNORE_FILE, GIT_IGNORE_FILE], + include_hidden=False, + verbose_callback=app.verbose, + ) + if p.suffix.lower() in allowed_suffixes + ), + key=_fast_discovery_path_sort_key, + ) + + +def _iter_supported_init_files(path: Path, allowed_suffixes: Set[str]) -> Iterable[Path]: + for suffix in allowed_suffixes: + if suffix == ".resource": + continue + init_file = path / f"__init__{suffix}" + if init_file.is_file(): + yield init_file + + +def _is_supported_init_file(path: Path, allowed_suffixes: Set[str]) -> bool: + return any(path == init_file for init_file in _iter_supported_init_files(path.parent, allowed_suffixes)) + + +@dataclass +class FastExtractedItem: + item_type: str + name: str + lineno: int + tags: List[str] + has_tags_setting: bool = False + + +@dataclass +class FastExtractedFileData: + force_tags: List[str] + default_tags: List[str] + items: List[FastExtractedItem] + + +def _apply_fast_tag_directives(base_tags: Iterable[str], directives: Iterable[str]) -> List[str]: + result = list(dict.fromkeys(str(t).strip() for t in base_tags if str(t).strip())) + + for directive in directives: + tag = str(directive).strip() + if not tag: + continue + + if tag.startswith("-"): + remove_pattern = tag[1:].strip() + if not remove_pattern: + continue + result = [existing for existing in result if not _fast_match(existing, remove_pattern)] + continue + + result.append(tag) + + return list(dict.fromkeys(result)) + + +def _is_fast_none_tag(value: str) -> bool: + return str(normalize(value, ignore="_")) == "none" + + +def _compose_fast_effective_tags( + inherited_force_tags: Iterable[str], + default_tags: Iterable[str], + item_tags: Iterable[str], + has_tags_setting: bool, +) -> List[str]: + combined_tags = list(dict.fromkeys(str(t).strip() for t in inherited_force_tags if str(t).strip())) + + if not has_tags_setting: + combined_tags = _apply_fast_tag_directives(combined_tags, default_tags) + + item_directives = [str(t).strip() for t in item_tags if str(t).strip() and not _is_fast_none_tag(str(t).strip())] + return _apply_fast_tag_directives(combined_tags, item_directives) + + +def _extract_fast_file_data_from_path(path: Path) -> FastExtractedFileData: + force_tags: List[str] = [] + default_tags: List[str] = [] + items: List[FastExtractedItem] = [] + current_section: Optional[str] = None + token_source = _get_token_source_for_path(path) + current_item: Optional[FastExtractedItem] = None + is_init_file = path.stem == "__init__" + + task_header_token = getattr(Token, "TASK_HEADER", getattr(Token, "TASKS_HEADER", None)) + task_name_token = getattr(Token, "TASK_NAME", None) + name_token_types = {Token.TESTCASE_NAME} + if task_name_token is not None: + name_token_types.add(task_name_token) + + force_tag_tokens = { + token_type + for token_type in ( + getattr(Token, "FORCE_TAGS", None), + getattr(Token, "TEST_TAGS", None), + ) + if token_type is not None + } + default_tags_token = getattr(Token, "DEFAULT_TAGS", None) + + def finalize_current_item() -> Optional[FastExtractedItem]: + nonlocal current_item + if current_item is None: + return None + if current_item.tags: + current_item.tags = list(dict.fromkeys(current_item.tags)) + item = current_item + current_item = None + return item + + for statement_tokens in _iter_statements(token_source): + first = statement_tokens[0] + if first.type == Token.SETTING_HEADER: + if item := finalize_current_item(): + items.append(item) + current_section = Token.SETTING_HEADER + continue + if first.type == Token.TESTCASE_HEADER: + if item := finalize_current_item(): + items.append(item) + current_section = "test" + continue + if task_header_token is not None and first.type == task_header_token: + if item := finalize_current_item(): + items.append(item) + current_section = "task" + continue + if first.type in Token.HEADER_TOKENS: + if item := finalize_current_item(): + items.append(item) + current_section = None + continue + + if current_section == Token.SETTING_HEADER: + if first.type in force_tag_tokens: + force_tags = _apply_fast_tag_directives(force_tags, statement_tokens[1:]) + continue + if not is_init_file and default_tags_token is not None and first.type == default_tags_token: + default_tags = _apply_fast_tag_directives(default_tags, statement_tokens[1:]) + continue + + if current_section != Token.SETTING_HEADER: + if current_section is None: + continue + + if first.type in name_token_types: + if item := finalize_current_item(): + items.append(item) + + name = str(first).strip() + if name: + current_item = FastExtractedItem(item_type=current_section, name=name, lineno=first.lineno, tags=[]) + continue + + if current_item is None: + continue + + if first.type == Token.TAGS: + current_item.has_tags_setting = True + current_item.tags.extend(str(t).strip() for t in statement_tokens[1:] if str(t).strip()) + + if item := finalize_current_item(): + items.append(item) + + return FastExtractedFileData( + force_tags=list(dict.fromkeys(force_tags)), + default_tags=list(dict.fromkeys(default_tags)), + items=items, + ) + + +def _extract_suite_name_from_path(path: Path) -> Optional[str]: + if RF_VERSION < (6, 1): + return None + + if not path.is_file(): + return None + + current_section: Optional[str] = None + token_source = _get_token_source_for_path(path) + name_setting_token = getattr(Token, "SUITE_NAME", None) + if name_setting_token is None: + return None + + suite_name: Optional[str] = None + + for statement_tokens in _iter_statements(token_source): + first = statement_tokens[0] + if first.type == Token.SETTING_HEADER: + current_section = Token.SETTING_HEADER + continue + if first.type in Token.HEADER_TOKENS: + current_section = first.type + continue + if current_section != Token.SETTING_HEADER: + continue + + if first.type == name_setting_token and len(statement_tokens) > 1: + value = str(statement_tokens[1]).strip() + if value: + suite_name = value + + if suite_name: + return suite_name + + return None + + +def _get_token_source_for_path(path: Path) -> Union[str, Path]: + if _stdin_data is not None: + uri = str(Uri.from_path(path)) + stdin_text = _stdin_data.get(Uri(uri).normalized()) + if stdin_text is not None: + return stdin_text + return path + + +def _iter_statements(token_source: Union[str, Path]) -> Iterable[List[Token]]: + statement: List[Token] = [] + + try: + tokens = get_tokens(token_source) + except (OSError, UnicodeDecodeError, DataError): + return + + for token in tokens: + if token.type == Token.EOS: + if statement: + yield statement + statement = [] + continue + if token.type in Token.NON_DATA_TOKENS: + continue + + statement.append(token) + + if statement: + yield statement + + +def _get_cached_fast_file_data( + path: Path, + allowed_suffixes: Set[str], + extracted_file_cache: Dict[Path, FastExtractedFileData], +) -> FastExtractedFileData: + if not _is_supported_init_file(path, allowed_suffixes): + return _extract_fast_file_data_from_path(path) + + if path not in extracted_file_cache: + extracted_file_cache[path] = _extract_fast_file_data_from_path(path) + return extracted_file_cache[path] + + +def _get_cached_suite_name_for_path( + path: Path, + allowed_suffixes: Set[str], + suite_name_cache: Dict[Path, str], +) -> str: + if path in suite_name_cache: + return suite_name_cache[path] + + source_for_name = path + if path.is_dir(): + source_for_name = next(iter(sorted(_iter_supported_init_files(path, allowed_suffixes))), path) + + suite_name = _extract_suite_name_from_path(source_for_name) + if not suite_name: + suite_name = TestSuite.name_from_source(path) + + suite_name_cache[path] = suite_name + return suite_name + + +def _get_cached_inherited_force_tags( + file_path: Path, + workspace_path: Path, + allowed_suffixes: Set[str], + extracted_file_cache: Dict[Path, FastExtractedFileData], + inherited_cache: Dict[Path, List[str]], + current_file_data: Optional[FastExtractedFileData] = None, +) -> List[str]: + if file_path in inherited_cache: + return inherited_cache[file_path] + + aggregated: List[str] = [] + + current_dir = file_path.parent + while True: + for init_file in _iter_supported_init_files(current_dir, allowed_suffixes): + if init_file != file_path: + init_file_data = _get_cached_fast_file_data( + init_file, + allowed_suffixes, + extracted_file_cache, + ) + aggregated.extend(init_file_data.force_tags) + + if current_dir == workspace_path or current_dir.parent == current_dir: + break + current_dir = current_dir.parent + + if current_file_data is None: + current_file_data = _extract_fast_file_data_from_path(file_path) + + aggregated.extend(current_file_data.force_tags) + inherited_cache[file_path] = list(dict.fromkeys(aggregated)) + return inherited_cache[file_path] + + +def _build_fast_discovery_result( + app: Application, + by_longname: Tuple[str, ...], + exclude_by_longname: Tuple[str, ...], + robot_options_and_args: Tuple[str, ...], +) -> ResultItem: + started = time.perf_counter() + root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) + after_handle_options = time.perf_counter() + + if unsupported_option := _has_fast_discovery_unsupported_options(cmd_options, robot_options_and_args): + raise click.ClickException( + f"Fast discovery does not support option '{unsupported_option}'. Use 'discover all' instead." + ) + + suite_filters = _get_robot_option_values(robot_options_and_args, "--suite") + test_filters = _get_robot_option_values(robot_options_and_args, "--test") + allowed_suffixes = _get_fast_discovery_suffixes(cmd_options, robot_options_and_args) + all_options = tuple([*cmd_options, *robot_options_and_args]) + include_tag_filters = _get_robot_option_values(all_options, "--include", "-i") + exclude_tag_filters = _get_robot_option_values(all_options, "--exclude", "-e") + include_tag_patterns, exclude_tag_patterns = _get_fast_discovery_tag_patterns(cmd_options, robot_options_and_args) + + app.verbose( + lambda: ( + "discover fast filters: " + f"include_tags={include_tag_filters} exclude_tags={exclude_tag_filters} " + f"suite_filters={suite_filters} test_filters={test_filters}" + ) + ) + + workspace_path = Path.cwd() + workspace_item = TestItem( + type="workspace", + id=str(workspace_path), + name=workspace_path.name, + longname=workspace_path.name, + uri=str(Uri.from_path(workspace_path)), + source=str(workspace_path), + rel_source=get_rel_source(workspace_path), + needs_parse_include=RF_VERSION >= (6, 1), + children=[], + ) + + suite_by_id: Dict[str, TestItem] = {workspace_item.id: workspace_item} + files = _iter_fast_discovery_files(app, root_folder, profile, _stdin_candidates, allowed_suffixes) + after_collect_files = time.perf_counter() + extracted_file_cache: Dict[Path, FastExtractedFileData] = {} + suite_name_cache: Dict[Path, str] = {} + inherited_force_tags_cache: Dict[Path, List[str]] = {} + tests_count = 0 + tasks_count = 0 + total_file_scan_seconds = 0.0 + total_tag_entries = 0 + total_tag_chars = 0 + max_tags_per_item = 0 + max_tag_chars_per_item = 0 + max_longname_len = 0 + max_source_len = 0 + + _emit_fast_discovery_log( + app, + "discover fast: " + f"files={len(files)} candidates={len(_stdin_candidates or [])} " + f"suite_filters={len(suite_filters)} test_filters={len(test_filters)}", + ) + + for index, file_path in enumerate(files, start=1): + file_started = time.perf_counter() + rel_parts = file_path.parts + try: + rel_parts = file_path.relative_to(workspace_path).parts + except ValueError: + pass + + parent = workspace_item + current_dir = workspace_path + for part in rel_parts[:-1]: + current_dir = current_dir / part + suite_name = _get_cached_suite_name_for_path(current_dir, allowed_suffixes, suite_name_cache) + suite_longname = _compose_fast_longname(parent, suite_name) + suite_id = f"{current_dir};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(current_dir)), + source=str(current_dir), + rel_source=get_rel_source(current_dir), + needs_parse_include=RF_VERSION >= (6, 1), + children=[], + rpa=False, + ) + parent.children = parent.children or [] + parent.children.append(suite_item) + suite_by_id[suite_id] = suite_item + parent = suite_item + + if _is_supported_init_file(file_path, allowed_suffixes): + target_suite = parent + else: + suite_name = _get_cached_suite_name_for_path(file_path, allowed_suffixes, suite_name_cache) + suite_longname = _compose_fast_longname(parent, suite_name) + suite_id = f"{file_path};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range(start=Position(line=0, character=0), end=Position(line=0, character=0)), + needs_parse_include=RF_VERSION >= (6, 1), + children=[], + rpa=False, + ) + parent.children = parent.children or [] + parent.children.append(suite_item) + suite_by_id[suite_id] = suite_item + target_suite = suite_item + + if suite_filters and not any(_fast_match(target_suite.longname, f) for f in suite_filters): + continue + if by_longname and not any(_fast_match(target_suite.longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(target_suite.longname, f) for f in exclude_by_longname): + continue + + extracted_file = _extract_fast_file_data_from_path(file_path) + inherited_force_tags = _get_cached_inherited_force_tags( + file_path, + workspace_path, + allowed_suffixes, + extracted_file_cache, + inherited_force_tags_cache, + extracted_file, + ) + default_tags = extracted_file.default_tags + extracted_count = 0 + for extracted_item in extracted_file.items: + extracted_count += 1 + combined_tags = _compose_fast_effective_tags( + inherited_force_tags, + default_tags, + extracted_item.tags, + extracted_item.has_tags_setting, + ) + if not _fast_match_tags(combined_tags, include_tag_patterns, exclude_tag_patterns): + continue + + longname = _compose_fast_longname(target_suite, extracted_item.name) + if test_filters and not any(_fast_match(longname, f) for f in test_filters): + continue + if by_longname and not any(_fast_match(longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(longname, f) for f in exclude_by_longname): + continue + + if extracted_item.item_type == "task": + target_suite.rpa = True + tasks_count += 1 + else: + tests_count += 1 + + tag_count = len(combined_tags) + tag_chars = sum(len(tag) for tag in combined_tags) + total_tag_entries += tag_count + total_tag_chars += tag_chars + max_tags_per_item = max(max_tags_per_item, tag_count) + max_tag_chars_per_item = max(max_tag_chars_per_item, tag_chars) + + child = TestItem( + type=extracted_item.item_type, + id=f"{file_path};{longname};{extracted_item.lineno}", + name=extracted_item.name, + longname=longname, + lineno=extracted_item.lineno, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range( + start=Position(line=extracted_item.lineno - 1, character=0), + end=Position(line=extracted_item.lineno - 1, character=0), + ), + tags=combined_tags if combined_tags else None, + rpa=extracted_item.item_type == "task", + ) + max_longname_len = max(max_longname_len, len(longname)) + max_source_len = max(max_source_len, len(str(file_path))) + target_suite.children = target_suite.children or [] + target_suite.children.append(child) + + file_elapsed = time.perf_counter() - file_started + total_file_scan_seconds += file_elapsed + if file_elapsed >= _FAST_DISCOVERY_SLOW_FILE_THRESHOLD_S: + _emit_fast_discovery_log( + app, + f"discover fast: slow file elapsed={file_elapsed:.3f}s extracted={extracted_count} path={file_path}", + ) + + if index % _FAST_DISCOVERY_PROGRESS_INTERVAL == 0: + elapsed = time.perf_counter() - started + progress_message = ( + f"discover fast: progress files={index}/{len(files)} " + f"tests={tests_count} tasks={tasks_count} elapsed={elapsed:.3f}s" + ) + _emit_fast_discovery_log( + app, + progress_message, + ) + + completed = time.perf_counter() + _emit_fast_discovery_log( + app, + "discover fast timings (s): " + f"handle_options={after_handle_options - started:.3f}, " + f"collect_files={after_collect_files - after_handle_options:.3f}, " + f"scan_files={total_file_scan_seconds:.3f}, " + f"total={completed - started:.3f}", + ) + _emit_fast_discovery_log( + app, + "discover fast payload stats: " + f"tests={tests_count} tasks={tasks_count} suites={len(suite_by_id)} " + f"total_tag_entries={total_tag_entries} total_tag_chars={total_tag_chars} " + f"max_tags_per_item={max_tags_per_item} max_tag_chars_per_item={max_tag_chars_per_item} " + f"max_longname_len={max_longname_len} max_source_len={max_source_len}", + ) + + app.verbose( + lambda: ( + "discover fast summary: " + f"files={len(files)} tests={tests_count} tasks={tasks_count} " + f"candidates={len(_stdin_candidates or [])}" + ) + ) + + if not _discover_run_empty_suite: + workspace_item.children = _prune_empty_test_items(workspace_item.children) + + return ResultItem([workspace_item], diagnostics=None) + + +def _write_incremental_discover_event(event: Dict[str, Any]) -> None: + sys.stdout.write(json.dumps(event, separators=(",", ":"))) + sys.stdout.write(os.linesep) + + +def _stream_fast_discovery_result( + app: Application, + by_longname: Tuple[str, ...], + exclude_by_longname: Tuple[str, ...], + robot_options_and_args: Tuple[str, ...], +) -> None: + started = time.perf_counter() + root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) + after_handle_options = time.perf_counter() + + if unsupported_option := _has_fast_discovery_unsupported_options(cmd_options, robot_options_and_args): + raise click.ClickException( + f"Fast discovery does not support option '{unsupported_option}'. Use 'discover all' instead." + ) + + suite_filters = _get_robot_option_values(robot_options_and_args, "--suite") + test_filters = _get_robot_option_values(robot_options_and_args, "--test") + allowed_suffixes = _get_fast_discovery_suffixes(cmd_options, robot_options_and_args) + all_options = tuple([*cmd_options, *robot_options_and_args]) + include_tag_filters = _get_robot_option_values(all_options, "--include", "-i") + exclude_tag_filters = _get_robot_option_values(all_options, "--exclude", "-e") + include_tag_patterns, exclude_tag_patterns = _get_fast_discovery_tag_patterns(cmd_options, robot_options_and_args) + + app.verbose( + lambda: ( + "discover fast filters: " + f"include_tags={include_tag_filters} exclude_tags={exclude_tag_filters} " + f"suite_filters={suite_filters} test_filters={test_filters}" + ) + ) + + workspace_path = Path.cwd() + workspace_item = TestItem( + type="workspace", + id=str(workspace_path), + name=workspace_path.name, + longname=workspace_path.name, + uri=str(Uri.from_path(workspace_path)), + source=str(workspace_path), + rel_source=get_rel_source(workspace_path), + needs_parse_include=RF_VERSION >= (6, 1), + ) + + app.verbose("discover output: incremental stream start") + write_started = time.perf_counter() + _write_incremental_discover_event({"event": "start", "version": 1}) + _write_incremental_discover_event( + { + "event": "item", + "item": as_dict(workspace_item, remove_defaults=True), + } + ) + + stream_empty_suites = _discover_run_empty_suite + suite_by_id: Dict[str, TestItem] = {workspace_item.id: workspace_item} + suite_parent_by_id: Dict[str, Optional[str]] = {workspace_item.id: None} + emitted_suite_ids: Set[str] = {workspace_item.id} + + def emit_suite_if_needed(suite_id: str) -> None: + if suite_id in emitted_suite_ids: + return + + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + return + + parent_id = suite_parent_by_id.get(suite_id) + if parent_id is not None: + emit_suite_if_needed(parent_id) + + _write_incremental_discover_event( + { + "event": "item", + "item": as_dict(suite_item, remove_defaults=True), + "parentId": parent_id, + } + ) + emitted_suite_ids.add(suite_id) + + files = _iter_fast_discovery_files(app, root_folder, profile, _stdin_candidates, allowed_suffixes) + after_collect_files = time.perf_counter() + extracted_file_cache: Dict[Path, FastExtractedFileData] = {} + suite_name_cache: Dict[Path, str] = {} + inherited_force_tags_cache: Dict[Path, List[str]] = {} + tests_count = 0 + tasks_count = 0 + total_file_scan_seconds = 0.0 + total_tag_entries = 0 + total_tag_chars = 0 + max_tags_per_item = 0 + max_tag_chars_per_item = 0 + max_longname_len = 0 + max_source_len = 0 + + _emit_fast_discovery_log( + app, + "discover fast: " + f"files={len(files)} candidates={len(_stdin_candidates or [])} " + f"suite_filters={len(suite_filters)} test_filters={len(test_filters)}", + ) + + for index, file_path in enumerate(files, start=1): + file_started = time.perf_counter() + rel_parts = file_path.parts + try: + rel_parts = file_path.relative_to(workspace_path).parts + except ValueError: + pass + + parent = workspace_item + current_dir = workspace_path + for part in rel_parts[:-1]: + current_dir = current_dir / part + suite_name = _get_cached_suite_name_for_path(current_dir, allowed_suffixes, suite_name_cache) + suite_longname = _compose_fast_longname(parent, suite_name) + suite_id = f"{current_dir};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(current_dir)), + source=str(current_dir), + rel_source=get_rel_source(current_dir), + needs_parse_include=RF_VERSION >= (6, 1), + rpa=False, + ) + suite_by_id[suite_id] = suite_item + suite_parent_by_id[suite_id] = parent.id + if stream_empty_suites: + _write_incremental_discover_event( + { + "event": "item", + "item": as_dict(suite_item, remove_defaults=True), + "parentId": parent.id, + } + ) + emitted_suite_ids.add(suite_id) + else: + suite_parent_by_id.setdefault(suite_id, parent.id) + parent = suite_item + + if _is_supported_init_file(file_path, allowed_suffixes): + target_suite = parent + else: + suite_name = _get_cached_suite_name_for_path(file_path, allowed_suffixes, suite_name_cache) + suite_longname = _compose_fast_longname(parent, suite_name) + suite_id = f"{file_path};{suite_longname}" + suite_item = suite_by_id.get(suite_id) + if suite_item is None: + suite_item = TestItem( + type="suite", + id=suite_id, + name=suite_name, + longname=suite_longname, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range(start=Position(line=0, character=0), end=Position(line=0, character=0)), + needs_parse_include=RF_VERSION >= (6, 1), + rpa=False, + ) + suite_by_id[suite_id] = suite_item + suite_parent_by_id[suite_id] = parent.id + if stream_empty_suites: + _write_incremental_discover_event( + { + "event": "item", + "item": as_dict(suite_item, remove_defaults=True), + "parentId": parent.id, + } + ) + emitted_suite_ids.add(suite_id) + else: + suite_parent_by_id.setdefault(suite_id, parent.id) + target_suite = suite_item + + if suite_filters and not any(_fast_match(target_suite.longname, f) for f in suite_filters): + continue + if by_longname and not any(_fast_match(target_suite.longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(target_suite.longname, f) for f in exclude_by_longname): + continue + + extracted_file = _extract_fast_file_data_from_path(file_path) + inherited_force_tags = _get_cached_inherited_force_tags( + file_path, + workspace_path, + allowed_suffixes, + extracted_file_cache, + inherited_force_tags_cache, + extracted_file, + ) + default_tags = extracted_file.default_tags + extracted_count = 0 + for extracted_item in extracted_file.items: + extracted_count += 1 + combined_tags = _compose_fast_effective_tags( + inherited_force_tags, + default_tags, + extracted_item.tags, + extracted_item.has_tags_setting, + ) + if not _fast_match_tags(combined_tags, include_tag_patterns, exclude_tag_patterns): + continue + + longname = _compose_fast_longname(target_suite, extracted_item.name) + if test_filters and not any(_fast_match(longname, f) for f in test_filters): + continue + if by_longname and not any(_fast_match(longname, f) for f in by_longname): + continue + if exclude_by_longname and any(_fast_match(longname, f) for f in exclude_by_longname): + continue + + if extracted_item.item_type == "task": + tasks_count += 1 + else: + tests_count += 1 + + tag_count = len(combined_tags) + tag_chars = sum(len(tag) for tag in combined_tags) + total_tag_entries += tag_count + total_tag_chars += tag_chars + max_tags_per_item = max(max_tags_per_item, tag_count) + max_tag_chars_per_item = max(max_tag_chars_per_item, tag_chars) + + child = TestItem( + type=extracted_item.item_type, + id=f"{file_path};{longname};{extracted_item.lineno}", + name=extracted_item.name, + longname=longname, + lineno=extracted_item.lineno, + uri=str(Uri.from_path(file_path)), + source=str(file_path), + rel_source=get_rel_source(file_path), + range=Range( + start=Position(line=extracted_item.lineno - 1, character=0), + end=Position(line=extracted_item.lineno - 1, character=0), + ), + tags=combined_tags if combined_tags else None, + rpa=extracted_item.item_type == "task", + ) + max_longname_len = max(max_longname_len, len(longname)) + max_source_len = max(max_source_len, len(str(file_path))) + if not stream_empty_suites: + emit_suite_if_needed(target_suite.id) + _write_incremental_discover_event( + { + "event": "item", + "item": as_dict(child, remove_defaults=True), + "parentId": target_suite.id, + } + ) + + file_elapsed = time.perf_counter() - file_started + total_file_scan_seconds += file_elapsed + if file_elapsed >= _FAST_DISCOVERY_SLOW_FILE_THRESHOLD_S: + _emit_fast_discovery_log( + app, + f"discover fast: slow file elapsed={file_elapsed:.3f}s extracted={extracted_count} path={file_path}", + ) + + if index % _FAST_DISCOVERY_PROGRESS_INTERVAL == 0: + elapsed = time.perf_counter() - started + progress_message = ( + f"discover fast: progress files={index}/{len(files)} " + f"tests={tests_count} tasks={tasks_count} elapsed={elapsed:.3f}s" + ) + _emit_fast_discovery_log( + app, + progress_message, + ) + + completed = time.perf_counter() + _emit_fast_discovery_log( + app, + "discover fast timings (s): " + f"handle_options={after_handle_options - started:.3f}, " + f"collect_files={after_collect_files - after_handle_options:.3f}, " + f"scan_files={total_file_scan_seconds:.3f}, " + f"total={completed - started:.3f}", + ) + _emit_fast_discovery_log( + app, + "discover fast payload stats: " + f"tests={tests_count} tasks={tasks_count} suites={len(suite_by_id)} " + f"total_tag_entries={total_tag_entries} total_tag_chars={total_tag_chars} " + f"max_tags_per_item={max_tags_per_item} max_tag_chars_per_item={max_tag_chars_per_item} " + f"max_longname_len={max_longname_len} max_source_len={max_source_len}", + ) + app.verbose( + lambda: ( + "discover fast summary: " + f"files={len(files)} tests={tests_count} tasks={tasks_count} " + f"candidates={len(_stdin_candidates or [])}" + ) + ) + + _write_incremental_discover_event({"event": "end"}) + sys.stdout.flush() + write_elapsed = time.perf_counter() - write_started + app.verbose(lambda: f"discover output: incremental stream done elapsed={write_elapsed:.3f}s") + + def get_rel_source(source: Union[str, Path, None]) -> Optional[str]: if source is None: return None @@ -241,8 +1295,9 @@ def get_rel_source(source: Union[str, Path, None]) -> Optional[str]: class Collector(SuiteVisitor): - def __init__(self) -> None: + def __init__(self, app: Optional[Application] = None) -> None: super().__init__() + self.app = app absolute_path = Path.cwd() self.all: TestItem = TestItem( type="workspace", @@ -263,6 +1318,11 @@ def __init__(self) -> None: self._collected: List[MutableMapping[str, Any]] = [NormalizedDict(ignore="_")] def visit_suite(self, suite: TestSuite) -> None: + if _discover_log_visited_files and self.app is not None and suite.source is not None: + source_path = Path(suite.source) + if source_path.is_file(): + self.app.verbose(lambda: f"discover: visit file {source_path}") + if suite.name in self._collected[-1] and suite.parent.source: LOGGER.warn( ( @@ -382,9 +1442,16 @@ def visit_test(self, test: TestCase) -> None: help="Read file contents from stdin. This is an internal option.", hidden=show_hidden_arguments(), ) +@click.option( + "--run-empty-suite / --no-run-empty-suite", + "run_empty_suite", + default=True, + show_default=True, + help="Keep empty suites in discovery results.", +) @add_options(*ROBOT_VERSION_OPTIONS) @pass_application -def discover(app: Application, show_diagnostics: bool, read_from_stdin: bool) -> None: +def discover(app: Application, show_diagnostics: bool, read_from_stdin: bool, run_empty_suite: bool) -> None: """\ Commands to discover informations about the current project. @@ -396,12 +1463,42 @@ def discover(app: Application, show_diagnostics: bool, read_from_stdin: bool) -> ``` """ app.show_diagnostics = show_diagnostics or app.config.log_enabled + global _stdin_data + global _stdin_candidates + global _discover_log_visited_files + global _discover_run_empty_suite + _stdin_data = None + _stdin_candidates = None + _discover_run_empty_suite = run_empty_suite + _discover_log_visited_files = os.getenv("ROBOTCODE_DISCOVER_LOG_VISITED_FILES", "").lower() in [ + "on", + "1", + "yes", + "true", + ] if read_from_stdin: - global _stdin_data - _stdin_data = { - Uri(k).normalized(): v for k, v in from_json(sys.stdin.buffer.read(), Dict[str, str], strict=True).items() - } - app.verbose(f"Read data from stdin: {_stdin_data!r}") + stdin_raw = json.loads(sys.stdin.buffer.read().decode("utf-8")) + + if isinstance(stdin_raw, dict) and ("documents" in stdin_raw or "candidates" in stdin_raw): + documents_raw = stdin_raw.get("documents", {}) + candidates_raw = stdin_raw.get("candidates", []) + _stdin_data = ( + {Uri(k).normalized(): v for k, v in documents_raw.items() if isinstance(k, str) and isinstance(v, str)} + if isinstance(documents_raw, dict) + else {} + ) + _stdin_candidates = ( + [v for v in candidates_raw if isinstance(v, str)] if isinstance(candidates_raw, list) else None + ) + else: + _stdin_data = ( + {Uri(k).normalized(): v for k, v in stdin_raw.items() if isinstance(k, str) and isinstance(v, str)} + if isinstance(stdin_raw, dict) + else {} + ) + _stdin_candidates = None + + app.verbose(f"Read data from stdin: documents={len(_stdin_data)} candidates={len(_stdin_candidates or [])}") RE_IN_FILE_LINE_MATCHER = re.compile( @@ -473,12 +1570,16 @@ def handle_options( robot_options_and_args: Tuple[str, ...], search_matcher: Optional[SearchMatcher] = None, ) -> Tuple[TestSuite, Collector, Optional[Dict[str, List[Diagnostic]]]]: + started = time.perf_counter() root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) + after_handle_robot_options = time.perf_counter() + with app.chdir(root_folder) as orig_folder: diagnostics_logger = DiagnosticsLogger() try: _patch() + after_patch = time.perf_counter() options, arguments = RobotFrameworkEx( app, @@ -495,7 +1596,14 @@ def handle_options( by_longname, exclude_by_longname, search_matcher=search_matcher, - ).parse_arguments((*cmd_options, "--runemptysuite", *robot_options_and_args)) + ).parse_arguments( + ( + *cmd_options, + *(("--runemptysuite",) if _discover_run_empty_suite else ()), + *robot_options_and_args, + ), + ) + after_parse_arguments = time.perf_counter() settings = RobotSettings(options) @@ -512,6 +1620,7 @@ def handle_options( # unaffected either way. LOGGER.disable_message_cache() LOGGER.register_logger(diagnostics_logger) + after_logger_setup = time.perf_counter() if settings.pythonpath: sys.path = settings.pythonpath + sys.path @@ -542,16 +1651,43 @@ def handle_options( ) suite = builder.build(*arguments) + after_build = time.perf_counter() settings.rpa = suite.rpa if settings.pre_run_modifiers: suite.visit(ModelModifier(settings.pre_run_modifiers, settings.run_empty_suite, LOGGER)) + after_modifiers = time.perf_counter() suite.configure(**settings.suite_config) + after_configure = time.perf_counter() - collector = Collector() + collector = Collector(app) suite.visit(collector) + after_collect = time.perf_counter() + diagnostics = build_diagnostics(diagnostics_logger.messages) + after_diagnostics = time.perf_counter() + + app.verbose( + lambda: ( + "discover timings (s): " + f"config/profile={after_handle_robot_options - started:.3f}, " + f"patch={after_patch - after_handle_robot_options:.3f}, " + f"parse_args={after_parse_arguments - after_patch:.3f}, " + f"logger_setup={after_logger_setup - after_parse_arguments:.3f}, " + f"builder_build={after_build - after_logger_setup:.3f}, " + f"pre_run_modifiers={after_modifiers - after_build:.3f}, " + f"suite_configure={after_configure - after_modifiers:.3f}, " + f"collector_visit={after_collect - after_configure:.3f}, " + f"diagnostics={after_diagnostics - after_collect:.3f}, " + f"total={after_diagnostics - started:.3f}, " + f"arguments={len(arguments)}, " + f"candidates={len(_stdin_candidates or [])}, " + f"tests={collector.statistics.tests}, " + f"tasks={collector.statistics.tasks}, " + f"suites={collector.statistics.suites}" + ) + ) - return suite, collector, build_diagnostics(diagnostics_logger.messages) + return suite, collector, diagnostics except Information as err: app.echo(str(err)) @@ -575,6 +1711,80 @@ def _filters_applied(search_substring: Optional[str], search_regex: Optional[str return None +def print_machine_data(app: Application, data: Any) -> None: + if app.config.output_format in (OutputFormat.JSON, OutputFormat.JSON_INDENT): + serialize_started = time.perf_counter() + app.verbose("discover output: json serialize start") + text = as_json( + data, + indent=app.config.output_format == OutputFormat.JSON_INDENT, + compact=app.config.output_format == OutputFormat.JSON, + ) + serialize_elapsed = time.perf_counter() - serialize_started + app.verbose(lambda: f"discover output: json serialize done chars={len(text)} elapsed={serialize_elapsed:.3f}s") + + write_started = time.perf_counter() + app.verbose("discover output: stdout write start") + sys.stdout.write(text) + if not text.endswith(os.linesep): + sys.stdout.write(os.linesep) + sys.stdout.flush() + write_elapsed = time.perf_counter() - write_started + app.verbose(lambda: f"discover output: stdout write done elapsed={write_elapsed:.3f}s") + return + + app.print_data(data, remove_defaults=True) + + +def _iter_items_with_parent( + items: Iterable[TestItem], parent_id: Optional[str] = None +) -> Iterable[Tuple[TestItem, Optional[str]]]: + for item in items: + yield item, parent_id + if item.children: + yield from _iter_items_with_parent(item.children, item.id) + + +def print_machine_data_incremental_result(app: Application, data: ResultItem) -> None: + app.verbose("discover output: incremental stream start") + write_started = time.perf_counter() + + sys.stdout.write('{"event":"start","version":1}' + os.linesep) + + for item, parent_id in _iter_items_with_parent(data.items): + item_dict = as_dict(item, remove_defaults=True) + if "children" in item_dict: + del item_dict["children"] + + event: Dict[str, Any] = { + "event": "item", + "item": item_dict, + } + if parent_id is not None: + event["parentId"] = parent_id + + sys.stdout.write(json.dumps(event, separators=(",", ":"))) + sys.stdout.write(os.linesep) + + if data.diagnostics is not None: + sys.stdout.write( + json.dumps( + { + "event": "diagnostics", + "diagnostics": as_dict(data.diagnostics, remove_defaults=True), + }, + separators=(",", ":"), + ) + ) + sys.stdout.write(os.linesep) + + sys.stdout.write('{"event":"end"}' + os.linesep) + sys.stdout.flush() + + write_elapsed = time.perf_counter() - write_started + app.verbose(lambda: f"discover output: incremental stream done elapsed={write_elapsed:.3f}s") + + @discover.command( context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, add_help_option=True, @@ -645,11 +1855,11 @@ def all( ) else: - app.print_data( + print_machine_data( + app, ResultItem( [collector.all], diagnostics, filters_applied=_filters_applied(search_substring, search_regex) ), - remove_defaults=True, ) @@ -689,13 +1899,13 @@ def _test_or_tasks( else: filtered = [item for item in collector.test_and_tasks if item.type == selected_type] - app.print_data( + print_machine_data( + app, ResultItem( filtered, diagnostics, filters_applied=_filters_applied(search_substring, search_regex), ), - remove_defaults=True, ) @@ -879,11 +2089,11 @@ def suites( ) else: - app.print_data( + print_machine_data( + app, ResultItem( collector.suites, diagnostics, filters_applied=_filters_applied(search_substring, search_regex) ), - remove_defaults=True, ) @@ -977,9 +2187,9 @@ def tags( else: tags_data = collector.normalized_tags if normalized else collector.tags - app.print_data( + print_machine_data( + app, TagsResult(tags_data, filters_applied=_filters_applied(search_substring, search_regex)), - remove_defaults=True, ) @@ -1032,7 +2242,7 @@ def info(app: Application) -> None: if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT: app.echo_as_markdown(_render.render_info(info)) else: - app.print_data(info, remove_defaults=True) + print_machine_data(app, info) @discover.command(add_help_option=True) @@ -1114,4 +2324,41 @@ def filter_extensions(p: Path) -> bool: if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT: app.echo_as_markdown(_render.render_files(result)) else: - app.print_data(result, remove_defaults=True) + print_machine_data(app, result) + + +@discover.command( + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, + add_help_option=True, + epilog="Use `-- --help` to see `robot` help.", +) +@add_options(*ROBOT_OPTIONS) +@click.option( + "--incremental-output / --no-incremental-output", + "incremental_output", + default=False, + hidden=show_hidden_arguments(), + help="Emit discovery output incrementally as NDJSON events. This is an internal option.", +) +@pass_application +def fast( + app: Application, + incremental_output: bool, + by_longname: Tuple[str, ...], + exclude_by_longname: Tuple[str, ...], + robot_options_and_args: Tuple[str, ...], +) -> None: + """\ + Fast test discovery using lexical scanning only. + + This mode is optimized for speed and intentionally does not support all + Robot Framework discovery semantics. + """ + if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT: + result = _build_fast_discovery_result(app, by_longname, exclude_by_longname, robot_options_and_args) + app.print_data(result, remove_defaults=True) + elif incremental_output: + _stream_fast_discovery_result(app, by_longname, exclude_by_longname, robot_options_and_args) + else: + result = _build_fast_discovery_result(app, by_longname, exclude_by_longname, robot_options_and_args) + print_machine_data(app, result) diff --git a/packages/runner/src/robotcode/runner/cli/robot.py b/packages/runner/src/robotcode/runner/cli/robot.py index f4c2caac0..895d9b35f 100644 --- a/packages/runner/src/robotcode/runner/cli/robot.py +++ b/packages/runner/src/robotcode/runner/cli/robot.py @@ -1,4 +1,5 @@ import os +import shlex import weakref from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast @@ -28,6 +29,58 @@ __patched = False +def _should_log_command_args() -> bool: + value = os.getenv("ROBOTCODE_DEBUG_LOG_COMMAND_ARGS") + if value is None: + return True + + return value.lower() in ["on", "1", "yes", "true"] + + +def _format_robot_options_for_verbose(options: List[str]) -> str: + quoted = [f'"{o}"' for o in options] + return " ".join(quoted) + + +def _format_robot_shell_command(options: List[str], positional_args: List[str]) -> str: + command = ["robot", *options, *positional_args] + return " ".join(shlex.quote(part) for part in command) + + +def _get_robot_option_values(options: Tuple[str, ...], *names: str) -> List[str]: + def _option_equals(arg: str, name: str) -> bool: + if name.startswith("--"): + return arg.lower() == name.lower() + return arg == name + + def _option_startswith(arg: str, name: str) -> bool: + if name.startswith("--"): + return arg.lower().startswith(f"{name.lower()}=") + return arg.startswith(f"{name}=") + + result: List[str] = [] + i = 0 + while i < len(options): + arg = options[i] + + matched_name: Optional[str] = next((name for name in names if _option_equals(arg, name)), None) + if matched_name is not None: + if i + 1 < len(options): + result.append(options[i + 1]) + i += 2 + continue + break + + for name in names: + if _option_startswith(arg, name): + result.append(arg[len(name) + 1 :]) + break + + i += 1 + + return result + + def _patch() -> None: global __patched if __patched: @@ -280,10 +333,41 @@ def handle_robot_options( cmd_options = profile.build_command_line() + cmd_options_tuple = tuple(cmd_options) + cmd_include_tags = _get_robot_option_values(cmd_options_tuple, "--include", "-i") + cmd_exclude_tags = _get_robot_option_values(cmd_options_tuple, "--exclude", "-e") + cmd_suite_filters = _get_robot_option_values(cmd_options_tuple, "--suite") + cmd_test_filters = _get_robot_option_values(cmd_options_tuple, "--test") + + cli_include_tags = _get_robot_option_values(robot_options_and_args, "--include", "-i") + cli_exclude_tags = _get_robot_option_values(robot_options_and_args, "--exclude", "-e") + cli_suite_filters = _get_robot_option_values(robot_options_and_args, "--suite") + cli_test_filters = _get_robot_option_values(robot_options_and_args, "--test") + + merged_options = cmd_options + list(robot_options_and_args) + merged_options_tuple = tuple(merged_options) + include_tags = _get_robot_option_values(merged_options_tuple, "--include", "-i") + exclude_tags = _get_robot_option_values(merged_options_tuple, "--exclude", "-e") + suite_filters = _get_robot_option_values(merged_options_tuple, "--suite") + test_filters = _get_robot_option_values(merged_options_tuple, "--test") + + app.verbose( + lambda: "Executing robot with following options:\n " + _format_robot_options_for_verbose(merged_options) + ) + app.verbose( + lambda: ( + "robot run filter sources: " + f"profile(include={cmd_include_tags}, exclude={cmd_exclude_tags}, " + f"suite={cmd_suite_filters}, test={cmd_test_filters}) " + f"cli(include={cli_include_tags}, exclude={cli_exclude_tags}, " + f"suite={cli_suite_filters}, test={cli_test_filters})" + ) + ) app.verbose( lambda: ( - "Executing robot with following options:\n " - + " ".join(f'"{o}"' for o in (cmd_options + list(robot_options_and_args))) + "robot run filters: " + f"include_tags={include_tags} exclude_tags={exclude_tags} " + f"suite_filters={suite_filters} test_filters={test_filters}" ) ) @@ -324,7 +408,6 @@ def robot( """ root_folder, profile, cmd_options = handle_robot_options(app, robot_options_and_args) - with app.chdir(root_folder) as orig_folder: console_links_args = [] if RF_VERSION >= (7, 1) and os.getenv("ROBOTCODE_DISABLE_ANSI_LINKS", "").lower() in [ @@ -335,23 +418,49 @@ def robot( ]: console_links_args = ["--consolelinks", "off"] + full_execute_cli_args = tuple([*cmd_options, *console_links_args, *robot_options_and_args]) + execute_paths = ( + [*(app.config.default_paths if app.config.default_paths else ())] + if profile.paths is None + else profile.paths + if isinstance(profile.paths, list) + else [profile.paths] + ) + + if _should_log_command_args(): + selection_args: List[str] = [] + if execute_paths: + app.echo("robot data sources: " + " ".join(shlex.quote(str(path)) for path in execute_paths)) + if by_longname or exclude_by_longname: + selection_args = [ + *[item for value in by_longname for item in ("--by-longname", value)], + *[item for value in exclude_by_longname for item in ("--exclude-by-longname", value)], + ] + app.echo("robot selection filters argv: " + " ".join(shlex.quote(part) for part in selection_args)) + + execute_cli_log_args = list(full_execute_cli_args) + execute_cli_log_paths = [str(path) for path in execute_paths] + app.echo( + "robot execute_cli argv: " + _format_robot_shell_command(execute_cli_log_args, execute_cli_log_paths) + ) + app.verbose( + lambda: ( + "robot python api execute_cli args:\n " + + _format_robot_options_for_verbose(list(full_execute_cli_args)) + ) + ) + app.exit( cast( int, RobotFrameworkEx( app, - ( - [*(app.config.default_paths if app.config.default_paths else ())] - if profile.paths is None - else profile.paths - if isinstance(profile.paths, list) - else [profile.paths] - ), + execute_paths, app.config.dry, root_folder, orig_folder, by_longname, exclude_by_longname, - ).execute_cli((*cmd_options, *console_links_args, *robot_options_and_args), exit=False), + ).execute_cli(full_execute_cli_args, exit=False), ) ) diff --git a/tests/robotcode/language_server/common/test_text_document.py b/tests/robotcode/language_server/common/test_text_document.py index d7ec7de75..5ff6617a4 100644 --- a/tests/robotcode/language_server/common/test_text_document.py +++ b/tests/robotcode/language_server/common/test_text_document.py @@ -1,3 +1,5 @@ +import threading + import pytest from robotcode.core.lsp.types import Position, Range @@ -340,3 +342,39 @@ def get_data(self, document: TextDocument, data: str) -> str: del dummy assert len(document._cache) == 0 + + +def test_document_get_cache_concurrent_should_wait_for_first_calculation() -> None: + document = TextDocument( + document_uri="file:///test.robot", + language_id="robotframework", + version=1, + text="*** Test Cases ***\nExample\n No Operation\n", + ) + + started = threading.Event() + release = threading.Event() + + def get_data(doc: TextDocument) -> str: + started.set() + release.wait(timeout=5) + return "computed" + + worker_result: list[str] = [] + + def worker() -> None: + worker_result.append(document.get_cache(get_data)) + + t = threading.Thread(target=worker) + t.start() + + assert started.wait(timeout=5) + + main_result = document.get_cache(get_data) + + release.set() + t.join(timeout=5) + + assert not t.is_alive() + assert main_result == "computed" + assert worker_result == ["computed"] diff --git a/tests/robotcode/runner/cli/discover/test_discover_fast.py b/tests/robotcode/runner/cli/discover/test_discover_fast.py new file mode 100644 index 000000000..21c6de3a0 --- /dev/null +++ b/tests/robotcode/runner/cli/discover/test_discover_fast.py @@ -0,0 +1,253 @@ +from importlib import import_module +from pathlib import Path +from textwrap import dedent + +import pytest +from robot.model import TagPatterns +from robot.version import get_version + +pytestmark = pytest.mark.skipif(get_version() < "6.1", reason="Fast discovery tests require Robot Framework >= 6.1") + +discover = import_module("robotcode.runner.cli.discover.discover") + + +def _write_robot(path: Path, content: str) -> None: + path.write_text(dedent(content).strip() + "\n", encoding="utf-8") + + +def test_extract_force_tags_from_path_supports_continuation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite = tmp_path / "suite.robot" + _write_robot( + suite, + """ + *** Settings *** + Force Tags parent smoke + ... fast + ... smoke + + *** Test Cases *** + Example + No Operation + """, + ) + + assert discover._extract_fast_file_data_from_path(suite).force_tags == ["parent", "smoke", "fast"] + + +def test_extract_force_tags_from_path_supports_test_tags_and_continuation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite = tmp_path / "suite.robot" + _write_robot( + suite, + """ + *** Settings *** + Test Tags smoke api + ... fast + + *** Test Cases *** + Example + No Operation + """, + ) + + assert discover._extract_fast_file_data_from_path(suite).force_tags == ["smoke", "api", "fast"] + + +def test_extract_default_tags_from_path_supports_continuation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite = tmp_path / "suite.robot" + _write_robot( + suite, + """ + *** Settings *** + Default Tags smoke api + ... fast + + *** Test Cases *** + Example + No Operation + """, + ) + + assert discover._extract_fast_file_data_from_path(suite).default_tags == ["smoke", "api", "fast"] + + +def test_extract_default_tags_from_path_ignores_init_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + init_file = tmp_path / "__init__.robot" + _write_robot( + init_file, + """ + *** Settings *** + Default Tags should_not_apply + """, + ) + + assert discover._extract_fast_file_data_from_path(init_file).default_tags == [] + + +def test_extract_suite_name_from_path_supports_name_setting(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite = tmp_path / "suite.robot" + _write_robot( + suite, + """ + *** Settings *** + Name Parent Suite Custom + + *** Test Cases *** + Example + No Operation + """, + ) + + assert discover._extract_suite_name_from_path(suite) == "Parent Suite Custom" + + +def test_get_cached_inherited_force_tags_collects_parent_init_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + level1 = tmp_path / "level1" + level2 = level1 / "level2" + level2.mkdir(parents=True) + + _write_robot( + level1 / "__init__.robot", + """ + *** Settings *** + Force Tags root + """, + ) + _write_robot( + level2 / "__init__.robot", + """ + *** Settings *** + Force Tags middle + """, + ) + + suite = level2 / "suite.robot" + _write_robot( + suite, + """ + *** Settings *** + Force Tags file + + *** Test Cases *** + Example + No Operation + """, + ) + + allowed_suffixes = {".robot", ".resource"} + force_tags_cache: dict[Path, list[str]] = {} + inherited_cache: dict[Path, list[str]] = {} + + inherited = discover._get_cached_inherited_force_tags( + suite, + tmp_path, + allowed_suffixes, + force_tags_cache, + inherited_cache, + ) + + assert set(inherited) == {"root", "middle", "file"} + assert inherited[-1] == "file" + + +def test_get_cached_suite_name_for_directory_without_init_uses_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite_dir = tmp_path / "238__SPSART2-2592_NM_Replacement_CGF" + suite_dir.mkdir() + + allowed_suffixes = {".robot", ".resource"} + suite_name_cache: dict[Path, str] = {} + expected = discover.TestSuite.name_from_source(suite_dir) + + assert discover._get_cached_suite_name_for_path(suite_dir, allowed_suffixes, suite_name_cache) == expected + + +def test_extract_fast_items_from_path_collects_tags_with_continuation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(discover, "_stdin_data", None) + + suite = tmp_path / "suite.robot" + _write_robot( + suite, + """ + *** Test Cases *** + First Test + [Tags] smoke fast + ... api + No Operation + + Second Test + [Tags] db db + No Operation + """, + ) + + items = discover._extract_fast_file_data_from_path(suite).items + + assert len(items) == 2 + + first = items[0] + assert first.item_type == "test" + assert first.name == "First Test" + assert first.lineno > 0 + assert first.tags == ["smoke", "fast", "api"] + + second = items[1] + assert second.item_type == "test" + assert second.name == "Second Test" + assert second.lineno > 0 + assert second.tags == ["db"] + + +def test_fast_match_tags_applies_include_and_exclude_patterns() -> None: + include = TagPatterns(["smoke"]) + exclude = TagPatterns(["flaky"]) + + assert discover._fast_match_tags(["smoke", "api"], include, exclude) + assert not discover._fast_match_tags(["api"], include, exclude) + assert not discover._fast_match_tags(["smoke", "flaky"], include, exclude) + + +def test_apply_fast_tag_directives_supports_addition_and_deduplication() -> None: + assert discover._apply_fast_tag_directives( + ["root", "smoke"], + ["api", "smoke", "fast"], + ) == ["root", "smoke", "api", "fast"] + + +def test_compose_fast_effective_tags_applies_default_tags_only_without_tags_setting() -> None: + assert discover._compose_fast_effective_tags( + ["force"], + ["default"], + ["item"], + False, + ) == ["force", "default", "item"] + assert discover._compose_fast_effective_tags(["force"], ["default"], ["item"], True) == ["force", "item"] + + +def test_compose_fast_effective_tags_supports_none_and_empty_override() -> None: + assert discover._compose_fast_effective_tags(["force"], ["default"], [], True) == ["force"] + assert discover._compose_fast_effective_tags(["force"], ["default"], ["NONE"], True) == ["force"] + + +def test_compose_fast_effective_tags_tags_minus_removes_inherited_test_tag() -> None: + assert discover._compose_fast_effective_tags(["smoke", "api"], [], ["-smoke", "ui"], True) == ["api", "ui"] diff --git a/vscode-client/extension/debugmanager.ts b/vscode-client/extension/debugmanager.ts index 13f7bacfa..25bbac2a1 100644 --- a/vscode-client/extension/debugmanager.ts +++ b/vscode-client/extension/debugmanager.ts @@ -17,6 +17,19 @@ const DEBUG_ADAPTER_DEFAULT_HOST = "127.0.0.1"; const DEBUG_ATTACH_DEFAULT_TCP_PORT = 6612; const DEBUG_ATTACH_DEFAULT_HOST = "127.0.0.1"; +function mapLegacyListenerLogTrafficToLevel(value: string | undefined): string { + switch ((value ?? "").trim().toLowerCase()) { + case "off": + return "OFF"; + case "warnandabove": + case "warn_and_above": + case "warn-and-above": + return "WARN"; + default: + return "TRACE"; + } +} + const DEBUG_CONFIGURATIONS = [ { label: "RobotCode: Run Current", @@ -175,6 +188,20 @@ class RobotCodeDebugConfigurationProvider implements vscode.DebugConfigurationPr debugConfiguration.groupOutput = (debugConfiguration?.groupOutput as boolean | undefined) ?? config.get("debug.groupOutput"); + debugConfiguration.logCommandArgs = + (debugConfiguration?.logCommandArgs as boolean | undefined) ?? + config.get("debug.logCommandArgs", false); + + const legacyListenerLogTraffic = + (debugConfiguration?.listenerLogTraffic as string | undefined) ?? + config.get("debug.listenerLogTraffic", "all"); + + debugConfiguration.listenerLogLevel = + (debugConfiguration?.listenerLogLevel as string | undefined) ?? + config.get("debug.listenerLogLevel", mapLegacyListenerLogTrafficToLevel(legacyListenerLogTraffic)); + + debugConfiguration.listenerLogTraffic = legacyListenerLogTraffic; + if (!debugConfiguration.attachPython || debugConfiguration.noDebug) { debugConfiguration.attachPython = false; } @@ -253,7 +280,12 @@ class RobotCodeDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescr cwd: session.workspaceFolder?.uri.fsPath, }; - this.outputChannel.appendLine(`Starting debug launcher in stdio mode: ${pythonCommand} ${args.join(" ")}`); + const shouldLogArgs = config.get("debug.logCommandArgs", false); + this.outputChannel.appendLine( + shouldLogArgs + ? `Starting debug launcher in stdio mode: ${pythonCommand} ${args.join(" ")}` + : `Starting debug launcher in stdio mode: ${pythonCommand} argsRedacted=true count=${args.length}`, + ); return new vscode.DebugAdapterExecutable(pythonCommand, args, options); } @@ -340,7 +372,12 @@ class RobotCodeDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescr const args: string[] = ["-u", this.pythonManager.robotCodeMain, ...robotcodeExtraArgs, ...launchArgs]; - this.outputChannel.appendLine(`Starting debug launcher with command: ${pythonCommand} ${args.join(" ")}`); + const shouldLogArgs = config.get("debug.logCommandArgs", false); + this.outputChannel.appendLine( + shouldLogArgs + ? `Starting debug launcher with command: ${pythonCommand} ${args.join(" ")}` + : `Starting debug launcher with command: ${pythonCommand} argsRedacted=true count=${args.length}`, + ); const p = cp.spawn(pythonCommand, args, options); p.stdout?.on("data", (data) => { @@ -364,7 +401,7 @@ class RobotCodeDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescr } } -export class DebugManager { +export class DebugManager implements vscode.Disposable { private _disposables: vscode.Disposable; private _attachedSessions = new WeakValueSet(); @@ -493,13 +530,6 @@ export class DebugManager { const args = []; - if (needs_parse_include) { - for (const s of rel_sources) { - args.push("-I"); - args.push(escapeRobotGlobPatterns(s)); - } - } - if (topLevelSuiteName) { args.push("-N"); args.push(topLevelSuiteName); @@ -540,8 +570,16 @@ export class DebugManager { testLaunchConfig.target = ""; } + const hasExplicitLaunchPaths = "paths" in testLaunchConfig; let paths = config.get("robot.paths", []); - paths = "paths" in testLaunchConfig ? [...(testLaunchConfig.paths as string[]), ...paths] : paths; + paths = hasExplicitLaunchPaths ? [...(testLaunchConfig.paths as string[]), ...paths] : paths; + + if (needs_parse_include) { + for (const s of rel_sources) { + args.push("-I"); + args.push(escapeRobotGlobPatterns(s)); + } + } if (profiles) testLaunchConfig.profiles = profiles; diff --git a/vscode-client/extension/keywordsTreeViewProvider.ts b/vscode-client/extension/keywordsTreeViewProvider.ts index 487cbd731..c7e18f289 100644 --- a/vscode-client/extension/keywordsTreeViewProvider.ts +++ b/vscode-client/extension/keywordsTreeViewProvider.ts @@ -248,7 +248,18 @@ export class KeywordsTreeViewProvider .sort((a, b) => (a.label as string).localeCompare(b.label as string)); } } catch (e) { - this.outputChannel.appendLine(`Error: Can't get items for keywords treeview: ${e?.toString()}`); + const message = `${e?.toString() ?? ""}`.toLowerCase(); + const isCanceled = + message.includes("request canceled") || + message.includes("request cancelled") || + message.includes("canceled") || + message.includes("cancelled"); + + if (isCanceled) { + this.outputChannel.appendLine("Keywords treeview request canceled."); + } else { + this.outputChannel.appendLine(`Error: Can't get items for keywords treeview: ${e?.toString()}`); + } this._currentDocumentData = undefined; } finally { diff --git a/vscode-client/extension/pythonmanger.ts b/vscode-client/extension/pythonmanger.ts index 869d767b1..32dd31092 100644 --- a/vscode-client/extension/pythonmanger.ts +++ b/vscode-client/extension/pythonmanger.ts @@ -1,4 +1,5 @@ import { spawn, spawnSync } from "child_process"; +import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; import { CONFIG_SECTION } from "./config"; @@ -19,6 +20,15 @@ export class PythonInfo { public readonly path?: string, ) {} } + +export type IncrementalDiscoverItem = { id?: string; children?: IncrementalDiscoverItem[] } & Record; + +export type IncrementalDiscoverEvent = + | { event: "start"; version?: number } + | { event: "item"; item?: IncrementalDiscoverItem; parentId?: string } + | { event: "diagnostics"; diagnostics?: Record } + | { event: "end" }; + export class PythonManager { public get pythonLanguageServerMain(): string { return this._pythonLanguageServerMain; @@ -179,6 +189,7 @@ export class PythonManager { noPager?: boolean, stdioData?: string, token?: vscode.CancellationToken, + onIncrementalDiscoverEvent?: (event: IncrementalDiscoverEvent) => void, ): Promise { const { pythonCommand, final_args } = await this.buildRobotCodeCommand( folder, @@ -189,7 +200,9 @@ export class PythonManager { noPager, ); - this.outputChannel.appendLine(`executeRobotCode: ${pythonCommand} ${final_args.join(" ")}`); + this.outputChannel.appendLine(`executeRobotCode: cwd=${folder.uri.fsPath}`); + this.outputChannel.appendLine(`executeRobotCode: command=${pythonCommand}`); + this.outputChannel.appendLine(`executeRobotCode: args=${this.formatArgsForLog(final_args)}`); return new Promise((resolve, reject) => { const abortController = new AbortController(); @@ -206,25 +219,134 @@ export class PythonManager { signal, }); - let stdout = ""; - let stderr = ""; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutBytes = 0; + let exitCode: number | null = null; + const incrementalOutput = final_args.includes("--incremental-output"); + const expectsStdin = final_args.includes("--read-from-stdin"); + const stderrLogLimit = 16_384; + let stderrLoggedChars = 0; + let stderrLogTruncated = false; + const incrementalResult: { items: IncrementalDiscoverItem[]; diagnostics?: Record } = { + items: [], + }; + const incrementalItemsById = new Map(); + let incrementalLineBuffer = ""; + let incrementalParseError: Error | undefined; + + const addIncrementalItem = (item: IncrementalDiscoverItem, parentId: string | undefined): void => { + const itemId = typeof item.id === "string" ? item.id : undefined; + if (itemId) { + incrementalItemsById.set(itemId, item); + } + + if (parentId) { + const parent = incrementalItemsById.get(parentId); + if (parent) { + if (!Array.isArray(parent.children)) { + parent.children = []; + } + parent.children.push(item); + } else { + incrementalParseError = new Error( + `Executing robotcode failed: incremental discovery item '${itemId ?? ""}' references missing parent '${parentId}'.`, + ); + } + } else { + incrementalResult.items.push(item); + } + }; + + const consumeIncrementalLine = (line: string): void => { + if (incrementalParseError !== undefined) { + return; + } + + const trimmed = line.trim(); + if (trimmed.length === 0) { + return; + } + + try { + const event = JSON.parse(trimmed) as IncrementalDiscoverEvent; + + if (onIncrementalDiscoverEvent) { + try { + onIncrementalDiscoverEvent(event); + } catch (callbackError) { + this.outputChannel.appendLine( + `executeRobotCode: incremental callback failed: ${(callbackError as Error).message}`, + ); + } + } + + if (event.event === "item") { + if (event.item && typeof event.item === "object") { + addIncrementalItem(event.item, typeof event.parentId === "string" ? event.parentId : undefined); + } + return; + } + + if (event.event === "diagnostics") { + if (event.diagnostics && typeof event.diagnostics === "object") { + incrementalResult.diagnostics = event.diagnostics; + } + } + } catch (error) { + incrementalParseError = new Error( + `Executing robotcode failed: invalid incremental discovery event. ${(error as Error).message}`, + ); + } + }; - process.stdout.setEncoding("utf8"); - process.stderr.setEncoding("utf8"); if (stdioData !== undefined) { - process.stdin.cork(); process.stdin.write(stdioData, "utf8"); - process.stdin.end(); + } else if (expectsStdin) { + this.outputChannel.appendLine("executeRobotCode: warning --read-from-stdin without stdioData payload"); } + process.stdin.end(); + + process.stdout.on("data", (data: Buffer | string) => { + const chunk = typeof data === "string" ? Buffer.from(data, "utf8") : data; + stdoutBytes += chunk.length; + if (incrementalOutput) { + incrementalLineBuffer += chunk.toString("utf8"); + let newlineIndex = incrementalLineBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = incrementalLineBuffer.slice(0, newlineIndex); + consumeIncrementalLine(line); + incrementalLineBuffer = incrementalLineBuffer.slice(newlineIndex + 1); + newlineIndex = incrementalLineBuffer.indexOf("\n"); + } + return; + } - process.stdout.on("data", (data) => { - stdout += data; + stdoutChunks.push(chunk); // this.outputChannel.appendLine(data as string); }); - process.stderr.on("data", (data) => { - stderr += data; - this.outputChannel.appendLine(data as string); + process.stderr.on("data", (data: Buffer | string) => { + const chunk = typeof data === "string" ? Buffer.from(data, "utf8") : data; + stderrChunks.push(chunk); + if (!stderrLogTruncated) { + const text = chunk.toString("utf8"); + const remaining = stderrLogLimit - stderrLoggedChars; + if (remaining > 0) { + const toLog = text.length > remaining ? text.slice(0, remaining) : text; + if (toLog.length > 0) { + this.outputChannel.appendLine(toLog); + stderrLoggedChars += toLog.length; + } + } + + if (stderrLoggedChars >= stderrLogLimit) { + stderrLogTruncated = true; + this.outputChannel.appendLine( + "executeRobotCode: stderr output truncated (showing first 16384 characters only)", + ); + } + } }); process.on("error", (err) => { @@ -232,22 +354,114 @@ export class PythonManager { }); process.on("exit", (code) => { - this.outputChannel.appendLine(`executeRobotCode: exit code ${code ?? "null"}`); - if (code === 0) { + exitCode = code; + }); + + process.on("close", async () => { + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + this.outputChannel.appendLine(`executeRobotCode: exit code ${exitCode ?? "null"}`); + this.outputChannel.appendLine(`executeRobotCode: stdout bytes=${stdoutBytes}`); + if (exitCode === 0) { + if (incrementalOutput) { + if (incrementalLineBuffer.length > 0) { + consumeIncrementalLine(incrementalLineBuffer); + incrementalLineBuffer = ""; + } + + if (incrementalParseError !== undefined) { + reject(incrementalParseError); + return; + } + + this.outputChannel.appendLine( + `executeRobotCode: incremental parse done rootItems=${incrementalResult.items.length}`, + ); + resolve(incrementalResult); + return; + } + + const stdoutDumpPath = await this.dumpDiscoveryStdout(final_args, stdoutChunks); + if (stdoutDumpPath !== undefined) { + this.outputChannel.appendLine(`executeRobotCode: dumped discovery stdout to ${stdoutDumpPath}`); + } + + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); try { + const parseStarted = Date.now(); + this.outputChannel.appendLine("executeRobotCode: parse json start"); resolve(JSON.parse(stdout)); + this.outputChannel.appendLine(`executeRobotCode: parse json done elapsedMs=${Date.now() - parseStarted}`); } catch (err) { + const head = stdout.slice(0, 1000); + const tail = stdout.slice(-1000); + this.outputChannel.appendLine( + `executeRobotCode: invalid json output length=${stdout.length} head:\n${head}\n...tail:\n${tail}`, + ); reject(err); } } else { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); this.outputChannel.appendLine(`executeRobotCode: ${stdout}\n${stderr}`); - reject(new Error(`Executing robotcode failed with code ${code ?? "null"}: ${stdout}\n${stderr}`)); + reject(new Error(`Executing robotcode failed with code ${exitCode ?? "null"}: ${stdout}\n${stderr}`)); } }); }); } + // eslint-disable-next-line class-methods-use-this + private isDiscoverCommand(args: string[]): boolean { + return args.includes("discover"); + } + + private getDiscoveryDumpDir(): string { + return path.join( + this.extensionContext.globalStorageUri?.fsPath ?? this.extensionContext.extensionPath, + "discover-dumps", + ); + } + + private async dumpDiscoveryStdout(args: string[], stdoutChunks: Buffer[]): Promise { + if (!this.isDiscoverCommand(args)) { + return undefined; + } + + try { + const dumpDir = this.getDiscoveryDumpDir(); + await fs.promises.mkdir(dumpDir, { recursive: true }); + + const mode = args.includes("fast") + ? "fast" + : args.includes("all") + ? "all" + : args.includes("tests") + ? "tests" + : "discover"; + const fileName = `${new Date().toISOString().replace(/[.:]/g, "-")}_runner_stdout_${mode}.json`; + const filePath = path.join(dumpDir, fileName); + + const fileHandle = await fs.promises.open(filePath, "w"); + try { + for (const chunk of stdoutChunks) { + await fileHandle.write(chunk); + } + await fileHandle.write("\n"); + } finally { + await fileHandle.close(); + } + + return filePath; + } catch (error) { + this.outputChannel.appendLine(`executeRobotCode: failed to dump discovery stdout (${(error as Error).message})`); + return undefined; + } + } + + // eslint-disable-next-line class-methods-use-this + private formatArgsForLog(args: string[]): string { + return JSON.stringify(args); + } + public async buildRobotCodeCommand( folder: vscode.WorkspaceFolder, args: string[], diff --git a/vscode-client/extension/testcontrollermanager.ts b/vscode-client/extension/testcontrollermanager.ts index fadd60b8d..b00ec8ede 100644 --- a/vscode-client/extension/testcontrollermanager.ts +++ b/vscode-client/extension/testcontrollermanager.ts @@ -1,9 +1,12 @@ import { red, yellow, blue } from "ansi-colors"; +import { spawn } from "child_process"; import * as vscode from "vscode"; import { DebugManager } from "./debugmanager"; import * as fs from "fs"; +import * as path from "path"; import { ClientState, LanguageClientsManager, toVsCodeRange } from "./languageclientsmanger"; +import type { IncrementalDiscoverEvent } from "./pythonmanger"; import { escapeRobotGlobPatterns, filterAsync, Mutex, truncateAndReplaceNewlines, WeakValueMap } from "./utils"; import { CONFIG_SECTION } from "./config"; import { Range, Diagnostic, DiagnosticSeverity } from "vscode-languageclient/node"; @@ -31,6 +34,13 @@ function diagnosticsSeverityToVsCode(severity?: DiagnosticSeverity): vscode.Diag } } +const LEGACY_DISCOVER_ARG_CANDIDATE_COUNT_LIMIT = 512; +const LEGACY_DISCOVER_ARG_TOTAL_LENGTH_LIMIT = 64_000; +const FAST_DISCOVERY_TIMEOUT_DEFAULT_MS = 120_000; +const FAST_DISCOVERY_TIMEOUT_MIN_MS = 1_000; +const FAST_DISCOVERY_TIMEOUT_MAX_MS = 900_000; +const FAST_DISCOVERY_TIMEOUT_PER_CANDIDATE_MS = 25; + enum RobotItemType { WORKSPACE = "workspace", SUITE = "suite", @@ -61,6 +71,10 @@ interface RobotCodeDiscoverResult { diagnostics?: { [Key: string]: Diagnostic[] }; } +type FastIncrementalDiscoverItemEvent = Extract; + +type FastDiscoveryPrefilterCommand = "auto" | "gitGrep" | "ripGrep" | "grep" | "none"; + interface RobotCodeProfileInfo { name: string; description: string; @@ -216,8 +230,9 @@ export class TestControllerManager { this.testController = vscode.tests.createTestController("robotCode.RobotFramework", "Robot Framework Tests/Tasks"); this.testController.resolveHandler = async (item) => { - // resolveHandler has no token parameter in the VS Code API — refresh() itself - // takes care of cancelling older calls via the single-inflight pattern. + if (item !== undefined && item.children.size > 0) { + return; + } await this.refresh(item); }; @@ -716,10 +731,63 @@ export class TestControllerManager { public readonly robotTestItems = new WeakMap(); + private findRobotItemInTree(items: RobotTestItem[] | undefined, id: string): RobotTestItem | undefined { + if (items === undefined) { + return undefined; + } + + for (const item of items) { + if (item.id === id) { + return item; + } + + const nested = this.findRobotItemInTree(item.children, id); + if (nested !== undefined) { + return nested; + } + } + + return undefined; + } + public findRobotItem(item: vscode.TestItem): RobotTestItem | undefined { - // The index is kept consistent with robotTestItems (populated in getTestsFromWorkspaceFolder / - // getTestsFromDocument, cleaned in removeNotAddedTestItems / removeWorkspaceFolderItems). - return this.robotItemIndex.get(item.id); + const indexedItem = this.robotItemIndex.get(item.id); + if (indexedItem !== undefined) { + return indexedItem; + } + + if (item.parent) { + const parentRobotItem = this.findRobotItem(item.parent); + const directParentChildMatch = parentRobotItem?.children?.find((i) => i.id === item.id); + if (directParentChildMatch !== undefined) { + return directParentChildMatch; + } + + if (parentRobotItem?.type === RobotItemType.WORKSPACE && parentRobotItem.children?.length === 1) { + const workspace = this.findWorkspaceFolderForItem(item.parent); + const rootSuite = parentRobotItem.children[0]; + if (workspace !== undefined && this.matchesWorkspaceRootSuite(workspace, rootSuite)) { + const flattenedChildMatch = rootSuite.children?.find((i) => i.id === item.id); + if (flattenedChildMatch !== undefined) { + return flattenedChildMatch; + } + } + } + } + + for (const workspace of vscode.workspace.workspaceFolders ?? []) { + if (!this.robotTestItems.has(workspace)) { + continue; + } + + const workspaceItems = this.robotTestItems.get(workspace)?.items; + const foundItem = this.findRobotItemInTree(workspaceItems, item.id); + if (foundItem !== undefined) { + return foundItem; + } + } + + return undefined; } // Recursively walks a RobotTestItem subtree and calls cb for each item. @@ -809,26 +877,28 @@ export class TestControllerManager { } const item = this.findTestItemForDocument(document); + const documentFolder = vscode.workspace.getWorkspaceFolder(document.uri); if (item) this.refresh(item, cancelationTokenSource.token).then( () => { if (item?.canResolveChildren && item.children.size === 0) { - this.refreshWorkspace( - vscode.workspace.getWorkspaceFolder(document.uri), - cancelationTokenSource.token, - ).then( - () => undefined, - () => undefined, - ); + if (this.shouldAllowWorkspaceRefreshFallbackOnDocumentInteraction(documentFolder)) { + this.refreshWorkspace(documentFolder, cancelationTokenSource.token).then( + () => undefined, + () => undefined, + ); + } } }, () => undefined, ); else { - this.refreshWorkspace(vscode.workspace.getWorkspaceFolder(document.uri), cancelationTokenSource.token).then( - () => undefined, - () => undefined, - ); + if (this.shouldAllowWorkspaceRefreshFallbackOnDocumentInteraction(documentFolder)) { + this.refreshWorkspace(documentFolder, cancelationTokenSource.token).then( + () => undefined, + () => undefined, + ); + } } }, TestControllerManager.DEBOUNCE_MS), cancelationTokenSource, @@ -856,6 +926,10 @@ export class TestControllerManager { return undefined; } + private findWorkspaceTestItem(folder: vscode.WorkspaceFolder): vscode.TestItem | undefined { + return this.testController.items.get(folder.uri.fsPath) ?? this.findTestItemByUri(folder.uri.toString()); + } + public findTestItemById(id: string): vscode.TestItem | undefined { return this.testItems.get(id); } @@ -866,13 +940,30 @@ export class TestControllerManager { // Earlier refreshes still waiting on the mutex abort right after acquiring it because // their CTS is already cancelled — effectively only the newest call really runs. private currentRefreshCts: vscode.CancellationTokenSource | undefined; + private currentRefreshScope: "workspace" | "item" | undefined; public async refresh(item?: vscode.TestItem, externalToken?: vscode.CancellationToken): Promise { - // Cancel any in-flight predecessor. - this.currentRefreshCts?.cancel(); + const requestedScope: "workspace" | "item" = item === undefined ? "workspace" : "item"; + + if (this.currentRefreshCts !== undefined && this.currentRefreshScope === "workspace") { + this.outputChannel.appendLine( + requestedScope === "workspace" + ? "discover tests: coalescing overlapping workspace refresh request" + : "discover tests: coalescing item refresh while workspace refresh is in progress", + ); + return; + } + + if (requestedScope === "workspace") { + // no-op, workspace refresh starts normally when none is running. + } else { + // Item-scoped refreshes should supersede an in-flight predecessor. + this.currentRefreshCts?.cancel(); + } const cts = new vscode.CancellationTokenSource(); this.currentRefreshCts = cts; + this.currentRefreshScope = requestedScope; // Bridge external cancellation (e.g. from VS Code's refreshHandler) onto our CTS. const externalSub = externalToken?.onCancellationRequested(() => cts.cancel()); @@ -886,6 +977,7 @@ export class TestControllerManager { externalSub?.dispose(); if (this.currentRefreshCts === cts) { this.currentRefreshCts = undefined; + this.currentRefreshScope = undefined; } cts.dispose(); } @@ -899,13 +991,33 @@ export class TestControllerManager { extraArgs: string[], stdioData?: string, prune?: boolean, + discoveryDumpLabel?: string, + executionTimeoutMs?: number, token?: vscode.CancellationToken, + onIncrementalDiscoverEvent?: (event: IncrementalDiscoverEvent) => void, ): Promise { if (!(await this.languageClientsManager.isValidRobotEnvironmentInFolder(folder))) { return {}; } const config = vscode.workspace.getConfiguration(CONFIG_SECTION, folder); + const shouldLogArgs = config.get("testExplorer.discovery.logCommandArgs", false); + const startTime = Date.now(); + this.outputChannel.appendLine( + shouldLogArgs + ? `discover tests: start workspace=${folder.name} discoverArgs=${discoverArgs.join(" ")} extraArgsCount=${extraArgs.length}` + : `discover tests: start workspace=${folder.name} discoverArgsCount=${discoverArgs.length} extraArgsCount=${extraArgs.length}`, + ); + + if (discoveryDumpLabel !== undefined) { + await this.writeDiscoveryDump(folder, `${discoveryDumpLabel}_request`, { + workspace: folder.name, + discoverArgs, + extraArgs, + stdioData, + }); + } + const profiles = config.get("profiles", []); const pythonPath = config.get("robot.pythonPath", []); const paths = config.get("robot.paths", undefined); @@ -924,24 +1036,89 @@ export class TestControllerManager { mode_args.push("--rpa"); break; } - const result = (await this.languageClientsManager.pythonManager.executeRobotCode( - folder, - [ - ...(paths?.length ? paths.flatMap((v) => ["-dp", v]) : ["-dp", "."]), - ...discoverArgs, - ...mode_args, - ...pythonPath.flatMap((v) => ["-P", v]), - ...languages.flatMap((v) => ["--language", v]), - ...robotArgs, - ...extraArgs, - ], - profiles, - "json", - true, - true, - stdioData, - token, - )) as RobotCodeDiscoverResult; + const discoverRunEmptySuiteArg = this.isRunEmptySuiteEnabledForDiscovery(folder) + ? "--run-empty-suite" + : "--no-run-empty-suite"; + const useIncrementalDiscoveryTransport = this.isFastDiscoveryEnabled(folder) && discoverArgs.includes("fast"); + const discoverArgsWithRunEmptySuiteOption = + discoverArgs.length > 0 && discoverArgs[0] === "discover" + ? ["discover", discoverRunEmptySuiteArg, ...discoverArgs.slice(1)] + : [...discoverArgs, discoverRunEmptySuiteArg]; + + const discoverCommandArgs = [ + ...(paths?.length ? paths.flatMap((v) => ["-dp", v]) : ["-dp", "."]), + ...discoverArgsWithRunEmptySuiteOption, + ...mode_args, + ...pythonPath.flatMap((v) => ["-P", v]), + ...languages.flatMap((v) => ["--language", v]), + ...robotArgs, + ...(useIncrementalDiscoveryTransport ? ["--incremental-output"] : []), + ...(extraArgs.length ? ["--", ...extraArgs] : []), + ]; + this.outputChannel.appendLine( + `discover tests: transport=${useIncrementalDiscoveryTransport ? "incremental" : "json"}`, + ); + if (executionTimeoutMs !== undefined && executionTimeoutMs > 0) { + this.outputChannel.appendLine( + shouldLogArgs + ? `discover tests: timeout configured ${executionTimeoutMs}ms workspace=${folder.name} discoverArgs=${discoverArgs.join(" ")}` + : `discover tests: timeout configured ${executionTimeoutMs}ms workspace=${folder.name} argsRedacted=true`, + ); + } + + let timeoutHandle: NodeJS.Timeout | undefined; + let timeoutTriggered = false; + const timeoutTokenSource = new vscode.CancellationTokenSource(); + const timeoutDisposables: vscode.Disposable[] = [timeoutTokenSource]; + + if (token) { + timeoutDisposables.push( + token.onCancellationRequested(() => { + timeoutTokenSource.cancel(); + }), + ); + } + + if (executionTimeoutMs !== undefined && executionTimeoutMs > 0) { + timeoutHandle = setTimeout(() => { + timeoutTriggered = true; + this.outputChannel.appendLine( + shouldLogArgs + ? `discover tests: timeout after ${executionTimeoutMs}ms workspace=${folder.name} discoverArgs=${discoverArgs.join(" ")} commandArgs=${discoverCommandArgs.join(" ")}` + : `discover tests: timeout after ${executionTimeoutMs}ms workspace=${folder.name} argsRedacted=true`, + ); + timeoutTokenSource.cancel(); + }, executionTimeoutMs); + } + + let result: RobotCodeDiscoverResult; + try { + result = (await this.languageClientsManager.pythonManager.executeRobotCode( + folder, + discoverCommandArgs, + profiles, + "json", + true, + true, + stdioData, + timeoutTokenSource.token, + onIncrementalDiscoverEvent, + )) as RobotCodeDiscoverResult; + } catch (error) { + if (timeoutTriggered) { + throw new Error( + shouldLogArgs + ? `discover command timed out after ${executionTimeoutMs}ms workspace=${folder.name} discoverArgs=${discoverArgs.join(" ")}` + : `discover command timed out after ${executionTimeoutMs}ms workspace=${folder.name}`, + ); + } + throw error; + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + timeoutDisposables.forEach((d) => d.dispose()); + } const added_uris = new Set(); @@ -972,10 +1149,485 @@ export class TestControllerManager { }); } + this.outputChannel.appendLine( + `discover tests: done workspace=${folder.name} elapsedMs=${Date.now() - startTime} items=${result?.items?.length ?? 0}`, + ); + + if (discoveryDumpLabel !== undefined) { + await this.writeDiscoveryDump(folder, `${discoveryDumpLabel}_result`, { + workspace: folder.name, + discoverArgs, + extraArgs, + result, + }); + } + return result; } + // eslint-disable-next-line class-methods-use-this + private sanitizeDiscoveryDumpSegment(value: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return sanitized.length > 0 ? sanitized : "discover"; + } + + private getDiscoveryDumpDir(): string { + return path.join( + this.extensionContext.globalStorageUri?.fsPath ?? this.extensionContext.extensionPath, + "discover-dumps", + ); + } + + private async writeDiscoveryDump( + folder: vscode.WorkspaceFolder, + label: string, + content: unknown, + ): Promise { + try { + const dir = this.getDiscoveryDumpDir(); + await fs.promises.mkdir(dir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[.:]/g, "-"); + const fileName = `${timestamp}_${this.sanitizeDiscoveryDumpSegment(folder.name)}_${this.sanitizeDiscoveryDumpSegment(label)}.json`; + const filePath = path.join(dir, fileName); + + await fs.promises.writeFile(filePath, `${JSON.stringify(content, null, 2)}\n`, "utf8"); + this.outputChannel.appendLine(`discover tests: dumped ${label} output to ${filePath}`); + return filePath; + } catch (error) { + this.outputChannel.appendLine(`discover tests: failed to dump ${label} output (${(error as Error).message})`); + return undefined; + } + } + + // eslint-disable-next-line class-methods-use-this + private estimateCandidateArgumentSize(candidates: string[]): number { + return candidates.reduce((size, candidate) => size + candidate.length + 1, 0); + } + private readonly lastDiscoverResults = new WeakMap(); + private readonly documentDiscoverResultsCache = new Map< + string, + { + version: number; + items: RobotTestItem[] | undefined; + } + >(); + + // eslint-disable-next-line class-methods-use-this + private isFastDiscoveryEnabled(folder: vscode.WorkspaceFolder): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.fastDiscovery.enabled", false); + } + + // eslint-disable-next-line class-methods-use-this + private isRunEmptySuiteEnabledForDiscovery(folder: vscode.WorkspaceFolder): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.discovery.runEmptySuite", true); + } + + // eslint-disable-next-line class-methods-use-this + private getFastDiscoveryPrefilterCommand(folder: vscode.WorkspaceFolder): FastDiscoveryPrefilterCommand { + return vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.fastDiscovery.prefilterCommand", "auto"); + } + + // eslint-disable-next-line class-methods-use-this + private getFastDiscoveryRoots(folder: vscode.WorkspaceFolder): string[] { + const configuredPaths = vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("robot.paths"); + const roots = configuredPaths?.length ? configuredPaths : ["."]; + return roots.map((v) => v.trim()).filter((v) => v.length > 0); + } + + // eslint-disable-next-line class-methods-use-this + private getConfiguredFastDiscoveryTimeoutMs(folder: vscode.WorkspaceFolder): number | undefined { + const configuredTimeout = vscode.workspace + .getConfiguration(CONFIG_SECTION, folder) + .get("testExplorer.discovery.fastTimeoutMs", 0); + + if (!Number.isFinite(configuredTimeout) || configuredTimeout <= 0) { + return undefined; + } + + return Math.max(FAST_DISCOVERY_TIMEOUT_MIN_MS, Math.floor(configuredTimeout)); + } + + private getFastDiscoveryTimeout( + folder: vscode.WorkspaceFolder, + candidateCount: number, + ): { timeoutMs: number; source: "adaptive" | "configured" } { + const configuredTimeout = this.getConfiguredFastDiscoveryTimeoutMs(folder); + if (configuredTimeout !== undefined) { + return { timeoutMs: configuredTimeout, source: "configured" }; + } + + if (candidateCount <= 0) { + return { timeoutMs: FAST_DISCOVERY_TIMEOUT_DEFAULT_MS, source: "adaptive" }; + } + + const adaptiveTimeoutMs = candidateCount * FAST_DISCOVERY_TIMEOUT_PER_CANDIDATE_MS; + + return { + timeoutMs: Math.min( + FAST_DISCOVERY_TIMEOUT_MAX_MS, + Math.max(FAST_DISCOVERY_TIMEOUT_DEFAULT_MS, adaptiveTimeoutMs), + ), + source: "adaptive", + }; + } + + private shouldAllowWorkspaceRefreshFallbackOnDocumentInteraction( + folder: vscode.WorkspaceFolder | undefined, + ): boolean { + if (folder === undefined) { + return true; + } + + return !this.isFastDiscoveryEnabled(folder); + } + + private isFastDiscoveryCommandEnabled(folder: vscode.WorkspaceFolder): boolean { + const fastDiscoveryEnabled = this.isFastDiscoveryEnabled(folder); + const config = vscode.workspace.getConfiguration(CONFIG_SECTION, folder); + const inspected = config.inspect("testExplorer.fastDiscovery.command.enabled"); + const explicitlyConfigured = + inspected?.globalValue !== undefined || + inspected?.workspaceValue !== undefined || + inspected?.workspaceFolderValue !== undefined; + + if (!explicitlyConfigured) { + return fastDiscoveryEnabled; + } + + return config.get("testExplorer.fastDiscovery.command.enabled", fastDiscoveryEnabled); + } + + // eslint-disable-next-line class-methods-use-this + private async runShellCommand( + command: string, + args: string[], + cwd: string, + token?: vscode.CancellationToken, + ): Promise<{ exitCode: number | null; stdout: string; stderr: string; error?: unknown }> { + return await new Promise((resolve) => { + const abortController = new AbortController(); + + token?.onCancellationRequested(() => { + abortController.abort(); + }); + + const process = spawn(command, args, { + cwd, + signal: abortController.signal, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let exitCode: number | null = null; + + process.stdout.on("data", (data: Buffer | string) => { + stdoutChunks.push(typeof data === "string" ? Buffer.from(data, "utf8") : data); + }); + process.stderr.on("data", (data: Buffer | string) => { + stderrChunks.push(typeof data === "string" ? Buffer.from(data, "utf8") : data); + }); + + process.on("error", (error) => { + resolve({ + exitCode: null, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + error, + }); + }); + + process.on("exit", (code) => { + exitCode = code; + }); + + process.on("close", () => { + resolve({ + exitCode, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + }); + }); + }); + } + + // eslint-disable-next-line class-methods-use-this + private toDiscoverPathArg(folder: vscode.WorkspaceFolder, filePath: string): string { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(folder.uri.fsPath, filePath); + const relativePath = path.relative(folder.uri.fsPath, absolutePath); + if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { + return relativePath; + } + + return absolutePath; + } + + // eslint-disable-next-line class-methods-use-this + private parsePrefilterFileList(stdout: string): string[] { + if (!stdout) return []; + if (stdout.includes("\0")) { + return stdout + .split("\0") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + } + return stdout + .split(/\r?\n/) + .map((v) => v.trim()) + .filter((v) => v.length > 0); + } + + // eslint-disable-next-line class-methods-use-this + private normalizeRootForSearch(folder: vscode.WorkspaceFolder, root: string): string | undefined { + const normalizedRoot = root.trim(); + if (!normalizedRoot) return undefined; + if (!path.isAbsolute(normalizedRoot)) return normalizedRoot; + + const relativeRoot = path.relative(folder.uri.fsPath, normalizedRoot); + if (!relativeRoot || relativeRoot.startsWith("..")) return undefined; + return relativeRoot; + } + + private getGrepExcludeDirPatterns(folder: vscode.WorkspaceFolder): string[] { + const result = new Set([".git", ".svn", "CVS"]); + const ignoreFiles = [".robotignore", ".gitignore"]; + + const addPattern = (pattern: string): void => { + let normalized = pattern.trim(); + if (!normalized || normalized.startsWith("#") || normalized.startsWith("!")) { + return; + } + + if (normalized.startsWith("\\#")) { + normalized = normalized.slice(1); + } + + normalized = normalized.replaceAll("\\", "/").replace(/^\/+/, ""); + + const isDirectoryPattern = normalized.endsWith("/") || normalized.endsWith("/*"); + if (!isDirectoryPattern) { + return; + } + + if (normalized.endsWith("/*")) { + normalized = normalized.slice(0, -2); + } + normalized = normalized.replace(/\/+$/, ""); + if (!normalized || normalized.includes("**")) { + return; + } + + const basename = normalized + .split("/") + .filter((v) => v.length > 0) + .at(-1); + if (basename) { + result.add(basename); + } + }; + + for (const ignoreFile of ignoreFiles) { + const ignorePath = path.join(folder.uri.fsPath, ignoreFile); + if (!fs.existsSync(ignorePath)) { + continue; + } + + try { + const content = fs.readFileSync(ignorePath, "utf8"); + for (const line of content.split(/\r?\n/)) { + addPattern(line); + } + } catch (error) { + this.outputChannel.appendLine( + `fast discovery: unable to read ${ignoreFile} for grep excludes (${(error as Error).message})`, + ); + } + } + + return Array.from(result); + } + + private async prefilterWithGitGrep( + folder: vscode.WorkspaceFolder, + roots: string[], + token?: vscode.CancellationToken, + ): Promise { + const fileExtensions = this.languageClientsManager.fileExtensions; + const normalizedRoots = roots + .map((v) => this.normalizeRootForSearch(folder, v)) + .filter((v) => v !== undefined) + .map((v) => v.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "")); + if (normalizedRoots.length === 0) return []; + const pathSpecs = normalizedRoots.flatMap((root) => + fileExtensions.map((ext) => `:(glob)${root.length > 0 && root !== "." ? `${root}/` : ""}**/*.${ext}`), + ); + + const result = await this.runShellCommand( + "git", + ["grep", "-z", "-l", "-E", "^\\*\\*\\*\\s*(Test Cases|Tasks)\\s*\\*\\*\\*\\s*$", "--", ...pathSpecs], + folder.uri.fsPath, + token, + ); + + if (result.error !== undefined) { + this.outputChannel.appendLine( + `fast discovery: git grep unavailable (${result.error?.toString() ?? "unknown error"})`, + ); + return undefined; + } + + if (result.exitCode !== 0 && result.exitCode !== 1) { + this.outputChannel.appendLine( + `fast discovery: git grep failed with exit code ${result.exitCode?.toString() ?? "null"}: ${result.stderr}`, + ); + return undefined; + } + + return this.parsePrefilterFileList(result.stdout).map((v) => this.toDiscoverPathArg(folder, v)); + } + + private async prefilterWithRipGrep( + folder: vscode.WorkspaceFolder, + roots: string[], + token?: vscode.CancellationToken, + ): Promise { + const normalizedRoots = roots.map((v) => this.normalizeRootForSearch(folder, v)).filter((v) => v !== undefined); + if (normalizedRoots.length === 0) return []; + + const robotIgnorePath = path.join(folder.uri.fsPath, ".robotignore"); + const includeArgs = this.languageClientsManager.fileExtensions.flatMap((ext) => ["--glob", `*.${ext}`]); + const rgArgs = [ + "-l", + "--null", + "--no-messages", + ...(fs.existsSync(robotIgnorePath) ? ["--ignore-file", robotIgnorePath] : []), + ...includeArgs, + "^\\*\\*\\*\\s*(Test Cases|Tasks)\\s*\\*\\*\\*\\s*$", + ...normalizedRoots, + ]; + + const result = await this.runShellCommand("rg", rgArgs, folder.uri.fsPath, token); + + if (result.error !== undefined) { + this.outputChannel.appendLine( + `fast discovery: ripgrep unavailable (${result.error?.toString() ?? "unknown error"})`, + ); + return undefined; + } + + if (result.exitCode !== 0 && result.exitCode !== 1) { + this.outputChannel.appendLine( + `fast discovery: ripgrep failed with exit code ${result.exitCode?.toString() ?? "null"}: ${result.stderr}`, + ); + return undefined; + } + + return this.parsePrefilterFileList(result.stdout).map((v) => this.toDiscoverPathArg(folder, v)); + } + + private async prefilterWithGrep( + folder: vscode.WorkspaceFolder, + roots: string[], + token?: vscode.CancellationToken, + ): Promise { + const normalizedRoots = roots.map((v) => this.normalizeRootForSearch(folder, v)).filter((v) => v !== undefined); + if (normalizedRoots.length === 0) return []; + + const includeArgs = this.languageClientsManager.fileExtensions.map((ext) => `--include=*.${ext}`); + const excludeDirArgs = this.getGrepExcludeDirPatterns(folder).flatMap((pattern) => ["--exclude-dir", pattern]); + const result = await this.runShellCommand( + "grep", + [ + "-RIlE", + "-Z", + "^\\*\\*\\*\\s*(Test Cases|Tasks)\\s*\\*\\*\\*\\s*$", + ...includeArgs, + ...excludeDirArgs, + ...normalizedRoots, + ], + folder.uri.fsPath, + token, + ); + + if (result.error !== undefined) { + this.outputChannel.appendLine( + `fast discovery: grep unavailable (${result.error?.toString() ?? "unknown error"})`, + ); + return undefined; + } + + if (result.exitCode !== 0 && result.exitCode !== 1) { + this.outputChannel.appendLine( + `fast discovery: grep failed with exit code ${result.exitCode?.toString() ?? "null"}: ${result.stderr}`, + ); + return undefined; + } + + return this.parsePrefilterFileList(result.stdout).map((v) => this.toDiscoverPathArg(folder, v)); + } + + private async getFastDiscoveryCandidates( + folder: vscode.WorkspaceFolder, + token?: vscode.CancellationToken, + ): Promise { + if (!this.isFastDiscoveryEnabled(folder)) return undefined; + + const roots = this.getFastDiscoveryRoots(folder); + const prefilterMode = this.getFastDiscoveryPrefilterCommand(folder); + this.outputChannel.appendLine( + `fast discovery: start workspace=${folder.name} mode=${prefilterMode} roots=${roots.join(",")}`, + ); + + const runGit = async () => await this.prefilterWithGitGrep(folder, roots, token); + const runRipGrep = async () => await this.prefilterWithRipGrep(folder, roots, token); + const runGrep = async () => await this.prefilterWithGrep(folder, roots, token); + + let files: string[] | undefined; + + switch (prefilterMode) { + case "gitGrep": + files = await runGit(); + break; + case "ripGrep": + files = await runRipGrep(); + break; + case "grep": + files = await runGrep(); + break; + case "none": + return undefined; + case "auto": + default: + files = (await runGit()) ?? (await runRipGrep()) ?? (await runGrep()); + break; + } + + if (files === undefined) return undefined; + + this.outputChannel.appendLine( + `fast discovery: prefilter mode=${prefilterMode} candidates=${files.length} in workspace ${folder.name}`, + ); + return files; + } + + // eslint-disable-next-line class-methods-use-this + private isLegacyDiscoverStdinFormatError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const message = error.message ?? ""; + return ( + message.includes('Invalid value for "documents"') || + message.includes("must be of type `` but is `dict`") + ); + } public async getTestsFromWorkspaceFolder( folder: vscode.WorkspaceFolder, @@ -1004,20 +1656,207 @@ export class TestControllerManager { } } - const result = await this.discoverTests( - folder, - ["discover", "--read-from-stdin", "all"], - [], - JSON.stringify(o), - true, - token, + const fastDiscoveryEnabled = this.isFastDiscoveryEnabled(folder); + const fastDiscoveryCommandEnabled = this.isFastDiscoveryCommandEnabled(folder); + this.outputChannel.appendLine( + `fast discovery: config workspace=${folder.name} enabled=${fastDiscoveryEnabled} commandEnabled=${fastDiscoveryCommandEnabled}`, ); - this.lastDiscoverResults.set(folder, result); - // Index the freshly discovered subtree for O(1) findRobotItem lookups. - this.indexRobotTree(result?.items); + const fastDiscoveryCandidates = await this.getFastDiscoveryCandidates(folder, token); + if (token?.isCancellationRequested) return undefined; - return result?.items; + const useFastDiscoveryCommand = fastDiscoveryCandidates !== undefined && fastDiscoveryCommandEnabled; + const initialDiscoverSubCommand = useFastDiscoveryCommand ? "fast" : "all"; + const fastDiscoveryTimeout = this.getFastDiscoveryTimeout(folder, fastDiscoveryCandidates?.length ?? 0); + if (useFastDiscoveryCommand) { + this.outputChannel.appendLine( + `fast discovery: timeoutMs=${fastDiscoveryTimeout.timeoutMs} source=${fastDiscoveryTimeout.source} candidates=${fastDiscoveryCandidates?.length ?? 0}`, + ); + } + const incrementalParentAliases = new Map(); + let incrementalParentOrderError: Error | undefined; + + const isRobotTestItemLike = (value: unknown): value is RobotTestItem => { + if (value === undefined || value === null || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.longname === "string" && + typeof candidate.type === "string" + ); + }; + + const applyIncrementalItem = (event: FastIncrementalDiscoverItemEvent): void => { + if (token?.isCancellationRequested) return; + if (incrementalParentOrderError !== undefined) return; + + const rawItem = event.item; + if (!isRobotTestItemLike(rawItem)) { + return; + } + + const item = rawItem; + const parentId = typeof event.parentId === "string" ? event.parentId : undefined; + + const resolveIncrementalParentId = (targetParentId: string | undefined): string | undefined => { + let resolvedId = targetParentId; + const seen = new Set(); + + while (resolvedId !== undefined && incrementalParentAliases.has(resolvedId) && !seen.has(resolvedId)) { + seen.add(resolvedId); + resolvedId = incrementalParentAliases.get(resolvedId); + } + + return resolvedId; + }; + + const applyItem = (targetItem: RobotTestItem, targetParentId: string | undefined): void => { + const resolvedParentId = resolveIncrementalParentId(targetParentId); + const parentTestItem = resolvedParentId ? this.findTestItemById(resolvedParentId) : undefined; + if (resolvedParentId !== undefined && parentTestItem === undefined) { + incrementalParentOrderError = new Error( + `fast discovery incremental stream: missing parent item '${resolvedParentId}' for child '${targetItem.id}'`, + ); + this.outputChannel.appendLine(incrementalParentOrderError.message); + return; + } + + if (parentTestItem !== undefined && this.isSyntheticWorkspaceRootSuite(parentTestItem, targetItem)) { + incrementalParentAliases.set(targetItem.id, parentTestItem.id); + return; + } + + this.addOrUpdateTestItem(parentTestItem, targetItem); + }; + + applyItem(item, parentId); + }; + + const onIncrementalDiscoverEvent = (event: IncrementalDiscoverEvent): void => { + if (!(this.isFastDiscoveryEnabled(folder) && event.event === "item")) { + return; + } + + applyIncrementalItem(event); + }; + + if (fastDiscoveryCandidates !== undefined && fastDiscoveryCandidates.length === 0) { + this.lastDiscoverResults.set(folder, { items: [] }); + return []; + } + + const fullStdioData = JSON.stringify(o); + const prefilteredStdioData = + fastDiscoveryCandidates !== undefined + ? JSON.stringify({ documents: o, candidates: fastDiscoveryCandidates }) + : fullStdioData; + + const runWorkspaceDiscover = async ( + subCommand: "all" | "fast", + extraArgs: string[], + stdioData: string, + ): Promise => { + if (subCommand === "fast") { + incrementalParentOrderError = undefined; + } + + const discoveryDumpLabel = `workspace_${subCommand}${extraArgs.length > 0 ? "_legacy_candidates" : "_stdin_candidates"}`; + + const discoverResult = await this.discoverTests( + folder, + ["discover", "--read-from-stdin", subCommand], + extraArgs, + stdioData, + true, + discoveryDumpLabel, + subCommand === "fast" ? fastDiscoveryTimeout.timeoutMs : undefined, + token, + subCommand === "fast" ? onIncrementalDiscoverEvent : undefined, + ); + + if (subCommand === "fast" && incrementalParentOrderError !== undefined) { + throw incrementalParentOrderError; + } + + return discoverResult; + }; + + const runWorkspaceDiscoverWithLegacyRetry = async ( + subCommand: "all" | "fast", + ): Promise => { + try { + return await runWorkspaceDiscover(subCommand, [], prefilteredStdioData); + } catch (error) { + if (fastDiscoveryCandidates !== undefined && this.isLegacyDiscoverStdinFormatError(error)) { + const candidateArgSize = this.estimateCandidateArgumentSize(fastDiscoveryCandidates); + const canRetryWithLegacyArgs = + fastDiscoveryCandidates.length <= LEGACY_DISCOVER_ARG_CANDIDATE_COUNT_LIMIT && + candidateArgSize <= LEGACY_DISCOVER_ARG_TOTAL_LENGTH_LIMIT; + + if (!canRetryWithLegacyArgs) { + this.outputChannel.appendLine( + `fast discovery: skipping legacy argv retry candidates=${fastDiscoveryCandidates.length} totalChars=${candidateArgSize}`, + ); + if (subCommand === "all") { + return await runWorkspaceDiscover("all", [], fullStdioData); + } + throw error; + } + + this.outputChannel.appendLine( + "fast discovery: stdin candidates payload not supported by bundled runner, retrying with legacy argv candidates", + ); + return await runWorkspaceDiscover(subCommand, fastDiscoveryCandidates, fullStdioData); + } + throw error; + } + }; + + let result: RobotCodeDiscoverResult; + try { + result = await runWorkspaceDiscoverWithLegacyRetry(initialDiscoverSubCommand); + } catch (error) { + if (useFastDiscoveryCommand) { + if (error instanceof Error && error.name === "AbortError") { + this.outputChannel.appendLine( + `fast discovery: discover ${initialDiscoverSubCommand} aborted, skipping discover all fallback`, + ); + throw error; + } + this.outputChannel.appendLine( + `fast discovery: discover ${initialDiscoverSubCommand} failed, retrying discover all (${(error as Error).message ?? String(error)})`, + ); + result = await runWorkspaceDiscoverWithLegacyRetry("all"); + } else { + throw error; + } + } + + let finalResult = result; + if (fastDiscoveryCandidates !== undefined && (result?.items?.length ?? 0) === 0) { + this.outputChannel.appendLine( + `fast discovery: empty result for workspace=${folder.name}, rerunning full discovery without prefilter candidates`, + ); + finalResult = await this.discoverTests( + folder, + ["discover", "--read-from-stdin", "all"], + [], + fullStdioData, + true, + "workspace_all_fallback", + undefined, + token, + ); + } + + this.lastDiscoverResults.set(folder, finalResult); + this.indexRobotTree(finalResult?.items); + + return finalResult?.items; } catch (e) { if (e instanceof Error) { if (e.name === "AbortError") { @@ -1050,11 +1889,17 @@ export class TestControllerManager { testItem: RobotTestItem, token?: vscode.CancellationToken, ): Promise { + const cacheKey = `${document.uri.toString()}::${testItem.id}`; + const cachedResult = this.documentDiscoverResultsCache.get(cacheKey); + if (cachedResult !== undefined && cachedResult.version === document.version) { + return cachedResult.items; + } + const folder = vscode.workspace.getWorkspaceFolder(document.uri); if (!folder) return undefined; - const workspaceItem = this.findTestItemByUri(folder.uri.toString()); + const workspaceItem = this.findWorkspaceTestItem(folder); const robotWorkspaceItem = workspaceItem ? this.findRobotItem(workspaceItem) : undefined; try { @@ -1079,15 +1924,18 @@ export class TestControllerManager { ...(robotWorkspaceItem?.needsParseInclude && testItem.relSource ? ["-I", escapeRobotGlobPatterns(testItem.relSource)] : []), - "--suite", - escapeRobotGlobPatterns(testItem.longname), ], JSON.stringify(o), false, + undefined, + undefined, token, ); - // Index the freshly discovered items. + this.documentDiscoverResultsCache.set(cacheKey, { + version: document.version, + items: result?.items, + }); this.indexRobotTree(result?.items); return result?.items; @@ -1166,20 +2014,30 @@ export class TestControllerManager { if (token?.isCancellationRequested) return; if (robotItem) { - // Result compare: if the current children structurally match the last seen - // state, there is nothing to update in the tree. The comparison is against the - // children that are currently in the TestController tree, represented by the - // lastKnownChildren entry for the parent TestItem. + const renderableTests = this.getRenderableChildrenForItem(item, robotItem, tests); + const shouldKeepExistingChildren = + robotItem.type === RobotItemType.WORKSPACE && + item.children.size > 0 && + renderableTests !== undefined && + renderableTests.length === 0; + + if (shouldKeepExistingChildren) { + this.outputChannel.appendLine( + `discover tests: preserving existing workspace children for ${item.label} due to empty refresh result`, + ); + return; + } + const lastKnown = this.lastKnownChildren.get(item.id); - if (robotItemListsEqual(lastKnown, tests)) return; + if (robotItemListsEqual(lastKnown, renderableTests)) return; const addedIds = new Set(); - for (const test of tests ?? []) { + for (const test of renderableTests ?? []) { addedIds.add(test.id); } - for (const test of tests ?? []) { + for (const test of renderableTests ?? []) { if (token?.isCancellationRequested) return; const newItem = this.addOrUpdateTestItem(item, test); await this.refreshItem(newItem, token, skipPerDocumentDiscover); @@ -1191,7 +2049,7 @@ export class TestControllerManager { } this.removeNotAddedTestItems(item, addedIds); - this.lastKnownChildren.set(item.id, this.snapshotChildren(tests)); + this.lastKnownChildren.set(item.id, this.snapshotChildren(renderableTests)); } } finally { item.busy = false; @@ -1246,6 +2104,32 @@ export class TestControllerManager { ? parentTestItem.children.get(robotTestItem.id) : this.testController.items.get(robotTestItem.id); + if (testItem === undefined) { + const existingItem = this.testItems.get(robotTestItem.id); + if (existingItem !== undefined) { + const currentParent = existingItem.parent; + const needsReparent = + (parentTestItem !== undefined && currentParent?.id !== parentTestItem.id) || + (parentTestItem === undefined && currentParent !== undefined); + + if (needsReparent) { + if (currentParent !== undefined) { + currentParent.children.delete(existingItem.id); + } else { + this.testController.items.delete(existingItem.id); + } + + if (parentTestItem !== undefined) { + parentTestItem.children.add(existingItem); + } else { + this.testController.items.add(existingItem); + } + } + + testItem = existingItem; + } + } + if (testItem === undefined) { testItem = this.testController.createTestItem( robotTestItem.id, @@ -1283,6 +2167,8 @@ export class TestControllerManager { testItem.label = robotTestItem.name; } + testItem.sortText = this.getSortTextForRobotItem(robotTestItem); + const newDescription = robotTestItem.type == RobotItemType.TEST || robotTestItem.type == RobotItemType.TASK || @@ -1312,6 +2198,20 @@ export class TestControllerManager { return testItem; } + // eslint-disable-next-line class-methods-use-this + private getSortTextForRobotItem(robotTestItem: RobotTestItem): string | undefined { + if (robotTestItem.type !== RobotItemType.SUITE) { + return undefined; + } + + const sourcePath = robotTestItem.relSource ?? robotTestItem.source; + if (sourcePath && sourcePath.length > 0) { + return sourcePath.toLowerCase(); + } + + return robotTestItem.name.toLowerCase(); + } + private removeNotAddedTestItems(parentTestItem: vscode.TestItem | undefined, addedIds: Set): boolean { const itemsToRemove = new Set(); @@ -1366,6 +2266,80 @@ export class TestControllerManager { return result; } + private isSyntheticWorkspaceRootSuite(parentItem: vscode.TestItem, childItem: RobotTestItem): boolean { + const parentRobotItem = this.findRobotItem(parentItem); + if (parentRobotItem?.type !== RobotItemType.WORKSPACE || childItem.type !== RobotItemType.SUITE) { + return false; + } + + const workspace = this.findWorkspaceFolderForItem(parentItem); + if (workspace === undefined) { + return false; + } + + return this.matchesWorkspaceRootSuite(workspace, childItem); + } + + // eslint-disable-next-line class-methods-use-this + private matchesWorkspaceRootSuite(workspace: vscode.WorkspaceFolder, suiteItem: RobotTestItem): boolean { + if (suiteItem.type !== RobotItemType.SUITE) { + return false; + } + + const workspacePath = path.normalize(workspace.uri.fsPath); + const workspaceName = workspace.name.toLowerCase(); + const suiteName = suiteItem.name.toLowerCase(); + const suiteLongname = suiteItem.longname.toLowerCase(); + + const sourcePath = suiteItem.source ? path.normalize(suiteItem.source) : undefined; + if (sourcePath !== undefined && sourcePath === workspacePath) { + return true; + } + + if (suiteItem.uri !== undefined) { + try { + const suiteUriPath = path.normalize(vscode.Uri.parse(suiteItem.uri).fsPath); + if (suiteUriPath === workspacePath) { + return true; + } + } catch { + // ignore invalid uri values in discovery payload + } + } + + if (suiteItem.id === workspace.uri.fsPath || suiteItem.id === workspace.uri.toString()) { + return true; + } + + if (suiteItem.relSource === "." || suiteItem.relSource === "") { + return true; + } + + return suiteName === workspaceName || suiteLongname === workspaceName; + } + + private getRenderableChildrenForItem( + item: vscode.TestItem, + robotItem: RobotTestItem, + children: RobotTestItem[] | undefined, + ): RobotTestItem[] | undefined { + if (robotItem.type !== RobotItemType.WORKSPACE || children === undefined || children.length !== 1) { + return children; + } + + const workspace = this.findWorkspaceFolderForItem(item); + if (workspace === undefined) { + return children; + } + + const rootSuite = children[0]; + if (!this.matchesWorkspaceRootSuite(workspace, rootSuite)) { + return children; + } + + return rootSuite.children ?? []; + } + private readonly refreshFromUriMutex = new Mutex(); private async refreshWorkspace(workspace?: vscode.WorkspaceFolder, token?: vscode.CancellationToken): Promise { @@ -1480,6 +2454,97 @@ export class TestControllerManager { return folders; } + private static extractLongnameFromTestItemId(item: vscode.TestItem): string | undefined { + const firstSeparator = item.id.indexOf(";"); + if (firstSeparator < 0) { + return undefined; + } + + if (item.canResolveChildren) { + const value = item.id.slice(firstSeparator + 1); + return value.length > 0 ? value : undefined; + } + + const lastSeparator = item.id.lastIndexOf(";"); + if (lastSeparator <= firstSeparator + 1) { + return undefined; + } + + return item.id.slice(firstSeparator + 1, lastSeparator); + } + + private static extractSourcePathFromTestItemId(item: vscode.TestItem): string | undefined { + const firstSeparator = item.id.indexOf(";"); + if (firstSeparator < 0) { + return undefined; + } + + const value = item.id.slice(0, firstSeparator); + return value.length > 0 ? value : undefined; + } + + private getSelectionRelSource(folder: vscode.WorkspaceFolder, item: vscode.TestItem): string | undefined { + const robotItem = this.findRobotItem(item); + + const sourceCandidate = + robotItem?.relSource ?? + robotItem?.source ?? + TestControllerManager.extractSourcePathFromTestItemId(item) ?? + item.uri?.fsPath; + + if (!sourceCandidate) { + return undefined; + } + + return this.toDiscoverPathArg(folder, sourceCandidate); + } + + private getSelectionLongname(item: vscode.TestItem): string | undefined { + const robotItem = this.findRobotItem(item); + if (robotItem?.type === RobotItemType.WORKSPACE) { + return undefined; + } + + return robotItem?.longname ?? TestControllerManager.extractLongnameFromTestItemId(item); + } + + private getWorkspaceRootSuiteFromRobotTree( + folder: vscode.WorkspaceFolder, + workspaceRobotItem: RobotTestItem | undefined, + ): RobotTestItem | undefined { + const candidateRoots = + workspaceRobotItem?.type === RobotItemType.WORKSPACE + ? workspaceRobotItem.children + : this.robotTestItems.get(folder)?.items; + + if (!candidateRoots || candidateRoots.length !== 1) { + return undefined; + } + + const rootSuite = candidateRoots[0]; + if (!this.matchesWorkspaceRootSuite(folder, rootSuite)) { + return undefined; + } + + return rootSuite; + } + + private getWorkspaceRootSuite( + folder: vscode.WorkspaceFolder, + workspaceItem: vscode.TestItem | undefined, + workspaceRobotItem: RobotTestItem | undefined, + ): { workspaceSelectionItem: vscode.TestItem | undefined; topLevelSuiteName: string | undefined } { + const rootSuite = this.getWorkspaceRootSuiteFromRobotTree(folder, workspaceRobotItem); + if (!rootSuite) { + return { workspaceSelectionItem: workspaceItem, topLevelSuiteName: undefined }; + } + + return { + workspaceSelectionItem: workspaceItem?.children.get(rootSuite.id) ?? workspaceItem, + topLevelSuiteName: rootSuite.longname || rootSuite.name, + }; + } + private static _runIdCounter = 0; private static nextRunId(): string { @@ -1552,24 +2617,44 @@ export class TestControllerManager { options.noDebug = true; } - let workspaceItem = this.findTestItemByUri(folder.uri.toString()); + const workspaceItem = this.findWorkspaceTestItem(folder); const workspaceRobotItem = workspaceItem ? this.findRobotItem(workspaceItem) : undefined; - if (workspaceRobotItem?.type == RobotItemType.WORKSPACE && workspaceRobotItem.children?.length) { - workspaceItem = workspaceItem?.children.get(workspaceRobotItem.children[0].id); - } + const { workspaceSelectionItem, topLevelSuiteName } = this.getWorkspaceRootSuite( + folder, + workspaceItem, + workspaceRobotItem, + ); + const resolvedTopLevelSuiteName = topLevelSuiteName ?? folder.name; + + const ensureTopLevelSuitePrefix = (longname: string | undefined): string | undefined => { + if (!longname || !resolvedTopLevelSuiteName) { + return longname; + } + + if (longname === resolvedTopLevelSuiteName || longname.startsWith(`${resolvedTopLevelSuiteName}.`)) { + return longname; + } + + return `${resolvedTopLevelSuiteName}.${longname}`; + }; + const allowParseInclude = this.isFastDiscoveryEnabled(folder); - if (testItems.length === 1 && testItems[0] === workspaceItem && excluded.size === 0) { + if (testItems.length === 1 && testItems[0] === workspaceSelectionItem && excluded.size === 0) { + const workspaceParseInclude = allowParseInclude && (workspaceRobotItem?.needsParseInclude ?? false); + this.outputChannel.appendLine( + `run tests selection: workspace=${folder.name} includedInWs=[] suites=[] relSources=[] excludedInWs=[] parseInclude=${workspaceParseInclude} topLevelSuiteName=${topLevelSuiteName ?? ""} resolvedTopLevelSuiteName=${resolvedTopLevelSuiteName}`, + ); const started = await DebugManager.runTests( folder, [], [], - workspaceRobotItem?.needsParseInclude ?? false, + workspaceParseInclude, [], [], runId, options, - undefined, + resolvedTopLevelSuiteName, profiles, testConfiguration, ); @@ -1578,10 +2663,10 @@ export class TestControllerManager { const includedInWs = testItems .map((i) => { const ritem = this.findRobotItem(i); - if (ritem?.type == RobotItemType.WORKSPACE && ritem.children?.length) { - return ritem.children[0].longname; + if (ritem?.type == RobotItemType.WORKSPACE) { + return resolvedTopLevelSuiteName; } - return ritem?.longname; + return ensureTopLevelSuitePrefix(this.getSelectionLongname(i)); }) .filter((i) => i !== undefined) as string[]; const excludedInWs = @@ -1589,10 +2674,10 @@ export class TestControllerManager { .get(folder) ?.map((i) => { const ritem = this.findRobotItem(i); - if (ritem?.type == RobotItemType.WORKSPACE && ritem.children?.length) { - return ritem.children[0].longname; + if (ritem?.type == RobotItemType.WORKSPACE) { + return resolvedTopLevelSuiteName; } - return ritem?.longname; + return ensureTopLevelSuitePrefix(this.getSelectionLongname(i)); }) .filter((i) => i !== undefined) as string[]) ?? []; @@ -1602,43 +2687,52 @@ export class TestControllerManager { for (const testItem of [...testItems, ...(excluded.get(folder) || [])]) { if (!testItem?.canResolveChildren) { if (testItem?.parent) { - const ritem = this.findRobotItem(testItem?.parent); - const longname = ritem?.longname; + const longname = ensureTopLevelSuitePrefix(this.getSelectionLongname(testItem.parent)); + const relSource = this.getSelectionRelSource(folder, testItem.parent); if (longname) { suites.add(longname); - if (ritem?.relSource) rel_sources.add(ritem?.relSource); + } + if (relSource) { + rel_sources.add(relSource); } } } else { const ritem = this.findRobotItem(testItem); - let longname = ritem?.longname; - if (ritem?.type == RobotItemType.WORKSPACE && ritem.children?.length) { - longname = ritem.children[0].longname; + const relSource = this.getSelectionRelSource(folder, testItem); + const suiteSelectionItem = allowParseInclude && relSource && testItem.parent ? testItem.parent : testItem; + let longname = this.getSelectionLongname(suiteSelectionItem); + if (ritem?.type == RobotItemType.WORKSPACE) { + longname = resolvedTopLevelSuiteName; } + longname = ensureTopLevelSuitePrefix(longname); if (longname) { suites.add(longname); - if (ritem?.relSource) rel_sources.add(ritem?.relSource); + } + if (relSource) { + rel_sources.add(relSource); } } } - let suiteName: string | undefined = undefined; - - if (workspaceRobotItem?.type == RobotItemType.WORKSPACE && workspaceRobotItem.children?.length) { - suiteName = workspaceRobotItem.children[0].longname; - } + const suitesArray = Array.from(suites); + const relSourcesArray = Array.from(rel_sources); + const effectiveParseInclude = + allowParseInclude && ((workspaceRobotItem?.needsParseInclude ?? false) || relSourcesArray.length > 0); + this.outputChannel.appendLine( + `run tests selection: workspace=${folder.name} includedInWs=${JSON.stringify(includedInWs)} suites=${JSON.stringify(suitesArray)} relSources=${JSON.stringify(relSourcesArray)} excludedInWs=${JSON.stringify(excludedInWs)} parseInclude=${effectiveParseInclude} topLevelSuiteName=${topLevelSuiteName ?? ""} resolvedTopLevelSuiteName=${resolvedTopLevelSuiteName}`, + ); const started = await DebugManager.runTests( folder, - Array.from(suites), - Array.from(rel_sources), - workspaceRobotItem?.needsParseInclude ?? false, + suitesArray, + relSourcesArray, + effectiveParseInclude, includedInWs, excludedInWs, runId, options, - suiteName, + resolvedTopLevelSuiteName, profiles, testConfiguration, );