Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Documentation/writing-test-cases.rst
Original file line number Diff line number Diff line change
Expand Up @@ -192,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

Expand Down
101 changes: 60 additions & 41 deletions src/ntfc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
39 changes: 36 additions & 3 deletions src/ntfc/coreconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -211,10 +213,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)
)
12 changes: 12 additions & 0 deletions src/ntfc/device/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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:
Expand Down
5 changes: 1 addition & 4 deletions src/ntfc/device/qemu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!")

Expand Down
6 changes: 1 addition & 5 deletions src/ntfc/device/sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,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
Expand Down
4 changes: 2 additions & 2 deletions src/ntfc/testfilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/device/test_qemu.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#
############################################################################

import os
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -89,6 +90,7 @@ def test_device_qemu_open():
config.exec_path = ""
config.exec_args = ""
config.elf_path = ""
config.exec_cwd = None

qemu = DeviceQemu(config)

Expand Down Expand Up @@ -175,3 +177,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")
30 changes: 30 additions & 0 deletions tests/device/test_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,33 @@ def send(self, data):
sent.clear()
sim._write(b"abc\n")
assert sent == [b"abc\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"]
Loading