diff --git a/.github/workflows/run-nightly-tests.yml b/.github/workflows/run-nightly-tests.yml index fe3567c91e..0c47a178e1 100644 --- a/.github/workflows/run-nightly-tests.yml +++ b/.github/workflows/run-nightly-tests.yml @@ -95,12 +95,11 @@ jobs: set -e set -x uname -a - PIPX_BIN_DIR=/usr/local/bin pipx install nose2 --force git clone https://github.com/${{ github.repository }} /nvme-cli git config --global --add safe.directory /nvme-cli cd /nvme-cli git checkout ${{ github.sha }} - scripts/build.sh -b release -c gcc + scripts/build.sh -b release -c gcc tests CONTROLLER=$(echo "${BDEV0}" | sed 's/n[0-9]*$//') cat > tests/config.json << EOJ { @@ -112,27 +111,7 @@ jobs: EOJ cat tests/config.json - nose2 --verbose --start-dir tests \ - nvme_attach_detach_ns_test \ - nvme_compare_test \ - nvme_copy_test \ - nvme_create_max_ns_test \ - nvme_ctrl_reset_test \ - nvme_dsm_test \ - nvme_error_log_test \ - nvme_flush_test \ - nvme_format_test \ - nvme_fw_log_test \ - nvme_get_features_test \ - nvme_get_lba_status_test \ - nvme_id_ctrl_test \ - nvme_id_ns_test \ - nvme_lba_status_log_test \ - nvme_read_write_test \ - nvme_smart_log_test \ - nvme_verify_test \ - nvme_writeuncor_test \ - nvme_writezeros_test + meson tests -C .build-ci EOF sudo chmod +x test.sh diff --git a/meson.build b/meson.build index cbae578d25..9ae1f25546 100644 --- a/meson.build +++ b/meson.build @@ -339,7 +339,7 @@ else # Check for libdbus availability. Optional, only required for MCTP dbus scan libdbus_dep = dependency( 'dbus-1', - required: true, + required: get_option('libdbus'), fallback: ['dbus', 'libdbus_dep'], default_options: [ 'default_library=static', diff --git a/scripts/build.sh b/scripts/build.sh index 06f1cfcfaa..5b06f68c37 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -37,6 +37,7 @@ usage() { echo " static build a static binary" echo " minimal_static build a static binary without fabrics support" echo " libnvme build only libnvme" + echo " tests build for nightly build" echo "" echo "configs with muon:" echo " [default] minimal static build" @@ -241,6 +242,14 @@ config_meson_minimal_static() { "${BUILDDIR}" } +config_meson_tests() { + CC="${CC}" "${MESON}" setup \ + --werror \ + --buildtype="${BUILDTYPE}" \ + -Dnvme-tests=true \ + "${BUILDDIR}" +} + config_meson_libnvme() { CC="${CC}" "${MESON}" setup \ --werror \ diff --git a/tests/README b/tests/README index ad3a98d36e..1ca4bd6db3 100644 --- a/tests/README +++ b/tests/README @@ -16,59 +16,10 @@ nvmetests DO NOT RUN THEM IF YOU DO NOT KNOW WHAT YOU ARE DOING! You have been warned. - -1. Common Package Dependencies ------------------------------- - - 1. Python(>= 3.3) - 2. nose2 (Installation guide http://nose2.readthedocs.io/) - 3. flake8 (https://pypi.python.org/pypi/flake8) - 4. mypy (https://pypi.org/project/mypy/) - 5. autopep8 (https://pypi.org/project/autopep8/) - 6. isort (https://pypi.org/project/isort/) - - Python package management system pip can be used to install most of the - listed packages(https://pip.pypa.io/en/stable/installing/) :- - $ pip install nose2 flake8 mypy autopep8 isort - -2. Overview ------------ - - This framework follows simple class hierarchy. Each test file contains - one test. Each test is direct subclass or indirect subclass of TestNVMe - class which represents one testcase. To write a new testcase one can copy - existing template "nvme_simple_template_test.py" and start adding new - testcase specific functionality. For detailed information please look into - section 3. - - For more information about tests, class hierarchy and code please refer :- - - 1. Documentation :- html/ - 2. Class Index :- html/index.html - 3. Class Hierarchy :- html/class-tree.html - - For each testcase it will create log directory mentioned in - configuration file. This directory will be used for a temporary files - and storing execution logs of each testcases. Current implementation stores - stdout and stderr for each testcase under log directory, e.g. :- - - $ tree nvmetests/ - nvmetests/ - ├── TestNVMeAttachDetachNSCmd - │   ├── stderr.log - │   └── stdout.log - ├── TestNVMeFlushCmd - │   ├── stderr.log - │   └── stdout.log - └── TestNVMeFormatCmd - ├── stderr.log - └── stdout.log - . - . . -3. Walk-Through Example for writing a new testcase --------------------------------------------------- +Walk-Through Example for writing a new testcase +----------------------------------------------- 1. Copy simple test template file from current directory with appropriate name, replace "simple_template" with testcase name in new file name. Update config.json if necessary. @@ -90,11 +41,11 @@ nvmetests - Example "$ ninja -C .build format-python" will run autopep8 and isort on all the python files in the current directory. -4. Running testcases with framework ------------------------------------ - 1. Running single testcase (in the source tree) with nose2 :- - $ nose2 --verbose --start-dir tests nvme_writezeros_test - $ nose2 --verbose --start-dir tests nvme_read_write_test +Running testcases with framework +-------------------------------- + 1. Running single testcase :- + $ python3 tests/tap_runner.py --start-dir tests nvme_id_ctrl_test + $ meson test -C .build 'nvme-cli - nvme_id_ctrl_test' - 2. Running all the testcases (in the build root directory) with ninja :- - $ ninja test -C .build + 2. Running all the testcases (in the build root directory) :- + $ meson test -C .build diff --git a/tests/TODO b/tests/TODO deleted file mode 100644 index 265ba97751..0000000000 --- a/tests/TODO +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-or-later - -nvmetests TODO List -=================== - -Feature list (with priority):- ------------------------------- - 1. PRE and POST section improvements :- - a. Add functionality to load and unload driver. - b. Make sure default namespace is present, if not create one before - any test begins. Read the default namespace size from config file. - 2. Add system statistics collection in PRE and POST section of testcase. - 3. Create execution summary file under log directory at the end of each - run. - 4. Add tracing functionality to track overall and current progress of the - testcase. diff --git a/tests/meson.build b/tests/meson.build index c9a5d0fad6..1b1a54b0ba 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,4 +1,9 @@ # SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of nvme. +# Copyright (c) 2026 SUSE LLC +# +# Authors: Daniel Wagner infra = [ 'config.json', @@ -6,6 +11,7 @@ infra = [ 'nvme_test_io.py', 'nvme_test_logger.py', 'nvme_simple_template_test.py', + 'tap_runner.py', ] tests = [ @@ -31,29 +37,26 @@ tests = [ 'nvme_ctrl_reset_test.py', ] -runtests = find_program('nose2', required : false) - -if runtests.found() - foreach file : infra + tests - configure_file(input: file, output: file, copy: true) - endforeach - - foreach t : tests - t_name = t.split('.')[0] - test( - 'nvme-cli - @0@'.format(t_name), - runtests, - args: ['--verbose', '--start-dir', meson.current_build_dir(), t_name], - env: ['PATH=' + meson.project_build_root() + ':/usr/bin:/usr/sbin'], - timeout: 500, - ) - endforeach -endif - python_module = import('python') python = python_module.find_installation('python3') +foreach t : tests + t_name = t.split('.')[0] + test( + 'nvme-cli - @0@'.format(t_name), + python, + args: [ + meson.current_source_dir() / 'tap_runner.py', + '--start-dir', meson.current_source_dir(), + t_name, + ], + env: ['PATH=' + meson.project_build_root() + ':/usr/bin:/usr/sbin'], + timeout: 500, + protocol: 'tap', + ) +endforeach + mypy = find_program( 'mypy', required : false, diff --git a/tests/nvme_attach_detach_ns_test.py b/tests/nvme_attach_detach_ns_test.py index f23630087a..afb1eafe20 100644 --- a/tests/nvme_attach_detach_ns_test.py +++ b/tests/nvme_attach_detach_ns_test.py @@ -48,6 +48,10 @@ class TestNVMeAttachDetachNSCmd(TestNVMe): def setUp(self): """ Pre Section for TestNVMeAttachDetachNSCmd """ super().setUp() + + if not self.ns_mgmt_supported: + self.skipTest("Namespace Management / Attach not supported by controller") + self.dps = 0 self.flbas = 0 (ds, ms) = self.get_lba_format_size() diff --git a/tests/nvme_create_max_ns_test.py b/tests/nvme_create_max_ns_test.py index 41e7a8d3c8..d8620a78b1 100644 --- a/tests/nvme_create_max_ns_test.py +++ b/tests/nvme_create_max_ns_test.py @@ -48,6 +48,10 @@ class TestNVMeCreateMaxNS(TestNVMe): def setUp(self): """ Pre Section for TestNVMeAttachDetachNSCmd """ super().setUp() + + if not self.ns_mgmt_supported: + self.skipTest("Namespace Management / Attach not supported by controller") + self.dps = 0 self.flbas = 0 (ds, ms) = self.get_lba_format_size() diff --git a/tests/nvme_format_test.py b/tests/nvme_format_test.py index 68445ac843..7cdd72df94 100644 --- a/tests/nvme_format_test.py +++ b/tests/nvme_format_test.py @@ -62,6 +62,10 @@ class TestNVMeFormatCmd(TestNVMe): def setUp(self): """ Pre Section for TestNVMeFormatCmd """ super().setUp() + + if not self.ns_mgmt_supported: + self.skipTest("Namespace Management / Attach not supported by controller") + self.dps = 0 self.flbas = 0 # Assuming run_ns_io with 4KiB * 10 writes. diff --git a/tests/nvme_test.py b/tests/nvme_test.py index 0c66be88a8..b34d720243 100644 --- a/tests/nvme_test.py +++ b/tests/nvme_test.py @@ -43,7 +43,12 @@ def to_decimal(value): - Returns: - Decimal integer """ - return int(str(value), 0) + val = 0 + try: + val = int(str(value), 0) + except (TypeError, ValueError): + raise ValueError(f"Invalid value: {value!r}") + return val class TestNVMe(unittest.TestCase): @@ -77,14 +82,17 @@ def setUp(self): self.load_config() if self.do_validate_pci_device: self.validate_pci_device() - self.create_and_attach_default_ns() + self.ns_mgmt_supported = self.get_ns_mgmt_support() + if self.ns_mgmt_supported: + self.create_and_attach_default_ns() print(f"\nsetup: ctrl: {self.ctrl}, ns1: {self.ns1}, default_nsid: {self.default_nsid}, flbas: {self.flbas}\n") def tearDown(self): """ Post Section for TestNVMe. """ if self.clear_log_dir is True: shutil.rmtree(self.log_dir, ignore_errors=True) - self.create_and_attach_default_ns() + if self.ns_mgmt_supported: + self.create_and_attach_default_ns() print(f"\nteardown: ctrl: {self.ctrl}, ns1: {self.ns1}, default_nsid: {self.default_nsid}, flbas: {self.flbas}\n") @classmethod @@ -209,6 +217,30 @@ def get_ctrl_id(self): "ERROR : nvme list-ctrl could not find ctrl") return str(json_output['ctrl_list'][0]['ctrl_id']) + def get_ns_mgmt_support(self): + """ + Determine whether Namespace Management and Namespace Attachment + operations are supported by the controller. + + This method reads the Optional Admin Command Support (OACS) field + from the Identify Controller data structure and evaluates specific + bits that indicate support for: + - Namespace Management (bit 3) + - Namespace Attachment (bit 4) + + Both features must be supported for this function to return True. + + Returns: + bool: True if both Namespace Management and Namespace Attachment + are supported, False otherwise. + """ + oacs = to_decimal(self.get_id_ctrl_field_value("oacs")) + + ns_mgmt_supported = bool(oacs & (1 << 3)) + ns_attach_supported = bool(oacs & (1 << 4)) + + return ns_mgmt_supported and ns_attach_supported + def get_nsid_list(self): """ Wrapper for extracting the namespace list. - Args: diff --git a/tests/tap_runner.py b/tests/tap_runner.py new file mode 100644 index 0000000000..e04ac9374a --- /dev/null +++ b/tests/tap_runner.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of nvme. +# Copyright (c) 2026 SUSE LLC +# +# Authors: Daniel Wagner +""" +TAP (Test Anything Protocol) version 13 runner for nvme-cli Python tests. + +Wraps Python's unittest framework and emits TAP output so that meson can +parse individual subtest results when protocol: 'tap' is set in meson.build. +""" + +import argparse +import importlib +import io +import sys +import traceback +import unittest + + +class DiagnosticCapture(io.TextIOBase): + """Capture writes and re-emit them as TAP diagnostic lines (# ...).""" + + def __init__(self, real_stdout: io.TextIOBase) -> None: + self._real = real_stdout + self._buf = '' + + def write(self, text: str) -> int: + self._buf += text + while '\n' in self._buf: + line, self._buf = self._buf.split('\n', 1) + self._real.write('# {}\n'.format(line)) + self._real.flush() + return len(text) + + def flush(self) -> None: + if self._buf: + self._real.write('# {}\n'.format(self._buf)) + self._buf = '' + self._real.flush() + + +class TAPTestResult(unittest.TestResult): + """Collect unittest results and render them as TAP version 13.""" + + def __init__(self) -> None: + super().__init__() + self._test_count = 0 + self._lines: list[str] = [] + + def _description(self, test: unittest.TestCase) -> str: + return '{} ({})'.format(test._testMethodName, type(test).__name__) + + def addSuccess(self, test: unittest.TestCase) -> None: + super().addSuccess(test) + self._test_count += 1 + self._lines.append('ok {} - {}\n'.format( + self._test_count, self._description(test))) + + def addError(self, test: unittest.TestCase, err: object) -> None: + super().addError(test, err) + self._test_count += 1 + self._lines.append('not ok {} - {}\n'.format( + self._test_count, self._description(test))) + for line in traceback.format_exception(*err): # type: ignore[misc] + for subline in line.splitlines(): + self._lines.append('# {}\n'.format(subline)) + + def addFailure(self, test: unittest.TestCase, err: object) -> None: + super().addFailure(test, err) + self._test_count += 1 + self._lines.append('not ok {} - {}\n'.format( + self._test_count, self._description(test))) + for line in traceback.format_exception(*err): # type: ignore[misc] + for subline in line.splitlines(): + self._lines.append('# {}\n'.format(subline)) + + def addSkip(self, test: unittest.TestCase, reason: str) -> None: + super().addSkip(test, reason) + self._test_count += 1 + self._lines.append('ok {} - {} # SKIP {}\n'.format( + self._test_count, self._description(test), reason)) + + def addExpectedFailure(self, test: unittest.TestCase, err: object) -> None: + super().addExpectedFailure(test, err) + self._test_count += 1 + self._lines.append('ok {} - {} # TODO expected failure\n'.format( + self._test_count, self._description(test))) + + def addUnexpectedSuccess(self, test: unittest.TestCase) -> None: + super().addUnexpectedSuccess(test) + self._test_count += 1 + self._lines.append('not ok {} - {} # TODO unexpected success\n'.format( + self._test_count, self._description(test))) + + def print_tap(self, stream: io.TextIOBase) -> None: + stream.write('1..{}\n'.format(self._test_count)) + for line in self._lines: + stream.write(line) + stream.flush() + + +def run_tests(test_module_name: str, start_dir: str | None = None) -> bool: + if start_dir: + sys.path.insert(0, start_dir) + + module = importlib.import_module(test_module_name) + + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(module) + + real_stdout = sys.stdout + # TAP version header must be the very first line on stdout. + real_stdout.write('TAP version 13\n') + real_stdout.flush() + + # Redirect stdout so any print() calls from setUp/tearDown/tests are + # re-emitted as TAP diagnostic lines and do not break the TAP stream. + sys.stdout = DiagnosticCapture(real_stdout) # type: ignore[assignment] + try: + result = TAPTestResult() + suite.run(result) + finally: + sys.stdout.flush() + sys.stdout = real_stdout + + result.print_tap(real_stdout) + return result.wasSuccessful() + + +def main() -> None: + parser = argparse.ArgumentParser( + description='TAP test runner for nvme-cli tests') + parser.add_argument('test_module', help='Test module name to run') + parser.add_argument('--start-dir', + help='Directory to prepend to sys.path for imports', + default=None) + args = parser.parse_args() + + success = run_tests(args.test_module, args.start_dir) + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main()