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, );