diff --git a/Documentation/config-yaml.rst b/Documentation/config-yaml.rst index 294f1ba..9c0d4e3 100644 --- a/Documentation/config-yaml.rst +++ b/Documentation/config-yaml.rst @@ -350,6 +350,10 @@ Flash command can use special tags that are handled by NTFC: - ``$IMAGE_BIN`` is replaced by path to ``nuttx.bin``. - ``$IMAGE_HEX`` is replaced by path to ``nuttx.hex``. +- ``$APPS_BINDIR`` is replaced by the application binaries directory + (kernel-mode builds). +- ``$APPS_IMG`` is replaced by the generated application filesystem + image (requires ``apps_image``). Example usage with ``st-flash`` tool: @@ -430,6 +434,11 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. - (Optional) Directory with kernel-mode application binaries. Defaults to the ``bin/`` directory next to the NuttX ELF for kernel-mode builds (``CONFIG_BUILD_KERNEL=y``) + * - ``apps_image`` + - (Optional) Generate a filesystem image with the application + binaries after build, e.g. ``apps_image: {type: romfs}``. The + image path is available as ``$APPS_IMG`` in the ``flash`` + command. Requires ``genromfs`` and a kernel-mode build * - ``defconfig`` - Path to NuttX defconfig (auto-build) * - ``elf_path`` diff --git a/Documentation/config.yaml b/Documentation/config.yaml index 1b38027..d684b27 100644 --- a/Documentation/config.yaml +++ b/Documentation/config.yaml @@ -77,6 +77,9 @@ product: # many products can be supported in tests (pro app_bindir: '' # (optional) directory with kernel-mode application binaries. # Defaults to the bin/ directory next to the NuttX ELF # for kernel-mode builds. + apps_image: # (optional) generate a filesystem image with the application + type: romfs # binaries after build (kernel-mode only, requires genromfs). + # The image path is available as $APPS_IMG in 'flash'. # NTFC can use pre-build image or build it from defconfig # the behavior will depend on the parameters specified in config. diff --git a/Documentation/ntfc.yaml b/Documentation/ntfc.yaml index 55558a1..a338833 100644 --- a/Documentation/ntfc.yaml +++ b/Documentation/ntfc.yaml @@ -5,4 +5,6 @@ dependencies: ["toml"] # python dependencies for test cases module requirements: # nuttx config requirements - ["CONFIG_DEBUG_SYMBOLS", True] - ["CONFIG_SYSTEM_NSH", True] - - ["CONFIG_INIT_ENTRYPOINT", "nsh_main"] + # alternatives: any one entry satisfies the requirement; + # "*" accepts any set value + - [["CONFIG_INIT_ENTRYPOINT", "nsh_main"], ["CONFIG_INIT_FILEPATH", "*"]] diff --git a/Documentation/writing-test-cases.rst b/Documentation/writing-test-cases.rst index b13a59b..1f3a30e 100644 --- a/Documentation/writing-test-cases.rst +++ b/Documentation/writing-test-cases.rst @@ -70,6 +70,20 @@ String/Value Requirements: - ["CONFIG_INIT_ENTRYPOINT", "nsh_main"] # CONFIG must equal value - ["CONFIG_TASK_NAME_SIZE", "32"] # CONFIG must equal value +Wildcard Requirements: + +.. code-block:: yaml + + requirements: + - ["CONFIG_INIT_FILEPATH", "*"] # CONFIG must be set (any value) + +Alternative Requirements (any one entry satisfies the requirement): + +.. code-block:: yaml + + requirements: + - [["CONFIG_INIT_ENTRYPOINT", "nsh_main"], ["CONFIG_INIT_FILEPATH", "*"]] + How Requirements Work: 1. NTFC reads NuttX ``.config`` file from configuration @@ -178,7 +192,11 @@ Execute NSH command and verify output: Decorators: -- ``@pytest.mark.cmd_check("symbol_name")``: Verify ELF symbol exists +- ``@pytest.mark.cmd_check("symbol_name")``: Verify ELF symbol exists. + On kernel-mode targets (``CONFIG_BUILD_KERNEL=y``) the marker is first + matched against application file names (a trailing ``_main`` maps to + the file name, so ``hello_main`` matches the ``hello`` binary) and + then against symbols in the unstripped application binaries - ``@pytest.mark.dep_config("CONFIG_X", "CONFIG_Y")``: Skip if configs not enabled diff --git a/config/nuttx-qemu-armv7a-knsh.yaml b/config/nuttx-qemu-armv7a-knsh.yaml new file mode 100644 index 0000000..aa431fd --- /dev/null +++ b/config/nuttx-qemu-armv7a-knsh.yaml @@ -0,0 +1,32 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU armv7a. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. Semihosting is +# required for the hostfs mount. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-armv7a-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-arm' + exec_args: '-semihosting -cpu cortex-a7 -nographic + -machine virt,highmem=off,virtualization=off,gic-version=2 + -chardev stdio,id=con,mux=on -serial chardev:con + -mon chardev=con,mode=readline' + defconfig: 'boards/arm/qemu/qemu-armv7a/configs/knsh' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" diff --git a/config/nuttx-qemu-armv8a-knsh.yaml b/config/nuttx-qemu-armv8a-knsh.yaml new file mode 100644 index 0000000..c54e37f --- /dev/null +++ b/config/nuttx-qemu-armv8a-knsh.yaml @@ -0,0 +1,32 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU armv8a. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. Semihosting is +# required for the hostfs mount. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-armv8a-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-aarch64' + exec_args: '-semihosting -cpu cortex-a53 -nographic + -machine virt,virtualization=on,gic-version=3 + -net none -chardev stdio,id=con,mux=on + -serial chardev:con -mon chardev=con,mode=readline' + defconfig: 'boards/arm64/qemu/qemu-armv8a/configs/knsh' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" diff --git a/config/nuttx-qemu-intel64-knsh.yaml b/config/nuttx-qemu-intel64-knsh.yaml new file mode 100644 index 0000000..95b727f --- /dev/null +++ b/config/nuttx-qemu-intel64-knsh.yaml @@ -0,0 +1,39 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU intel64. +# +# The knsh_romfs configuration loads user-space applications from a +# ROMFS image linked into the kernel; there is no hostfs, so exec_cwd +# is not relevant for the mount. Application command discovery uses the +# /bin directory registered by the builder. +# +# Requires NuttX with CMake kernel-build support for x86_64 (branch +# fix-x86_64-cmake-kernel-build in the vendored checkout: arch_interface +# guard, CMAKE_LD, relocatable binary install, board ROMFS generation). + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-intel64-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-x86_64' + exec_args: '-m 2G -cpu host -enable-kvm -nographic -serial mon:stdio' + defconfig: 'boards/x86_64/qemu/qemu-intel64/configs/knsh_romfs' + boot_timeout: 15 + kv: + # resolve bare command names against the ROMFS mount point + CONFIG_LIBC_ENVPATH: "y" + CONFIG_PATH_INITIAL: "/system/bin" + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" + # setlocale()/nl_langinfo(), referenced by the LTP strftime cases + CONFIG_LIBC_LOCALE: "y" + # without it SIGKILL is absent from the default action table and + # can be ignored, which the LTP sigignore cases check for + CONFIG_SIG_SIGKILL_ACTION: "y" diff --git a/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml b/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml new file mode 100644 index 0000000..08f4df1 --- /dev/null +++ b/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml @@ -0,0 +1,31 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) reference target. +# +# The knsh64 configuration is an S-mode build: QEMU boots its bundled +# OpenSBI firmware (no '-bios none') and NTFC appends '-kernel '. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-rv-virt-knsh64" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-riscv64' + exec_args: '-semihosting -M virt,aclint=on -cpu rv64 -smp 1 -nographic' + defconfig: 'boards/risc-v/qemu-rv/rv-virt/configs/knsh64' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" diff --git a/config/nuttx-serial-knsh.yaml.example b/config/nuttx-serial-knsh.yaml.example new file mode 100644 index 0000000..db4b083 --- /dev/null +++ b/config/nuttx-serial-knsh.yaml.example @@ -0,0 +1,33 @@ +# Template for a kernel-mode (CONFIG_BUILD_KERNEL=y) hardware target +# on a serial console. Copy, rename and fill in the board specifics. +# +# Kernel-mode hardware notes: +# - There is no hostfs on hardware: application binaries must reach the +# target filesystem. Either the board defconfig bakes a ROMFS into the +# kernel image, or 'apps_image' generates one from /bin and the +# 'flash' command writes it with $APPS_IMG. +# - When NTFC builds the image, command discovery uses /bin. For +# prebuilt images (no 'defconfig'), commands are discovered once from +# the running target by listing CONFIG_PATH_INITIAL. +# - Kernel boot (mount filesystem, load init ELF) plus a bootloader can +# exceed the 5 second default boot wait; raise 'boot_timeout'. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc--knsh" + cores: + core0: + name: 'main' + device: 'serial' + exec_path: '/dev/ttyUSB0' + exec_args: '115200,n,8,1' + defconfig: 'boards////configs/knsh' + boot_timeout: 30 + apps_image: + type: romfs + flash: ' write $IMAGE_BIN $APPS_IMG ' + reboot: '' diff --git a/src/ntfc/builder.py b/src/ntfc/builder.py index ace5eeb..496f137 100644 --- a/src/ntfc/builder.py +++ b/src/ntfc/builder.py @@ -24,6 +24,7 @@ import re import shutil import subprocess +from functools import partial from pathlib import Path from typing import Any, Dict, List, Optional @@ -39,6 +40,9 @@ class NuttXBuilder: IMAGE_BIN_STR = "$IMAGE_BIN" IMAGE_HEX_STR = "$IMAGE_HEX" + APPS_BINDIR_STR = "$APPS_BINDIR" + APPS_IMG_STR = "$APPS_IMG" + APPS_IMG_NAME = "apps.romfs.img" _KCONFIG_DISABLED_RE = re.compile( r"^#\s+(CONFIG_[A-Za-z0-9_]+)\s+is not set" ) @@ -331,7 +335,10 @@ def _run_cmake( self._run_command(cmd, env=run_env) def _run_build( - self, build: str, env: Optional[Dict[str, str]] = None + self, + build: str, + env: Optional[Dict[str, str]] = None, + target: Optional[str] = None, ) -> None: """Run the CMake build step.""" build_path = Path(build) @@ -341,6 +348,8 @@ def _run_build( "--build", str(build_path), ] + if target: + cmd += ["--target", target] run_env = os.environ.copy() if env: @@ -396,8 +405,8 @@ def _build_core( if not already_build or self._rebuild: # pragma: no cover self._log_kconfig_overrides(kv_overrides) - # configure build - self._run_cmake( + configure = partial( + self._run_cmake, source=nuttx_dir, build=build_path, generator="Ninja", @@ -405,11 +414,28 @@ def _build_core( env=build_env, ) + # configure build + configure() + # apply Kconfig overrides to generated .config before build self._apply_kconfig_overrides( nuttx_conf_path, kv_overrides, cfg_cwd ) + if kv_overrides: + # fill in defaults of suboptions the overrides + # unlocked (e.g. *_PROGNAME): applications are + # silently dropped at configure when these are + # missing from .config + self._run_build( + build_path, env=build_env, target="olddefconfig" + ) + + # application targets are registered at configure + # time from .config: configure again so overrides + # that enable new applications take effect + configure() + # build self._run_build(build_path, env=build_env) @@ -417,6 +443,78 @@ def _build_core( cores[core]["elf_path"] = nuttx_elf_path cores[core]["conf_path"] = nuttx_conf_path + if self._is_kernel_config(nuttx_conf_path): + # kernel-mode: applications are installed to /bin + # and hostfs mounts resolve relative to the process cwd + cores[core].setdefault( + "app_bindir", os.path.join(build_path, "bin") + ) + cores[core].setdefault("exec_cwd", build_path) + + self._make_apps_image(cores[core], build_path) + + def _make_apps_image( + self, core_cfg: Dict[str, Any], build_path: str + ) -> None: + """Generate a filesystem image with application binaries. + + Enabled with the per-core ``apps_image`` option; the image path + is registered as ``apps_img`` for the ``$APPS_IMG`` flash + placeholder. + """ + img_cfg = core_cfg.get("apps_image", None) + if not img_cfg: + return + + if not isinstance(img_cfg, dict): + raise BuilderConfigError("apps_image must be a mapping") + + img_type = img_cfg.get("type", "romfs") + if img_type != "romfs": + raise BuilderConfigError( + f"unsupported apps_image type: {img_type}" + ) + + bindir = core_cfg.get("app_bindir", None) + if not bindir: + raise BuilderConfigError( + "apps_image requires app_bindir (kernel-mode build)" + ) + + tool = shutil.which("genromfs") + if not tool: + raise BuilderConfigError("genromfs not found in PATH") + + img_path = os.path.join(build_path, self.APPS_IMG_NAME) + self._run_command([tool, "-f", img_path, "-d", bindir], env=None) + core_cfg["apps_img"] = img_path + + def _expand_flash_cmd( + self, flash_cmd: str, core_cfg: Dict[str, Any] + ) -> str: + """Expand image placeholders in a flash command.""" + parent = Path(core_cfg["elf_path"]).parent + values = { + self.IMAGE_BIN_STR: str(parent / "nuttx.bin"), + self.IMAGE_HEX_STR: str(parent / "nuttx.hex"), + self.APPS_BINDIR_STR: core_cfg.get("app_bindir", ""), + self.APPS_IMG_STR: core_cfg.get("apps_img", ""), + } + + for placeholder, value in values.items(): + flash_cmd = flash_cmd.replace(placeholder, value) + + return flash_cmd + + @staticmethod + def _is_kernel_config(conf_path: str) -> bool: + """Check if a generated .config selects a kernel build.""" + if not os.path.isfile(conf_path): + return False + + with open(conf_path, "r", encoding="utf-8") as f: + return any(line.strip() == "CONFIG_BUILD_KERNEL=y" for line in f) + def _reboot_core( self, core: str, cores: Dict[str, Any] ) -> None: # pragma: no cover @@ -433,12 +531,7 @@ def _flash_core( """Flash single core image.""" flash_cmd = cores[core].get("flash", None) if flash_cmd: - img_path = Path(cores[core]["elf_path"]) - image_hex = str(img_path.parent) + "/nuttx.hex" - image_bin = str(img_path.parent) + "/nuttx.bin" - - flash_cmd = flash_cmd.replace(self.IMAGE_BIN_STR, image_bin) - flash_cmd = flash_cmd.replace(self.IMAGE_HEX_STR, image_hex) + flash_cmd = self._expand_flash_cmd(flash_cmd, cores[core]) cmd = flash_cmd.split() diff --git a/src/ntfc/core.py b/src/ntfc/core.py index e51376a..4e6a3e9 100644 --- a/src/ntfc/core.py +++ b/src/ntfc/core.py @@ -35,6 +35,7 @@ from ntfc.command_builder import CommandBuilder from ntfc.coreconfig import CoreConfig from ntfc.device.common import CmdReturn, CmdStatus +from ntfc.lib.elf.app_bindir import match_command, symbol_patterns from ntfc.log.logger import logger if TYPE_CHECKING: @@ -72,6 +73,8 @@ class CoreStatus(_Enum): class ProductCore: """This class implements product core under test.""" + _PATH_NAME_RE = re.compile(r"[A-Za-z0-9_.+-]+") + def __init__( self, device: "DeviceCommon", @@ -99,6 +102,7 @@ def __init__( list(ignored_cores) if ignored_cores is not None else ["dsp"] ) self._builder = CommandBuilder(device.prompt, device.no_cmd) + self._runtime_cmds: Optional[List[str]] = None self._prompt = device.prompt self._main_prompt = self._prompt @@ -527,67 +531,82 @@ def start(self) -> None: """Start device.""" self._device.start() + def _check_cmd_runtime(self, cmd_pattern: str) -> bool: + """Check a command against the target PATH listing. + + The listing is fetched once from the running target and cached; + a failed listing is not cached so it is retried on next use. + """ + if self._runtime_cmds is None: + result = self.sendCommandReadUntilPattern( + f"ls {self._conf.path_initial}", timeout=5 + ) + if result.status != CmdStatus.SUCCESS: + return False + + output_lower = result.output.lower() + no_cmd = str(self._device.no_cmd).lower() + if no_cmd in output_lower or any( + line.lstrip().lower().startswith("ls:") + for line in result.output.splitlines() + ): + return False + + # drop the command echo line; path headers and the prompt + # do not match the name pattern + tokens = " ".join(result.output.splitlines()[1:]).split() + self._runtime_cmds = [ + token + for token in tokens + if self._PATH_NAME_RE.fullmatch(token) + ] + + return match_command(cmd_pattern, self._runtime_cmds) + def check_cmd(self, cmd_pattern: str) -> bool: - """Check if a command pattern is available in the ELF binary. + """Check if a command pattern is available on the core. - This method validates whether a specific command or set of - commands is present in the core's ELF binary by searching for - corresponding symbols. It supports both single commands and - alternative command patterns separated by '|'. + Commands are resolved from the application binaries, the running + target, the device ELF parser or the shell help output, in that + order. - :param cmd_pattern: Command pattern to check for. Can contain - alternatives separated by '|' - (e.g., 'test1|test2') + :param cmd_pattern: Command pattern, may contain alternatives + separated by '|' (e.g. 'test1|test2') :return: True if the command pattern is found, False otherwise - - Note: - - Requires ELF parser to be available in device - - Supports alternative patterns with '|' - - Used to validate core capabilities before executing """ - # Check if device supports command checking + # Kernel-mode: resolve against the application binary directory + # so runtime checks agree with collection-time filtering + if self._conf.has_app_bindir: + return self._conf.cmd_check(cmd_pattern) + + if self._conf.is_kernel_build: + # prebuilt image without host binaries: discover the + # command set once from the running target + return self._check_cmd_runtime(cmd_pattern) + + # Devices that expose their own ELF parser resolve the command + # from its symbols if hasattr(self._device, "elf_parser") and self._device.elf_parser: logger.debug(f"Checking command pattern: {cmd_pattern}") - # Split by '|' to support alternative patterns - alternatives = ( - cmd_pattern.split("|") if "|" in cmd_pattern else [cmd_pattern] - ) - - for pattern in alternatives: - # For cmocka tests, append _main to symbol name - symbol_pattern_str: str = ( - f"{pattern}_main" if "cmocka" in pattern else pattern - ) - - # Support regex wildcards - if ".*" in symbol_pattern_str: - symbol_pattern = re.compile(symbol_pattern_str) - else: - tmp = symbol_pattern_str - symbol_pattern = tmp # type: ignore[assignment] - - if self._device.elf_parser.has_symbol(symbol_pattern): + for symbol in symbol_patterns(cmd_pattern): + if self._device.elf_parser.has_symbol(symbol): return True return False - # Fallback: try to execute the command and check if it exists + # Fallback: check the command against the shell help output logger.warning( "ELF parser not available, trying command check for: " f"{cmd_pattern}" ) - # Send 'help' command to check if command exists result = self.sendCommandReadUntilPattern("help", timeout=5) if result.status == CmdStatus.SUCCESS: - # Check if any of the command alternatives are in the help output - alternatives = ( - cmd_pattern.split("|") if "|" in cmd_pattern else [cmd_pattern] + return any( + pattern.lower() in result.output.lower() + for pattern in cmd_pattern.split("|") ) - for pattern in alternatives: - if pattern.lower() in result.output.lower(): - return True return False diff --git a/src/ntfc/coreconfig.py b/src/ntfc/coreconfig.py index 9f7f99d..c93aa01 100644 --- a/src/ntfc/coreconfig.py +++ b/src/ntfc/coreconfig.py @@ -21,8 +21,10 @@ """Product core configuration handler.""" import os +from functools import cached_property from typing import Any, Dict, Optional, Union +from ntfc.lib.elf.app_bindir import AppBinDir, symbol_patterns from ntfc.lib.elf.elf_parser import ElfParser @@ -193,10 +195,41 @@ def kv_check(self, cfg: str) -> Any: return self._kv_values.get(cfg, False) + @cached_property + def _appbin(self) -> Optional[AppBinDir]: + """Return the installed kernel-mode application binaries.""" + bindir = self.app_bindir + if not bindir or not os.path.isdir(bindir): + return None + + return AppBinDir(bindir) + + @property + def has_app_bindir(self) -> bool: + """Return True when kernel-mode application binaries are found.""" + return self._appbin is not None + + @property + def path_initial(self) -> str: + """Return the initial target PATH, see CONFIG_PATH_INITIAL.""" + return str(self.kv_check("CONFIG_PATH_INITIAL") or "/system/bin") + def cmd_check(self, cmd: str, core: int = 0) -> bool: - """Check if command is available in binary.""" + """Check if command is available in binary. + + Kernel-mode cores resolve commands against the application binary + directory; otherwise the symbol must exist in the core ELF. + """ + if self._appbin: + if self._appbin.has_command(cmd): + return True + # not an application: NSH builtins and test entry points + # are symbols inside the application binaries + return self._appbin.has_symbol(cmd) + if not self._elf: raise AttributeError("no elf data") - symbol_name = f"{cmd}_main" if "cmocka" in cmd else cmd - return self._elf.has_symbol(symbol_name) + return any( + self._elf.has_symbol(symbol) for symbol in symbol_patterns(cmd) + ) diff --git a/src/ntfc/device/host.py b/src/ntfc/device/host.py index fb11750..9a1372b 100644 --- a/src/ntfc/device/host.py +++ b/src/ntfc/device/host.py @@ -58,6 +58,18 @@ def pid(self) -> Optional[int]: """Return the spawned child PID, or ``None`` before start.""" return self._child.pid if self._child else None + def _image_path(self) -> str: + """Return the image path, absolute when spawning in another cwd. + + Relative paths are relative to the NTFC working directory, not + to the spawn directory. + """ + elf = self._conf.elf_path + if not elf: + raise IOError + + return os.path.abspath(elf) if self._cwd else str(elf) + def _dev_is_health_priv(self) -> bool: """Check if the host device is OK.""" if not self._child: diff --git a/src/ntfc/device/nuttx.py b/src/ntfc/device/nuttx.py index a12cc94..6d40ef9 100644 --- a/src/ntfc/device/nuttx.py +++ b/src/ntfc/device/nuttx.py @@ -51,6 +51,13 @@ class DeviceNuttx(OSCommon): b"up_dump_register", b"dump_tasks", ], + # kernel-mode (CONFIG_BUILD_KERNEL) user task faults: the kernel + # kills the offending task and keeps running, so the console + # message is the only crash evidence + CrashType.SEGFAULT: [ + b"Segmentation fault in", + b"PANIC: Unhandled user exception", + ], } _PANIC_CHAR = r"/" diff --git a/src/ntfc/device/qemu.py b/src/ntfc/device/qemu.py index 81521b8..ee9c26a 100644 --- a/src/ntfc/device/qemu.py +++ b/src/ntfc/device/qemu.py @@ -44,12 +44,9 @@ def __init__(self, conf: "CoreConfig"): def _start_impl(self) -> None: """Start QEMU emulator implementation.""" - elf = self._conf.elf_path + elf = self._image_path() exec_path = self._conf.exec_path exec_args = self._conf.exec_args - - if not elf: - raise IOError if not exec_path: raise KeyError("no exec_path in configuration file!") diff --git a/src/ntfc/device/sim.py b/src/ntfc/device/sim.py index 720c5fc..5e1e850 100644 --- a/src/ntfc/device/sim.py +++ b/src/ntfc/device/sim.py @@ -41,11 +41,7 @@ def __init__(self, conf: "CoreConfig"): def _start_impl(self) -> None: """Start sim emulator implementation.""" - elf = self._conf.elf_path - if not elf: - raise IOError - - cmd = [elf] + cmd = [self._image_path()] uptime = self._conf.uptime # open host-based emulation diff --git a/src/ntfc/pytest/mypytest.py b/src/ntfc/pytest/mypytest.py index b85f695..5a1c15d 100644 --- a/src/ntfc/pytest/mypytest.py +++ b/src/ntfc/pytest/mypytest.py @@ -124,14 +124,31 @@ def _write_session_config(self, result_dir: str) -> None: json.dump(self._config.config, f, indent=2, sort_keys=True) f.write("\n") + def _req_satisfied(self, product: Product, core: int, req: Any) -> bool: + """Check a single ntfc.yaml requirement entry. + + Supported entry forms: + + - ``[key, value]``: config value must equal ``value`` + - ``[key, "*"]``: any truthy config value satisfies + - ``[[key, value], ...]``: alternatives, any one satisfies + """ + if isinstance(req[0], list): + return any(self._req_satisfied(product, core, r) for r in req) + + value = product.conf.kv_check(req[0], core) + if req[1] == "*": + return bool(value) + return bool(value == req[1]) + def _kv_validate( self, product: Product, core: int - ) -> Tuple[bool, Optional[Any]]: # pragma: no cover + ) -> Tuple[bool, Optional[Any]]: """Check if configuration can be used with this tool.""" requirements = pytest.ntfcyaml.get("requirements", {}) for req in requirements: - if product.conf.kv_check(req[0], core) != req[1]: + if not self._req_satisfied(product, core, req): return False, req return True, None @@ -143,9 +160,12 @@ def _create_products(self, config: "EnvConfig") -> List[Product]: # check config requirements only on cores that participate in tests for core in p.conf.active_core_indices: - ret = self._kv_validate(p, core) - if ret[0] is False: # pragma: no cover - raise IOError(f"Missing kconfig dependency: {ret[1]}") + ok, req = self._kv_validate(p, core) + if not ok: + raise IOError( + f"product '{p.conf.name}' core {core}: " + f"missing kconfig requirement: {req}" + ) tmp.append(p) diff --git a/src/ntfc/testfilter.py b/src/ntfc/testfilter.py index 155c882..00d5bab 100644 --- a/src/ntfc/testfilter.py +++ b/src/ntfc/testfilter.py @@ -85,12 +85,12 @@ def check_test_support( reason = f"Required config '{d}' not enabled" break - # command available in ELF + # command available on the target if skip is False: for c in cmd: if self._config.cmd_check(c) is False: skip = True - reason = f"Required symbol '{c}' not found in ELF" + reason = f"Required command '{c}' not available" break # check extra parameters diff --git a/tests/debug/test_gdb_controller.py b/tests/debug/test_gdb_controller.py index 48eaf2f..0218fda 100644 --- a/tests/debug/test_gdb_controller.py +++ b/tests/debug/test_gdb_controller.py @@ -76,6 +76,24 @@ def _pipe_process() -> Tuple[MagicMock, BinaryIO]: return proc, w_file +def _respond_on_gcore( + proc: MagicMock, w_file: BinaryIO, response: bytes +) -> None: + """Feed *response* into the pipe when the gcore marker echo is sent. + + Replying only after GdbController has issued the command mirrors real + gdb and avoids racing generate_coredump()'s state reset: lines written + to the pipe before the call may be consumed by the reader thread and + then discarded by the reset, leaving the wait to time out. + """ + + def write(data: bytes) -> None: + if GdbController.GCORE_MARKER.encode() in data: + w_file.write(response) + + proc.stdin.write.side_effect = write + + @pytest.fixture def elf(tmp_path: "Path") -> "Path": p = tmp_path / "app.elf" @@ -468,8 +486,14 @@ def test_generate_coredump_returns_path_on_success( assert start_ok is True - w_file.write(f"Saved corefile {corefile}\n".encode()) - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, + w_file, + ( + f"Saved corefile {corefile}\n" + f"{GdbController.GCORE_MARKER}\n" + ).encode(), + ) result = ctrl.generate_coredump(tmp_path, "test", timeout=5.0) # Close write end → EOF → reader exits; join to avoid ResourceWarning w_file.close() @@ -489,8 +513,14 @@ def test_generate_coredump_returns_none_when_gcore_fails( w_file.write(b"(gdb) \n") ctrl.start(timeout=5.0) - w_file.write(b"Unable to fetch a corefile\n") - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, + w_file, + ( + "Unable to fetch a corefile\n" + f"{GdbController.GCORE_MARKER}\n" + ).encode(), + ) result = ctrl.generate_coredump(tmp_path, "test", timeout=5.0) w_file.close() ctrl.stop() @@ -553,7 +583,9 @@ def test_generate_coredump_uses_gcore_cmd( ctrl = GdbController(elf, _cfg(gcore_cmd="gcore -t nuttx")) w_file.write(b"(gdb) \n") ctrl.start(timeout=5.0) - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, w_file, f"{GdbController.GCORE_MARKER}\n".encode() + ) ctrl.generate_coredump(tmp_path, "t", timeout=5.0) w_file.close() ctrl.stop() diff --git a/tests/device/test_nuttx.py b/tests/device/test_nuttx.py index dd90df5..0275236 100644 --- a/tests/device/test_nuttx.py +++ b/tests/device/test_nuttx.py @@ -43,6 +43,22 @@ def test_device_nuttx_init(): assert CrashType.ASSERTION in sigs assert all(isinstance(v, list) for v in sigs.values()) + # kernel-mode user task faults kill only the task; the console + # message is the only crash evidence + assert CrashType.SEGFAULT in sigs + assert b"Segmentation fault in" in sigs[CrashType.SEGFAULT] + assert b"PANIC: Unhandled user exception" in sigs[CrashType.SEGFAULT] + + # real rv-virt knsh64 console output is classified as SEGFAULT + from ntfc.device.state import DeviceStateManager + + mgr = DeviceStateManager(crash_signatures=sigs) + line = ( + b"[ 2.966000] riscv_fault_handler: " + b"Segmentation fault in getprime (PID 4: getprime)" + ) + assert mgr._detect_crash_type(line) is CrashType.SEGFAULT + config.kv_check.return_value = 0 assert d.panic_char == "" config.kv_check.return_value = 1 diff --git a/tests/device/test_qemu.py b/tests/device/test_qemu.py index aed9d1c..737c899 100644 --- a/tests/device/test_qemu.py +++ b/tests/device/test_qemu.py @@ -18,6 +18,7 @@ # ############################################################################ +import os from unittest.mock import patch import pytest @@ -33,6 +34,7 @@ def test_device_qemu_open(): config.exec_path = "" config.exec_args = "" config.elf_path = "" + config.exec_cwd = None qemu = DeviceQemu(config) @@ -119,3 +121,27 @@ def host_open_dummy4(cmd, uptime): config.uptime = 3 qemu.start() + + +def test_device_qemu_exec_cwd_absolute_image(tmp_path): + + from ntfc.coreconfig import CoreConfig + + config = CoreConfig( + { + "name": "t", + "device": "qemu", + "exec_path": "qemu-system-riscv64", + "exec_args": "-nographic", + "exec_cwd": str(tmp_path), + } + ) + # relative image path, resolved against the NTFC cwd at spawn time + config._config["elf_path"] = "some/image" + + qemu = DeviceQemu(config) + cmds = [] + qemu.host_open = lambda cmd, uptime: cmds.append(cmd) + qemu.start() + + assert cmds[0][2] == "-kernel " + os.path.abspath("some/image") diff --git a/tests/device/test_sim.py b/tests/device/test_sim.py index d722058..ec06f56 100644 --- a/tests/device/test_sim.py +++ b/tests/device/test_sim.py @@ -101,3 +101,33 @@ def send(self, data): sim._write(b"abc\n") assert sent == [b"a", b"b", b"c", b"\n"] + + +def test_device_sim_exec_cwd_absolute_image(tmp_path): + + import os + + from ntfc.coreconfig import CoreConfig + + config = CoreConfig( + {"name": "t", "device": "sim", "exec_cwd": str(tmp_path)} + ) + # relative image path, resolved against the NTFC cwd at spawn time + config._config["elf_path"] = "some/image" + + sim = DeviceSim(config) + cmds = [] + sim.host_open = lambda cmd, uptime: cmds.append(cmd) + sim.start() + + assert cmds[0] == [os.path.abspath("some/image")] + + # without exec_cwd the image path is passed through unchanged + config = CoreConfig({"name": "t", "device": "sim"}) + config._config["elf_path"] = "some/image" + + sim = DeviceSim(config) + sim.host_open = lambda cmd, uptime: cmds.append(cmd) + sim.start() + + assert cmds[1] == ["some/image"] diff --git a/tests/pytest/test_mypytest.py b/tests/pytest/test_mypytest.py index c8affd5..6686b4f 100644 --- a/tests/pytest/test_mypytest.py +++ b/tests/pytest/test_mypytest.py @@ -212,6 +212,76 @@ def test_create_products_skips_requirements_for_flash_only_core( assert products[0].cores == ["cpuapp"] +def test_create_products_requirement_forms(device_dummy, monkeypatch): + import pytest as pytest_module + + config = { + "config": {}, + "product": { + "name": "product", + "cores": { + "core0": { + "name": "cpuapp", + "device": "sim", + "conf_path": "./tests/resources/nuttx/sim/kv_config", + "elf_path": "./tests/resources/nuttx/sim/nuttx", + }, + }, + }, + } + + monkeypatch.setattr( + pytest_module, + "ntfcyaml", + { + "requirements": [ + # plain form: exact match + ["CONFIG_HOST_LINUX", True], + # alternatives: first unset, second matches + [ + ["CONFIG_INIT_FILEPATH", "*"], + ["CONFIG_INIT_ENTRYPOINT", "nsh_main"], + ], + # wildcard: any truthy value + ["CONFIG_NSH_PROMPT_STRING", "*"], + ] + }, + raising=False, + ) + with patch("ntfc.cores.get_device", return_value=device_dummy): + products = MyPytest(config)._create_products(EnvConfig(config)) + assert len(products) == 1 + + +def test_create_products_requirement_unmet(device_dummy, monkeypatch): + import pytest as pytest_module + + config = { + "config": {}, + "product": { + "name": "product", + "cores": { + "core0": { + "name": "cpuapp", + "device": "sim", + "conf_path": "./tests/resources/nuttx/sim/kv_config", + "elf_path": "./tests/resources/nuttx/sim/nuttx", + }, + }, + }, + } + + monkeypatch.setattr( + pytest_module, + "ntfcyaml", + {"requirements": [["CONFIG_INIT_FILEPATH", "*"]]}, + raising=False, + ) + with patch("ntfc.cores.get_device", return_value=device_dummy): + with pytest.raises(IOError, match="product.*core 0.*INIT_FILEPATH"): + MyPytest(config)._create_products(EnvConfig(config)) + + def test_device_stop_calls_stop(config_dummy, device_dummy): """_device_stop calls device.stop() for each core.""" with patch("ntfc.cores.get_device", return_value=device_dummy): diff --git a/tests/test_builder.py b/tests/test_builder.py index 0ed6d65..627b47c 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -79,6 +79,192 @@ def test_builder_init(): ) +def test_builder_kernel_mode_core_config(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert core["app_bindir"] == str(build_path / "bin") + assert core["exec_cwd"] == str(build_path) + + +def test_builder_kernel_mode_keeps_user_overrides(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["app_bindir"] = "/custom/bin" + config["product"]["cores"]["core0"]["exec_cwd"] = "/custom/cwd" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert core["app_bindir"] == "/custom/bin" + assert core["exec_cwd"] == "/custom/cwd" + + +def test_builder_flat_mode_no_kernel_keys(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_FLAT=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert "app_bindir" not in core + assert "exec_cwd" not in core + + # missing .config (dummy build) is treated as a flat build + assert ( + NuttXBuilder._is_kernel_config(str(tmp_path / "nonexistent")) is False + ) + + +def test_builder_reconfigures_after_kv_overrides(monkeypatch) -> None: + config = copy.deepcopy(conf_dir) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["kv"] = {"CONFIG_SYSTEM_X": "y"} + + calls = [] + + def run_command_capture(cmd, env): + calls.append(cmd) + + b = NuttXBuilder(config) + b._run_command = run_command_capture + b._make_dir = builder_make_dir_dummy + monkeypatch.setattr( + b, "_apply_kconfig_overrides", lambda *args, **kwargs: None + ) + + b.build_all() + + # application targets are registered at configure time: overrides + # require an olddefconfig (defaults of unlocked suboptions) and a + # second configure before the build + assert [cmd[0] for cmd in calls] == ["cmake"] * 4 + assert calls[1][-1] == "olddefconfig" + assert calls[0][:2] == calls[2][:2] + assert calls[3][:2] == ["cmake", "--build"] + assert "olddefconfig" not in calls[3] + + +def test_builder_expand_flash_cmd() -> None: + b = NuttXBuilder(copy.deepcopy(conf_dir)) + core_cfg = { + "elf_path": "bbb/core/nuttx", + "app_bindir": "bbb/core/bin", + "apps_img": "bbb/core/apps.romfs.img", + } + + cmd = b._expand_flash_cmd( + "flash $IMAGE_BIN $IMAGE_HEX $APPS_BINDIR $APPS_IMG", core_cfg + ) + assert cmd == ( + "flash bbb/core/nuttx.bin bbb/core/nuttx.hex " + "bbb/core/bin bbb/core/apps.romfs.img" + ) + + # placeholders without registered values expand to empty strings + cmd = b._expand_flash_cmd( + "flash $APPS_BINDIR $APPS_IMG", {"elf_path": "bbb/core/nuttx"} + ) + assert cmd == "flash " + + +def test_builder_apps_image(tmp_path, monkeypatch) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["apps_image"] = {"type": "romfs"} + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + calls = [] + + def run_command_capture(cmd, env): + calls.append(cmd) + + monkeypatch.setattr( + "ntfc.builder.shutil.which", lambda tool: f"/usr/bin/{tool}" + ) + + b = NuttXBuilder(config) + b._run_command = run_command_capture + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + img_path = str(build_path / "apps.romfs.img") + assert core["apps_img"] == img_path + assert calls[-1] == [ + "/usr/bin/genromfs", + "-f", + img_path, + "-d", + str(build_path / "bin"), + ] + + +def test_builder_apps_image_errors(tmp_path, monkeypatch) -> None: + def make_builder(config_txt, apps_image): + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["apps_image"] = apps_image + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir(exist_ok=True) + (build_path / ".config").write_text(config_txt) + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + return b + + # unsupported image type + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", {"type": "vfat"}).build_all() + + # not a mapping + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", "romfs").build_all() + + # no application directory (flat build) + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_FLAT=y\n", {"type": "romfs"}).build_all() + + # genromfs not installed + monkeypatch.setattr("ntfc.builder.shutil.which", lambda tool: None) + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", {"type": "romfs"}).build_all() + + def test_builder_passes_build_env() -> None: config = copy.deepcopy(conf_dir) config["config"]["build_env"] = {"CC": "gcc-13", "CXX": "g++-13"} @@ -430,11 +616,14 @@ def test_builder_applies_kv_before_build() -> None: def run_command_capture(cmd, env): calls.append(cmd) if "--build" not in cmd: + # cmake generates .config only when it does not exist yet expected_build_path.mkdir(parents=True, exist_ok=True) - expected_conf_path.write_text( - "# CONFIG_TEST_BOOL is not set\n" "CONFIG_TEST_STR=old\n", - encoding="utf-8", - ) + if not expected_conf_path.exists(): + expected_conf_path.write_text( + "# CONFIG_TEST_BOOL is not set\n" + "CONFIG_TEST_STR=old\n", + encoding="utf-8", + ) else: cfg_text = expected_conf_path.read_text(encoding="utf-8") assert "CONFIG_TEST_BOOL=y\n" in cfg_text @@ -454,9 +643,12 @@ def fake_apply_with_tool(conf_path, overrides, _cfg_cwd): with patch("ntfc.builder.logger.info", side_effect=logs.append): b.build_all() - assert len(calls) == 2 + # configure, olddefconfig and reconfigure after overrides, build + assert len(calls) == 4 assert calls[0][0] == "cmake" - assert calls[1][:2] == ["cmake", "--build"] + assert calls[1][-1] == "olddefconfig" + assert calls[2][0] == "cmake" + assert calls[3][:2] == ["cmake", "--build"] assert any( "Applying Kconfig overrides before build:" == msg for msg in logs ) diff --git a/tests/test_core.py b/tests/test_core.py index 838513d..7439831 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -585,8 +585,6 @@ def test_core_check_cmd_with_elf_parser(envconfig_dummy): # Test regex wildcard pattern mock_elf_parser.has_symbol.side_effect = None mock_elf_parser.has_symbol.return_value = True - import re - assert p.check_cmd("test.*") is True # The pattern should be compiled as regex call_args = mock_elf_parser.has_symbol.call_args @@ -595,3 +593,115 @@ def test_core_check_cmd_with_elf_parser(envconfig_dummy): # Test all alternatives not found mock_elf_parser.has_symbol.return_value = False assert p.check_cmd("nonexistent1|nonexistent2") is False + + # Test with failed help command + dev.send_cmd_read_until_pattern.return_value = CmdReturn( + CmdStatus.TIMEOUT + ) + assert p.check_cmd("test") is False + + +def test_core_check_cmd_kernel_mode(tmp_path): + """Test check_cmd resolves via app_bindir on kernel builds.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + bindir = tmp_path / "bin" + bindir.mkdir() + (bindir / "hello").write_bytes(b"\x7fELF" + b"\x00" * 12) + + conf = CoreConfig( + { + "name": "t", + "conf_path": str(kernel_cfg), + "app_bindir": str(bindir), + } + ) + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + + # resolved from the application directory, device is not queried + assert p.check_cmd("hello") is True + assert p.check_cmd("hello_main") is True + assert p.check_cmd("nonexistent") is False + mockdevice.return_value.send_cmd_read_until_pattern.assert_not_called() + + +def test_core_check_cmd_kernel_runtime_fallback(tmp_path, monkeypatch): + """Test check_cmd discovers commands from the running target.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text( + 'CONFIG_BUILD_KERNEL=y\nCONFIG_PATH_INITIAL="/system/bin"\n' + ) + # a kernel core whose application binaries are not on the host: + # the command set has to come from the running target + conf = CoreConfig( + { + "name": "t", + "conf_path": str(kernel_cfg), + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + ) + assert conf.has_app_bindir is False + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + + calls = [] + status = [CmdStatus.TIMEOUT] + + def fake_send(cmd, pattern=None, args=None, timeout=30): + calls.append(cmd) + output = "ls /system/bin\n/system/bin:\n hello\n init\n sh\nnsh> " + return CmdReturn(status[0], output=output) + + monkeypatch.setattr(p, "sendCommandReadUntilPattern", fake_send) + + # listing failure is not cached + assert p.check_cmd("hello") is False + assert len(calls) == 1 + + status[0] = CmdStatus.SUCCESS + assert p.check_cmd("hello") is True + assert p.check_cmd("hello_main") is True + assert p.check_cmd("missing|sh") is True + assert p.check_cmd("missing") is False + + # target was listed once after the failed attempt + assert calls == ["ls /system/bin", "ls /system/bin"] + + +def test_core_check_cmd_kernel_runtime_does_not_cache_ls_error( + tmp_path, monkeypatch +): + """Do not interpret words from an ls error as target commands.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + conf = CoreConfig({"name": "t", "conf_path": str(kernel_cfg)}) + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + calls = [] + + def fake_send(cmd, pattern=None, args=None, timeout=30): + calls.append(cmd) + return CmdReturn( + CmdStatus.SUCCESS, + output=( + "ls /system/bin\n" + "ls: /system/bin: No such file or directory\n" + "nsh> " + ), + ) + + monkeypatch.setattr(p, "sendCommandReadUntilPattern", fake_send) + + assert p.check_cmd("No") is False + assert p.check_cmd("file") is False + assert calls == ["ls /system/bin", "ls /system/bin"] diff --git a/tests/test_coreconfig.py b/tests/test_coreconfig.py index f4e80f2..67943bb 100644 --- a/tests/test_coreconfig.py +++ b/tests/test_coreconfig.py @@ -18,6 +18,8 @@ # ############################################################################ +import shutil + import pytest from ntfc.coreconfig import CoreConfig @@ -164,6 +166,48 @@ def test_core_config_app_bindir(tmp_path): } assert CoreConfig(conf).app_bindir is None + # a derived directory that does not exist is not a command source + conf = { + "name": "t", + "conf_path": str(kernel_cfg), + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + core_conf = CoreConfig(conf) + assert core_conf.app_bindir == "./tests/resources/nuttx/sim/bin" + assert core_conf.has_app_bindir is False + + +def test_core_config_cmd_check_kernel_mode(tmp_path): + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + bindir = tmp_path / "bin" + bindir.mkdir() + (bindir / "hello").write_bytes(b"\x7fELF" + b"\x00" * 12) + + conf = { + "name": "t", + "conf_path": str(kernel_cfg), + "app_bindir": str(bindir), + } + + p = CoreConfig(conf) + assert p.has_app_bindir is True + # command resolution by application file name, no ELF configured + assert p.cmd_check("hello") is True + assert p.cmd_check("hello_main") is True + assert p.cmd_check("cmd_df") is False + + # symbol fallback over unstripped binaries in bin_debug + debug = tmp_path / "bin_debug" + debug.mkdir() + shutil.copy("./tests/resources/nuttx/sim/nuttx", debug / "sh") + + p = CoreConfig(conf) + assert p.cmd_check("cmd_df") is True + assert p.cmd_check("missing|cmd_df") is True + assert p.cmd_check("cmd_d.*") is True + assert p.cmd_check("no_such_symbol_xyz") is False + def test_core_config_prompt(): # Test with explicit prompt in YAML config diff --git a/tests/test_filtertest.py b/tests/test_filtertest.py index e7df74b..cf85ce6 100644 --- a/tests/test_filtertest.py +++ b/tests/test_filtertest.py @@ -57,7 +57,7 @@ def test_filterest_filter(): f.extract_test_requirements = mock_extract_test_requirements2 skip, reason = f.check_test_support(None) assert skip is True - assert reason == "Required symbol 'CMD1' not found in ELF" + assert reason == "Required command 'CMD1' not available" config.kv_check.return_value = True config.cmd_check.return_value = True